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

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 (121) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/dist/commands/deploy.d.ts +181 -0
  3. package/dist/commands/deploy.d.ts.map +1 -0
  4. package/dist/commands/deploy.js +2974 -0
  5. package/dist/commands/deploy.js.map +1 -0
  6. package/dist/commands/project.d.ts +134 -0
  7. package/dist/commands/project.d.ts.map +1 -0
  8. package/dist/commands/project.js +919 -0
  9. package/dist/commands/project.js.map +1 -0
  10. package/dist/commands/reads.d.ts +64 -0
  11. package/dist/commands/reads.d.ts.map +1 -0
  12. package/dist/commands/reads.js +635 -0
  13. package/dist/commands/reads.js.map +1 -0
  14. package/dist/commands/scaffold.d.ts +69 -0
  15. package/dist/commands/scaffold.d.ts.map +1 -0
  16. package/dist/commands/scaffold.js +508 -0
  17. package/dist/commands/scaffold.js.map +1 -0
  18. package/dist/commands/service.d.ts +41 -0
  19. package/dist/commands/service.d.ts.map +1 -0
  20. package/dist/commands/service.js +618 -0
  21. package/dist/commands/service.js.map +1 -0
  22. package/dist/commands/storage.d.ts +37 -0
  23. package/dist/commands/storage.d.ts.map +1 -0
  24. package/dist/commands/storage.js +335 -0
  25. package/dist/commands/storage.js.map +1 -0
  26. package/dist/config.d.ts +72 -2
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js +201 -4
  29. package/dist/config.js.map +1 -1
  30. package/dist/conformance.d.ts +31 -0
  31. package/dist/conformance.d.ts.map +1 -0
  32. package/dist/conformance.js +371 -0
  33. package/dist/conformance.js.map +1 -0
  34. package/dist/deps.d.ts +6 -39
  35. package/dist/deps.d.ts.map +1 -1
  36. package/dist/deps.js +4 -68
  37. package/dist/deps.js.map +1 -1
  38. package/dist/errors.d.ts +20 -0
  39. package/dist/errors.d.ts.map +1 -0
  40. package/dist/errors.js +25 -0
  41. package/dist/errors.js.map +1 -0
  42. package/dist/flags.d.ts +34 -0
  43. package/dist/flags.d.ts.map +1 -1
  44. package/dist/flags.js +74 -0
  45. package/dist/flags.js.map +1 -1
  46. package/dist/main.js +220 -6905
  47. package/dist/main.js.map +1 -1
  48. package/dist/mintpage.d.ts +3 -1
  49. package/dist/mintpage.d.ts.map +1 -1
  50. package/dist/mintpage.js +48 -48
  51. package/dist/mintpage.js.map +1 -1
  52. package/dist/output.d.ts +137 -0
  53. package/dist/output.d.ts.map +1 -0
  54. package/dist/output.js +504 -0
  55. package/dist/output.js.map +1 -0
  56. package/dist/ownerops.d.ts +23 -44
  57. package/dist/ownerops.d.ts.map +1 -1
  58. package/dist/ownerops.js +151 -233
  59. package/dist/ownerops.js.map +1 -1
  60. package/dist/preview.d.ts +8 -4
  61. package/dist/preview.d.ts.map +1 -1
  62. package/dist/preview.js +8 -17
  63. package/dist/preview.js.map +1 -1
  64. package/dist/provision.d.ts.map +1 -1
  65. package/dist/provision.js +10 -7
  66. package/dist/provision.js.map +1 -1
  67. package/dist/remote.d.ts +72 -7
  68. package/dist/remote.d.ts.map +1 -1
  69. package/dist/remote.js +211 -9
  70. package/dist/remote.js.map +1 -1
  71. package/dist/riskgate.d.ts +29 -0
  72. package/dist/riskgate.d.ts.map +1 -0
  73. package/dist/riskgate.js +91 -0
  74. package/dist/riskgate.js.map +1 -0
  75. package/dist/schema.d.ts +6 -0
  76. package/dist/schema.d.ts.map +1 -1
  77. package/dist/schema.js +12 -16
  78. package/dist/schema.js.map +1 -1
  79. package/dist/served.d.ts +1 -17
  80. package/dist/served.d.ts.map +1 -1
  81. package/dist/served.js +47 -0
  82. package/dist/served.js.map +1 -1
  83. package/dist/signer.d.ts +13 -0
  84. package/dist/signer.d.ts.map +1 -1
  85. package/dist/signer.js +42 -19
  86. package/dist/signer.js.map +1 -1
  87. package/dist/update-check.d.ts +27 -0
  88. package/dist/update-check.d.ts.map +1 -1
  89. package/dist/update-check.js +30 -0
  90. package/dist/update-check.js.map +1 -1
  91. package/package.json +6 -6
  92. package/skill/SKILL.md +7 -11
  93. package/skill/reference/code-projects.md +3 -3
  94. package/skill/reference/hosting.md +6 -6
  95. package/skill/reference/operating.md +2 -5
  96. package/skill/reference/setup.md +2 -2
  97. package/skill/reference/troubleshooting.md +1 -1
  98. package/dist/gas.d.ts +0 -63
  99. package/dist/gas.d.ts.map +0 -1
  100. package/dist/gas.js +0 -76
  101. package/dist/gas.js.map +0 -1
  102. package/dist/inspect.d.ts +0 -48
  103. package/dist/inspect.d.ts.map +0 -1
  104. package/dist/inspect.js +0 -290
  105. package/dist/inspect.js.map +0 -1
  106. package/dist/migrate.d.ts +0 -65
  107. package/dist/migrate.d.ts.map +0 -1
  108. package/dist/migrate.js +0 -180
  109. package/dist/migrate.js.map +0 -1
  110. package/dist/onchain-uri.d.ts +0 -81
  111. package/dist/onchain-uri.d.ts.map +0 -1
  112. package/dist/onchain-uri.js +0 -251
  113. package/dist/onchain-uri.js.map +0 -1
  114. package/dist/resume.d.ts +0 -96
  115. package/dist/resume.d.ts.map +0 -1
  116. package/dist/resume.js +0 -95
  117. package/dist/resume.js.map +0 -1
  118. package/dist/upload.d.ts +0 -28
  119. package/dist/upload.d.ts.map +0 -1
  120. package/dist/upload.js +0 -41
  121. package/dist/upload.js.map +0 -1
@@ -0,0 +1,2974 @@
1
+ /**
2
+ * Deploy commands — the biggest domain, kept as ONE module (they share the salt/dry-run/staging
3
+ * machinery too tightly to split further without duplicating it):
4
+ *
5
+ * `abx demo` / `abx deploy` a 1/1 image NFT (SeriesImage's sibling contract, OneOfOneImage)
6
+ * `abx deploy-series` a multi-token image Series from a folder of media
7
+ * `abx deploy-code` a generative/code drop (SeriesCode) — on-chain script chunks or a
8
+ * directory build, PostParam schemas, optional `--resume` to finish
9
+ * an existing contract whose setup tx never landed
10
+ *
11
+ * Private helpers: the deploy-command-line reproducers (pinned-salt copy/paste), the
12
+ * placeholder-identity + preview-deployer guards, content staging narration (image fields,
13
+ * storage-plan readiness), and the demo-only walkthrough (spine/rebuild/read-back) sections.
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
17
+ import { basename, extname, join as joinPath, resolve as resolvePath } from 'node:path';
18
+ import { DEFAULT_CHAIN_KEY, DEP_RESOLUTION, METADATA_FIELD as F, METADATA_REPRESENTATION as R, analyzeScript, assertChainId, checkRegistryDeps, dependencySetupCalls, deployOneOfOne, deploySeries, discoverDeployBlock, encodeFieldRenderer, encodeTag, expectedChainComplete, makeHotSender, makePublicClient, makeWalletClient, normalizeAttributes, onchainUriSetupCalls, oneOfOneImageAbi, parseTraitPairs, planResume, predictClone, prepareCodeSetup, prepareDeployOneOfOne, prepareDeploySeries, prepareDeploySeriesCode, redactRpcUrl, resolveChain, resolveDepRegistryPointer, resolveGenerator, resolveRecommendedTransferValidator, resolveRpcUrl, resolveSeedSource, resolveSeriesCodeFactory, saltFor, seriesCodeAbi, seriesCodeFactoryAbi, tryReadContract, } from '@artblocks/abx-sdk';
19
+ import { DIRECT_URL_BACKENDS, contentTypeFromPath, decideImageContentLane, hashContent, isTurboArweave, resolveBackend, validateRenderStorageCombo, } from '@artblocks/abx-storage';
20
+ import { DEFAULT_PORT, artContentHash, generateArt, resolveBaseUrl, startTokenApiServer } from '@artblocks/abx-token-api';
21
+ import { encodeFunctionData, toHex, zeroAddress } from 'viem';
22
+ import { CHAIN, explorerBase, assertTurboFundsForUpload, backendResolution, collectContentLocators, ensureArweaveIdentityForUpload, factoryAddress, faucetHint, localIndexer, loopbackBaseUrl, noteArweavePlan, noteStorageReadiness, rendererAddress, seriesFactoryAddress, storageOptions, storageOverrides, storageSignerChoice, } from '../config.js';
23
+ import { parseDepFlag } from '../deps.js';
24
+ import { isDryRun, parseSaltFlag, refuseStrayFlags, warnSignWithoutFor } from '../flags.js';
25
+ import { jsonSafe, withJson } from '../jsonout.js';
26
+ import { bold, c, dim, ensureFactory, ensureRenderer, ensureSeedSource, ensureSeriesCodeFactory, ensureSeriesFactory, g, info, keepAlive, ok, p, portInUse, printServing, registerAndIndexLocally, reindexAfterDeploy, step, warn, } from '../output.js';
27
+ import { AUTHORSHIP_DEPLOY_FIELDS, ONCHAIN_PROJECT_SOFT_LIMIT, authorshipContractFields, computeContentPlan, envStagingSender, parseCompress, parseTransferValidatorValue, previewImageStaging, sessionStagingSender, stageImageField, stageImageFieldsBatch, } from '../ownerops.js';
28
+ import { canonicalLabel } from '../remote.js';
29
+ import { confirmSend, gatedSend, laneFromFlags } from '../riskgate.js';
30
+ import { describeSchema, parseSchemaSpecs } from '../schema.js';
31
+ import { parseSeriesTraits } from '../series-traits.js';
32
+ import { decodeOnChainJson } from '../served.js';
33
+ import { openWalletSession, signHotSequence, signTx } from '../signer.js';
34
+ // Product dimensions — what you can launch. One concrete contract exists today
35
+ // (the 1/1 image); this table is the seam future implementations slot into, so
36
+ // `abx deploy --type <dimension>` is stable while the contracts grow under it.
37
+ export const DIMENSIONS = {
38
+ '1of1': { label: '1/1 image NFT', aliases: ['one-of-one', 'oneofone', '1-of-1'] },
39
+ series: { label: 'multi-token image Series', aliases: ['multi', 'collection'] },
40
+ };
41
+ export function resolveDimension(type) {
42
+ const key = Object.keys(DIMENSIONS).find((k) => k === type || DIMENSIONS[k].aliases.includes(type));
43
+ if (!key) {
44
+ const known = Object.keys(DIMENSIONS).join(', ');
45
+ throw new Error(`Unknown --type '${type}'. Implemented: ${known}. (More dimensions land as their contracts are exposed.)`);
46
+ }
47
+ return key;
48
+ }
49
+ /**
50
+ * A `--dry-run` computes the deterministic deploy address, which is a pure function of
51
+ * (factory, salt, deployer) — so it needs a deployer even though it signs nothing. Resolve it the
52
+ * same way the preview will (`--for`, else an env key) and fail EARLY with the fix if neither
53
+ * exists, rather than after the preview has printed several steps of work.
54
+ */
55
+ /**
56
+ * A dry run only checks whether a factory address is CONFIGURED, not whether it has code on this
57
+ * chain — and the manifest always has an address, so a chain where the trust anchor isn't deployed
58
+ * (a private/local chain, or a wrong-network RPC) sailed past this and died inside
59
+ * `predictDeterministicAddress` with a raw `returned no data ("0x")` and a list of ABI hypotheses.
60
+ * The real deploy and `abx predict` both explain that case; a preview of the same deploy must too.
61
+ * Returns false when the caller should stop (message already printed).
62
+ */
63
+ export async function previewFactoryLive(client, factory, label) {
64
+ const code = await client.getCode({ address: factory }).catch(() => undefined);
65
+ if (code && code !== '0x')
66
+ return true;
67
+ warn(`the configured ${label} ${factory} has no code on '${CHAIN}' (asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}).`);
68
+ info('so this preview can\'t compute the deterministic address. Either point at a chain where the trust anchor is deployed');
69
+ info(`(${bold('ABX_CHAIN=' + DEFAULT_CHAIN_KEY)} is the default and has one), or deploy your own on this chain with ${bold('--bootstrap-factory')}`);
70
+ info(dim('(a private anchor — platforms won\'t recognize its clones, so it\'s for private/sandbox chains).'));
71
+ console.log(`\n ${g('dry run')} ${dim('— nothing sent.')}\n`);
72
+ return false;
73
+ }
74
+ /**
75
+ * Placeholder-identity guard, shared by ALL THREE deploy commands. `name`/`symbol` are written
76
+ * on-chain as the public collection identity and are effectively permanent, so a real deploy must
77
+ * never bake a tool default silently: warn in a preview, HARD-STOP a real send unless `--yes`.
78
+ *
79
+ * It was duplicated per command, and `deploy-series` simply never got a copy — its default
80
+ * "ABX Series"/"ABXS" went on-chain with at most a warning, while the skill promises the CLI
81
+ * refuses demo defaults. (deploy-code's copy even said "mirror deploy/deploy-series", which made
82
+ * the gap look closed.) One predicate now, like `loopbackBaseUrl()`, so a fourth command can't drift.
83
+ */
84
+ export function assertRealIdentity(flags, o) {
85
+ if (flags.name && flags.symbol)
86
+ return;
87
+ if (!flags.name)
88
+ warn(`no --name → default "${o.name}" would be the on-chain collection name`);
89
+ if (!flags.symbol)
90
+ warn(`no --symbol → default "${o.symbol}" would be the on-chain symbol`);
91
+ if (o.dryRun || flags.yes)
92
+ return; // a preview still runs; --yes is the explicit opt-in
93
+ throw new Error('refusing to write tool placeholders as your public on-chain identity — pass --name "Your Title" --symbol SYM ' +
94
+ '(or --yes to accept the defaults). On-chain identity is effectively permanent.');
95
+ }
96
+ export function assertPreviewDeployer(flags) {
97
+ if (flags.for)
98
+ return;
99
+ try {
100
+ makeWalletClient({ chainKey: CHAIN });
101
+ }
102
+ catch {
103
+ throw new Error('dry run needs a deployer address to compute the deterministic deploy address — pass --for 0x.. ' +
104
+ '(a preview signs nothing, so no key is needed). For the REAL deploy with no key in .env, use the ' +
105
+ 'wallet lane: --sign --for 0x.. (you approve in your own wallet).');
106
+ }
107
+ }
108
+ // Funding preflight — a 0-balance signer fails only at the tx, with a confusing error. Surface it
109
+ // up front. Especially the wallet lane (--sign/--for), which has no env key for `doctor` to check.
110
+ export async function warnUnfunded(publicClient, address) {
111
+ try {
112
+ const bal = await publicClient.getBalance({ address });
113
+ if (bal === 0n)
114
+ warn(`signer ${address} has 0 ${CHAIN} ETH — ${faucetHint(CHAIN)}, then sign.`);
115
+ }
116
+ catch {
117
+ /* RPC hiccup — skip the advisory preflight, the real send still validates */
118
+ }
119
+ }
120
+ // Reconstruct a copy-pasteable `abx deploy` from the flags used + a pinned salt, so a previewed
121
+ // address is reproducible in one paste (a plain re-run reserves a different salt → different addr).
122
+ //
123
+ // A reproduce line is meant to be copy-pasted into a shell, so every value MUST be shell-safe. The
124
+ // old rule (wrap only whitespace values in DOUBLE quotes) corrupted any name/description containing
125
+ // a quote, `&`, `<`, `$`, etc. — the shell reparsed it and silently truncated/mangled the text
126
+ // (round-3 finding: `Bob's "Café" & Friends` came back broken). POSIX single-quote escaping is the
127
+ // robust fix: it makes ANY string a single safe token (embedded `'` → `'\''`).
128
+ export function shArg(v) {
129
+ return /^[A-Za-z0-9,._+:@%/=-]+$/.test(v) ? v : `'${v.replace(/'/g, "'\\''")}'`;
130
+ }
131
+ export function deployCommandLine(flags, salt) {
132
+ const parts = ['abx deploy'];
133
+ const str = (k, v) => { if (v)
134
+ parts.push(`--${k} ${shArg(v)}`); };
135
+ const bool = (k) => { if (flags[k] === '' || flags[k] === 'true')
136
+ parts.push(`--${k}`); };
137
+ str('image', flags.image);
138
+ str('name', flags.name);
139
+ str('symbol', flags.symbol);
140
+ str('description', flags.description);
141
+ str('external-url', flags['external-url']);
142
+ str('traits', flags.traits);
143
+ str('attributes', flags.attributes);
144
+ bool('traits-onchain');
145
+ bool('description-onchain');
146
+ bool('onchain-uri');
147
+ bool('onchain-image');
148
+ str('compress', flags.compress);
149
+ str('royalty-bps', flags['royalty-bps']);
150
+ bool('no-mint');
151
+ str('721c', flags['721c'] === 'true' ? 'recommended' : flags['721c']); // bare --721c ≡ recommended
152
+ str('backend', flags.backend);
153
+ str('gateway', flags.gateway);
154
+ str('bucket', flags.bucket);
155
+ str('public-base-url', flags['public-base-url']);
156
+ if (flags.sign !== undefined)
157
+ parts.push('--sign');
158
+ if (flags.unsigned !== undefined)
159
+ parts.push('--unsigned');
160
+ str('for', flags.for);
161
+ parts.push(`--salt ${salt}`);
162
+ return parts.join(' ');
163
+ }
164
+ // Copy-pasteable `abx deploy-series` with the pinned salt — the STATELESS way to reproduce a
165
+ // previewed address in one line (we print it; nothing is remembered). `--for`/`--sign` are carried
166
+ // through so the reproduced command keeps the same signer enforcement (a safety feature, not dropped).
167
+ export function deploySeriesCommandLine(flags, salt) {
168
+ const parts = ['abx deploy-series'];
169
+ const str = (k, v) => { if (v)
170
+ parts.push(`--${k} ${shArg(v)}`); };
171
+ const bool = (k) => { if (flags[k] === '' || flags[k] === 'true')
172
+ parts.push(`--${k}`); };
173
+ str('dir', flags.dir);
174
+ str('count', flags.count);
175
+ str('name', flags.name);
176
+ str('symbol', flags.symbol);
177
+ str('description', flags.description);
178
+ str('external-url', flags['external-url']);
179
+ str('attributes', flags.attributes); // were DROPPED → the "redeploy identically" line lost the description
180
+ bool('onchain-uri');
181
+ bool('onchain-image');
182
+ str('compress', flags.compress);
183
+ str('backend', flags.backend);
184
+ str('gateway', flags.gateway);
185
+ str('bucket', flags.bucket);
186
+ str('public-base', flags['public-base']);
187
+ str('public-base-url', flags['public-base-url']);
188
+ bool('mint-all');
189
+ str('mint-count', flags['mint-count']);
190
+ bool('no-mint');
191
+ bool('unpaused');
192
+ str('minter', flags.minter);
193
+ str('primary-payee', flags['primary-payee']);
194
+ str('royalty-bps', flags['royalty-bps']);
195
+ str('721c', flags['721c'] === 'true' ? 'recommended' : flags['721c']); // bare --721c ≡ recommended
196
+ if (flags.sign !== undefined)
197
+ parts.push('--sign');
198
+ if (flags.unsigned !== undefined)
199
+ parts.push('--unsigned');
200
+ str('for', flags.for);
201
+ parts.push(`--salt ${salt}`);
202
+ return parts.join(' ');
203
+ }
204
+ // Copy-pasteable `abx deploy-code` with the pinned salt — same stateless reproduce contract as the
205
+ // 1/1 / Series lines above (a plain re-run reserves a different salt → a different clone address).
206
+ export function deployCodeCommandLine(flags, salt) {
207
+ const parts = ['abx deploy-code'];
208
+ const str = (k, v) => { if (v)
209
+ parts.push(`--${k} ${shArg(v)}`); };
210
+ const bool = (k) => { if (flags[k] === '' || flags[k] === 'true')
211
+ parts.push(`--${k}`); };
212
+ str('script', flags.script);
213
+ str('code-dir', flags['code-dir']);
214
+ str('name', flags.name);
215
+ str('symbol', flags.symbol);
216
+ str('description', flags.description);
217
+ str('external-url', flags['external-url']);
218
+ str('image-renderer', flags['image-renderer']);
219
+ str('image-base', flags['image-base']);
220
+ str('attributes-renderer', flags['attributes-renderer']);
221
+ str('max', flags.max);
222
+ str('schema', flags.schema);
223
+ bool('no-seed');
224
+ bool('onchain-uri');
225
+ str('generator', flags.generator);
226
+ str('renderer', flags.renderer);
227
+ str('dep', flags.dep);
228
+ str('dep-registry', flags['dep-registry']); // repeats already comma-joined — one --dep reproduces them in order
229
+ bool('mint-all');
230
+ str('mint-count', flags['mint-count']);
231
+ bool('no-mint');
232
+ bool('unpaused');
233
+ str('minter', flags.minter);
234
+ str('primary-payee', flags['primary-payee']);
235
+ str('royalty-bps', flags['royalty-bps']);
236
+ str('721c', flags['721c'] === 'true' ? 'recommended' : flags['721c']); // bare --721c ≡ recommended
237
+ str('backend', flags.backend);
238
+ str('gateway', flags.gateway);
239
+ str('public-base-url', flags['public-base-url']);
240
+ if (flags.sign !== undefined)
241
+ parts.push('--sign');
242
+ if (flags.unsigned !== undefined)
243
+ parts.push('--unsigned');
244
+ str('for', flags.for);
245
+ parts.push(`--salt ${salt}`);
246
+ return parts.join(' ');
247
+ }
248
+ // ── content custody ───────────────────────────────────────────────────────--
249
+ // Build the token's `image` field as an on-chain keccak256 commitment over the bytes,
250
+ // and (with --image) store those bytes in custody keyed by that hash so the resolver
251
+ // can serve + verify them. Without --image, fall back to the demo's generative-from-
252
+ // address art (recomputable, nothing to store). Returns on-chain field inputs.
253
+ export const imageKeccakField = (value) => ({
254
+ field: encodeTag(F.image),
255
+ representation: encodeTag(R.keccak256),
256
+ value,
257
+ });
258
+ export const imageInlineField = (svg) => ({
259
+ field: encodeTag(F.image),
260
+ representation: encodeTag(R.inline),
261
+ value: toHex(svg),
262
+ });
263
+ /** An `image` field pointing at an off-chain URL (the renderer emits it verbatim). */
264
+ export const imageUrlField = (url) => ({
265
+ field: encodeTag(F.image),
266
+ representation: encodeTag(R.url),
267
+ value: toHex(url),
268
+ });
269
+ /** An `image` field as a `url-template` — `{id}` is substituted with the tokenId at render, so ONE
270
+ * (collection-scope) field addresses a whole pinned directory / Arweave manifest. */
271
+ export const imageUrlTemplateField = (template) => ({
272
+ field: encodeTag(F.image),
273
+ representation: encodeTag(R.urlTemplate),
274
+ value: toHex(template),
275
+ });
276
+ export const looksLikeSvg = (s) => /^\s*<(\?xml|svg)/i.test(s);
277
+ /**
278
+ * Does the IMAGE itself end up on-chain for this deploy? Two ways it can:
279
+ * • `--onchain-image` — the bytes are staged in the chunk store and read back via a `reader` field.
280
+ * • `--onchain-uri` with an SVG — v1 inlines SVG directly as a data: URI (raster cannot be).
281
+ * Anything else falls through to keccak256 custody, and the on-chain renderer serves its PLACEHOLDER
282
+ * image. This distinction is the difference between a token that is durable and one that only looks
283
+ * durable, so it must be computed from the actual file — never inferred from the flag alone (the
284
+ * success banner used to claim "fully on-chain, no hosting needed" for a keccak-anchored raster).
285
+ */
286
+ /**
287
+ * Will `tokenURI` resolve to the creator's REAL image, with nothing to keep running?
288
+ *
289
+ * Three ways yes, and they are not the same promise: the bytes are on-chain
290
+ * ({@link imageEndsUpOnChain}), or they sit at a durable public URL that the on-chain JSON points at
291
+ * (a direct-URL backend — the "no server" pattern). Only `fs` custody under `--onchain-uri` fails,
292
+ * because the renderer then holds a hash and nothing can serve it. Keep this separate from
293
+ * "fully on-chain": conflating them is what let a keccak-anchored raster ship under a permanence
294
+ * banner it did not earn.
295
+ */
296
+ export function imageResolvesWithoutServer(flags, onchainImage) {
297
+ if (imageEndsUpOnChain(flags, onchainImage))
298
+ return true;
299
+ return !!flags.image && DIRECT_URL_BACKENDS.has(backendResolution(storageOverrides(flags)).backend);
300
+ }
301
+ export function imageEndsUpOnChain(flags, onchainImage) {
302
+ if (onchainImage)
303
+ return true;
304
+ const image = typeof flags.image === 'string' ? flags.image : undefined;
305
+ if (!image)
306
+ return false;
307
+ try {
308
+ const p = resolvePath(image);
309
+ if (!statSync(p).isFile())
310
+ return false;
311
+ // Only the head matters — looksLikeSvg tests the leading tag.
312
+ return looksLikeSvg(readFileSync(p).subarray(0, 256).toString('utf8'));
313
+ }
314
+ catch {
315
+ return false;
316
+ }
317
+ }
318
+ export async function prepareContent(imagePath, clone, overrides, store = true, // false for --dry-run: compute the hash but don't custody bytes
319
+ onChain = false, // true (--onchain-uri): put the image ON-CHAIN (inline SVG) so it self-resolves
320
+ remoteEth, // wallet-lane Turbo uploads: sign+pay with the connected browser wallet
321
+ walletAddr) {
322
+ if (!imagePath) {
323
+ // demo default = generative SVG. On-chain mode inlines it (self-resolving, no custody);
324
+ // off-chain mode commits its keccak and regenerates/serves it.
325
+ if (onChain) {
326
+ return {
327
+ tokenFields: [imageInlineField(generateArt(clone))],
328
+ contentNote: 'content: generative SVG, INLINE on-chain (self-resolving, no custody)',
329
+ };
330
+ }
331
+ return {
332
+ tokenFields: [imageKeccakField(artContentHash(clone))],
333
+ contentNote: 'content: generative SVG from the contract address (demo default)',
334
+ };
335
+ }
336
+ const path = resolvePath(imagePath);
337
+ const bytes = new Uint8Array(readFileSync(path));
338
+ const contentType = contentTypeFromPath(path);
339
+ // On-chain mode can inline SVG directly (the renderer emits it as a data: URI). A raster can't be
340
+ // inlined in v1. That used to mean "fall through to keccak custody and let the renderer show a
341
+ // placeholder" — which quietly made the documented pattern-2 recipe (image off-chain, JSON
342
+ // on-chain, no server) impossible for a 1/1 even though `deploy-series` did it fine. Now the 1/1
343
+ // takes the same route the Series takes: upload to a durable, publicly-readable backend and bake
344
+ // the resulting URL on-chain as the image field, so `tokenURI` resolves from the chain to a real
345
+ // image with nothing to keep running. Only a backend that can hand out a public read URL
346
+ // qualifies; `fs` (this machine only) still falls through to custody. `decideImageContentLane`
347
+ // (the SDK's storage package — it's the one that knows which backends serve a public URL) makes
348
+ // this call from local facts alone: the file's sniffed bytes + the resolved backend id.
349
+ let onchainUrlLane = false;
350
+ if (onChain) {
351
+ const text = Buffer.from(bytes).toString('utf8');
352
+ const decision = decideImageContentLane({ onChain, isSvg: looksLikeSvg(text), backendId: backendResolution(overrides).backend });
353
+ if (decision.lane === 'inline-svg') {
354
+ return {
355
+ tokenFields: [imageInlineField(text)],
356
+ contentNote: `content: ${basename(path)} (${bytes.length} bytes SVG) INLINE on-chain`,
357
+ };
358
+ }
359
+ onchainUrlLane = decision.lane === 'onchain-url';
360
+ if (decision.onchainFallback) {
361
+ warn(`--onchain-uri can't inline ${contentType} on-chain (v1 inlines SVG only), and '${backendResolution(overrides).backend}' can't serve a public URL — ` +
362
+ `storing in custody, so the on-chain image will be a placeholder. Add ${bold('--backend arweave')} (or ipfs/cloud) to bake a real image URL, or ${bold('--onchain-image')} to put the bytes themselves on-chain.`);
363
+ }
364
+ }
365
+ const hash = hashContent(bytes);
366
+ const opts = storageOptions(overrides); // flags override config/env
367
+ // Wallet-lane Turbo: route uploads through the connected browser wallet (its ETH Turbo credits pay).
368
+ if (remoteEth && opts.arweave)
369
+ opts.arweave = { ...opts.arweave, remoteEth, jwk: undefined, ethSignerKey: undefined };
370
+ noteArweavePlan(opts, bytes.length); // free-vs-credit readout (Turbo) — shown for dry-run too
371
+ if (!store) {
372
+ const backendId = backendResolution(overrides).backend;
373
+ if (onchainUrlLane) {
374
+ return {
375
+ tokenFields: [imageUrlField(`<${backendId}-url>`)],
376
+ contentNote: `content: ${basename(path)} (${bytes.length} bytes, ${contentType}) → would upload to ${backendId}; its public URL becomes the ON-CHAIN image field (no server)`,
377
+ };
378
+ }
379
+ return {
380
+ tokenFields: [imageKeccakField(hash)],
381
+ contentNote: `content: ${basename(path)} (${bytes.length} bytes, ${contentType}) · image keccak256 ${hash} (would be stored on deploy)`,
382
+ };
383
+ }
384
+ ensureArweaveIdentityForUpload(opts); // create + announce the Turbo identity if this is its first upload
385
+ // Balance guard for the local-identity Turbo lanes. Skip it for a remote wallet — checking balance
386
+ // there needs the signer's pubkey (an extra personal_sign prompt); the upload itself surfaces any shortfall.
387
+ if (!opts.arweave?.remoteEth)
388
+ await assertTurboFundsForUpload(opts, [bytes.length], walletAddr);
389
+ const backend = resolveBackend(opts);
390
+ if (onchainUrlLane) {
391
+ // One file, uploaded as a one-entry directory — the same call `deploy-series` uses for its
392
+ // uniform-extension folder, so both commands produce the same URL shape from the same code path.
393
+ // Named `0.<ext>` because a 1/1 is token 0. Falls back to custody if the backend turns out not to
394
+ // support directories, rather than silently producing a token whose image never resolves.
395
+ const ext = extname(path).toLowerCase() || '.bin';
396
+ if (backend.putDirectory) {
397
+ const { base } = await backend.putDirectory([{ name: `0${ext}`, bytes, contentType }]);
398
+ const url = `${base}/0${ext}`;
399
+ // Keep the bytes under their hash too: `abx verify` can then still prove the served image
400
+ // matches what was uploaded, even though the on-chain field addresses it by URL.
401
+ await backend.put(hash, { bytes, contentType });
402
+ ok(`image → ${url} ${dim(`(${backend.id}; baked on-chain, no server)`)}`);
403
+ return {
404
+ tokenFields: [imageUrlField(url)],
405
+ contentNote: `content: ${basename(path)} (${bytes.length} bytes, ${contentType}) on ${backend.id} · ON-CHAIN image url ${url}`,
406
+ };
407
+ }
408
+ warn(`'${backend.id}' cannot upload a directory, so no public image URL could be baked — falling back to custody (the on-chain image will be a placeholder).`);
409
+ }
410
+ await backend.put(hash, { bytes, contentType });
411
+ return {
412
+ tokenFields: [imageKeccakField(hash)],
413
+ contentNote: `content: stored ${basename(path)} (${bytes.length} bytes, ${contentType}) in '${backend.id}' custody · image keccak256 ${hash}`,
414
+ };
415
+ }
416
+ /**
417
+ * Real OpenSea traits supplied by the creator, from `--attributes <file.json>` (canonical array or
418
+ * `{name: value}` map) and/or `--traits "Background=Blue; Edition=3"` (quick inline form). These are
419
+ * the marketplace trait array — NOT a place for protocol facts. Empty when neither flag is given.
420
+ */
421
+ export function parseDeployTraits(flags) {
422
+ const out = [];
423
+ if (flags.attributes) {
424
+ const raw = readFileSync(resolvePath(process.cwd(), flags.attributes), 'utf8');
425
+ out.push(...normalizeAttributes(JSON.parse(raw)));
426
+ }
427
+ if (flags.traits)
428
+ out.push(...parseTraitPairs(flags.traits));
429
+ return out;
430
+ }
431
+ /** Encode creator traits as the on-chain `attributes` field (inline JSON bytes) — for `--traits-onchain`. */
432
+ export const attributesInlineField = (attrs) => ({
433
+ field: encodeTag(F.attributes),
434
+ representation: encodeTag(R.inline),
435
+ value: toHex(JSON.stringify(attrs)),
436
+ });
437
+ /** Warn when IPFS/Arweave custody is pointed at a LOCAL gateway: the served image URL
438
+ * (`<gateway>/ipfs/<cid>`) would resolve only on this machine, so marketplaces/wallets can't
439
+ * load it — a pinning service + a PUBLIC gateway is part of operating off-chain custody. */
440
+ export function localGatewayWarning(flags) {
441
+ const opts = storageOptions(storageOverrides(flags));
442
+ const gateway = opts.backend === 'ipfs' ? opts.ipfs?.gateway : opts.backend === 'arweave' ? opts.arweave?.gateway : undefined;
443
+ if (gateway && /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0)\b/i.test(gateway)) {
444
+ return (`${opts.backend} gateway is ${gateway} — a LOCAL gateway. The served image URL resolves only on THIS machine; ` +
445
+ `marketplaces and wallets won't load it. Pin to a service with a PUBLIC gateway (e.g. a Pinata dedicated gateway) ` +
446
+ `via --gateway https://… or ABX_IPFS_GATEWAY.`);
447
+ }
448
+ return null;
449
+ }
450
+ /** A readout line for an optional creator text field — states exactly what's written (and where),
451
+ * or that NOTHING is, so the deploy never silently invents one. */
452
+ export function describeDeployField(label, value, onchain) {
453
+ if (value)
454
+ return `${label} "${value}" ${dim(onchain ? '(on-chain, inline)' : '(off-chain operator metadata)')}`;
455
+ return `${label}: ${dim(`none — none is written (pass --${label} to set it)`)}`;
456
+ }
457
+ /** Readout lines for whichever authorship/rights fields a deploy will write (always on-chain,
458
+ * inline collection fields). Empty when none are set — so the readout stays quiet by default. */
459
+ export function authorshipReadout(flags) {
460
+ return AUTHORSHIP_DEPLOY_FIELDS.filter(([flag]) => flags[flag]).map(([flag]) => describeDeployField(flag, flags[flag], true));
461
+ }
462
+ /** A readout line for creator traits — the exact traits written (and where), or that none are. */
463
+ export function describeTraits(traits, onchain) {
464
+ if (!traits.length)
465
+ return `traits: ${dim('none — none is written (add --traits "Key=Value" or --attributes file.json)')}`;
466
+ const list = traits.map((t) => `${t.trait_type}=${t.value}`).join(', ');
467
+ return `traits: ${list} ${dim(onchain ? '(on-chain, inline JSON)' : '(off-chain operator metadata)')}`;
468
+ }
469
+ // ── deploy (+ optionally serve) ──────────────────────────────────────────────
470
+ export async function cmdDeploy(flags, serveAfter) {
471
+ // `--json`: the deployed ADDRESS is the value a program came for (B19). Narration goes to stderr,
472
+ // so `ADDR=$(abx deploy … --json | jq -r .address)` works without parsing a success banner.
473
+ return withJson(flags, async (emit) => cmdDeployBody(flags, serveAfter, emit));
474
+ }
475
+ export async function cmdDeployBody(flags, serveAfter, emit) {
476
+ refuseStrayFlags(flags, DEPLOY_FLAGS, 'deploy');
477
+ warnSignWithoutFor(flags);
478
+ const dimension = resolveDimension(flags.type ?? '1of1');
479
+ // A multi-token collection is its OWN command; `abx deploy` is the single-token (1/1) path.
480
+ // Redirect instead of silently deploying a 1/1 when someone reaches for `--type series`.
481
+ if (dimension === 'series') {
482
+ throw new Error('A multi-token Series is a separate command — use `abx deploy-series --dir <media-dir> …` ' +
483
+ '(not `abx deploy --type series`). Run `abx help deploy-series` for its flags.');
484
+ }
485
+ const name = flags.name ?? 'ABX Self-Host Demo';
486
+ const symbol = flags.symbol ?? 'ABXSH';
487
+ const royaltyBps = Number(flags['royalty-bps'] ?? 500);
488
+ const port = Number(flags.port ?? process.env.ABX_PORT ?? DEFAULT_PORT);
489
+ const baseUrl = flags['public-base-url'] ?? resolveBaseUrl(port);
490
+ // Creator traits (real OpenSea attributes). Off-chain operator metadata by default (cheap,
491
+ // editable later via `abx add --traits`); `--traits-onchain` inlines them on-chain (durable,
492
+ // lockable) — mirrors the description model. On-chain wins if both ever set the same trait.
493
+ const traits = parseDeployTraits(flags);
494
+ console.log(serveAfter
495
+ ? bold(`\n ABX · your first token\n ${dim("we'll put art on a blockchain — then delete our copy and get all of it back")}`)
496
+ : bold(`\n ABX Self-Host Toolkit — deploy\n ${dim('a project, served from chain alone')}`));
497
+ // Signing lane. `demo` used to hard-force the hot lane, which SILENTLY dropped `--sign`: with no
498
+ // env key it died confusingly, and WITH one it signed from that key while the operator had asked
499
+ // for their browser wallet — a signing choke point that ignored the lane it was handed. The demo
500
+ // deploy is a single tx, so the wallet lane works here exactly as it does for `deploy`.
501
+ const lane = laneFromFlags(flags);
502
+ // The cold lane only PRINTS a tx; demo's whole point is to index + serve what it just deployed,
503
+ // and there is nothing to index until someone broadcasts. Refuse the combo instead of doing
504
+ // half the job — `abx deploy --unsigned` is the command for that lane.
505
+ if (serveAfter && lane === 'unsigned') {
506
+ throw new Error('`abx demo --unsigned` can\'t work: the cold lane only prints a transaction, and the demo ' +
507
+ 'indexes + serves the contract it just deployed. Use `abx demo` (hot key) or `abx demo --sign` ' +
508
+ '(browser wallet) — or `abx deploy --unsigned` if you only want the raw tx.');
509
+ }
510
+ // Same shape for --dry-run: previewing sends nothing, so there is nothing to serve.
511
+ if (serveAfter && isDryRun(flags)) {
512
+ throw new Error('`abx demo --dry-run` can\'t work: a dry run sends nothing, and the demo indexes + serves what ' +
513
+ 'it deployed. Use `abx deploy --dry-run` to preview a 1/1 deploy without sending.');
514
+ }
515
+ // Port preflight BEFORE anything irreversible. The demo ends by serving, and `listen` used to be
516
+ // the first thing to discover the port was taken — after the deploy tx had already been signed and
517
+ // paid for, so a second `abx demo` (a very normal thing to try) spent gas and then died with a raw
518
+ // Node EADDRINUSE stack trace. Check first, and name the fix.
519
+ if (serveAfter) {
520
+ const wanted = Number(flags.port ?? process.env.ABX_PORT ?? DEFAULT_PORT);
521
+ if (await portInUse(wanted)) {
522
+ throw new Error(`port ${wanted} is already in use — probably an \`abx demo\`/\`abx serve\` still running in another terminal.\n` +
523
+ ` Nothing was deployed. Stop that one (Ctrl-C), or run this on another port: \`abx demo --port ${wanted + 1}\`.`);
524
+ }
525
+ }
526
+ // (demo keeps its own message: "Nothing was deployed" is the load-bearing part here, and it must
527
+ // be true — this check runs before anything irreversible. assertPortFree covers serve/preview.)
528
+ const dryRun = !serveAfter && isDryRun(flags); // preview only — no send, no custody, no factory deploy
529
+ // A keyless preview needs `--for` (the address is a pure function of factory+salt+deployer). Check
530
+ // it HERE, before the trust-anchor/content/plan steps print — hitting this after a wall of output
531
+ // reads as "it half-worked", and a first-timer previewing with no key in .env always hits it.
532
+ if (dryRun)
533
+ assertPreviewDeployer(flags);
534
+ const publicClient = makePublicClient({ chainKey: CHAIN });
535
+ // Verify the RPC really is CHAIN before any send (factory/renderer/staging/deploy). A dry run
536
+ // sends nothing, but it DOES read the chain (predict address, resolve the factory/renderer), so
537
+ // a wrong-network RPC must still be caught with the clear mismatch message rather than failing
538
+ // opaquely inside predict; `allowUnreachable` keeps a genuinely offline dry-run previewable.
539
+ await assertChainId(CHAIN, { allowUnreachable: dryRun });
540
+ // --721c (opt-in ERC-721C): absent → zeroAddress, a plain ERC-721 exactly as before.
541
+ const transferValidator = await resolveTransferValidatorFlag(flags, publicClient, dryRun);
542
+ // `demo` is exempt: its whole job is a zero-argument first token.
543
+ if (!serveAfter)
544
+ assertRealIdentity(flags, { name, symbol, dryRun });
545
+ // Funding preflight (real deploys): warn now if the signer is unfunded, not at the tx.
546
+ if (!dryRun) {
547
+ let signer = flags.for;
548
+ if (!signer && lane === 'send') {
549
+ try {
550
+ signer = makeWalletClient({ chainKey: CHAIN }).account.address;
551
+ }
552
+ catch { /* no key yet — later steps handle it */ }
553
+ }
554
+ if (signer)
555
+ await warnUnfunded(publicClient, signer);
556
+ }
557
+ // `deploy` surfaces the trust anchor as a step; the demo does NOT. It briefly opened on "only this
558
+ // factory can make a token that IS an ABX token", which is simply false — anything that follows the
559
+ // protocol's event spine is an ABX token, and the factory is one way to get there, not the
560
+ // definition of the thing. Rather than restate it more carefully, the demo skips it: a first-timer
561
+ // does not need a provenance lecture before they have made anything.
562
+ if (!serveAfter)
563
+ step('Trust anchor');
564
+ let factory;
565
+ if (dryRun) {
566
+ const existing = factoryAddress(flags.factory);
567
+ if (!existing) {
568
+ info('no canonical factory for this chain yet — a real deploy would deploy the trust anchor first.');
569
+ console.log(`\n ${g('dry run')} ${dim('— nothing sent.')}\n`);
570
+ return;
571
+ }
572
+ factory = existing;
573
+ if (!(await previewFactoryLive(publicClient, factory, 'factory')))
574
+ return;
575
+ info(`would reuse canonical factory ${factory}`);
576
+ }
577
+ else {
578
+ factory = await ensureFactory(flags.factory, !!flags['bootstrap-factory'], serveAfter);
579
+ }
580
+ // --onchain-uri: resolve tokenURI/contractURI fully on-chain via the canonical renderer
581
+ // (the JSON is assembled from on-chain fields → no resolver needed, ever). The off-chain
582
+ // URLs are still baked as a fallback if the renderer is later cleared.
583
+ // --onchain-image: stage the --image bytes on-chain (chunk store, ownerless) and bake a
584
+ // `reader` field into the deploy — so large on-chain content needs NO post-deploy tx. It
585
+ // implies on-chain URI resolution (the renderer emits the reader-backed image).
586
+ // A fully on-chain token self-resolves via the renderer; the stored off-chain pointer is
587
+ // never read while a renderer is set. So bake a real URL only if one was explicitly given —
588
+ // otherwise leave it EMPTY rather than baking a misleading localhost into the contract.
589
+ const hasPublicUrl = !!(flags['public-base-url'] || process.env.ABX_PUBLIC_BASE_URL);
590
+ const onchainImage = !!flags['onchain-image'];
591
+ // `abx demo` defaults to FULLY ON-CHAIN. Its default art is a generative SVG, which the renderer
592
+ // can inline — so the token self-resolves and, crucially, NO localhost gets baked into the
593
+ // contract as the tokenURI base. The old default shipped a first-ever token that resolved for
594
+ // nobody but its author (broken on every marketplace, dead the moment `abx serve` stops) and
595
+ // taught that as the normal shape of an NFT. It also undercut the demo's own claim: with the art
596
+ // on-chain, "rebuilt from the chain alone" now covers the IMAGE, not just the metadata.
597
+ //
598
+ // Opting back into off-chain custody is anything that says "I have somewhere to host": a real base
599
+ // URL, an explicit --backend, or a RASTER --image. An SVG --image still goes on-chain — inlining is
600
+ // exactly what the renderer supports (v1 is SVG-only), so there's no reason to send someone's own
601
+ // vector art down the localhost path. Raster stays off-chain because forcing it on-chain would
602
+ // silently inline a placeholder instead of their image.
603
+ const demoImageInlineable = !flags.image || contentTypeFromPath(flags.image) === 'image/svg+xml';
604
+ const demoDefaultsOnChain = serveAfter && demoImageInlineable && !flags.backend && !hasPublicUrl;
605
+ const onChainUri = !!flags['onchain-uri'] || onchainImage || demoDefaultsOnChain;
606
+ // On-chain traits when explicitly asked, or implied by --onchain-uri (the renderer can only
607
+ // emit on-chain fields, so off-chain-only traits would be invisible there). Else off-chain.
608
+ const traitsOnchain = traits.length > 0 && (!!flags['traits-onchain'] || onChainUri);
609
+ let renderer = zeroAddress;
610
+ if (onChainUri) {
611
+ step(serveAfter ? 'What will answer when someone asks about your token' : 'On-chain renderer');
612
+ if (dryRun) {
613
+ renderer = rendererAddress(flags.renderer) ?? zeroAddress;
614
+ info(renderer === zeroAddress ? 'would deploy the canonical renderer first' : `would use renderer ${renderer}`);
615
+ }
616
+ else {
617
+ renderer = await ensureRenderer(flags.renderer);
618
+ }
619
+ // A first-timer has no idea what a "renderer" is, and the word suggests something that draws
620
+ // pictures. What it actually does is assemble the JSON a marketplace asks for, on-chain, out of
621
+ // the fields your contract holds — worth one plain sentence, since it's why no server is needed.
622
+ if (serveAfter) {
623
+ info(dim('a marketplace asks your contract a question; this shared contract composes the answer'));
624
+ info(dim('already deployed, used by every ABX token, owned by no one — you are not paying to set it up'));
625
+ }
626
+ }
627
+ step(serveAfter ? `Mint it · one transaction on ${CHAIN}` : `Deploy a ${DIMENSIONS[dimension].label} (--type ${dimension}) to ${CHAIN}`);
628
+ // What the chain ends up holding is the single most useful thing to understand about an ABX token,
629
+ // and it's invisible unless someone says it out loud. Two honest versions, because the answer is
630
+ // genuinely different per lane. Demo only — `deploy` prints the same facts per-field.
631
+ if (serveAfter && onChainUri) {
632
+ console.log(` Your art goes ${bold('INTO')} the contract. Not a link to it — the image itself.`);
633
+ console.log(` ${g('✦')} the chain will hold ${dim('your art · your name on it · your royalty · you as owner')}`);
634
+ console.log(` ${g('✦')} you will need ${dim('nothing else. no server, no IPFS pin, no monthly bill to forget')}`);
635
+ }
636
+ else if (serveAfter) {
637
+ console.log(` ${g('✦')} the chain will hold ${dim('your name · your royalty · you as owner · a fingerprint of the art')}`);
638
+ console.log(` ${g('✦')} this computer holds ${dim('the image bytes themselves')}`);
639
+ info(dim('the chain proves the bytes are unaltered; it does not store them (that fingerprint is a keccak256 hash)'));
640
+ }
641
+ // Off-chain custody (no renderer) bakes the resolver URL straight into the on-chain
642
+ // tokenURI/contractURI at deploy. A localhost / loopback URL there resolves for NO ONE —
643
+ // not marketplaces, not wallets, not even your own browser unless `abx serve` is running —
644
+ // so it is a broken NFT by construction. There is no "throwaway testnet" exception: we
645
+ // refuse to bake it, full stop, and point at the two paths that actually work. (Not a
646
+ // concern with --onchain-uri: the renderer is authoritative and resolves from chain.)
647
+ // ABX_DEV_ALLOW_LOCALHOST_URI=1 is a DEV/TEST-ONLY escape (the e2e harness) — never a user path.
648
+ if (!serveAfter && !onChainUri) {
649
+ const loopback = loopbackBaseUrl(baseUrl);
650
+ if (!hasPublicUrl || loopback) {
651
+ const devAllow = process.env.ABX_DEV_ALLOW_LOCALHOST_URI === '1';
652
+ const msg = `Off-chain metadata needs a PUBLIC resolver URL baked on-chain — ${hasPublicUrl ? baseUrl : 'localhost'} resolves for no one ` +
653
+ `(not marketplaces, not wallets, not even your own browser unless \`abx serve\` is running). Pick a real path:\n` +
654
+ ` • Fully on-chain, no host: --onchain-image --compress fastlz (best for small static art)\n` +
655
+ ` • Hosted resolver: --public-base-url https://your.domain (or set ABX_PUBLIC_BASE_URL)`;
656
+ if (!devAllow) {
657
+ if (dryRun)
658
+ warn(`would REFUSE to deploy — ${msg}`);
659
+ else
660
+ throw new Error(msg);
661
+ }
662
+ else {
663
+ warn(`DEV ONLY (ABX_DEV_ALLOW_LOCALHOST_URI): baking ${bold(baseUrl)} on-chain — resolves only on THIS machine; not a real NFT.`);
664
+ }
665
+ }
666
+ }
667
+ if (onChainUri && serveAfter) {
668
+ // The demo's version of the same fact, in words a first-timer can act on. Naming what we are
669
+ // NOT doing matters here: a localhost URL written into a contract is the single most common way
670
+ // a first NFT ends up permanently broken, and the demo used to model exactly that.
671
+ info(dim('nothing points at this computer — no http://localhost anywhere in your contract.'));
672
+ info(dim('anyone can read your token from the chain, forever, with you offline.'));
673
+ }
674
+ else if (onChainUri) {
675
+ info('tokenURI/contractURI resolve ON-CHAIN via the renderer — no resolver, no server, no localhost.');
676
+ if (!hasPublicUrl)
677
+ info('off-chain fallback pointer left empty (the renderer is authoritative); set --public-base-url to bake one anyway.');
678
+ }
679
+ // `--onchain-uri` puts the tokenURI JSON on-chain; it does NOT put the IMAGE on-chain. Without
680
+ // `--onchain-image` the image is committed as a keccak256 anchor, which the on-chain renderer
681
+ // cannot serve bytes for — so `tokenURI` returns a PLACEHOLDER image. Say that here, before the
682
+ // spend (this runs in `--dry-run` too), because the success banner used to claim "fully on-chain,
683
+ // no hosting needed" for exactly this config and a creator would believe they had permanence they
684
+ // don't. The code lane's dry run already warns about this case; the 1/1 lane shipped it silently.
685
+ if (onChainUri && !imageResolvesWithoutServer(flags, onchainImage)) {
686
+ warn(`${bold('--onchain-uri puts the metadata JSON on-chain, not the image.')} With '${backendResolution(storageOverrides(flags)).backend}' custody the image is only ` +
687
+ `a keccak256 anchor, so ${bold('tokenURI')} will serve a PLACEHOLDER — nothing can fetch the bytes.`);
688
+ info(`two ways to get a real image with no server: ${bold('--backend arweave')} (or ipfs/cloud) uploads it and bakes the URL on-chain, or ${bold('--onchain-image --compress fastlz')} puts the bytes themselves on-chain (best under ~24KB).`);
689
+ }
690
+ else if (onChainUri && !imageEndsUpOnChain(flags, onchainImage)) {
691
+ // Resolves, durably, with no server — but the IMAGE is not on-chain, and that distinction is
692
+ // exactly what a creator asking for permanence is buying. Say which one they are getting.
693
+ info(`${bold('image off-chain, JSON on-chain')} — the image uploads to ${bold(backendResolution(storageOverrides(flags)).backend)} and its URL is baked into the on-chain metadata. ` +
694
+ `No server to run; permanence is the backend's (Arweave is pay-once-forever, IPFS lasts as long as it stays pinned). For the image itself on-chain, use ${bold('--onchain-image')}.`);
695
+ }
696
+ // IPFS/Arweave custody serves the image from the gateway URL. A LOCAL gateway produces an image
697
+ // URL only this machine can load — the image equivalent of the localhost-tokenURI footgun above.
698
+ if (!onChainUri && !onchainImage) {
699
+ const sr = backendResolution(storageOverrides(flags));
700
+ info(`storage: ${sr.backend} ${dim(`(${sr.source === 'flag' ? '--backend' : sr.source === 'env' ? 'env' : 'default'})`)} — byte custody for the image`);
701
+ const gwWarn = localGatewayWarning(flags);
702
+ if (gwWarn)
703
+ warn(gwWarn);
704
+ }
705
+ // Stage on-chain content ONCE (it's deployer-independent — the chunk-store manifest
706
+ // doesn't depend on the clone address) and bake it as a `reader` field below. Ownerless
707
+ // staging tx(s) via the env key; the deploy then references the manifest. Skipped on a
708
+ // dry run (no bytes written) — we note that a real deploy would stage it.
709
+ let bakedImage;
710
+ if (onchainImage) {
711
+ if (!flags.image)
712
+ throw new Error('--onchain-image needs --image <path> (the bytes to put on-chain)');
713
+ // On-chain staging is a SEQUENCE (chunk write(s) → the deploy that references the manifest)
714
+ // where each tx's receipt feeds the next, so it can't be signed offline in one run.
715
+ if (lane === 'unsigned') {
716
+ throw new Error('Staging an on-chain image (--onchain-image) needs interactive signing — each chunk tx feeds ' +
717
+ "the next, so it can't run on the cold lane (--unsigned). Use the hot lane (a funded key) or --sign (browser wallet).");
718
+ }
719
+ step('Stage on-chain image');
720
+ if (dryRun) {
721
+ info(previewImageStaging(flags.image, parseCompress(flags.compress)));
722
+ }
723
+ else if (lane === 'send') {
724
+ // hot lane: the env key stages now (deployer-independent — the manifest doesn't depend
725
+ // on the clone address), then the deploy below bakes in the reader field.
726
+ const { field, note } = await stageImageField(flags.image, parseCompress(flags.compress), envStagingSender());
727
+ bakedImage = field;
728
+ info(note);
729
+ }
730
+ else {
731
+ // wallet lane: the staging tx(s) are signed by the connecting wallet, so they're deferred
732
+ // into the sign session below (one connect, then approve each step) and baked there.
733
+ info('image will be staged on-chain in your wallet (one connect, then approve each step), then baked into the deploy.');
734
+ }
735
+ }
736
+ // Wallet-signature count for THIS deploy — the same chunk math that sizes the wallet-lane
737
+ // session `total` below (B2), computed ONCE so the preview/confirm text and the real session can
738
+ // never disagree. Counts TX signatures only: a storage upload signed by a connected wallet
739
+ // (Arweave via --storage-signer eth) is a message signature, not a transaction, and is already
740
+ // named separately (see the `remoteEthUpload` narration) — it does not add to this count.
741
+ const approvals = onchainImage
742
+ ? (() => {
743
+ const plan = computeContentPlan(readFileSync(resolvePath(flags.image)), parseCompress(flags.compress)).plan;
744
+ return (plan.mode === 'single' ? 1 : plan.txCount) + 1; // staging tx(s) + the deploy tx
745
+ })()
746
+ : 1;
747
+ // Mint-on-deploy is the default; --no-mint defers it so you can stand up + warm
748
+ // the resolver at the (known) address first, then `abx mint`. The demo always mints.
749
+ const noMint = !serveAfter && (flags['no-mint'] === 'true' || flags['no-mint'] === '');
750
+ const explicitSalt = parseSaltFlag(flags.salt);
751
+ // Wallet-lane Arweave uploads paid by the connecting wallet's Turbo credits (`--storage-signer eth`
752
+ // + `--sign`). When set (after connect), buildForDeployer routes uploads through it.
753
+ let uploadRemoteEth;
754
+ const remoteEthUpload = lane === 'sign' &&
755
+ !onchainImage &&
756
+ storageSignerChoice(storageOverrides(flags)) === 'eth' &&
757
+ isTurboArweave(storageOptions(storageOverrides(flags)));
758
+ // Build the init params for a given deployer. The clone address is deterministic
759
+ // in (factory, salt) — independent of the signer — so the on-chain URIs are baked
760
+ // to point at this node before the tx is signed. owner + royalty receiver are the
761
+ // deployer; mintTo is the deployer (or 0 when --no-mint). A per-deployer salt
762
+ // (`saltFor`) reserves the address to that signer (front-run-proof) unless an
763
+ // explicit --salt is given. With the wallet lane the signer is unknown until they
764
+ // connect, so this runs per-signer.
765
+ const buildForDeployer = async (deployer) => {
766
+ const salt = explicitSalt ?? saltFor(deployer);
767
+ const clone = await predictClone(publicClient, { factory, salt });
768
+ // A baked on-chain image (--onchain-image) replaces prepareContent's image field —
769
+ // the bytes already live on-chain behind the reader, nothing to custody or hash. On a
770
+ // dry run staging is skipped (bakedImage undefined), so don't fall through to the inline
771
+ // path — that would mislabel a reader deploy as INLINE. Note the reader path instead.
772
+ const { tokenFields, contentNote } = bakedImage
773
+ ? { tokenFields: [bakedImage], contentNote: 'content: image staged ON-CHAIN via reader (self-resolving, no custody)' }
774
+ : onchainImage
775
+ ? { tokenFields: [], contentNote: 'content: image would be staged ON-CHAIN via reader (chunk store) — dry run skips the staging write' }
776
+ : await prepareContent(flags.image, clone, storageOverrides(flags), !dryRun, onChainUri, uploadRemoteEth, deployer);
777
+ // Put the description ON-CHAIN (inline) when asked, or always under --onchain-uri
778
+ // (the on-chain renderer can only emit on-chain fields, so an off-chain-only
779
+ // description would simply be omitted). On-chain wins at resolve either way.
780
+ if (flags.description && (onChainUri || flags['description-onchain'])) {
781
+ tokenFields.push({ field: encodeTag(F.description), representation: encodeTag(R.inline), value: toHex(flags.description) });
782
+ }
783
+ // Creator traits on-chain (inline JSON) when --traits-onchain / --onchain-uri; off-chain
784
+ // traits ride in the registration instead (added after deploy, below).
785
+ if (traitsOnchain)
786
+ tokenFields.push(attributesInlineField(traits));
787
+ // Off-chain resolution bakes only the resolver BASE (incl. the route prefix); the
788
+ // contract derives each pointer on-chain as {base}/{chainId}/{address}/{tokenId}. So we
789
+ // never bake an address or tokenId into a string — the contract owns the grammar.
790
+ const params = {
791
+ owner: deployer,
792
+ mintTo: noMint ? zeroAddress : deployer,
793
+ name,
794
+ symbol,
795
+ tokenURIBase: onChainUri && !hasPublicUrl ? '' : `${baseUrl}/t`,
796
+ tokenURIRenderer: renderer,
797
+ contractURIBase: onChainUri && !hasPublicUrl ? '' : `${baseUrl}/c`,
798
+ contractURIRenderer: renderer,
799
+ royaltyReceiver: deployer,
800
+ royaltyBps,
801
+ transferValidator,
802
+ tokenFields,
803
+ // authorship + rights (artist / license / …) are collection-scope on-chain fields.
804
+ contractFields: authorshipContractFields(flags),
805
+ };
806
+ return { clone, params, salt, contentNote };
807
+ };
808
+ let clone;
809
+ let blockNumber;
810
+ if (dryRun) {
811
+ let deployer;
812
+ if (flags.for)
813
+ deployer = flags.for;
814
+ else {
815
+ try {
816
+ deployer = makeWalletClient({ chainKey: CHAIN }).account.address;
817
+ }
818
+ catch {
819
+ throw new Error('dry run needs a deployer address to compute the deterministic deploy address — pass --for 0x.. ' +
820
+ '(a preview signs nothing, so no key is needed). For the REAL deploy with no key in .env, use the ' +
821
+ 'wallet lane: --sign --for 0x.. (you approve in your own wallet).');
822
+ }
823
+ }
824
+ const { clone: predicted, params, salt, contentNote } = await buildForDeployer(deployer);
825
+ info(`deployer ${deployer}`);
826
+ await warnUnfunded(publicClient, deployer); // advisory: fund this before the real deploy
827
+ // B1: the address is a pure function of (factory, salt), and WITHOUT --salt this salt was just
828
+ // freshly randomly reserved (saltFor mixes in entropy) — a plain re-run gets a DIFFERENT one, so
829
+ // the address below is real for THIS preview but not reproducible by habit. Rather than print an
830
+ // address that quietly stops being true, print it ONLY when --salt pinned it (below, unchanged);
831
+ // otherwise the salt itself is the thing to act on — see the block after the readout.
832
+ if (explicitSalt)
833
+ info(`deterministic address: ${predicted}`);
834
+ info(`name "${name}"${flags.name ? '' : dim(' (default — pass --name)')}`);
835
+ info(`symbol ${symbol}${flags.symbol ? '' : dim(' (default — pass --symbol)')}`);
836
+ info(describeDeployField('description', flags.description, onChainUri || !!flags['description-onchain']));
837
+ authorshipReadout(flags).forEach((line) => info(line));
838
+ info(describeTraits(traits, traitsOnchain));
839
+ info(`royalty ${royaltyBps / 100}% → ${deployer}${flags['royalty-bps'] ? '' : dim(' (default 5%)')}`);
840
+ info(contentNote);
841
+ info(`tokenURI base ${params.tokenURIBase || dim('(empty — resolves on-chain via the renderer)')}`);
842
+ // Same reasoning as above: this line derives token #0's URL from `predicted`, so it's only an
843
+ // honest thing to print when that address is actually pinned.
844
+ if (params.tokenURIBase && explicitSalt) {
845
+ info(dim(` ↳ token #0 resolves to ${params.tokenURIBase}/${resolveChain(CHAIN).id}/${predicted.toLowerCase()}/0 (derived on-chain)`));
846
+ }
847
+ info(`contractURI base ${params.contractURIBase || dim('(empty — resolves on-chain via the renderer)')}`);
848
+ info(noMint ? 'mint: deferred (--no-mint)' : `mint: token #0 → ${deployer} at deploy`);
849
+ info(`approvals ${approvals} wallet approval(s)`); // B2 — TX signatures only; storage uploads are named separately above
850
+ if (!explicitSalt) {
851
+ // B1: enforce, don't warn — no predicted address to anchor on, so print the one thing that
852
+ // DOES stay true: the salt itself, pinned via --salt (or `abx predict`) reproduces this exact
853
+ // address on the real deploy.
854
+ console.log(`\n ${bold('salt')} ${g(salt)}`);
855
+ info(`address: pinned by salt — re-run with ${bold(`--salt ${salt}`)} (same address), or ${bold(`abx predict --salt ${salt} --for ${deployer}`)}.`);
856
+ info(`reproduce this exact preview (salt included): ${bold(deployCommandLine(flags, salt))}`);
857
+ }
858
+ // The whole point of the readout: it is the contract. The real deploy writes EXACTLY these
859
+ // values — nothing is added, inferred, or substituted between here and the on-chain tx.
860
+ info(dim('the values above are exactly what a real deploy writes — nothing else is added.'));
861
+ console.log(`\n ${g('dry run')} ${dim('— nothing sent, no bytes stored. Re-run without --dry-run to deploy.')}\n`);
862
+ // A dry run DOES know the address when --salt pinned it (a pure function of factory+salt); without
863
+ // --salt it's null — reporting the freshly-reserved one would be a real-looking value a script
864
+ // could act on that the actual deploy will NOT land at (B1). `saltPinned` still says why.
865
+ emit(jsonSafe({ command: 'deploy', dryRun: true, sent: false, address: explicitSalt ? predicted : null, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt, saltPinned: !!explicitSalt, name, symbol }));
866
+ return;
867
+ }
868
+ // opt-in --confirm: a final y/N before the real send (no-op without --confirm; never blocks scripts)
869
+ await confirmSend(`About to deploy 1/1 "${name}" (${symbol})${flags.image ? ' with your image' : ' (generative demo art)'}; mint: ${noMint ? 'deferred' : 'token #0 at deploy'}; approvals: ${approvals} wallet approval(s); owner+royalty: your wallet @ ${royaltyBps / 100}%.` +
870
+ (transferValidator !== zeroAddress ? ` ERC-721C: enrolled at deploy, permanently (validator ${transferValidator}).` : ''), flags);
871
+ if (lane === 'send') {
872
+ // hot lane: the env key is the deployer, known up front.
873
+ const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
874
+ const { clone: predicted, params, salt, contentNote } = await buildForDeployer(account.address);
875
+ info(`deterministic address: ${predicted}`);
876
+ info(describeDeployField('description', flags.description, onChainUri || !!flags['description-onchain']));
877
+ authorshipReadout(flags).forEach((line) => info(line));
878
+ info(describeTraits(traits, traitsOnchain));
879
+ info(contentNote);
880
+ info(`committing tokenURI base ${params.tokenURIBase || dim('(empty — on-chain renderer)')}`);
881
+ info(`committing contractURI base ${params.contractURIBase || dim('(empty — on-chain renderer)')}`);
882
+ info(noMint ? 'mint: deferred — no token minted at deploy (mint later with `abx mint`)' : `mint: token #0 → ${account.address} at deploy`);
883
+ const send = makeHotSender({ wallet, account, publicClient });
884
+ const r = await deployOneOfOne(send, publicClient, { factory, params, salt });
885
+ clone = predicted;
886
+ blockNumber = r.blockNumber;
887
+ ok(`deployed ${clone}`);
888
+ info(`tx ${explorerBase()}/tx/${r.txHash} (block ${blockNumber})`);
889
+ info(`on-chain content commitment: keccak256 of the served image`);
890
+ }
891
+ else if (lane === 'sign' && onchainImage) {
892
+ // wallet lane + on-chain staging: ONE sign session signs every chunk write AND the deploy.
893
+ // The connecting wallet pays for (and is the deployer of) all of it — staging can't precede
894
+ // the connect here, so it happens inside the session, then the deploy bakes in the manifest.
895
+ // --onchain-image implies on-chain resolution, so NOTHING points at baseUrl — saying it did was
896
+ // a flat contradiction of the line above it (and reintroduced the localhost the lane exists to avoid).
897
+ info('a wallet will become the owner; the token resolves from chain — no URI base is baked in.');
898
+ const session = await openWalletSession({
899
+ chainKey: CHAIN,
900
+ // The wallet that connects BECOMES the owner/mintTo. Pin it to the address this deploy
901
+ // was prepared/previewed for (--for) so a different connected wallet can't silently
902
+ // become the owner — the page + server both refuse a mismatched signer.
903
+ expectedSigner: flags.for,
904
+ total: approvals, // B2 — the SAME staging-tx math the preview's `approvals` line reports
905
+ port: flags.port ? Number(flags.port) : undefined,
906
+ signUrlFile: flags['sign-url-file'],
907
+ });
908
+ let r;
909
+ try {
910
+ const signer = await session.connect();
911
+ // Stage on-chain through the session (sets bakedImage, which buildForDeployer reads below).
912
+ const { field, note } = await stageImageField(flags.image, parseCompress(flags.compress), sessionStagingSender(session));
913
+ bakedImage = field;
914
+ info(note);
915
+ const { clone: predicted, params, salt, contentNote } = await buildForDeployer(signer);
916
+ info(contentNote);
917
+ info(noMint ? 'mint: deferred — mint later with `abx mint`' : `mint: token #0 → ${signer} at deploy`);
918
+ const sent = await session.send(prepareDeployOneOfOne({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted }));
919
+ r = { txHash: sent.txHash, blockNumber: sent.receipt.blockNumber };
920
+ clone = predicted;
921
+ }
922
+ finally {
923
+ session.close();
924
+ }
925
+ blockNumber = r.blockNumber;
926
+ ok(`deployed ${clone}`);
927
+ info(`tx ${explorerBase()}/tx/${r.txHash} (block ${blockNumber})`);
928
+ }
929
+ else if (lane === 'sign' && remoteEthUpload) {
930
+ // wallet lane + Arweave uploads paid by the CONNECTING wallet's Turbo credits (--storage-signer
931
+ // eth + --sign). ONE session signs each upload's data-item (personal_sign, no gas) AND the deploy
932
+ // tx — the wallet's ETH identity holds the credits, so no managed key is funded.
933
+ info(`your wallet will sign the Arweave upload(s) — paid from its Turbo credits — AND the deploy; URIs point at ${baseUrl}`);
934
+ const session = await openWalletSession({
935
+ chainKey: CHAIN,
936
+ expectedSigner: flags.for,
937
+ port: flags.port ? Number(flags.port) : undefined,
938
+ signUrlFile: flags['sign-url-file'],
939
+ });
940
+ let r;
941
+ try {
942
+ const signer = await session.connect();
943
+ // Route Turbo uploads' signatures to this session before building content (which uploads).
944
+ uploadRemoteEth = { address: signer, signMessage: (m) => session.signMessage(m, 'Sign Arweave upload (paid from your Turbo credits)') };
945
+ const { clone: predicted, params, salt, contentNote } = await buildForDeployer(signer);
946
+ info(contentNote);
947
+ info(noMint ? 'mint: deferred — mint later with `abx mint`' : `mint: token #0 → ${signer} at deploy`);
948
+ const sent = await session.send(prepareDeployOneOfOne({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted }));
949
+ r = { txHash: sent.txHash, blockNumber: sent.receipt.blockNumber };
950
+ clone = predicted;
951
+ }
952
+ finally {
953
+ session.close();
954
+ }
955
+ blockNumber = r.blockNumber;
956
+ ok(`deployed ${clone}`);
957
+ info(`tx ${explorerBase()}/tx/${r.txHash} (block ${blockNumber})`);
958
+ }
959
+ else {
960
+ // wallet lane (off-chain custody OR on-chain URI) or cold lane: a single deploy tx, no staging.
961
+ // Only claim a URI base when one is actually written — on the on-chain lane this line used to
962
+ // announce `http://localhost:8787` two lines after promising no localhost anywhere.
963
+ info(onChainUri
964
+ ? 'a wallet will become the owner; the token resolves from chain — no URI base is baked in.'
965
+ : `a wallet will become the owner; URIs point at ${baseUrl}`);
966
+ const result = await signTx(async (signer) => {
967
+ const { clone: predicted, params, salt } = await buildForDeployer(signer);
968
+ return prepareDeployOneOfOne({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
969
+ }, { lane, chainKey: CHAIN, yes: !!flags.yes, expectedSigner: flags.for, port: flags.port ? Number(flags.port) : undefined, signUrlFile: flags['sign-url-file'] });
970
+ if (!result) {
971
+ console.log(`\n${dim(` unsigned — broadcast it, then: abx add <clone> --factory ${factory} --from-block <deployBlock>`)}\n`);
972
+ return;
973
+ }
974
+ clone = result.prepared.fields.clone;
975
+ blockNumber = result.blockNumber;
976
+ ok(`deployed ${clone}`);
977
+ }
978
+ // Emitted the moment the address is known, and again below once minting is settled — so a crash in
979
+ // the indexing steps that follow still yields the address of a contract that really does exist.
980
+ emit(jsonSafe({ command: 'deploy', address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol }));
981
+ step(serveAfter ? 'What the chain knows now' : 'Index it — replay the event spine from chain');
982
+ const indexer = localIndexer();
983
+ // Off-chain traits ride in the registration (on-chain ones are already in the contract fields).
984
+ const offChainTraits = !traitsOnchain && traits.length ? JSON.stringify(traits) : undefined;
985
+ const baseReg = {
986
+ address: clone,
987
+ chainKey: CHAIN,
988
+ fromBlock: blockNumber.toString(),
989
+ factory,
990
+ label: name,
991
+ description: flags.description,
992
+ externalUrl: flags['external-url'],
993
+ attributes: offChainTraits,
994
+ };
995
+ indexer.register(baseReg);
996
+ const { state, elapsedMs } = await reindexAfterDeploy(indexer, clone);
997
+ if (serveAfter) {
998
+ if (state.eventCount > 0)
999
+ ok(`read ${bold(String(state.eventCount))} events straight off ${CHAIN} in ${elapsedMs}ms — no API key, no company's server`);
1000
+ // "verified real" overstated it in the same way the removed trust-anchor step did — factory
1001
+ // provenance is a fact about how this contract was made, not a verdict on whether a token counts
1002
+ // as ABX. Report the fact.
1003
+ info(`it says: "${state.name}" · owned by ${state.owner} · ${state.isCanonical ? g('made by the canonical factory') : dim('not factory-made')}`);
1004
+ info(dim(`optional features switched on: ${state.extensions.map((e) => e.name.replace(/^abx\.extension\./, '')).join(' · ') || 'none'}`));
1005
+ walkthroughSpine(state);
1006
+ }
1007
+ else {
1008
+ if (state.eventCount > 0)
1009
+ ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — no provider involved`);
1010
+ info(`name "${state.name}" · owner ${state.owner} · canonical: ${canonicalLabel(state.isCanonical)}`);
1011
+ info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
1012
+ }
1013
+ // For off-chain custody (image committed as keccak256), resolve the durable locator (ipfs://…)
1014
+ // from this machine's content index and store it on the registration — so the LOCAL resolver
1015
+ // points `image` at IPFS, and `abx add --remote` can ship it to a hosted one (the localhost-image fix).
1016
+ const contentLocators = !onChainUri && !onchainImage
1017
+ ? await collectContentLocators(state, resolveBackend(storageOptions(storageOverrides(flags))))
1018
+ : {};
1019
+ if (Object.keys(contentLocators).length) {
1020
+ indexer.register({ ...baseReg, contentLocators: JSON.stringify(contentLocators) });
1021
+ info(`content locator: image → ${Object.values(contentLocators)[0]} ${dim('(durable; points off this node)')}`);
1022
+ }
1023
+ if (!serveAfter) {
1024
+ if (onChainUri) {
1025
+ // Fully on-chain → it self-resolves. Prove it from the chain, no server/localhost.
1026
+ // BUT only claim "fully on-chain" when the IMAGE is on-chain too. With `--onchain-uri` alone
1027
+ // the image is a keccak256 anchor the renderer can't serve, so tokenURI yields a placeholder —
1028
+ // and this banner previously said "no server or hosting needed" over exactly that, which is a
1029
+ // promise of permanence the token does not have. Truthful, per-config wording instead.
1030
+ if (imageEndsUpOnChain(flags, onchainImage)) {
1031
+ console.log(`\n${g('Done — fully on-chain.')} ${dim('The token self-resolves; no server or hosting needed.')}`);
1032
+ }
1033
+ else if (imageResolvesWithoutServer(flags, onchainImage)) {
1034
+ const bk = backendResolution(storageOverrides(flags)).backend;
1035
+ console.log(`\n${g('Done — metadata on-chain, image on ' + bk + '.')} ${dim('No server to run: the on-chain JSON points straight at the image.')}`);
1036
+ console.log(` ${dim(`the image is addressed by URL rather than stored on-chain — its permanence is ${bk}'s (Arweave: paid once, kept forever · IPFS: kept while pinned).`)}`);
1037
+ }
1038
+ else {
1039
+ console.log(`\n${g('Done — metadata on-chain.')} ${dim('tokenURI/contractURI resolve from the chain with no server.')}`);
1040
+ console.log(` ${c.orange}The IMAGE is not on-chain${c.reset} ${dim('— it is a keccak256 anchor, so tokenURI serves a placeholder image.')}`);
1041
+ console.log(` ${dim(`prove it: ${bold(`abx tokenuri ${clone}`)} reports the image field's provenance. To fix it, redeploy with ${bold('--backend arweave')} (URL baked on-chain) or ${bold('--onchain-image')} (bytes on-chain).`)}`);
1042
+ }
1043
+ console.log(` ${bold(`abx tokenuri ${clone}`)} ${dim('# read tokenURI(0) straight from the contract + decode the JSON')}`);
1044
+ if (noMint)
1045
+ console.log(` ${bold(`abx mint ${clone}`)} ${dim('# issue token #0')}`);
1046
+ console.log(` ${bold(`abx refresh ${clone}`)} ${dim('# nudge marketplaces to index it')}`);
1047
+ console.log(dim(` (once you've confirmed it resolves, ${bold('abx lock-uri')} + ${bold('lock-field')} freeze it forever — see below)\n`));
1048
+ return;
1049
+ }
1050
+ // Off-chain resolution bakes `baseUrl` into the on-chain tokenURI. If that base is a
1051
+ // REMOTE host, the contract was indexed into THIS machine's local store — the remote
1052
+ // resolver is a separate store and won't serve it until told. That gap is the #1
1053
+ // "works for me / unknown project for everyone" footgun, so make the step explicit.
1054
+ const isRemoteBase = !/^https?:\/\/(localhost|127\.0\.0\.1)\b/i.test(baseUrl);
1055
+ if (noMint) {
1056
+ console.log(`\n${g('Deployed — not yet minted.')} The careful path keeps marketplaces warm:`);
1057
+ if (isRemoteBase) {
1058
+ console.log(` ${bold(`abx add ${clone} --remote`)} ${dim(`# REGISTER with the hosted resolver at ${baseUrl} (a local deploy does NOT)`)}`);
1059
+ }
1060
+ else {
1061
+ console.log(` ${bold('abx serve')} ${dim('# stand up the resolver at the address above (LOCAL)')}`);
1062
+ }
1063
+ console.log(` ${bold(`abx verify ${clone}`)} ${dim('# confirm served content matches the chain')}`);
1064
+ console.log(` ${bold(`abx mint ${clone}`)} ${dim('# issue token #0 once metadata is live')}`);
1065
+ console.log(` ${bold(`abx refresh ${clone}`)} ${dim('# nudge marketplaces to index it')}\n`);
1066
+ }
1067
+ else if (isRemoteBase) {
1068
+ console.log(`\n${g('Done — but the hosted resolver must be told about it.')} ${dim(`tokenURI points at ${baseUrl}, a REMOTE store.`)}`);
1069
+ console.log(` ${bold(`abx add ${clone} --remote`)} ${dim('# register + index on the hosted resolver (this local deploy only indexed HERE)')}`);
1070
+ console.log(` ${bold(`abx refresh ${clone}`)} ${dim('# then nudge marketplaces to index it')}\n`);
1071
+ }
1072
+ else {
1073
+ console.log(`\n${g('Done.')} ${bold(`abx verify ${clone}`)} ${dim('to confirm integrity · ')}${bold('abx serve')}${dim(' to host LOCALLY · ')}${bold(`abx refresh ${clone}`)}${dim(' to warm marketplaces')}\n`);
1074
+ }
1075
+ return;
1076
+ }
1077
+ // The demo's teaching sections — see the walkthrough helpers. `deploy` skips them: someone
1078
+ // shipping real work doesn't need their projection deleted to make a point.
1079
+ if (serveAfter)
1080
+ await walkthroughRebuild(indexer, clone, state);
1081
+ // Start the server BEFORE the read-back step (which fetches the served metadata over HTTP), but
1082
+ // print the serve banner after it — otherwise the "Serve" step header lands with nothing under it
1083
+ // while the read-back prints below, which reads like the step failed.
1084
+ const { url } = await startTokenApiServer({ indexer, port, baseUrl, storage: resolveBackend(storageOptions()) });
1085
+ if (serveAfter)
1086
+ await walkthroughReadBack(state, url, onChainUri);
1087
+ // On the on-chain lane this server is a convenience, not infrastructure — say so, or standing one
1088
+ // up as the finale re-teaches the dependency the whole run just disproved.
1089
+ step(onChainUri ? 'Go look at it' : 'Serve the token API + dashboard');
1090
+ if (onChainUri)
1091
+ info(dim('a local viewer, purely for your eyes — your token does not need it. Ctrl-C whenever; the token stays up.'));
1092
+ printServing(url, clone);
1093
+ keepAlive();
1094
+ }
1095
+ // ── deploy-series ─────────────────────────────────────────────────────────────
1096
+ // Point at a directory of media and deploy a multi-token Series. Each file becomes a
1097
+ // token (`0..N-1`, natural-sorted) — a token's metadata is its token id, and tokens mint
1098
+ // strictly in order. Content is hosted exactly like a 1/1, per token — three placements:
1099
+ // • off-chain custody (default): keccak-committed, bytes served by the resolver.
1100
+ // • `--onchain-uri`: inline each token's SVG on-chain (tiny art; SVG only).
1101
+ // • `--onchain-image [--compress fastlz]`: stage each token's bytes (raster or SVG) into the
1102
+ // shared chunk store and bake a per-token `reader` field — multi-chunk on-chain content,
1103
+ // self-resolving via the renderer. Implies on-chain URI resolution.
1104
+ // Mint timing: `--mint-all` / `--mint-count N` / default deferred (deploy → warm → mint).
1105
+ // `--minter` delegates minting; `--primary-payee` declares sale proceeds. Content is
1106
+ // deployer-independent (hashes / inline bytes / chunk manifests don't depend on the clone
1107
+ // address), so it's prepared once; --onchain-image staging runs on the hot or wallet lane
1108
+ // (the cold lane can't stage interactively — same as the 1/1).
1109
+ export const tokenFieldOf = (tokenId, f) => ({
1110
+ tokenId,
1111
+ field: f.field,
1112
+ representation: f.representation,
1113
+ value: f.value,
1114
+ });
1115
+ export async function cmdDeploySeries(flags) {
1116
+ return withJson(flags, async (emit) => cmdDeploySeriesBody(flags, emit));
1117
+ }
1118
+ export async function cmdDeploySeriesBody(flags, emit) {
1119
+ refuseStrayFlags(flags, DEPLOY_SERIES_FLAGS, 'deploy-series');
1120
+ warnSignWithoutFor(flags);
1121
+ const name = flags.name ?? 'ABX Series';
1122
+ const symbol = flags.symbol ?? 'ABXS';
1123
+ const royaltyBps = Number(flags['royalty-bps'] ?? 500);
1124
+ const port = Number(flags.port ?? process.env.ABX_PORT ?? DEFAULT_PORT);
1125
+ const baseUrl = flags['public-base-url'] ?? resolveBaseUrl(port);
1126
+ console.log(bold(`\n ABX Self-Host Toolkit — deploy series\n ${dim('a multi-token drop, served from chain alone')}`));
1127
+ if (!flags.dir) {
1128
+ throw new Error('abx deploy-series --dir <media-dir> [--count N] [--mint-all | --mint-count N | --no-mint] ' +
1129
+ '[--onchain-image [--compress fastlz] | --onchain-uri --backend ipfs|arweave|cloud (image off-chain, JSON on-chain; cloud needs --public-base) | --onchain-uri (inline SVG) | --public-base-url https://… (hosted resolver)] ' +
1130
+ '[--minter 0x..] [--primary-payee 0x..] [--unpaused] [--721c recommended|0x..] --name "Title" --symbol SYM');
1131
+ }
1132
+ const dirPath = resolvePath(flags.dir);
1133
+ const files = readdirSync(dirPath)
1134
+ .filter((f) => !f.startsWith('.') && statSync(joinPath(dirPath, f)).isFile())
1135
+ .sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); // img1,img2,…,img10
1136
+ if (files.length === 0)
1137
+ throw new Error(`no media files in ${dirPath}`);
1138
+ const count = flags.count ? Number(flags.count) : files.length;
1139
+ if (!Number.isInteger(count) || count <= 0)
1140
+ throw new Error('--count must be a positive integer');
1141
+ if (count > files.length)
1142
+ throw new Error(`--count ${count} exceeds the ${files.length} media file(s) in ${dirPath}`);
1143
+ const slots = files.slice(0, count);
1144
+ const dryRun = isDryRun(flags);
1145
+ if (dryRun)
1146
+ assertPreviewDeployer(flags); // fail fast, before the preview does any work (see cmdDeploy)
1147
+ assertRealIdentity(flags, { name, symbol, dryRun });
1148
+ const lane = laneFromFlags(flags);
1149
+ const publicClient = makePublicClient({ chainKey: CHAIN });
1150
+ // Catch a wrong-network RPC with the clear mismatch message even on dry-run (which still reads
1151
+ // the chain to predict the address); tolerate an unreachable RPC so an offline preview still works.
1152
+ await assertChainId(CHAIN, { allowUnreachable: dryRun });
1153
+ // --721c (opt-in ERC-721C): absent → zeroAddress, a plain ERC-721 exactly as before.
1154
+ const transferValidator = await resolveTransferValidatorFlag(flags, publicClient, dryRun);
1155
+ // (identity guard already ran above, via the shared assertRealIdentity — before any RPC)
1156
+ // Mint timing: mint-all → the whole series; mint-count N → the first N; else deferred.
1157
+ const mintCount = flags['mint-all'] !== undefined ? count : flags['mint-count'] ? Number(flags['mint-count']) : 0;
1158
+ if (mintCount > count)
1159
+ throw new Error(`--mint-count ${mintCount} exceeds the series size ${count}`);
1160
+ // --onchain-image stages each slot's bytes in the chunk store (reader field); it implies
1161
+ // on-chain URI resolution (the renderer emits the reader-backed image). --onchain-uri alone
1162
+ // inlines SVG. Either way the URIs self-resolve on-chain.
1163
+ const onchainImage = !!flags['onchain-image'];
1164
+ const onChainUri = !!flags['onchain-uri'] || onchainImage;
1165
+ const compress = parseCompress(flags.compress);
1166
+ // Staging is a SEQUENCE (chunk write(s) → the deploy that references each manifest) where each
1167
+ // receipt feeds the next, so it can't be signed offline in one pass — reject the cold lane
1168
+ // up front (same rule as the 1/1's --onchain-image).
1169
+ if (onchainImage && lane === 'unsigned') {
1170
+ throw new Error('Staging on-chain images (--onchain-image) needs interactive signing — each chunk tx feeds ' +
1171
+ "the next, so it can't run on the cold lane (--unsigned). Use the hot lane (a funded key) or --sign (browser wallet).");
1172
+ }
1173
+ // Whole-collection cost guard: per-file warnings fire during staging/preview, but a set of many
1174
+ // small files can still sum to an expensive on-chain deploy — flag the total once, up front.
1175
+ if (onchainImage) {
1176
+ const totalBytes = slots.reduce((n, s) => n + statSync(joinPath(dirPath, s)).size, 0);
1177
+ if (totalBytes > ONCHAIN_PROJECT_SOFT_LIMIT) {
1178
+ warn(`on-chain collection total is ${Math.round(totalBytes / 1024)}KB across ${slots.length} file(s) — on-chain costs ~200 gas/byte, so this is expensive, not cheaper. ` +
1179
+ `Past ~${Math.round(ONCHAIN_PROJECT_SOFT_LIMIT / 1024)}KB total, prefer off-chain: --backend arweave (pay-once, permanent) or a hosted resolver + IPFS.`);
1180
+ }
1181
+ }
1182
+ const minter = flags.minter ?? zeroAddress;
1183
+ // Default paused=true (safe: public/minter mint closed until the owner opens it); --unpaused
1184
+ // deploys open. Owner reserves still mint at deploy regardless (initialize bypasses the gate).
1185
+ const paused = flags.unpaused === undefined;
1186
+ const primaryPayee = flags['primary-payee'] ?? zeroAddress;
1187
+ const explicitSalt = parseSaltFlag(flags.salt);
1188
+ const hasPublicUrl = !!(flags['public-base-url'] || process.env.ABX_PUBLIC_BASE_URL);
1189
+ step('Trust anchor');
1190
+ let factory;
1191
+ if (dryRun) {
1192
+ const existing = seriesFactoryAddress(flags.factory);
1193
+ if (!existing) {
1194
+ info('no canonical Series factory for this chain yet — a real deploy would deploy the trust anchor first.');
1195
+ console.log(`\n ${g('dry run')} ${dim('— nothing sent.')}\n`);
1196
+ return;
1197
+ }
1198
+ factory = existing;
1199
+ if (!(await previewFactoryLive(publicClient, factory, 'Series factory')))
1200
+ return;
1201
+ info(`would reuse canonical Series factory ${factory}`);
1202
+ }
1203
+ else {
1204
+ factory = await ensureSeriesFactory(flags.factory, !!flags['bootstrap-factory']);
1205
+ }
1206
+ let renderer = zeroAddress;
1207
+ if (onChainUri) {
1208
+ step('On-chain renderer');
1209
+ renderer = dryRun ? (rendererAddress(flags.renderer) ?? zeroAddress) : await ensureRenderer(flags.renderer);
1210
+ }
1211
+ // Off-chain custody bakes the resolver URL on-chain — a localhost URL resolves for no one.
1212
+ // (Not a concern with --onchain-uri: the renderer is authoritative and resolves from chain.)
1213
+ if (!onChainUri) {
1214
+ const loopback = loopbackBaseUrl(baseUrl);
1215
+ if (!hasPublicUrl || loopback) {
1216
+ const devAllow = process.env.ABX_DEV_ALLOW_LOCALHOST_URI === '1';
1217
+ const msg = `Off-chain metadata needs a PUBLIC resolver URL baked on-chain — ${hasPublicUrl ? baseUrl : 'localhost'} resolves for no one. ` +
1218
+ `Pick a real path:\n • Fully on-chain (SVG art): --onchain-uri\n • Fully on-chain (any media): --onchain-image --compress fastlz\n • Hosted resolver: --public-base-url https://your.domain`;
1219
+ if (!devAllow) {
1220
+ if (dryRun)
1221
+ warn(`would REFUSE to deploy — ${msg}`);
1222
+ else
1223
+ throw new Error(msg);
1224
+ }
1225
+ else {
1226
+ warn(`DEV ONLY (ABX_DEV_ALLOW_LOCALHOST_URI): baking ${bold(baseUrl)} on-chain — resolves only on THIS machine.`);
1227
+ }
1228
+ }
1229
+ }
1230
+ step(`Prepare ${count} token(s) from ${basename(dirPath)}/`);
1231
+ const overrides = storageOverrides(flags);
1232
+ const opts = storageOptions(overrides);
1233
+ const backendId = overrides.backend ?? process.env.ABX_STORAGE_BACKEND ?? 'fs';
1234
+ // Image custody × URI resolution (see reference/hosting.md):
1235
+ // - --onchain-image → chunk store (on-chain bytes), JSON on-chain
1236
+ // - --onchain-uri + a durable backend → image OFF-CHAIN (ipfs/arweave), JSON on-chain via the
1237
+ // (ipfs/arweave) renderer: ONE collection `url-template` when files share
1238
+ // an extension (O(1) directory), else per-token `url` (O(N))
1239
+ // - --onchain-uri alone → inline SVG on-chain
1240
+ // - neither → off-chain custody (keccak), served by a hosted resolver
1241
+ // Backends that expose a PUBLIC direct URL (so the on-chain renderer can point at them): ipfs +
1242
+ // arweave (content-addressed, permanent) and cloud/S3 (needs a public base / CDN; centralized).
1243
+ const directUrlBackend = backendId === 'ipfs' || backendId === 'arweave' || backendId === 'cloud';
1244
+ const offchainImageOnchainJson = onChainUri && !onchainImage && directUrlBackend;
1245
+ const backend = (offchainImageOnchainJson || !onChainUri) && !dryRun ? resolveBackend(opts) : undefined;
1246
+ const tokenPaths = slots.map((s, i) => ({ tokenId: i, name: s, path: joinPath(dirPath, s) }));
1247
+ // Wallet-signature count for THIS deploy — the same per-token chunk math that sizes the
1248
+ // wallet-lane session `total` below (B2), computed ONCE so preview/confirm text and the real
1249
+ // session can never disagree. TX signatures only — see the 1/1's `approvals` for the same note
1250
+ // on why an Arweave message signature doesn't add to this count.
1251
+ const approvals = onchainImage
1252
+ ? tokenPaths.reduce((n, { path }) => {
1253
+ const plan = computeContentPlan(readFileSync(path), compress).plan;
1254
+ return n + (plan.mode === 'single' ? 1 : plan.txCount);
1255
+ }, 0) + 1 // + the deploy tx
1256
+ : 1;
1257
+ // Wallet-lane Arweave uploads paid by the connecting wallet's Turbo credits (`--storage-signer eth`
1258
+ // + `--sign`). The identity + uploads are deferred to the sign session (after connect), so skip the
1259
+ // early managed-key creation + funds guard and the up-front buildFields; the session branch runs them.
1260
+ const remoteEthUpload = lane === 'sign' && !onchainImage && !!backend && storageSignerChoice(overrides) === 'eth' && isTurboArweave(opts);
1261
+ if (backend && !remoteEthUpload) {
1262
+ ensureArweaveIdentityForUpload(opts); // Turbo identity, created on first upload
1263
+ // The deployer wallet (--for, else the .env key's address) — checked for existing Turbo credits
1264
+ // if the managed key is short, so we recommend `--storage-signer eth` before any top-up.
1265
+ let guardWallet = flags.for;
1266
+ if (!guardWallet)
1267
+ try {
1268
+ guardWallet = makeWalletClient({ chainKey: CHAIN }).account.address;
1269
+ }
1270
+ catch { /* no key — skip */ }
1271
+ await assertTurboFundsForUpload(opts, tokenPaths.map(({ path }) => statSync(path).size), guardWallet); // stop before deploy if short
1272
+ }
1273
+ // Fields are deployer-independent (content hashes / inline bytes / chunk manifests / directory
1274
+ // locators don't depend on the clone address), so they're built once. `tokenFields` are per-token;
1275
+ // `contractFields` carry the collection-scope image (the O(1) directory template). `stage` is
1276
+ // required only for --onchain-image (chunk-store writes) and runs per-lane below.
1277
+ let tokenFields = [];
1278
+ let contractFields = [];
1279
+ // Per-token marketplace traits (--attributes). Lane-aware, mirroring the 1/1: traits go ON-CHAIN
1280
+ // (inline tokenFields) when the token resolves on-chain (or --traits-onchain), else OFF-CHAIN
1281
+ // operator metadata (per-token, resolver-served + editable) — so a Series has the 1/1's parity.
1282
+ const seriesTraits = parseSeriesTraits(flags.attributes ? readFileSync(resolvePath(process.cwd(), flags.attributes), 'utf8') : undefined, slots);
1283
+ const seriesTraitsOnchain = seriesTraits.size > 0 && (!!flags['traits-onchain'] || onChainUri);
1284
+ // Off-chain lane: serialize `{ "<tokenId>": attrs }` for the registration (the resolver stitches it).
1285
+ const offChainTokenTraits = seriesTraits.size && !seriesTraitsOnchain
1286
+ ? JSON.stringify(Object.fromEntries([...seriesTraits].map(([id, a]) => [String(id), a])))
1287
+ : undefined;
1288
+ const buildImageFields = async (stage) => {
1289
+ // --onchain-image: stage every token's bytes into ONE shared chunk store, each a `reader` field.
1290
+ if (onchainImage) {
1291
+ const { fields } = await stageImageFieldsBatch(tokenPaths.map((s) => s.path), compress, stage, flags['chunk-store']);
1292
+ tokenFields = tokenPaths.map(({ tokenId }, i) => tokenFieldOf(tokenId, fields[i]));
1293
+ return;
1294
+ }
1295
+ // --onchain-uri + durable backend: image lives off-chain (ipfs/arweave); JSON renders on-chain.
1296
+ if (offchainImageOnchainJson) {
1297
+ const exts = tokenPaths.map(({ path }) => extname(path).toLowerCase());
1298
+ const uniform = exts[0] !== '' && exts.every((e) => e === exts[0]);
1299
+ if (dryRun || !backend) {
1300
+ if (uniform) {
1301
+ contractFields = [imageUrlTemplateField(`<${backendId}-dir>/{id}${exts[0]}`)];
1302
+ info(`would upload ${tokenPaths.length} file(s) as ONE ${backendId} directory → collection image url-template <${backendId}-dir>/{id}${exts[0]} (O(1))`);
1303
+ }
1304
+ else {
1305
+ tokenFields = tokenPaths.map(({ tokenId }) => tokenFieldOf(tokenId, imageUrlField(`<${backendId}-url-${tokenId}>`)));
1306
+ info(`would upload ${tokenPaths.length} file(s) to ${backendId} → per-token url image fields (mixed extensions, O(N))`);
1307
+ }
1308
+ return;
1309
+ }
1310
+ if (uniform && backend.putDirectory) {
1311
+ // O(1) directory-base: upload the folder renamed to {id}{ext}; ONE collection url-template.
1312
+ const entries = tokenPaths.map(({ tokenId, path }) => ({ name: `${tokenId}${exts[0]}`, bytes: new Uint8Array(readFileSync(path)), contentType: contentTypeFromPath(path) }));
1313
+ const { base } = await backend.putDirectory(entries);
1314
+ const template = `${base}/{id}${exts[0]}`;
1315
+ contractFields = [imageUrlTemplateField(template)];
1316
+ info(`uploaded ${entries.length} file(s) as one ${backendId} directory`);
1317
+ info(`collection image (url-template): ${template} ${dim('— one field covers every token (O(1))')}`);
1318
+ }
1319
+ else {
1320
+ // mixed extensions (or no directory support): per-token url fields (O(N)).
1321
+ for (const { tokenId, name: fname, path } of tokenPaths) {
1322
+ const bytes = new Uint8Array(readFileSync(path));
1323
+ const hash = hashContent(bytes);
1324
+ await backend.put(hash, { bytes, contentType: contentTypeFromPath(path) });
1325
+ const locator = (await backend.locator?.(hash)) ?? '';
1326
+ if (!locator)
1327
+ throw new Error(`backend ${backendId} returned no public locator for token ${tokenId} — a durable backend (ipfs/arweave) is required for --onchain-uri image hosting`);
1328
+ tokenFields.push(tokenFieldOf(tokenId, imageUrlField(locator)));
1329
+ info(`token ${tokenId} ← ${fname} → ${locator}`);
1330
+ }
1331
+ info(`mixed file extensions → per-token url fields (O(N)); a uniform extension enables the O(1) directory template`);
1332
+ }
1333
+ return;
1334
+ }
1335
+ // --onchain-uri alone (inline SVG) or off-chain custody (keccak, hosted resolver).
1336
+ for (const { tokenId, name: fname, path } of tokenPaths) {
1337
+ const bytes = new Uint8Array(readFileSync(path));
1338
+ const contentType = contentTypeFromPath(path);
1339
+ if (onChainUri) {
1340
+ const text = Buffer.from(bytes).toString('utf8');
1341
+ if (!looksLikeSvg(text)) {
1342
+ throw new Error(`--onchain-uri inlines SVG only; token ${tokenId} "${fname}" is ${contentType}. ` +
1343
+ `Use --onchain-image (on-chain bytes), or --backend ipfs|arweave (image off-chain, JSON on-chain), or drop --onchain-uri to host off-chain.`);
1344
+ }
1345
+ tokenFields.push(tokenFieldOf(tokenId, imageInlineField(text)));
1346
+ }
1347
+ else {
1348
+ const hash = hashContent(bytes);
1349
+ if (backend)
1350
+ await backend.put(hash, { bytes, contentType });
1351
+ tokenFields.push(tokenFieldOf(tokenId, imageKeccakField(hash)));
1352
+ }
1353
+ info(`token ${tokenId} ← ${fname} ${dim(`(${bytes.length}B ${contentType})`)}`);
1354
+ }
1355
+ };
1356
+ // Build the image fields (per lane), THEN append per-token attributes fields — so traits ride the
1357
+ // same deploy in every custody lane. (A huge series should instead set these post-deploy via
1358
+ // set-field under a gas budget; deploy-time inline suits the small/medium collections deploy-series targets.)
1359
+ const buildFields = async (stage) => {
1360
+ await buildImageFields(stage);
1361
+ // On-chain lane only: inline per-token attributes ride the deploy. (Off-chain traits go to the
1362
+ // registration via offChainTokenTraits — see the baseReg below — so nothing rides the tx.)
1363
+ if (seriesTraitsOnchain) {
1364
+ for (const [tokenId, attrs] of seriesTraits)
1365
+ tokenFields.push(tokenFieldOf(tokenId, attributesInlineField(attrs)));
1366
+ }
1367
+ };
1368
+ // --onchain-image staging is deferred to the lane branch (hot: env key up front; wallet: inside
1369
+ // the sign session). Every other mode is lane-independent, so build it now.
1370
+ // opt-in --confirm: a final y/N before ANY upload or send (no-op without --confirm; never blocks scripts)
1371
+ if (!dryRun) {
1372
+ const custody = onchainImage
1373
+ ? 'image on-chain (chunk store)'
1374
+ : offchainImageOnchainJson
1375
+ ? `image off-chain on ${backendId} + on-chain renderer`
1376
+ : onChainUri
1377
+ ? 'inline SVG on-chain'
1378
+ : `off-chain custody → ${baseUrl}`;
1379
+ await confirmSend(`About to deploy Series "${name}" (${symbol}) — ${count} token(s); ${custody}; mint: ${mintCount > 0 ? `${mintCount} at deploy` : 'deferred'}; approvals: ${approvals} wallet approval(s); owner+royalty: your wallet @ ${royaltyBps / 100}%.` +
1380
+ (transferValidator !== zeroAddress ? ` ERC-721C: enrolled at deploy, permanently (validator ${transferValidator}).` : ''), flags);
1381
+ }
1382
+ if (!onchainImage && !remoteEthUpload)
1383
+ await buildFields();
1384
+ // Assemble the init params for a given deployer (owner/royalty/mintTo = deployer). The clone
1385
+ // address is deterministic in (factory, salt) — so URIs are baked before the tx is signed.
1386
+ const buildForDeployer = async (deployer) => {
1387
+ const salt = explicitSalt ?? saltFor(deployer);
1388
+ const clone = await predictClone(publicClient, { factory, salt });
1389
+ const params = {
1390
+ owner: deployer,
1391
+ name,
1392
+ symbol,
1393
+ tokenURIBase: onChainUri && !hasPublicUrl ? '' : `${baseUrl}/t`,
1394
+ tokenURIRenderer: renderer,
1395
+ contractURIBase: onChainUri && !hasPublicUrl ? '' : `${baseUrl}/c`,
1396
+ contractURIRenderer: renderer,
1397
+ royaltyReceiver: deployer,
1398
+ royaltyBps,
1399
+ transferValidator,
1400
+ maxInvocations: count,
1401
+ primaryPayee,
1402
+ minter,
1403
+ paused,
1404
+ mintTo: mintCount > 0 ? deployer : zeroAddress,
1405
+ mintCount,
1406
+ tokenFields,
1407
+ // image contract fields (the O(1) url-template) + authorship/rights (artist / license / …).
1408
+ contractFields: [...contractFields, ...authorshipContractFields(flags)],
1409
+ };
1410
+ return { clone, params, salt };
1411
+ };
1412
+ step(`Deploy a ${count}-token Series to ${CHAIN}`);
1413
+ if (dryRun) {
1414
+ let deployer;
1415
+ if (flags.for)
1416
+ deployer = flags.for;
1417
+ else {
1418
+ try {
1419
+ deployer = makeWalletClient({ chainKey: CHAIN }).account.address;
1420
+ }
1421
+ catch {
1422
+ throw new Error('dry run needs a deployer address to compute the deterministic deploy address — pass --for 0x.. ' +
1423
+ '(a preview signs nothing, so no key is needed). For the REAL deploy with no key in .env, use the ' +
1424
+ 'wallet lane: --sign --for 0x.. (you approve in your own wallet).');
1425
+ }
1426
+ }
1427
+ const { clone: predicted, params, salt } = await buildForDeployer(deployer);
1428
+ info(`deployer ${deployer}`);
1429
+ // B1: without --salt this salt was just freshly randomly reserved — a plain re-run gets a
1430
+ // DIFFERENT one, so only print the address when --salt pinned it (see the block after the
1431
+ // readout for the no-salt case).
1432
+ if (explicitSalt)
1433
+ info(`deterministic address: ${predicted}`);
1434
+ info(`name "${name}" · symbol ${symbol} · size ${count} · royalty ${royaltyBps / 100}%`);
1435
+ authorshipReadout(flags).forEach((line) => info(line));
1436
+ info(`mint: ${mintCount > 0 ? `${mintCount} token(s) in order → ${deployer} at deploy` : 'deferred (mint later / external minter)'}`);
1437
+ if (minter !== zeroAddress)
1438
+ info(`authorized minter: ${minter}`);
1439
+ if (primaryPayee !== zeroAddress)
1440
+ info(`primary payee: ${primaryPayee}`);
1441
+ info(paused ? 'paused: yes — public/minter mint closed until `abx unpause` (owner reserves still mint)' : 'paused: no — mint open at deploy');
1442
+ info(onchainImage
1443
+ ? 'resolution: ON-CHAIN via the renderer (chunked reader per token — image bytes on-chain)'
1444
+ : offchainImageOnchainJson
1445
+ ? `resolution: ON-CHAIN via the renderer; image OFF-CHAIN on ${backendId} (${contractFields.length ? 'one collection url-template, O(1)' : 'per-token url, O(N)'})`
1446
+ : onChainUri
1447
+ ? 'resolution: ON-CHAIN via the renderer (inline SVG per token)'
1448
+ : `resolution: off-chain, base ${params.tokenURIBase}`);
1449
+ info(onchainImage
1450
+ ? `transactions: on-chain image staging (≥1 tx per file, per-token detail below) + 1 deploy${mintCount ? ' (mints the reserve)' : ''}`
1451
+ : `transactions: 1 — the deploy${mintCount ? ' (mints the reserve at deploy)' : ''}${offchainImageOnchainJson ? ` · your ${count} image(s) upload to ${backendId} FIRST, with NO wallet signature (an upload is not a tx)` : ''}`);
1452
+ if (flags.attributes) {
1453
+ info(seriesTraits.size
1454
+ ? `traits: per-token ${seriesTraitsOnchain ? 'ON-CHAIN (inline, rides the deploy)' : 'off-chain (operator metadata — resolver-served + editable later via `abx add --attributes`)'} for ${seriesTraits.size}/${count} token(s) (from --attributes)`
1455
+ : `⚠ traits: --attributes matched NO token — keys must be a token-id index, a filename, or a token-id string (see \`abx help deploy-series\`)`);
1456
+ }
1457
+ if (onchainImage) {
1458
+ for (const { tokenId, name: fname, path } of tokenPaths) {
1459
+ info(`token ${tokenId} ← ${fname}: ${previewImageStaging(path, compress)}`);
1460
+ }
1461
+ }
1462
+ info(`approvals ${approvals} wallet approval(s)`); // B2 — TX signatures only
1463
+ // storage readiness — what a REAL deploy needs (Arweave credits / Pinata JWT / cloud public base)
1464
+ if (offchainImageOnchainJson || (!onChainUri && !onchainImage)) {
1465
+ await noteStorageReadiness(opts, tokenPaths.map(({ path }) => statSync(path).size));
1466
+ }
1467
+ // B1: enforce, don't warn — without --salt there is no predicted address to reproduce (see
1468
+ // above), so print the salt itself, prominently, plus how to pin it.
1469
+ if (!explicitSalt) {
1470
+ console.log(`\n ${bold('salt')} ${g(salt)}`);
1471
+ info(`address: pinned by salt — re-run with ${bold(`--salt ${salt}`)} (same address), or ${bold(`abx predict --dir ${flags.dir} --salt ${salt} --for ${deployer}`)}.`);
1472
+ info(`reproduce this exact preview (salt included): ${bold(deploySeriesCommandLine(flags, salt))}`);
1473
+ }
1474
+ console.log(`\n ${g('dry run')} ${dim('— nothing sent, no bytes stored.')}\n`);
1475
+ // Without --salt, `address` would be a freshly-reserved value a real deploy will NOT land at —
1476
+ // report null rather than a real-looking value a script could wrongly act on (B1).
1477
+ emit(jsonSafe({ command: 'deploy-series', dryRun: true, sent: false, address: explicitSalt ? predicted : null, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt, saltPinned: !!explicitSalt, name, symbol }));
1478
+ return;
1479
+ }
1480
+ let clone;
1481
+ let blockNumber;
1482
+ if (lane === 'send') {
1483
+ // hot lane: the env key stages every token up front (deployer-independent), then deploys.
1484
+ const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
1485
+ if (onchainImage)
1486
+ await buildFields(envStagingSender());
1487
+ const { clone: predicted, params, salt } = await buildForDeployer(account.address);
1488
+ info(`deterministic address: ${predicted}`);
1489
+ info(`mint: ${mintCount > 0 ? `${mintCount} token(s) → ${account.address} at deploy` : 'deferred'}`);
1490
+ const send = makeHotSender({ wallet, account, publicClient });
1491
+ const r = await deploySeries(send, publicClient, { factory, params, salt });
1492
+ clone = predicted;
1493
+ blockNumber = r.blockNumber;
1494
+ ok(`deployed ${clone}`);
1495
+ info(`tx ${explorerBase()}/tx/${r.txHash} (block ${blockNumber})`);
1496
+ }
1497
+ else if (lane === 'sign' && onchainImage) {
1498
+ // wallet lane + on-chain staging: ONE session signs every chunk write across all tokens AND
1499
+ // the deploy. The connecting wallet pays for (and owns) it; staging can't precede the connect
1500
+ // here, so it runs inside the session, then the deploy bakes in every token's manifest.
1501
+ info(`a wallet will become the owner; it will approve ${approvals - 1} staging tx(s) + the deploy in one session.`);
1502
+ const session = await openWalletSession({
1503
+ chainKey: CHAIN,
1504
+ expectedSigner: flags.for,
1505
+ total: approvals, // B2 — the SAME staging-tx math the preview's `approvals` line reports
1506
+ port: flags.port ? Number(flags.port) : undefined,
1507
+ signUrlFile: flags['sign-url-file'],
1508
+ });
1509
+ try {
1510
+ const signer = await session.connect();
1511
+ if (onchainImage)
1512
+ await buildFields(sessionStagingSender(session));
1513
+ const { clone: predicted, params, salt } = await buildForDeployer(signer);
1514
+ const sent = await session.send(prepareDeploySeries({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted }));
1515
+ clone = predicted;
1516
+ blockNumber = sent.receipt.blockNumber;
1517
+ }
1518
+ finally {
1519
+ session.close();
1520
+ }
1521
+ ok(`deployed ${clone}`);
1522
+ }
1523
+ else if (lane === 'sign' && remoteEthUpload) {
1524
+ // wallet lane + Arweave uploads paid by the CONNECTING wallet's Turbo credits: ONE session signs
1525
+ // each token's upload data-item (personal_sign, no gas) AND the deploy tx. Uploads are deferred to
1526
+ // here so they route through the connected wallet (its ETH identity holds the credits).
1527
+ info(`your wallet will sign ${tokenPaths.length} Arweave upload(s) — paid from its Turbo credits — plus the deploy, in one session.`);
1528
+ const session = await openWalletSession({
1529
+ chainKey: CHAIN,
1530
+ expectedSigner: flags.for,
1531
+ port: flags.port ? Number(flags.port) : undefined,
1532
+ signUrlFile: flags['sign-url-file'],
1533
+ });
1534
+ try {
1535
+ const signer = await session.connect();
1536
+ // Route Turbo uploads through the wallet (mutate the SAME opts.arweave the backend captured).
1537
+ if (opts.arweave)
1538
+ opts.arweave.remoteEth = { address: signer, signMessage: (m) => session.signMessage(m, 'Sign Arweave upload (paid from your Turbo credits)') };
1539
+ await buildFields();
1540
+ const { clone: predicted, params, salt } = await buildForDeployer(signer);
1541
+ const sent = await session.send(prepareDeploySeries({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted }));
1542
+ clone = predicted;
1543
+ blockNumber = sent.receipt.blockNumber;
1544
+ }
1545
+ finally {
1546
+ session.close();
1547
+ }
1548
+ ok(`deployed ${clone}`);
1549
+ }
1550
+ else {
1551
+ // wallet lane (off-chain / inline) or cold lane: a single deploy tx, no staging sequence —
1552
+ // tokenFields is already built. Same correction as the 1/1 lane: only claim a URI base when one
1553
+ // is actually written, or an --onchain-uri Series announces a localhost it never bakes.
1554
+ info(onChainUri
1555
+ ? 'a wallet will become the owner; tokens resolve from chain — no URI base is baked in.'
1556
+ : `a wallet will become the owner; URIs point at ${baseUrl}`);
1557
+ const result = await signTx(async (signer) => {
1558
+ const { clone: predicted, params, salt } = await buildForDeployer(signer);
1559
+ return prepareDeploySeries({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
1560
+ }, { lane, chainKey: CHAIN, yes: !!flags.yes, expectedSigner: flags.for, port: flags.port ? Number(flags.port) : undefined, signUrlFile: flags['sign-url-file'] });
1561
+ if (!result) {
1562
+ console.log(`\n${dim(` unsigned — broadcast it, then: abx add <clone> --factory ${factory} --from-block <deployBlock>`)}\n`);
1563
+ return;
1564
+ }
1565
+ clone = result.prepared.fields.clone;
1566
+ blockNumber = result.blockNumber;
1567
+ ok(`deployed ${clone}`);
1568
+ }
1569
+ emit(jsonSafe({ command: 'deploy-series', address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol }));
1570
+ step('Index it — replay the event spine from chain');
1571
+ const indexer = localIndexer();
1572
+ const baseReg = {
1573
+ address: clone,
1574
+ chainKey: CHAIN,
1575
+ fromBlock: blockNumber.toString(),
1576
+ factory,
1577
+ label: name,
1578
+ description: flags.description,
1579
+ externalUrl: flags['external-url'],
1580
+ // Off-chain per-token traits (resolver-served + editable). On-chain traits already rode the deploy
1581
+ // (tokenFields); undefined here when they did, or when there are none.
1582
+ tokenAttributes: offChainTokenTraits,
1583
+ };
1584
+ indexer.register(baseReg);
1585
+ const { state, elapsedMs } = await reindexAfterDeploy(indexer, clone);
1586
+ if (state.eventCount > 0)
1587
+ ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — ${state.tokens.length} token(s), max ${state.maxInvocations}`);
1588
+ info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
1589
+ // Off-chain custody: bridge each token's keccak → durable locator so the resolver (local and,
1590
+ // via `abx add --remote`, a hosted one) points images off this node.
1591
+ if (!onChainUri) {
1592
+ const contentLocators = await collectContentLocators(state, resolveBackend(opts));
1593
+ if (Object.keys(contentLocators).length) {
1594
+ indexer.register({ ...baseReg, contentLocators: JSON.stringify(contentLocators) });
1595
+ info(`content locators: ${Object.keys(contentLocators).length} token image(s) bridged off-node`);
1596
+ }
1597
+ }
1598
+ // Fixed supply: `--mint-all` (mintCount == count) leaves nothing for anyone else to mint, so the
1599
+ // collection is COMPLETE — suppress the `mint`/`unpause` next-steps (unpausing an exhausted supply
1600
+ // does nothing; suggesting it reads as "unfinished" and misleads).
1601
+ const fullyMinted = mintCount >= count;
1602
+ const completeNote = ` ${dim(`All ${count} token(s) minted — collection complete (fixed supply; nothing left to mint, no unpause needed).`)}`;
1603
+ if (onChainUri) {
1604
+ console.log(`\n${g('Done — fully on-chain.')} ${dim('Every token self-resolves; no server needed.')}`);
1605
+ console.log(` ${bold(`abx tokenuri ${clone}`)} ${dim('# read tokenURI(0) straight from the contract')}`);
1606
+ if (fullyMinted) {
1607
+ console.log(completeNote);
1608
+ }
1609
+ else {
1610
+ console.log(` ${bold(`abx mint ${clone}`)} ${dim('# mint the next token in order (or --count N)')}`);
1611
+ if (paused)
1612
+ console.log(` ${bold(`abx unpause ${clone}`)} ${dim('# open the mint to your minter/public (owner can mint while paused)')}`);
1613
+ }
1614
+ console.log(` ${bold(`abx refresh ${clone}`)} ${dim('# nudge marketplaces')}\n`);
1615
+ return;
1616
+ }
1617
+ const isRemoteBase = !/^https?:\/\/(localhost|127\.0\.0\.1)\b/i.test(baseUrl);
1618
+ console.log(`\n${g('Series deployed.')} ${mintCount > 0 ? '' : dim('(deferred mint — warm the resolver, then mint)')}`);
1619
+ if (isRemoteBase)
1620
+ console.log(` ${bold(`abx add ${clone} --remote`)} ${dim('# register with the hosted resolver (a local deploy does NOT)')}`);
1621
+ else
1622
+ console.log(` ${bold('abx serve')} ${dim('# stand up the resolver locally')}`);
1623
+ if (fullyMinted) {
1624
+ console.log(completeNote);
1625
+ }
1626
+ else {
1627
+ console.log(` ${bold(`abx mint ${clone}`)} ${dim('· mint the next token in order (or --count N)')}`);
1628
+ if (paused)
1629
+ console.log(` ${bold(`abx unpause ${clone}`)} ${dim('# open the mint to your minter/public (owner can mint while paused)')}`);
1630
+ }
1631
+ console.log(` ${bold(`abx refresh ${clone}`)} ${dim('# nudge marketplaces once metadata is live')}\n`);
1632
+ }
1633
+ // ── deploy-code ──────────────────────────────────────────────────────────────
1634
+ /**
1635
+ * `abx deploy-code` — deploy a code project ({SeriesCode}) end to end, hot lane:
1636
+ * the canonical factory + seed source from the manifest, fat initialize (the `code`
1637
+ * field rides it in directory mode), then ONE post-deploy multicall for the script
1638
+ * chunks (template mode) + any PostParam schemas, optional reserve mints, and local
1639
+ * register + index. Grown directly from the Sepolia drop rehearsal
1640
+ * (`packages/cli/scripts/code-drop-rehearsal.ts`).
1641
+ *
1642
+ * Modes (exactly one):
1643
+ * --script <file> template mode — the program stored on-chain in chunks
1644
+ * --code-dir <dir> directory mode — the build uploaded via the storage backend
1645
+ * (`putDirectory`; ipfs/arweave), its root as the `code` field
1646
+ *
1647
+ * Schemas: --schema key:Type:Auth[,key:Type:Auth…] (e.g. palette:HexColor:TokenOwner).
1648
+ * Seeds: canonical randomizer by default; --no-seed opts out.
1649
+ */
1650
+ // ── the demo walkthrough: teaching sections, demo-only ────────────────────────
1651
+ //
1652
+ // `abx demo` is a TEACHING command, not a shortcut — the docs point a first-time reader here to
1653
+ // learn what the toolkit does on their behalf. Its old form asserted the interesting claims
1654
+ // ("reconstructed 9 events — no provider involved") without ever showing them, which made it a
1655
+ // smoke test wearing a demo's clothes. These sections demonstrate instead: print the spine the
1656
+ // chain now holds, throw the local projection away and rebuild it, then read the token back the way
1657
+ // a marketplace would. They run only for `demo` (never `deploy`), and never pause — an agent or CI
1658
+ // run has to behave identically.
1659
+ /**
1660
+ * The deterministic part of a projection: everything that is a pure function of the chain.
1661
+ * Deliberately EXCLUDES `reconstructedAt`, `rpcUrl` and `toBlock` — a timestamp, the endpoint that
1662
+ * happened to answer, and the head at scan time all legitimately differ between two replays, so
1663
+ * folding them in would make the rebuild proof fail for reasons that aren't about correctness.
1664
+ */
1665
+ export function projectionFingerprint(s) {
1666
+ const canonical = {
1667
+ address: s.address.toLowerCase(),
1668
+ name: s.name,
1669
+ symbol: s.symbol,
1670
+ owner: s.owner?.toLowerCase() ?? null,
1671
+ isCanonical: s.isCanonical,
1672
+ deployBlock: s.deployBlock,
1673
+ eventCount: s.eventCount,
1674
+ royalty: s.royalty ? { bps: s.royalty.bps, receiver: s.royalty.receiver.toLowerCase() } : null,
1675
+ extensions: s.extensions.map((e) => e.name).sort(),
1676
+ collectionFields: s.collectionFields.map((f) => `${f.field}=${f.value}`).sort(),
1677
+ tokens: s.tokens.map((t) => ({ id: t.tokenId, minted: t.minted, owner: t.owner?.toLowerCase() ?? null })),
1678
+ events: s.events.map((e) => `${e.blockNumber}:${e.logIndex}:${e.name}`),
1679
+ };
1680
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
1681
+ }
1682
+ /**
1683
+ * Print the reconstructed spine — the point being that this list IS the database. Rendered inside
1684
+ * the index step (no header of its own: "Index it — replay the event spine" immediately followed by
1685
+ * a separate "The event spine" step read as a stutter).
1686
+ */
1687
+ export function walkthroughSpine(state) {
1688
+ if (state.events.length === 0) {
1689
+ warn('no events to show (the index came back empty — see the recovery hint above).');
1690
+ return;
1691
+ }
1692
+ const width = Math.max(...state.events.map((e) => e.name.length));
1693
+ state.events.forEach((e, i) => {
1694
+ // ERC vs ABX register: standard ERC-721 events a marketplace already understands, versus ABX's
1695
+ // own. Worth surfacing — it's why an ABX token indexes fine on tools that know nothing about ABX.
1696
+ const reg = e.register === 2 ? p('ABX') : dim('ERC');
1697
+ console.log(` ${dim(`#${String(i + 1).padStart(2)}`)} ${reg} ${e.name.padEnd(width)} ${dim(e.what)}`);
1698
+ });
1699
+ console.log(` ${dim('→')} those ${bold(String(state.events.length))} lines ${bold('are')} the database. ` +
1700
+ dim('There is no other copy that counts — not ours, not anyone\'s.'));
1701
+ console.log(` ${p('ABX')} ${dim('= ABX\'s own events')} ${dim('ERC')} ${dim('= bog-standard ERC-721/7572, which is why wallets and marketplaces that have never heard of ABX still show your token.')}`);
1702
+ }
1703
+ /**
1704
+ * [4] The claim, demonstrated: delete the local projection and rebuild it from the chain.
1705
+ *
1706
+ * This is the one step that can't be faked by good output — it drops the projection for real
1707
+ * (registration kept), confirms it's gone, replays from the deploy block, and compares a
1708
+ * fingerprint of everything chain-derived. If ABX's premise is wrong, this step fails loudly.
1709
+ */
1710
+ export async function walkthroughRebuild(indexer, address, before) {
1711
+ step('The moment of truth · delete it all');
1712
+ const fpBefore = projectionFingerprint(before);
1713
+ indexer.dropProjection(address);
1714
+ const gone = indexer.getProject(address) === null;
1715
+ console.log(` ${dim('wiping this computer\'s copy …')} ${gone ? g('gone. nothing left locally.') : `${c.orange}⚠ still present${c.reset}`}`);
1716
+ const { state: after, elapsedMs } = await indexer.reindex(address, { full: true });
1717
+ const fpAfter = projectionFingerprint(after);
1718
+ console.log(` ${dim(`asking ${CHAIN} to tell us everything again …`)} ` +
1719
+ `${g(`${after.eventCount} events, ${elapsedMs}ms`)}`);
1720
+ if (fpBefore === fpAfter) {
1721
+ ok(bold('byte-for-byte identical.'));
1722
+ info('Your token just survived losing every local file. No backup, no API key, no company —');
1723
+ info(`the chain remembered. ${dim('That is the whole point of ABX.')}`);
1724
+ info(dim(`checked by hashing every chain-derived field, not by eyeballing it: sha256 ${fpAfter.slice(0, 12)}…`));
1725
+ }
1726
+ else {
1727
+ warn('the rebuilt state does NOT match what we just had — that is a real bug, please report it.');
1728
+ info(`before ${fpBefore.slice(0, 16)}… · after ${fpAfter.slice(0, 16)}…`);
1729
+ }
1730
+ }
1731
+ /**
1732
+ * [5] Read the token back the way a marketplace would.
1733
+ *
1734
+ * Which is genuinely a different act per lane, so it reads from the real source in each case rather
1735
+ * than always going through the local server:
1736
+ * • fully on-chain → call `tokenURI(0)` on the contract. That IS what a marketplace does, and on
1737
+ * this lane the whole answer (art included) comes back from the chain with nothing else running.
1738
+ * • off-chain custody → fetch the resolver, because that's what the baked URI points at.
1739
+ * Reading the on-chain lane over HTTP would have quietly implied the local server was load-bearing
1740
+ * when it isn't — the opposite of the lesson.
1741
+ */
1742
+ export async function walkthroughReadBack(state, baseUrl, onChainUri) {
1743
+ step('Read it back the way a marketplace would');
1744
+ const token = state.tokens[0];
1745
+ if (!token?.minted) {
1746
+ info('no minted token to read yet.');
1747
+ return;
1748
+ }
1749
+ if (onChainUri) {
1750
+ info(dim(`calling tokenURI(0) on your contract — the same call OpenSea makes …`));
1751
+ try {
1752
+ const uri = (await makePublicClient({ chainKey: CHAIN }).readContract({
1753
+ address: state.address,
1754
+ abi: oneOfOneImageAbi,
1755
+ functionName: 'tokenURI',
1756
+ args: [0n],
1757
+ }));
1758
+ const json = decodeOnChainJson(uri);
1759
+ if (json) {
1760
+ const parsed = JSON.parse(json);
1761
+ const img = typeof parsed.image === 'string' ? parsed.image : '';
1762
+ console.log(` ${g('✓')} came back with: ${bold(String(parsed.name ?? '(no name)'))}`);
1763
+ console.log(` ${dim(`image: ${img.slice(0, 48)}${img.length > 48 ? '…' : ''}`)}`);
1764
+ // The punchline of the whole lane: a data: URI means the art travelled IN the answer.
1765
+ if (img.startsWith('data:'))
1766
+ info(`${g('the art itself came back in that answer')} ${dim('— no link to follow, nothing to go missing')}`);
1767
+ }
1768
+ else {
1769
+ console.log(` ${dim(uri.slice(0, 160))}${uri.length > 160 ? dim('…') : ''}`);
1770
+ }
1771
+ info(dim('nothing was running to answer that. no server of ours, no server of yours.'));
1772
+ }
1773
+ catch (e) {
1774
+ info(dim(`could not read tokenURI from the chain: ${e.message}`));
1775
+ }
1776
+ console.log(` ${dim('read it yourself any time:')} ${bold(`abx tokenuri ${state.address}`)}`);
1777
+ return;
1778
+ }
1779
+ info(`tokenURI(0) ${dim('on chain →')} ${token.tokenURI ?? dim('(none)')}`);
1780
+ // Off-chain custody: the CHAIN holds a keccak256 commitment to the image and a URI base; this NODE
1781
+ // holds the bytes. That split is the thing worth understanding, so name it rather than implying the
1782
+ // JSON came from the chain.
1783
+ info(dim('the chain stored a URI base + a keccak256 commitment; this node serves the bytes.'));
1784
+ try {
1785
+ const res = await fetch(`${baseUrl}/t/${resolveChain(CHAIN).id}/${state.address}/0`);
1786
+ const json = (await res.json());
1787
+ const shown = { name: json.name, image: json.image };
1788
+ console.log(` ${dim(JSON.stringify(shown))}`);
1789
+ if (Array.isArray(json.abx_provenance)) {
1790
+ info(dim(`every field is tagged with where it came from (abx_provenance: ${json.abx_provenance.length} entries)`));
1791
+ }
1792
+ }
1793
+ catch (e) {
1794
+ info(dim(`could not read the served metadata: ${e.message}`));
1795
+ }
1796
+ console.log(` ${dim('prove the bytes match the chain:')} ${bold(`abx verify ${state.address}`)}`);
1797
+ }
1798
+ /** Every flag `deploy-code` recognizes — so an unsupported/typo'd one warns instead of silently
1799
+ * no-opping. Keep in lockstep with the flags read below + the storageOverrides/lane helpers. */
1800
+ export const DEPLOY_CODE_FLAGS = new Set([
1801
+ // content + collection identity
1802
+ 'script', 'code-dir', 'name', 'symbol', 'description', 'external-url', 'description-onchain',
1803
+ ...AUTHORSHIP_DEPLOY_FIELDS.map(([flag]) => flag), // artist · display-notes · artist-links · license
1804
+ 'image-base', 'attributes-renderer', 'image-renderer',
1805
+ // supply + mint + economics
1806
+ 'max', 'mint-count', 'mint-all', 'no-mint', 'unpaused', 'minter', 'primary-payee', 'royalty-bps', '721c',
1807
+ // params + dependencies
1808
+ 'schema', 'no-seed', 'no-delegation', 'dep', 'dep-registry',
1809
+ // serving lane
1810
+ 'public-base-url', 'onchain-uri', 'generator', 'renderer', 'port',
1811
+ // storage (directory mode) — mirrors storageOverrides()
1812
+ 'backend', 'endpoint', 'bucket', 'region', 'prefix', 'public-base', 'gateway', 'mode', 'api-url', 'upload-url', 'provider', 'storage-signer',
1813
+ // signing lane + safety + trust anchor
1814
+ 'sign', 'unsigned', 'for', 'salt', 'factory', 'bootstrap-factory', 'sign-url-file',
1815
+ // preview / confirm
1816
+ 'dry-run', 'confirm', 'yes',
1817
+ 'json', // B19: the deployed ADDRESS as data, narration to stderr
1818
+ 'resume', // B15: finish an EXISTING contract whose setup tx never landed (no deploy)
1819
+ ]);
1820
+ // Storage + signing + preview flags shared by every deploy path (mirrors storageOverrides() + the lanes).
1821
+ export const SHARED_DEPLOY_FLAGS = [
1822
+ 'backend', 'endpoint', 'bucket', 'region', 'prefix', 'public-base', 'gateway', 'mode', 'api-url', 'upload-url', 'provider', 'storage-signer',
1823
+ 'sign', 'unsigned', 'for', 'sign-url-file', 'dry-run', 'confirm', 'yes', 'salt', 'factory', 'bootstrap-factory', 'port', 'renderer', 'public-base-url',
1824
+ '721c', // opt-in ERC-721C enrollment (recommended | 0x…) — every deploy path takes it
1825
+ 'json', // B19: the deployed ADDRESS as data, narration to stderr — every deploy path takes it
1826
+ ];
1827
+ // A 1/1 `abx deploy` accepts these (see `abx help deploy`). Anything else warns (typo'd/unsupported).
1828
+ export const DEPLOY_FLAGS = new Set([
1829
+ 'image', 'name', 'symbol', 'type', 'description', 'description-onchain', 'external-url', 'traits', 'traits-onchain', 'attributes',
1830
+ 'onchain-uri', 'onchain-image', 'compress', 'royalty-bps', 'no-mint',
1831
+ ...AUTHORSHIP_DEPLOY_FIELDS.map(([flag]) => flag),
1832
+ ...SHARED_DEPLOY_FLAGS,
1833
+ ]);
1834
+ // `abx deploy-series` accepts these (see `abx help deploy-series`).
1835
+ export const DEPLOY_SERIES_FLAGS = new Set([
1836
+ 'dir', 'count', 'name', 'symbol', 'description', 'external-url', 'royalty-bps', 'attributes', 'traits-onchain',
1837
+ 'no-mint', 'mint-all', 'mint-count', 'unpaused', 'minter', 'primary-payee',
1838
+ 'onchain-uri', 'onchain-image', 'compress', 'chunk-store',
1839
+ ...AUTHORSHIP_DEPLOY_FIELDS.map(([flag]) => flag),
1840
+ ...SHARED_DEPLOY_FLAGS,
1841
+ ]);
1842
+ export async function cmdDeployCode(flags) {
1843
+ return withJson(flags, async (emit) => cmdDeployCodeBody(flags, emit));
1844
+ }
1845
+ export async function cmdDeployCodeBody(flags, emit) {
1846
+ const usage = 'abx deploy-code (--script <file> | --code-dir <dir>) --name "Title" --symbol SYM ' +
1847
+ '(--public-base-url https://your.resolver.domain | --onchain-uri) ' +
1848
+ '[--description "<s>"] [--external-url <url>] [--image-base <url> | --image-renderer 0x..] [--attributes-renderer 0x..] ' +
1849
+ '[--max N] [--mint-count N | --mint-all] [--schema key:Type:Auth,…] [--no-seed] ' +
1850
+ '[--dep <name@version|0x..>[,…]] [--dep-registry 0x..] ' +
1851
+ '[--unpaused] [--minter 0x..] [--primary-payee 0x..] [--royalty-bps N] [--721c recommended|0x..] [--backend ipfs|arweave] [--dry-run] [--confirm] [--bootstrap-factory]';
1852
+ const scriptPath = flags.script;
1853
+ const codeDir = flags['code-dir'];
1854
+ const hasProgram = !!(scriptPath || codeDir); // a JS program (script or built dir)
1855
+ // Renderer-only (in-chain SVG) lane: NO program — the `image`/`attributes` are Solidity field
1856
+ // renderers, so there's nothing to chunk. Allowed only when at least one field renderer is given
1857
+ // (else it's just a misconfigured code deploy). Both program modes together is still an error.
1858
+ const rendererOnly = !hasProgram && !!(flags['image-renderer'] || flags['attributes-renderer']);
1859
+ if (scriptPath && codeDir)
1860
+ throw new Error(usage);
1861
+ if (!hasProgram && !rendererOnly) {
1862
+ throw new Error(`${usage}\n (or, for a FULLY on-chain Solidity render with no program: --image-renderer 0x.. [--attributes-renderer 0x..] --onchain-uri)`);
1863
+ }
1864
+ // Surface unsupported/typo'd flags BEFORE any work — a silent no-op on a write-adjacent value
1865
+ // (e.g. --description landing nowhere) is the worst failure mode. Non-fatal (see unknownFlags).
1866
+ // Shared path so the "did you mean --max / price is post-deploy" hints fire here too.
1867
+ refuseStrayFlags(flags, DEPLOY_CODE_FLAGS, 'deploy-code');
1868
+ warnSignWithoutFor(flags);
1869
+ const dryRun = isDryRun(flags);
1870
+ const name = flags.name ?? 'ABX Code';
1871
+ const symbol = flags.symbol ?? 'ABXC';
1872
+ assertRealIdentity(flags, { name, symbol, dryRun });
1873
+ const maxProvided = flags.max !== undefined;
1874
+ const max = Number(flags.max ?? 16);
1875
+ if (!Number.isInteger(max) || max <= 0)
1876
+ throw new Error('--max must be a positive integer');
1877
+ const royaltyProvided = flags['royalty-bps'] !== undefined;
1878
+ // Mint is DEFERRED by default (no reserve mints at deploy). `--no-mint` is the explicit form of
1879
+ // that default (the skill + reproduce line reference it) and wins over --mint-all/--mint-count.
1880
+ const mintCount = flags['no-mint'] !== undefined ? 0 : flags['mint-all'] !== undefined ? max : Number(flags['mint-count'] ?? 0);
1881
+ if (mintCount > max)
1882
+ throw new Error(`--mint-count ${mintCount} exceeds --max ${max}`);
1883
+ const paused = flags.unpaused === undefined;
1884
+ const royaltyBps = Number(flags['royalty-bps'] ?? 500);
1885
+ const port = Number(flags.port ?? process.env.ABX_PORT ?? DEFAULT_PORT);
1886
+ const baseUrl = (flags['public-base-url'] ?? resolveBaseUrl(port)).replace(/\/$/, '');
1887
+ const hasPublicUrl = !!(flags['public-base-url'] || process.env.ABX_PUBLIC_BASE_URL);
1888
+ // --onchain-uri: the chain-complete lane. tokenURI resolves ON-CHAIN via the canonical
1889
+ // metadata renderer, and the collection's `animation_url` is COMPUTED on-chain by the
1890
+ // canonical generator (template branch: the full HTML document; directory branch: a
1891
+ // parameterized gateway URL). No resolver base is baked, so no resolver is required —
1892
+ // though local register+index still happens below (a resolver remains the kinder serving
1893
+ // path, and the effect runner still owns the thumbnail).
1894
+ const onChainUri = !!flags['onchain-uri'];
1895
+ console.log(bold(`\n ABX Self-Host Toolkit — deploy code project\n ${dim('a program is the content; output is a function of its params')}`));
1896
+ // Without --onchain-uri a code project ALWAYS resolves its metadata (`tokenURI`) and its live
1897
+ // view (`/a/…`, where the resolver injects the seed + current PostParams) through a resolver.
1898
+ // The deploy bakes the resolver's PUBLIC base on-chain, and a localhost / loopback base
1899
+ // resolves for NO ONE. Refuse it, same as deploy/deploy-series. Checked FIRST — a pure config
1900
+ // error, before any RPC. ABX_DEV_ALLOW_LOCALHOST_URI=1 is the DEV/TEST-ONLY escape
1901
+ // (e2e/sandbox). (Not a concern with --onchain-uri: the renderer + generator are
1902
+ // authoritative and resolve from chain — no resolver base is baked.)
1903
+ if (!onChainUri && (!hasPublicUrl || loopbackBaseUrl(baseUrl))) {
1904
+ const devAllow = process.env.ABX_DEV_ALLOW_LOCALHOST_URI === '1';
1905
+ const msg = `A code project resolves its metadata + live view through your resolver, so this deploy bakes that resolver's PUBLIC URL on-chain — ${hasPublicUrl ? baseUrl : 'localhost'} resolves for no one ` +
1906
+ `(not marketplaces, not wallets). Pick a real path:\n` +
1907
+ ` • Fully on-chain, no server: --onchain-uri (tokenURI via the on-chain renderer; the generator computes the live view)\n` +
1908
+ ` • Hosted resolver: abx deploy-resolver --provider fly --domain meta.you.xyz, then --public-base-url https://meta.you.xyz (or set ABX_PUBLIC_BASE_URL)`;
1909
+ if (!devAllow) {
1910
+ if (dryRun)
1911
+ warn(`would REFUSE to deploy — ${msg}`);
1912
+ else
1913
+ throw new Error(msg);
1914
+ }
1915
+ else {
1916
+ warn(`DEV ONLY (ABX_DEV_ALLOW_LOCALHOST_URI): baking ${bold(baseUrl)} on-chain — resolves only on THIS machine; not a real NFT.`);
1917
+ }
1918
+ }
1919
+ const publicClient = makePublicClient({ chainKey: CHAIN });
1920
+ // Catch a wrong-network RPC with the clear mismatch message even on dry-run (which reads the
1921
+ // chain to resolve the factory/renderer and predict the address); tolerate an offline preview.
1922
+ await assertChainId(CHAIN, { allowUnreachable: dryRun });
1923
+ const chainId = dryRun ? resolveChain(CHAIN).id : (await publicClient.getChainId());
1924
+ // --721c (opt-in ERC-721C): absent → zeroAddress, a plain ERC-721 exactly as before.
1925
+ const transferValidator = await resolveTransferValidatorFlag(flags, publicClient, dryRun);
1926
+ step('Trust anchor');
1927
+ let factory;
1928
+ if (dryRun) {
1929
+ const existing = resolveSeriesCodeFactory(chainId, flags.factory);
1930
+ if (!existing) {
1931
+ info('no canonical SeriesCode factory for this chain yet — a real deploy would deploy the trust anchor first (or --bootstrap-factory).');
1932
+ console.log(`\n ${g('dry run')} ${dim('— nothing sent.')}\n`);
1933
+ return;
1934
+ }
1935
+ factory = existing;
1936
+ info(`would reuse canonical SeriesCode factory ${factory}`);
1937
+ }
1938
+ else {
1939
+ factory = await ensureSeriesCodeFactory(publicClient, flags.factory, !!flags['bootstrap-factory']);
1940
+ }
1941
+ const seedSource = flags['no-seed'] !== undefined
1942
+ ? zeroAddress
1943
+ : dryRun
1944
+ ? (resolveSeedSource(chainId) ?? zeroAddress)
1945
+ : await ensureSeedSource(publicClient);
1946
+ // --onchain-uri: resolve the two canonical singletons the lane points at. The generator is
1947
+ // per-chain infrastructure (constructor-wired to the dependency registry, the abx.js/gunzip
1948
+ // runtime pointers, and default gateways), so it is NEVER auto-deployed — a chain without one
1949
+ // stops with guidance, same policy as the trust anchor. The metadata renderer reuses the 1/1
1950
+ // lane's ensureRenderer (deploys only if missing or a stale spec version).
1951
+ let generator = zeroAddress;
1952
+ let metadataRenderer = zeroAddress;
1953
+ if (onChainUri) {
1954
+ step('On-chain URI');
1955
+ // The generator computes `animation_url` from a PROGRAM (the on-chain HTML doc / directory
1956
+ // gateway URL). A renderer-only drop has no program → no animation_url, so it needs NO
1957
+ // generator (and must NOT bake an animation leg at a zero generator — that would revert every
1958
+ // tokenURI, since the metadata renderer staticcalls the field renderer). Only the metadata
1959
+ // renderer (which makes tokenURI resolve on-chain) is required in both cases.
1960
+ if (hasProgram) {
1961
+ const knownGenerator = resolveGenerator(chainId, flags.generator);
1962
+ if (!knownGenerator) {
1963
+ throw new Error(`--onchain-uri needs the canonical AbxGenerator for '${CHAIN}', and none is configured.\n` +
1964
+ ` A canonical generator may already exist — check with the ABX community / update @artblocks/abx-sdk ` +
1965
+ `(the shipped manifest: packages/sdk/src/deployments.ts), or set ABX_GENERATOR=0x… / --generator 0x… if you know the address.\n` +
1966
+ ` (The generator is constructor-wired per chain — registry, runtime pointers, gateways — so this deploy never mints one.)`);
1967
+ }
1968
+ generator = knownGenerator;
1969
+ if (!dryRun) {
1970
+ const generatorCode = await publicClient.getCode({ address: generator });
1971
+ if (!generatorCode || generatorCode === '0x') {
1972
+ throw new Error(`the configured generator ${generator} has no code on '${CHAIN}' — check ABX_GENERATOR / --generator / the RPC endpoint.`);
1973
+ }
1974
+ }
1975
+ info(dryRun ? `would point the collection animation_url at the canonical generator ${generator} (computed on-chain)` : `animation_url computes ON-CHAIN via the canonical generator ${generator}`);
1976
+ }
1977
+ else {
1978
+ info('renderer-only (no program) — no animation_url; tokenURI = name + on-chain image/attributes from your Solidity renderer(s)');
1979
+ }
1980
+ if (dryRun) {
1981
+ metadataRenderer = rendererAddress(flags.renderer) ?? zeroAddress;
1982
+ info(metadataRenderer === zeroAddress
1983
+ ? 'would deploy the canonical metadata renderer first, then set tokenURIRenderer/contractURIRenderer to it'
1984
+ : `would resolve tokenURI/contractURI ON-CHAIN via the renderer ${metadataRenderer}`);
1985
+ }
1986
+ else {
1987
+ metadataRenderer = await ensureRenderer(flags.renderer);
1988
+ info(`tokenURI/contractURI resolve ON-CHAIN via the renderer ${metadataRenderer} — no resolver in the token's graph.`);
1989
+ }
1990
+ if (!hasPublicUrl)
1991
+ info('off-chain fallback pointer left empty (the renderer is authoritative); set --public-base-url to bake one anyway.');
1992
+ }
1993
+ // schemas: key:Type:Auth (comma-separated), Type carrying optional Select options / Range bounds
1994
+ // (Select[A|B|C], Uint256Range[0..100], …) → key, enum indices, bytes32 min/max, selectOptions.
1995
+ const schemas = parseSchemaSpecs(flags.schema);
1996
+ // Dependencies: ordered `--dep` refs (repeatable / comma-separable) — index 0 = the runtime,
1997
+ // by convention. `name@version` ⇒ Resolution.Registry (resolved through the soft registry
1998
+ // pointer); `0x…` ⇒ Resolution.OnChain (a raw data contract, no registry involved). The legs
1999
+ // ride the SAME setup multicall as chunks/schemas/mints, in every signing lane.
2000
+ const deps = parseDepFlag(flags.dep);
2001
+ const hasRegistryDeps = deps.some((d) => d.resolution === DEP_RESOLUTION.registry);
2002
+ // The pointer is SOFT and non-validating (never blocks a deploy): --dep-registry wins, else
2003
+ // the chain's known AB Dependency Registry, else warn + skip the leg.
2004
+ const depPointer = hasRegistryDeps
2005
+ ? resolveDepRegistryPointer(flags['dep-registry'], chainId)
2006
+ : { registry: null, source: 'none' };
2007
+ const depRegistry = depPointer.registry;
2008
+ let depChecks = []; // the P1 selection-time report — feeds the chain-complete expectation under --onchain-uri
2009
+ if (deps.length) {
2010
+ step('Dependencies');
2011
+ deps.forEach((d, i) => info(`[${i}] ${bold(d.display)} ${dim(d.resolution === DEP_RESOLUTION.registry ? '(registry name@version)' : '(on-chain data contract — read directly)')}${i === 0 ? dim(' · index 0 = the runtime') : ''}`));
2012
+ if (hasRegistryDeps) {
2013
+ if (depRegistry) {
2014
+ info(`registry pointer → ${depRegistry} ${dim(depPointer.source === 'flag' ? '(--dep-registry)' : "(the chain's AB Dependency Registry — soft, non-validating)")}`);
2015
+ // Selection-time check — best-effort eth_call per Registry dep; the deploy proceeds
2016
+ // whatever it finds (the owner may intend a custom registry; offline just skips).
2017
+ const { checks, rpcOk } = await checkRegistryDeps(publicClient, depRegistry, deps);
2018
+ depChecks = checks;
2019
+ for (const chk of checks) {
2020
+ if (chk.status === 'found') {
2021
+ ok(chk.details.availableOnChain
2022
+ ? `${chk.dep} — on registry; ON-CHAIN bytes available (${chk.details.scriptCount} chunk(s)) — chain-complete capable`
2023
+ : `${chk.dep} — on registry; served from CDN ${chk.details.preferredCDN || '(none listed)'} ${dim('— the normal production path, not a degradation')}`);
2024
+ }
2025
+ else if (chk.status === 'not-found') {
2026
+ warn(`${bold(chk.dep)} NOT FOUND on registry ${depRegistry} — the resolver won't resolve it from there. Deploy proceeds (you may intend a custom registry / a pending addition); double-check the exact name@version spelling.`);
2027
+ }
2028
+ }
2029
+ if (!rpcOk)
2030
+ info('registry check skipped (RPC unreachable) — the deploy does not depend on it.');
2031
+ }
2032
+ else {
2033
+ warn('no dependency registry known for this chain — skipping the setDependencyRegistry leg (the pointer is SOFT; the resolver falls back to its built-in CDN map). Pass --dep-registry 0x… or run `abx set-dependency-registry` later.');
2034
+ }
2035
+ }
2036
+ }
2037
+ // Content: directory mode uploads the build (its root locator → the `code` collection field);
2038
+ // template mode chunks the script for on-chain storage. The directory UPLOAD is deferred until
2039
+ // after the --confirm gate (and skipped entirely on --dry-run) so a preview never pins bytes.
2040
+ step('Content');
2041
+ const contractFields = [];
2042
+ let scriptChunks = [];
2043
+ let scriptAnalysis = null; // template mode only — directory builds can't be statically analyzed
2044
+ let dirUpload = null;
2045
+ let contentSummary;
2046
+ const schemaSummary = schemas.length
2047
+ ? schemas.map((s) => describeSchema(s)).join(', ')
2048
+ : 'none';
2049
+ if (codeDir) {
2050
+ const dirPath = resolvePath(codeDir);
2051
+ const files = readdirSync(dirPath).filter((f) => !f.startsWith('.') && statSync(joinPath(dirPath, f)).isFile());
2052
+ if (!files.includes('index.html'))
2053
+ throw new Error(`${dirPath} has no index.html — the directory entry, by convention`);
2054
+ const backend = resolveBackend(storageOptions(storageOverrides(flags)));
2055
+ if (!backend.putDirectory)
2056
+ throw new Error(`storage backend '${backend.id}' has no directory upload — use --backend ipfs (pinata) or arweave`);
2057
+ const entries = files.map((f) => ({ name: f, bytes: new Uint8Array(readFileSync(joinPath(dirPath, f))), contentType: contentTypeFromPath(joinPath(dirPath, f)) }));
2058
+ dirUpload = { backend, entries, sizes: entries.map((e) => e.bytes.length) };
2059
+ contentSummary = `${entries.length} file(s) → ${backend.id} directory (code field)`;
2060
+ info(`${entries.length} file(s) from ${basename(dirPath)}/ → ${backend.id} directory (code field)`);
2061
+ info(dim('the live view 302s through the gateway — it must serve HTML (the shared Pinata public gateway does not; use a dedicated gateway or arweave).'));
2062
+ }
2063
+ else if (scriptPath) {
2064
+ const source = readFileSync(resolvePath(scriptPath), 'utf8');
2065
+ const bytes = new TextEncoder().encode(source);
2066
+ const CHUNK = 22_000; // SSTORE2 headroom
2067
+ for (let off = 0; off < bytes.length; off += CHUNK)
2068
+ scriptChunks.push(toHex(bytes.slice(off, off + CHUNK)));
2069
+ contentSummary = `script ${bytes.length} bytes → ${scriptChunks.length} on-chain chunk(s)`;
2070
+ ok(contentSummary);
2071
+ scriptAnalysis = analyzeScript(source, deps.map((d) => d.display)); // traits + PostParam reads + doc size → the Surfaces disposition below
2072
+ }
2073
+ else {
2074
+ // Renderer-only (in-chain SVG): no program to chunk — the image/attributes are Solidity field
2075
+ // renderers. Nothing rides SSTORE2; the whole artwork is computed on-chain per view.
2076
+ contentSummary = 'no program — image + traits computed on-chain by Solidity field renderers';
2077
+ ok(contentSummary);
2078
+ }
2079
+ // Collection identity that lands ON-CHAIN in the deploy tx's init params (rides `contractFields`,
2080
+ // the same slot directory mode uses for `code` — no extra multicall leg). A code project has no
2081
+ // resolver-served metadata table of its own, so `--description`/`--external-url` MUST be written
2082
+ // as on-chain collection fields or they'd be silently dropped (they were, pre-fix). Under
2083
+ // --onchain-uri the metadata renderer stitches them into tokenURI from chain; on the resolver
2084
+ // lane the resolver reads the same on-chain fields. Inline + on-chain is the greenfield default
2085
+ // for this tiny, durable data (mirrors the 1/1 lane's F.description write).
2086
+ const identityFields = [];
2087
+ // Field-renderer code-presence, captured from the verify probes below so the Surfaces block
2088
+ // (which the skill tells the agent to trust) reflects a codeless renderer — not just the Content
2089
+ // section. null = not applicable / RPC unknown; true = code present; false = no code (would refuse).
2090
+ let imageRendererCodePresent = null;
2091
+ let attributesRendererCodePresent = null;
2092
+ if (flags.description) {
2093
+ contractFields.push({ field: encodeTag(F.description), representation: encodeTag(R.inline), value: toHex(String(flags.description)) });
2094
+ identityFields.push('description');
2095
+ }
2096
+ if (flags['external-url']) {
2097
+ contractFields.push({ field: encodeTag(F.externalUrl), representation: encodeTag(R.inline), value: toHex(String(flags['external-url'])) });
2098
+ identityFields.push('external_url');
2099
+ }
2100
+ // authorship + rights — same on-chain inline collection-field slot (artist / license / …).
2101
+ contractFields.push(...authorshipContractFields(flags));
2102
+ for (const [flag, field] of AUTHORSHIP_DEPLOY_FIELDS)
2103
+ if (flags[flag])
2104
+ identityFields.push(field);
2105
+ // --image-renderer: bake the on-chain `image` field to a `renderer` representation — the SVG is
2106
+ // COMPUTED on-chain by a Solidity IAbxFieldRenderer (e.g. a SeedSvgRenderer fork) from the token's
2107
+ // seed + params. THE in-chain-art lane: the marketplace still lives in tokenURI itself — no bucket,
2108
+ // no effect runner, no resolver. Mutually exclusive with --image-base (both set `image`). Verified
2109
+ // like --attributes-renderer: a real deploy REFUSES a codeless address; dry-run probes best-effort.
2110
+ if (flags['image-renderer']) {
2111
+ if (flags['image-base']) {
2112
+ throw new Error('--image-renderer and --image-base both set the `image` field — pick ONE: an ON-CHAIN Solidity render (--image-renderer, no infra) OR an off-chain bucket URL (--image-base).');
2113
+ }
2114
+ const addr = String(flags['image-renderer']);
2115
+ if (!/^0x[0-9a-fA-F]{40}$/.test(addr))
2116
+ throw new Error('--image-renderer must be a 0x address (an IAbxFieldRenderer returning image/svg+xml on-chain, e.g. a SeedSvgRenderer fork).');
2117
+ if (dryRun) {
2118
+ let codePresent = null;
2119
+ try {
2120
+ const c = await publicClient.getCode({ address: addr });
2121
+ codePresent = !!c && c !== '0x';
2122
+ }
2123
+ catch {
2124
+ codePresent = null;
2125
+ }
2126
+ imageRendererCodePresent = codePresent;
2127
+ if (codePresent === false) {
2128
+ warn(`--image-renderer ${bold(addr)} has NO code on '${CHAIN}' — that is NOT a deployed renderer. A real deploy REFUSES it. Deploy a Solidity image renderer (fork SeedSvgRenderer.sol) first.`);
2129
+ }
2130
+ else {
2131
+ info(`collection image → on-chain field-renderer ${bold(addr)} ${dim(codePresent ? '(SVG computed on-chain; code present ✓)' : '(SVG computed on-chain — a real deploy VERIFIES this is a deployed renderer and refuses if not)')}`);
2132
+ }
2133
+ }
2134
+ else {
2135
+ const code = await publicClient.getCode({ address: addr });
2136
+ if (!code || code === '0x')
2137
+ throw new Error(`--image-renderer ${addr} has no code on '${CHAIN}' — it must be a DEPLOYED IAbxFieldRenderer (e.g. a SeedSvgRenderer fork), not a placeholder address. Deploy the renderer first.`);
2138
+ imageRendererCodePresent = true;
2139
+ info(`collection image → on-chain field-renderer ${bold(addr)} ${dim('(SVG computed on-chain, embedded as a data URI in tokenURI; code present ✓)')}`);
2140
+ }
2141
+ contractFields.push({ field: encodeTag(F.image), representation: encodeTag(R.renderer), value: encodeFieldRenderer(addr) });
2142
+ identityFields.push('image');
2143
+ }
2144
+ // --image-base: bake the on-chain `image` as a url-template (`{base}/{id}.png`) — the marketplace
2145
+ // thumbnail lives OFF-CHAIN at a stable per-token URL the chain names, and the effect runner
2146
+ // overwrites each key as tokens mint/update (no metadata resolver, no chain rewrite on re-render).
2147
+ // Prefer a mutable, path-addressed host (S3/R2/CDN); NOT ipfs/arweave (content-addressed → the
2148
+ // URL changes with the bytes). See the effect runner's deterministic-image lane.
2149
+ if (flags['image-base']) {
2150
+ const base = String(flags['image-base']);
2151
+ // B3 combo #1: --image-base needs a MUTABLE, path-addressed URL (the effect runner overwrites
2152
+ // the SAME key in place) — a content-addressed gateway URL (ipfs/arweave) can't back a *fixed*
2153
+ // per-token address, no matter what THIS deploy's own --backend/ABX_STORAGE_BACKEND happens to
2154
+ // be (that config is unrelated — it's where THIS deploy's own uploads go, not where the effect
2155
+ // runner later writes stills). One shared validator (packages/storage/content-plan.ts) decides
2156
+ // this, so the real-run refusal here and the dry-run `render/storage` row below can never
2157
+ // disagree about which combos are bad.
2158
+ const imageBaseCombo = validateRenderStorageCombo({ imageBaseUrl: base });
2159
+ if (!imageBaseCombo.ok) {
2160
+ if (dryRun)
2161
+ warn(`would REFUSE — ${imageBaseCombo.reason}`);
2162
+ else
2163
+ throw new Error(imageBaseCombo.reason);
2164
+ }
2165
+ // NEVER bake a localhost/loopback image host on-chain — the token's `image` would resolve for no
2166
+ // one (same rule as the resolver base). This is the marketplace still's PERMANENT address.
2167
+ if (loopbackBaseUrl(base)) {
2168
+ const m = `--image-base ${base} is localhost/loopback — the on-chain image URL would resolve for no marketplace. Use a PUBLIC bucket (S3/R2/CDN) you control.`;
2169
+ if (dryRun)
2170
+ warn(`would REFUSE — ${m}`);
2171
+ else
2172
+ throw new Error(m);
2173
+ }
2174
+ // The exact trap a real agent hit: it baked the S3 *API endpoint* (auth-only) as --image-base.
2175
+ // `*.r2.cloudflarestorage.com` is R2's signed API — NEVER publicly readable → marketplaces 403.
2176
+ // `s3[.-]…amazonaws.com` is the S3 API host too (public only if you front it with a public bucket
2177
+ // policy / CloudFront). --image-base must be the PUBLIC READ base; the API endpoint goes in
2178
+ // ABX_S3_ENDPOINT (uploads). Refuse the R2 API form (never public); warn on the S3 API form.
2179
+ if (/\.r2\.cloudflarestorage\.com/i.test(base)) {
2180
+ const m = `--image-base ${base} is R2's S3 API endpoint (auth-only) — marketplaces get 403, it is NEVER public. Put THIS URL in ${bold('ABX_S3_ENDPOINT')} (uploads), and pass --image-base your bucket's PUBLIC read URL — enable an ${bold('r2.dev')} public URL (\`https://pub-<hash>.r2.dev\`) or a custom domain in the R2 dashboard.`;
2181
+ if (dryRun)
2182
+ warn(`would REFUSE — ${m}`);
2183
+ else
2184
+ throw new Error(m);
2185
+ }
2186
+ else if (/(^|\/\/)s3[.-][^/]*amazonaws\.com/i.test(base) || /\.s3[.-][^/]*amazonaws\.com/i.test(base)) {
2187
+ warn(`--image-base ${base} looks like the S3 API host — marketplaces can read it ONLY if the bucket has public-read (or you front it with CloudFront/a domain). If it's not public, use that URL in ${bold('ABX_S3_ENDPOINT')} and pass a public URL here instead.`);
2188
+ }
2189
+ const imageTemplate = base.includes('{id}') ? base : `${base.replace(/\/+$/, '')}/{id}.png`;
2190
+ contractFields.push({ field: encodeTag(F.image), representation: encodeTag(R.urlTemplate), value: toHex(imageTemplate) });
2191
+ identityFields.push('image');
2192
+ info(`collection image → on-chain url-template ${bold(imageTemplate)} ${dim('(off-chain thumbnail at a deterministic per-token URL; the effect runner overwrites each key)')}`);
2193
+ // Name the EXACT upload vars the runner needs (an agent guessed R2_* names that abx never reads,
2194
+ // breaking the upload). --image-base is the public READ base; these are the write side.
2195
+ info(` ${dim('the runner uploads stills here via')} ${bold('ABX_S3_ENDPOINT · ABX_S3_BUCKET · ABX_S3_ACCESS_KEY_ID · ABX_S3_SECRET_ACCESS_KEY')} ${dim('(set ABX_S3_PUBLIC_BASE to this same --image-base; `abx storage show` checks them; details → reference/hosting.md)')}`);
2196
+ const s3Base = process.env.ABX_S3_PUBLIC_BASE?.replace(/\/+$/, '');
2197
+ if (s3Base && !imageTemplate.startsWith(`${s3Base}/`)) {
2198
+ warn(`--image-base isn't under ABX_S3_PUBLIC_BASE (${s3Base}) — the runner keys its deterministic publish off that base, so it won't auto-upload here unless they share an origin.`);
2199
+ }
2200
+ }
2201
+ // --attributes-renderer: bake the on-chain `attributes` field to a `renderer` representation
2202
+ // (abi.encode(address)) — traits COMPUTED on-chain in tokenURI by an IAbxFieldRenderer (e.g. a
2203
+ // SeedTraitsRenderer fork). Fully on-chain traits, no resolver. See docs/research/onchain-traits-feasibility.md.
2204
+ if (flags['attributes-renderer']) {
2205
+ const addr = String(flags['attributes-renderer']);
2206
+ if (!/^0x[0-9a-fA-F]{40}$/.test(addr))
2207
+ throw new Error('--attributes-renderer must be a 0x address (an IAbxFieldRenderer that computes the attributes JSON on-chain, e.g. a SeedTraitsRenderer fork).');
2208
+ // On-chain traits are NOT a free flag: this address must be a DEPLOYED Solidity renderer (a
2209
+ // SeedTraitsRenderer fork), never a placeholder. A real session put a GUESSED address here and
2210
+ // presented "traits on-chain" in a confirm readout — so verify code is actually present. Real
2211
+ // deploy REFUSES an empty address (like the localhost guard); dry-run probes best-effort (like
2212
+ // the gas estimate) and, either way, marks the line as pending verification, never settled.
2213
+ if (dryRun) {
2214
+ let codePresent = null;
2215
+ try {
2216
+ const c = await publicClient.getCode({ address: addr });
2217
+ codePresent = !!c && c !== '0x';
2218
+ }
2219
+ catch {
2220
+ codePresent = null;
2221
+ }
2222
+ attributesRendererCodePresent = codePresent;
2223
+ if (codePresent === false) {
2224
+ warn(`--attributes-renderer ${bold(addr)} has NO code on '${CHAIN}' — that is NOT a deployed renderer. A real deploy REFUSES it. Deploy a Solidity attributes-renderer (fork SeedTraitsRenderer.sol) first, or drop the flag and serve traits via a resolver / omit them.`);
2225
+ }
2226
+ else {
2227
+ info(`collection attributes → on-chain field-renderer ${bold(addr)} ${dim(codePresent ? '(traits computed on-chain; code present ✓ — a real deploy re-verifies it is a deployed renderer)' : '(traits computed on-chain — a real deploy VERIFIES this is a deployed renderer and refuses if not; do not present traits as on-chain until verified)')}`);
2228
+ }
2229
+ }
2230
+ else {
2231
+ const code = await publicClient.getCode({ address: addr });
2232
+ if (!code || code === '0x')
2233
+ throw new Error(`--attributes-renderer ${addr} has no code on '${CHAIN}' — it must be a DEPLOYED IAbxFieldRenderer (e.g. a SeedTraitsRenderer fork), not a placeholder address. Deploy the renderer first, or drop the flag (serve traits via a resolver with --public-base-url, or omit marketplace traits — the tokenURI + animation stay fully on-chain).`);
2234
+ attributesRendererCodePresent = true;
2235
+ info(`collection attributes → on-chain field-renderer ${bold(addr)} ${dim('(traits computed on-chain, embedded verbatim in tokenURI; code present ✓)')}`);
2236
+ }
2237
+ contractFields.push({ field: encodeTag(F.attributes), representation: encodeTag(R.renderer), value: encodeFieldRenderer(addr) });
2238
+ identityFields.push('attributes');
2239
+ }
2240
+ if (identityFields.length)
2241
+ info(`collection ${identityFields.join(' + ')} → on-chain field(s) (ride the deploy tx)`);
2242
+ // Renderer behavior is the creator's to guarantee — the CLI can only confirm the address has code,
2243
+ // not that render() behaves (simulating it is hard + uncertain; competent Solidity devs forge-test).
2244
+ // Surface the invariant that actually bricks a drop so the agent reviews the render function first.
2245
+ if (flags['image-renderer'] || flags['attributes-renderer']) {
2246
+ warn(`${bold('renderer check — yours to verify')} (the CLI confirms code at the address, NOT that render() behaves): an IAbxFieldRenderer must ` +
2247
+ `${bold('NEVER revert')} for any token/param state incl. the collection surface (tokenId = type(uint256).max) — a revert bricks the WHOLE tokenURI (no try/catch) — ` +
2248
+ `and must return the right content-type (image → ${bold('image/svg+xml')} · attributes → a JSON array). Review the render function + forge-test it before shipping. Invariants: reference/code-projects.md.`);
2249
+ }
2250
+ // ── Surfaces disposition — resolve EVERY product dimension explicitly, up front ──────────────
2251
+ // A code project has four surfaces that each land somewhere (or nowhere). A real session shipped
2252
+ // "fully on-chain, no server!" and discovered — one at a time, after deploy — that it carried no
2253
+ // thumbnail DESTINATION, no traits, and had dropped the palette PostParam. These are all DEPLOY-TIME
2254
+ // decisions (an on-chain field with no pointer can't be backfilled to localhost). Compute each here
2255
+ // so the dry-run + confirm can lay them out as one block, and the agent can't miss one.
2256
+ const resolverInGraph = !onChainUri || hasPublicUrl; // a resolver is in the token's graph
2257
+ const hasAttributesRenderer = !!flags['attributes-renderer'];
2258
+ const hasImageBase = !!flags['image-base'];
2259
+ const hasImageRenderer = !!flags['image-renderer'];
2260
+ // THUMBNAIL DESTINATION — the killer. The on-chain `image` is a placeholder UNLESS it's computed
2261
+ // on-chain (--image-renderer, an in-chain SVG — nothing off-chain at all), OR --image-base (a public
2262
+ // bucket the chain names), OR a resolver serves it. With none, `abx render` writes to a local store
2263
+ // the tokenURI never points at → orphaned; marketplaces show the placeholder forever.
2264
+ const imageBaseLoopback = hasImageBase && loopbackBaseUrl(String(flags['image-base']));
2265
+ // A renderer whose address has NO code is a broken surface too — surface it HERE (the block the
2266
+ // skill says to trust), not only in the Content section.
2267
+ const imageRendererCodeless = hasImageRenderer && imageRendererCodePresent === false;
2268
+ const imageOrphaned = imageBaseLoopback || imageRendererCodeless || (!hasImageRenderer && !hasImageBase && !resolverInGraph);
2269
+ const imageDisposition = imageRendererCodeless
2270
+ ? `⚠ --image-renderer has NO code on-chain — a real deploy REFUSES it; deploy the Solidity renderer first (fork SeedSvgRenderer.sol)`
2271
+ : hasImageRenderer
2272
+ ? `ON-CHAIN — computed by your Solidity image renderer (an in-chain SVG, embedded in tokenURI; no bucket, no runner ✓)`
2273
+ : imageBaseLoopback
2274
+ ? `⚠ --image-base is localhost/loopback — the on-chain image resolves for NO marketplace. Use a PUBLIC bucket (S3/R2/CDN)`
2275
+ : hasImageBase
2276
+ ? `on-chain url-template → your bucket (renders upload there; marketplace-visible ✓)`
2277
+ : resolverInGraph
2278
+ ? `served by the resolver (/image; renders publish to it ✓)`
2279
+ : `⚠ NO PUBLIC DESTINATION — the on-chain image is a placeholder. \`abx render\` to a local store is ORPHANED (marketplaces never see it) and you CANNOT backfill a thumbnail to an on-chain image with no pointer. Decide NOW: --image-renderer <a Solidity SVG renderer>, --image-base <public bucket>, or run a resolver`;
2280
+ // TRAITS — on-chain renderer / off-chain resolver / omitted / codeless-renderer.
2281
+ const attributesRendererCodeless = hasAttributesRenderer && attributesRendererCodePresent === false;
2282
+ const traitsOmitted = !hasAttributesRenderer && !resolverInGraph && !!scriptAnalysis?.traits.present;
2283
+ const traitsBroken = traitsOmitted || attributesRendererCodeless;
2284
+ const traitsDisposition = attributesRendererCodeless
2285
+ ? '⚠ --attributes-renderer has NO code on-chain — a real deploy REFUSES it; deploy the Solidity renderer first (fork SeedTraitsRenderer.sol)'
2286
+ : hasAttributesRenderer
2287
+ ? 'on-chain via --attributes-renderer (verified at deploy)'
2288
+ : resolverInGraph
2289
+ ? "off-chain — the resolver serves the script's JS-derived traits"
2290
+ : scriptAnalysis?.traits.present
2291
+ ? `⚠ OMITTED — the script reports ${scriptAnalysis.traits.keys.length} trait(s) (${scriptAnalysis.traits.keys.join(', ')}) but this lane has NO resolver and NO --attributes-renderer, so they will NOT appear in marketplace metadata`
2292
+ : scriptAnalysis
2293
+ ? 'none (the script reports no traits)'
2294
+ : 'unknown (directory build — not statically analyzed)';
2295
+ // POSTPARAMS — the script reads collector inputs (e.g. `td.palette`) that MUST be declared in
2296
+ // --schema or they're silently dropped at render (default value used). A real session identified
2297
+ // the palette param, then deployed without it.
2298
+ const declaredSchemaKeys = new Set(schemas.map((s) => s.key));
2299
+ const undeclaredParams = (scriptAnalysis?.paramHints ?? []).filter((k) => !declaredSchemaKeys.has(k));
2300
+ const paramsDisposition = scriptAnalysis
2301
+ ? undeclaredParams.length
2302
+ ? `⚠ the script READS ${undeclaredParams.join(', ')} but ${undeclaredParams.length === 1 ? "it isn't" : "they aren't"} in --schema → dropped at render (default used). Add --schema ${undeclaredParams.map((k) => `${k}:<Type>:<Auth>`).join(',')} (a palette collectors set = HexColor:TokenOwner)`
2303
+ : schemas.length
2304
+ ? `${schemas.length} declared: ${schemaSummary}`
2305
+ : 'none (the script reads no collector params)'
2306
+ : schemas.length
2307
+ ? `${schemas.length} declared: ${schemaSummary}`
2308
+ : (hasImageRenderer || hasAttributesRenderer)
2309
+ // Renderer-only lane: the CLI can't introspect an opaque Solidity renderer to know which
2310
+ // PostParams it reads (unlike the JS lane's static paramHints), so nudge — the palette-miss.
2311
+ ? `⚠ none declared — if your Solidity renderer reads ANY collector PostParam, declare EACH with ${bold('--schema <key>:Type:Auth')} (matching the key your renderer reads — e.g. ${bold('palette:HexColor:TokenOwner')}) or that input is FIXED at the renderer's default forever and collectors can't set it (the CLI can't detect the key — a Solidity renderer is opaque, so this is on you to declare)`
2312
+ : 'none declared';
2313
+ // TOKENURI PUBLIC-READ GAS — a large --onchain-uri document assembles on-chain per call; an
2314
+ // unauthenticated public read (Etherscan "Read Contract" with no wallet connected) can hit a client
2315
+ // gas cap and appear to REVERT. Expected, not a bug — a real session mis-diagnosed it as indexing lag.
2316
+ const tokenUriGasRisk = onChainUri && !codeDir && (scriptAnalysis?.doc.estBytes ?? 0) > 30_000;
2317
+ // --onchain-uri legs (ride the setup multicall, before the mints): the animation_url field
2318
+ // pointing at the generator, and the two URI renderers. The param surface needs NO leg — the
2319
+ // generator enumerates it from the token on-chain (`contractParamKeys`/`tokenParamKeys`, kept in
2320
+ // step by the write paths themselves), so there is nothing for a deploy to declare and nothing to
2321
+ // drift. (The retired `params.keys` CSV convention is gone; a legacy project keeps its old generator.)
2322
+ // THE FOLD: on the renderer-only in-chain lane (no program), the on-chain-URI wiring (tokenURI/
2323
+ // contractURI renderers) + the reserve mint move INTO the deploy tx's init params — the token is
2324
+ // fully configured at deploy (the renderers already exist on-chain), so the setup multicall is
2325
+ // needed ONLY for a PostParam schema (and is skipped entirely when there's none → a 1-tx drop).
2326
+ // The script/dir lane is UNCHANGED: its chunks always need the multicall, and its mint must ride
2327
+ // AFTER the chunks (a mint-before-chunks token would be transiently unresolvable), so its
2328
+ // URI-renderers + animation + mint stay in the multicall exactly as before.
2329
+ const foldIntoInit = onChainUri && !hasProgram;
2330
+ // The URI-renderer/animation legs only ride the multicall for a PROGRAM (renderer-only has no
2331
+ // generator and folds the renderers into init).
2332
+ const onchainUriLegs = onChainUri && hasProgram ? onchainUriSetupCalls({ generator, metadataRenderer }) : null;
2333
+ const setupMintCount = foldIntoInit ? 0 : mintCount; // folded mints ride init, not the multicall
2334
+ // Hoisted once so the confirm text (B2's `approvals` clause below), the dry-run's `transactions:`
2335
+ // line, and the wallet-lane sign session's `total` (further down) all read the SAME number —
2336
+ // previously each re-derived it, and the confirm text's own formula silently disagreed with this
2337
+ // one whenever a dependency leg rode the multicall alongside a renderer-only (`foldIntoInit`) fold.
2338
+ const depLegs = deps.length + (depRegistry ? 1 : 0);
2339
+ const setupLen = scriptChunks.length + schemas.length + depLegs + (onchainUriLegs?.length ?? 0) + setupMintCount;
2340
+ const approvals = 1 + (setupLen > 0 ? 1 : 0); // deploy tx + (a setup multicall only if something rides it)
2341
+ // opt-in --confirm: one y/N before ANY upload or send (no-op without --confirm; never blocks scripts).
2342
+ if (!dryRun) {
2343
+ const renderHome = process.env.ABX_STORAGE_BACKEND || 'fs'; // empty string counts as unset (=fs)
2344
+ await confirmSend(`About to deploy code project "${name}" (${symbol}) — ${contentSummary}; ` +
2345
+ (identityFields.length ? `${identityFields.join(' + ')} on-chain; ` : '') +
2346
+ (onChainUri
2347
+ ? `tokenURI ON-CHAIN via renderer ${metadataRenderer}${hasProgram ? ` (generator ${generator})` : ' (renderer-only — image + traits from your Solidity renderers, no animation)'}; `
2348
+ : `resolver base ${baseUrl}; `) +
2349
+ `dependencies: ${deps.length ? deps.map((d) => d.display).join(', ') + (depRegistry ? ` (registry ${depRegistry})` : '') : 'none'}; ` +
2350
+ `mint: ${mintCount > 0 ? `${mintCount} at deploy${foldIntoInit ? ' (in the deploy tx)' : ''}` : 'deferred'}; ` +
2351
+ `tx count: ${foldIntoInit ? (schemas.length ? '2 (deploy + enable your params)' : '1 (everything in the deploy tx)') : '2 (deploy + setup)'}; ` +
2352
+ `approvals: ${approvals} wallet approval(s); ` +
2353
+ `owner+royalty: ${flags.for ? `pinned to ${String(flags.for)}` : flags.sign !== undefined ? '⚠ the wallet you connect (NOT pinned — pass --for to enforce)' : 'your wallet'} @ ${royaltyBps / 100}%.` +
2354
+ (transferValidator !== zeroAddress ? ` ERC-721C: enrolled at deploy, permanently (validator ${transferValidator}).` : '') + `\n` +
2355
+ ` Surfaces — thumbnail: ${imageDisposition}. traits: ${traitsDisposition}. postparams: ${paramsDisposition}.` +
2356
+ ((imageOrphaned || traitsBroken || undeclaredParams.length)
2357
+ ? `\n ⚠ One or more surfaces resolve to NOTHING marketplaces can see — these are DEPLOY-TIME decisions, not backfillable. Re-run --dry-run after fixing, or pass --yes to ship as-is.`
2358
+ : ''), flags);
2359
+ }
2360
+ // --dry-run: emit the full plan (the on-chain values the skill formats into its confirm table),
2361
+ // then stop — no upload, no factory/seed deploy, no send.
2362
+ //
2363
+ // `--resume` skips this preview entirely and previews its OWN plan further down. Without the guard,
2364
+ // `--resume 0xLive --dry-run` printed a fresh-deploy plan complete with a newly-reserved salt and a
2365
+ // different predicted address — a preview of the exact thing the flag exists not to do.
2366
+ if (dryRun && flags.resume === undefined) {
2367
+ // The deployer is needed ONLY to derive the CREATE2 salt → the deterministic address (and the
2368
+ // pinned-salt re-run line). Everything else in the plan — content, deps, chain-complete
2369
+ // expectation, tx count, cost — is deployer-independent, so a wallet-less creator (the common
2370
+ // "before I set up a key" preview) still gets the full readout. Missing deployer ⇒ null, and
2371
+ // the address-dependent lines degrade to a clear placeholder instead of refusing the preview.
2372
+ let deployer = null;
2373
+ if (flags.for)
2374
+ deployer = flags.for;
2375
+ else {
2376
+ try {
2377
+ deployer = makeWalletClient({ chainKey: CHAIN }).account.address;
2378
+ }
2379
+ catch {
2380
+ deployer = null;
2381
+ }
2382
+ }
2383
+ const explicitSalt = parseSaltFlag(flags.salt);
2384
+ const salt = deployer ? (explicitSalt ?? saltFor(deployer)) : null;
2385
+ const predicted = salt
2386
+ ? (await publicClient.readContract({ address: factory, abi: seriesCodeFactoryAbi, functionName: 'predictDeterministicAddress', args: [salt] }))
2387
+ : null;
2388
+ // B1: enforce, don't warn — without --salt, `salt` above was just freshly randomly reserved
2389
+ // (saltFor mixes in entropy), so `predicted` is real for THIS preview but not reproducible by a
2390
+ // plain re-run. Only show it when --salt actually pinned it; every display site below reads
2391
+ // `shownAddr`, never `predicted` directly.
2392
+ const shownAddr = explicitSalt ? predicted : null;
2393
+ step(`Deploy plan — a ${max}-token code project to ${CHAIN}`);
2394
+ if (deployer)
2395
+ info(`deployer ${deployer}`);
2396
+ else
2397
+ info(`deployer: ${dim('not set')} — pass ${bold('--for 0x..')} (or set a signing key) to preview the exact deterministic address; the plan below is otherwise deployer-independent.`);
2398
+ if (shownAddr)
2399
+ info(`deterministic address: ${shownAddr}`);
2400
+ info(`name "${name}" · symbol ${symbol} · max ${max}${maxProvided ? '' : ' ⚠ default — set --max N'} · royalty ${royaltyBps / 100}%${royaltyProvided ? '' : ' ⚠ default — set --royalty-bps N'}`);
2401
+ if (!flags.description)
2402
+ warn(`no --description — the on-chain metadata ships with no description (the one creators most often forget). Add --description "…" or deploy bare.`);
2403
+ info(`content: ${contentSummary}`);
2404
+ info(`seed source: ${seedSource === zeroAddress ? 'none (--no-seed / not configured)' : seedSource}`);
2405
+ info(`PostParam schema(s): ${schemaSummary}`);
2406
+ info(`dependencies: ${deps.length ? deps.map((d, i) => `[${i}] ${d.display}`).join(' · ') + ` — ${deps.length} setDependency leg(s)` : 'none'}` +
2407
+ (depRegistry ? ` + setDependencyRegistry → ${depRegistry}` : hasRegistryDeps ? ' (no registry pointer — see the warning above)' : ''));
2408
+ info(`mint: ${mintCount > 0 ? `${mintCount} token(s) in order → ${deployer ?? 'your wallet'} at deploy` : 'deferred (mint later / external minter)'}`);
2409
+ info(paused ? 'paused: yes — public/minter mint closed until `abx unpause` (owner reserves still mint)' : 'paused: no — mint open at deploy');
2410
+ if (onChainUri) {
2411
+ info(`resolution: ON-CHAIN — tokenURI/contractURI via the metadata renderer ${metadataRenderer === zeroAddress ? '(deployed at run time)' : metadataRenderer}` +
2412
+ (hasProgram
2413
+ ? `; animation_url computed by the generator ${generator} (${codeDir ? 'directory branch — a parameterized gateway URL' : 'template branch — the full HTML document, inline'})`
2414
+ : `; no animation_url (renderer-only — image + attributes computed on-chain by your Solidity renderer(s))`));
2415
+ if (hasProgram) {
2416
+ info(` on-chain URI legs (ride the setup multicall): collection animation_url field (renderer rep → ${generator}) · ` +
2417
+ `setTokenURIRenderer · setContractURIRenderer ${dim('(the param surface enumerates on-chain — no leg, nothing to maintain)')}`);
2418
+ }
2419
+ else {
2420
+ info(` on-chain URI wiring rides the ${bold('deploy tx')} itself (tokenURI/contractURI renderers${mintCount > 0 ? ' + the reserve mint' : ''} in init) — no separate setup tx for it.`);
2421
+ }
2422
+ if (hasProgram && !codeDir) {
2423
+ const expectation = expectedChainComplete(deps, depChecks);
2424
+ info(` chain-complete expectation: ${expectation.expected === true ? g('yes') : expectation.expected === false ? 'no' : 'unknown'} — ${expectation.detail}` +
2425
+ ` ${dim('(abx verify reads onChainStatus from chain after deploy)')}`);
2426
+ }
2427
+ else if (!hasProgram) {
2428
+ info(` ${g('fully on-chain')} — the whole tokenURI (name + image SVG + traits) is computed on-chain; zero dependency outside the EVM ${dim('(abx verify confirms from chain)')}`);
2429
+ }
2430
+ else {
2431
+ info(` directory branch: no-server, not chain-complete — liveness rides the gateway (generator default; the ${bold('display.gateway')} contract param repoints), and the params ride the URL (8KB budget — abx verify reports urlOverBudget).`);
2432
+ }
2433
+ // A resolver is a "kinder serving path" ONLY when there's a live view / off-chain still to
2434
+ // serve — i.e. a program. The in-chain renderer-only lane has neither, so don't imply you
2435
+ // should run one (both haiku agents read this as "you need a resolver" — you don't).
2436
+ if (hasProgram)
2437
+ info(` local register+index still happens — a resolver remains the kinder serving path (live view at a real URL, thumbnail publishing).`);
2438
+ else
2439
+ info(` ${dim('nothing to run — image + traits are on-chain; a resolver is neither needed nor a benefit here.')}`);
2440
+ if (tokenUriGasRisk) {
2441
+ info(` ${dim('public-read note:')} this ${Math.round((scriptAnalysis?.doc.estBytes ?? 0) / 1000)}KB doc assembles on-chain per call, so an ${bold('unauthenticated read on Etherscan')} ("Read Contract", no wallet connected) may hit its gas cap and appear to ${bold('REVERT')} — ${dim('EXPECTED, not a broken token and NOT an indexing issue. Verify with `abx tokenuri`, or connect a wallet / use a high-gas RPC. Do not run `abx index --full` to "fix" it.')}`);
2442
+ }
2443
+ }
2444
+ else {
2445
+ info(`resolution: OFF-CHAIN via your resolver — tokenURI base ${baseUrl}/t · live view ${baseUrl}/a/${chainId}/${shownAddr ?? '<address>'}/{id}`);
2446
+ }
2447
+ const setupBits = [
2448
+ scriptChunks.length ? 'chunks' : '',
2449
+ schemas.length ? 'param schemas' : '',
2450
+ depLegs ? 'deps' : '',
2451
+ onchainUriLegs ? 'on-chain-uri' : '',
2452
+ setupMintCount ? 'mints' : '',
2453
+ ].filter(Boolean).join('/');
2454
+ info(`transactions: ${1 + (setupLen > 0 ? 1 : 0)} (deploy${foldIntoInit ? ' — on-chain-uri wiring + mint fold into it' : ''}${setupLen > 0 ? ` + one setup multicall: ${setupBits}` : ''})` +
2455
+ (foldIntoInit && setupLen === 0 ? ` ${dim('— single transaction, fully self-contained')}` : ''));
2456
+ info(`approvals ${approvals} wallet approval(s)`); // B2 — TX signatures only, the SAME number the tx-count line above already implies
2457
+ // Best-effort cost guidance (owner: a bonus in dry-run, never a blocker — silently skip if the
2458
+ // RPC can't price gas; see cli-ux-decisions). On-chain byte storage dominates a template drop,
2459
+ // so apply the skill's own ~200 gas/byte heuristic to the stored bytes + coarse per-op costs,
2460
+ // priced at the live gas price. Clearly hedged — it's an order-of-magnitude figure, not a quote.
2461
+ try {
2462
+ const chunkBytes = scriptChunks.reduce((n, c) => n + (c.length - 2) / 2, 0);
2463
+ const fieldBytes = contractFields.reduce((n, f) => n + (f.value.length - 2) / 2, 0);
2464
+ const storedBytes = Math.round(chunkBytes + fieldBytes);
2465
+ const roughGas = 250000n + // clone + init (also writes the collection fields)
2466
+ BigInt(scriptChunks.length) * 34000n + // per-chunk SSTORE2 CREATE overhead
2467
+ BigInt(storedBytes) * 216n + // ~200 gas/byte code deposit + ~16 gas/byte calldata
2468
+ BigInt(schemas.length) * 45000n +
2469
+ BigInt(depLegs) * 55000n +
2470
+ BigInt(onchainUriLegs?.length ?? 0) * 60000n +
2471
+ BigInt(mintCount) * 65000n;
2472
+ const gasPrice = await publicClient.getGasPrice();
2473
+ const eth = Number(roughGas * gasPrice) / 1e18;
2474
+ const ethStr = eth >= 0.00001 ? eth.toFixed(5) : '<0.00001';
2475
+ info(`est. on-chain cost: ${dim('~')}${ethStr} ETH ` +
2476
+ dim(`(very rough — ~${Number(roughGas).toLocaleString()} gas @ ${(Number(gasPrice) / 1e9).toFixed(2)} gwei; excludes off-chain storage${codeDir ? ', the dominant cost for a directory drop' : ''})`));
2477
+ }
2478
+ catch { /* gas pricing unavailable — cost guidance is a bonus, skip silently */ }
2479
+ // ── Surfaces — every marketplace-facing dimension, resolved NOW (none is backfillable) ──────
2480
+ const renderHome = process.env.ABX_STORAGE_BACKEND || 'fs'; // empty string counts as unset (=fs)
2481
+ // A surface is BROKEN only when it resolves to nothing a marketplace can see. A dropped param is
2482
+ // NOT that: the token still renders, the param just takes its default. Folding it in here made
2483
+ // the block contradict itself two lines apart — "thumbnail: ON-CHAIN ✓ / traits: on-chain ✓"
2484
+ // followed by "one or more surfaces resolve to NOTHING", re-recommending the very flags that
2485
+ // were already set. The alarm now covers only the surfaces it can honestly speak for, and names
2486
+ // only the remedies for what is actually broken.
2487
+ const anySurfaceBroken = imageOrphaned || traitsBroken;
2488
+ step('Surfaces — what marketplaces will see (all DEPLOY-TIME; not backfillable)');
2489
+ (imageOrphaned ? warn : info)(`thumbnail: ${imageDisposition}`);
2490
+ // Render-mode / render-home guidance is for the RENDERED-still lanes only. The in-chain
2491
+ // Solidity image (--image-renderer) has no off-chain still — nothing to render, host, or refresh.
2492
+ if (!imageOrphaned && !hasImageRenderer) {
2493
+ if (!hasPublicUrl)
2494
+ info(` ${dim('render mode (a still is never on-chain):')} ${bold('service')} (auto-render every mint/param change — a live/for-sale drop) · ${bold('once')} (fixed supply) · ${bold('none')} (placeholder). ${dim('freshness: no resolver ⇒ no chain-watcher ⇒ MANUAL/backfill (`abx render` after mints + each param change; the on-chain animation updates live, the bucket still does NOT). Continuous/live stills ⇒ run a resolver.')}`);
2495
+ // The fs render-home footgun bites the RESOLVER lane hardest (a hosted resolver can't read your
2496
+ // laptop disk → placeholder on OpenSea), so warn on BOTH lanes and name the concrete fix.
2497
+ if (renderHome === 'fs')
2498
+ warn(` render storage home is ${bold('fs')} — LOCAL to this machine, so ${hasPublicUrl ? 'a hosted resolver' : 'a marketplace'} can't read the rendered stills → placeholder forever. FIX before you render: ${bold('set ABX_STORAGE_BACKEND=arweave|s3|ipfs')} in .env (a PUBLIC home the runner uploads to).`);
2499
+ else
2500
+ info(` ${dim('render storage home:')} ${bold(renderHome)} ${dim('— a public home the runner uploads to ✓')}`);
2501
+ }
2502
+ (traitsBroken ? warn : info)(`traits: ${traitsDisposition}`);
2503
+ const paramsNudge = !scriptAnalysis && !schemas.length && (hasImageRenderer || hasAttributesRenderer);
2504
+ ((undeclaredParams.length || paramsNudge ? warn : info))(`postparams: ${paramsDisposition}`);
2505
+ if (anySurfaceBroken) {
2506
+ const remedies = [
2507
+ imageOrphaned && `thumbnail ⇒ ${bold('--image-renderer <Solidity SVG>')} / ${bold('--image-base <public bucket>')} / a resolver`,
2508
+ traitsBroken && `traits ⇒ ${bold('--attributes-renderer')} or a resolver`,
2509
+ ].filter(Boolean).join(' · ');
2510
+ const which = imageOrphaned && traitsBroken ? 'the thumbnail and traits surfaces resolve' : `the ${imageOrphaned ? 'thumbnail' : 'traits'} surface resolves`;
2511
+ warn(`${bold(`${which} to NOTHING a marketplace can see`)} — fix before deploy (a deploy-time decision you can't add later without a re-point tx): ${remedies}.`);
2512
+ }
2513
+ // A dropped param is its own, milder problem: the piece renders, that input just takes its
2514
+ // default. Kept separate from the broken-surface alarm above (see the note there).
2515
+ if (undeclaredParams.length)
2516
+ info(` ${dim('dropped params render with their defaults — declare them to make them real:')} ${bold('--schema <key>:<Type>:<Auth>')}`);
2517
+ // The runner/verify line is for lanes that need an off-chain STILL. An on-chain image renderer has
2518
+ // no still to render, host, or refresh — whether or not there's also a program driving
2519
+ // animation_url — so pointing at a runner and a bucket backend there is simply wrong.
2520
+ if (hasImageRenderer) {
2521
+ info(` ${g('nothing to render')} — the thumbnail is computed on-chain${hasProgram ? ' and the animation assembles on-chain from your script' : ''}; no runner, no bucket, no refresh. ` +
2522
+ `verify from chain: ${bold('abx tokenuri ' + (shownAddr ?? '<address>'))} ${dim('(decodes name + on-chain SVG + traits)')}`);
2523
+ }
2524
+ else {
2525
+ const remoteRender = !(onChainUri && hasImageBase);
2526
+ info(` ${dim('stand up the runner:')} ${bold('abx deploy-effects --resolver-url ' + baseUrl)} · one-shot: ${bold('abx render ' + (shownAddr ?? '<address>') + (remoteRender ? ' --remote ' + baseUrl : ''))} · verify: ${bold('abx verify ' + (shownAddr ?? '<address>'))}`);
2527
+ // Rendering against a resolver you don't share a disk with means YOU hold the bytes and it
2528
+ // holds the URL — so say now which backend that needs, rather than letting the render fail.
2529
+ if (remoteRender && !resolveBackend(storageOptions(storageOverrides(flags))).locator) {
2530
+ info(` ${dim('that render publishes a URL, so it needs a backend that can name one:')} ${bold('--backend cloud')} ${dim('(S3/R2 + public base) ·')} ${bold('ipfs')} ${dim('·')} ${bold('arweave')} ${dim('— or render co-located with the resolver (`abx effects` on its host).')}`);
2531
+ }
2532
+ }
2533
+ // B3: the one render×storage combo validator (packages/storage/content-plan.ts) — the SAME
2534
+ // check that already refused a bad --image-base above (real run) — summarized as one row here,
2535
+ // so a preview never disagrees with what the real run just enforced (or, for the remote-publish
2536
+ // combo, with what `abx render --remote`/`abx effects` enforce later — deploy-code can't know FOR
2537
+ // CERTAIN whether a future render will be remote or co-located, so this best-effort signal
2538
+ // mirrors the nudge above rather than hard-refusing a legitimate co-located workflow).
2539
+ {
2540
+ const renderStorageBackendId = backendResolution(storageOverrides(flags)).backend;
2541
+ const renderStorageOpts = storageOptions(storageOverrides(flags));
2542
+ const renderStorageCombo = validateRenderStorageCombo({
2543
+ imageBaseUrl: hasImageBase ? String(flags['image-base']) : undefined,
2544
+ backendId: renderStorageBackendId,
2545
+ cloudHasPublicBase: renderStorageBackendId === 'cloud' ? !!renderStorageOpts.cloud?.publicBase : undefined,
2546
+ publishesToRemoteResolver: !hasImageRenderer && !(onChainUri && hasImageBase),
2547
+ });
2548
+ if (renderStorageCombo.ok)
2549
+ info(`render/storage ✓ ${renderStorageBackendId}${hasImageBase ? ' + --image-base' : ''}`);
2550
+ else
2551
+ warn(`render/storage ✗ ${renderStorageCombo.reason}`);
2552
+ }
2553
+ if (dirUpload)
2554
+ await noteStorageReadiness(storageOptions(storageOverrides(flags)), dirUpload.sizes);
2555
+ if (deployer && !explicitSalt) {
2556
+ // B1: enforce, don't warn — no predicted address was shown above (see `shownAddr`), so print
2557
+ // the one thing that DOES stay true: the salt itself, pinned via --salt (or `abx predict`)
2558
+ // reproduces this exact address on the real deploy. `salt` is non-null here — it's only ever
2559
+ // null when `deployer` is (see its ternary above), which this branch already checked.
2560
+ const pinnedSalt = salt;
2561
+ const predictContent = flags.script ? `--script ${flags.script}` : flags['code-dir'] ? `--code-dir ${flags['code-dir']}` : flags['image-renderer'] ? `--image-renderer ${flags['image-renderer']}` : '';
2562
+ console.log(`\n ${bold('salt')} ${g(pinnedSalt)}`);
2563
+ info(`address: pinned by salt — re-run with ${bold(`--salt ${pinnedSalt}`)} (same address), or ${bold(`abx predict ${predictContent ? `${predictContent} ` : ''}--salt ${pinnedSalt} --for ${deployer}`)}.`);
2564
+ info(`reproduce this exact preview (salt included): ${bold(deployCodeCommandLine(flags, pinnedSalt))}`);
2565
+ }
2566
+ else if (!deployer) {
2567
+ info(`pass ${bold('--for 0x..')} to see the exact deterministic address + a pinned-salt re-run command.`);
2568
+ }
2569
+ console.log(`\n ${g('dry run')} ${dim('— nothing sent, no bytes stored. Re-run without --dry-run to deploy.')}\n`);
2570
+ // `address` is null without --salt pinning it (B1) OR in the wallet lane with no --for (the
2571
+ // signer decides the salt, so no address exists yet) — reporting null is the honest answer;
2572
+ // inventing one would be a wrong reservation.
2573
+ emit(jsonSafe({ command: 'deploy-code', dryRun: true, sent: false, address: shownAddr, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt: salt ?? null, saltPinned: !!explicitSalt, name, symbol, onChainUri }));
2574
+ return;
2575
+ }
2576
+ // Real deploy: perform the deferred directory upload now (past the confirm gate).
2577
+ if (dirUpload) {
2578
+ const { base } = await dirUpload.backend.putDirectory(dirUpload.entries);
2579
+ // Store the BARE locator (CID / arweave txid), never a full gateway URL — the resolver applies
2580
+ // the gateway at serve time per `representation`. Storing `https://arweave.net/<txid>` with
2581
+ // representation `arweave` double-prefixed the live view to a broken URL.
2582
+ const ipfsCid = base.match(/\/ipfs\/([^/]+)/)?.[1];
2583
+ const arTxid = base.match(/arweave\.net\/([^/?#]+)/i)?.[1];
2584
+ const rep = ipfsCid ? 'ipfs' : dirUpload.backend.id === 'arweave' ? 'arweave' : 'url';
2585
+ const value = ipfsCid ?? (rep === 'arweave' ? (arTxid ?? base) : base);
2586
+ contractFields.push({ field: encodeTag('code'), representation: encodeTag(rep), value: toHex(value) });
2587
+ ok(`uploaded ${dirUpload.entries.length} file(s) → code field (${rep}): ${value}`);
2588
+ }
2589
+ step('Deploy');
2590
+ const lane = laneFromFlags(flags);
2591
+ // --onchain-uri with no explicit public URL: leave the off-chain pointer EMPTY rather than
2592
+ // baking a misleading localhost (the renderer is authoritative while set) — the same rule as
2593
+ // the 1/1's on-chain lane. The renderers themselves are set as setup-multicall legs, so the
2594
+ // on-chain lane flips on atomically WITH the content/params it resolves from.
2595
+ const initParamsFor = (owner) => ({
2596
+ owner,
2597
+ name,
2598
+ symbol,
2599
+ tokenURIBase: onChainUri && !hasPublicUrl ? '' : `${baseUrl}/t`,
2600
+ // Renderer-only fold: the metadata renderer (what makes tokenURI resolve on-chain) rides the
2601
+ // deploy tx. The script/dir lane still sets it via the multicall (0 here), because its content
2602
+ // isn't on-chain until that same multicall — flipping the renderer on before the chunks land
2603
+ // would leave a transient window of broken tokenURIs.
2604
+ tokenURIRenderer: foldIntoInit ? metadataRenderer : zeroAddress,
2605
+ contractURIBase: onChainUri && !hasPublicUrl ? '' : `${baseUrl}/c`,
2606
+ contractURIRenderer: foldIntoInit ? metadataRenderer : zeroAddress,
2607
+ royaltyReceiver: owner,
2608
+ royaltyBps,
2609
+ transferValidator,
2610
+ maxInvocations: BigInt(max),
2611
+ primaryPayee: flags['primary-payee'] ?? zeroAddress,
2612
+ minter: flags.minter ?? zeroAddress,
2613
+ paused,
2614
+ seedSource,
2615
+ disableTokenOwnerDelegation: flags['no-delegation'] !== undefined,
2616
+ // Reserve mints fold into init ONLY for renderer-only (the token is fully configured at deploy).
2617
+ mintTo: foldIntoInit && mintCount > 0 ? owner : zeroAddress,
2618
+ mintCount: foldIntoInit ? BigInt(mintCount) : 0n,
2619
+ tokenFields: [],
2620
+ contractFields,
2621
+ });
2622
+ // The setup legs, GROUPED. The normal deploy flattens them; `--resume` diffs them against chain
2623
+ // state and sends only what is missing (see resume.ts). One builder for both, so a resume can never
2624
+ // drift from what a fresh deploy would have written — a second implementation of this sequence is
2625
+ // the failure mode a repair verb most easily introduces.
2626
+ const setupLegGroups = (owner) => ({
2627
+ chunks: scriptChunks.map((chunk, i) => ({
2628
+ index: i,
2629
+ hex: chunk,
2630
+ data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setScriptChunk', args: [BigInt(i), chunk] }),
2631
+ })),
2632
+ schemas: schemas.map(({ key, paramType, auth, authAddress, lockAfter, min, max, selectOptions }) => ({
2633
+ key,
2634
+ data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setParamSchema', args: [encodeTag(key), paramType, auth, authAddress, lockAfter, min, max, selectOptions] }),
2635
+ })),
2636
+ deps: { count: deps.length, registry: depRegistry ?? null, calls: dependencySetupCalls(deps, depRegistry) },
2637
+ // program lane only: animation_url field · the URI renderers (before the mints)
2638
+ uri: { calls: onchainUriLegs ?? [], animationField: onchainUriLegs?.length ? F.animationUrl : null },
2639
+ // The INTENDED TOTAL, not a count to add — a resume mints the shortfall. `mintCount` covers both
2640
+ // lanes: folded-into-init (renderer-only) and setup-carried, since either way it is what the
2641
+ // creator asked for.
2642
+ mints: { intendedTotal: mintCount, data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'mint', args: [owner] }) },
2643
+ });
2644
+ const setupCalls = (owner) => {
2645
+ const g = setupLegGroups(owner);
2646
+ return [
2647
+ ...g.chunks.map((c) => c.data),
2648
+ ...g.schemas.map((x) => x.data),
2649
+ ...g.deps.calls,
2650
+ ...g.uri.calls,
2651
+ // folded mints ride init, not here — so this uses setupMintCount, not the intended total.
2652
+ ...Array.from({ length: setupMintCount }, () => g.mints.data),
2653
+ ];
2654
+ };
2655
+ const cid = resolveChain(CHAIN).id;
2656
+ const preparedFor = async (owner) => {
2657
+ const salt = parseSaltFlag(flags.salt) ?? saltFor(owner);
2658
+ const clone = (await publicClient.readContract({ address: factory, abi: seriesCodeFactoryAbi, functionName: 'predictDeterministicAddress', args: [salt] }));
2659
+ const calls = setupCalls(owner);
2660
+ const txs = [prepareDeploySeriesCode({ factory, params: initParamsFor(owner), salt, chainId: cid, clone })];
2661
+ if (calls.length)
2662
+ 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 }));
2663
+ return { clone, txs };
2664
+ };
2665
+ // ── --resume: finish an EXISTING contract, deploy nothing (backlog B15) ─────────────────────
2666
+ // A code deploy is two transactions. When the second fails you own a live-but-unusable contract and
2667
+ // the salt reserved for its address is spent, so the dry run's pinned-salt reproduce command can
2668
+ // never be run again. The contract is recoverable, not lost — a tester proved that by resending the
2669
+ // setup by hand with `cast`. This is that, as a verb: read what is missing, send only that.
2670
+ const resumeAddr = flags.resume;
2671
+ if (resumeAddr !== undefined) {
2672
+ if (!/^0x[0-9a-fA-F]{40}$/.test(resumeAddr)) {
2673
+ throw new Error(`--resume wants the address of the contract to finish; '${resumeAddr}' isn't a 0x address.`);
2674
+ }
2675
+ const target = resumeAddr;
2676
+ const code = await publicClient.getCode({ address: target }).catch(() => undefined);
2677
+ if (!code || code === '0x') {
2678
+ throw new Error(`--resume ${target}: no contract at that address on ${CHAIN} (asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}). ` +
2679
+ `There is nothing to finish — if the DEPLOY tx is what failed, run a normal deploy instead. ` +
2680
+ `If you JUST deployed, give the tx a block or two to mine.`);
2681
+ }
2682
+ // Deploy-time-only flags are refused rather than ignored: --salt/--721c/--bootstrap-factory all
2683
+ // describe how a contract is CREATED, and this creates nothing. 721C especially — enrollment is
2684
+ // permanent and deploy-time-only, so accepting the flag here would imply it can be added later.
2685
+ for (const [flag, why] of [
2686
+ ['salt', 'the address already exists, so no salt is used'],
2687
+ ['721c', 'ERC-721C enrollment is deploy-time-only and PERMANENT — it cannot be added to an existing collection'],
2688
+ ['bootstrap-factory', 'no factory is involved: nothing is being created'],
2689
+ ['mint-all', 'use --mint-count <n> on a resume; --mint-all is resolved against the cap at deploy time'],
2690
+ ]) {
2691
+ if (flags[flag] !== undefined)
2692
+ throw new Error(`--resume cannot be combined with --${flag}: ${why}.`);
2693
+ }
2694
+ const owner = await publicClient
2695
+ .readContract({ address: target, abi: seriesCodeAbi, functionName: 'owner' })
2696
+ .catch(() => undefined);
2697
+ if (!owner)
2698
+ throw new Error(`--resume ${target}: could not read owner() — is this an ABX SeriesCode contract?`);
2699
+ step('Resume — read what is missing, send only that');
2700
+ info(`target ${bold(target)} ${dim(`· owner ${owner}`)}`);
2701
+ // Every read is a view on the target; the intended legs come from the SAME builder a fresh deploy
2702
+ // uses, so what gets sent is by construction what the deploy would have written.
2703
+ const legs = setupLegGroups(owner);
2704
+ // ResumeReader's contract is `T | null` (its planner distinguishes "read failed" from a
2705
+ // legitimately-falsy result); tryReadContract's is `T | undefined` — adapt at the boundary.
2706
+ const readAt = async (functionName, args = []) => {
2707
+ const result = await tryReadContract(publicClient, { address: target, abi: seriesCodeAbi, functionName, args });
2708
+ return result === undefined ? null : result;
2709
+ };
2710
+ const nonZero = (a) => (a && a !== zeroAddress ? a : null);
2711
+ const reader = {
2712
+ scriptChunkCount: async () => Number((await readAt('scriptChunkCount')) ?? 0n),
2713
+ scriptChunk: (index) => readAt('scriptChunk', [BigInt(index)]),
2714
+ schemaExists: async (key) => {
2715
+ const r = await readAt('paramSchema', [encodeTag(key)]);
2716
+ return !!r?.[0];
2717
+ },
2718
+ dependencyCount: async () => Number((await readAt('dependencyCount')) ?? 0n),
2719
+ dependencyRegistry: async () => nonZero(await readAt('dependencyRegistry')),
2720
+ tokenURIRenderer: async () => nonZero(await readAt('tokenURIRenderer')),
2721
+ contractURIRenderer: async () => nonZero(await readAt('contractURIRenderer')),
2722
+ contractFieldSet: async (field) => {
2723
+ const r = await readAt('contractField', [encodeTag(field)]);
2724
+ return !!r && r[0] !== `0x${'0'.repeat(64)}`;
2725
+ },
2726
+ totalSupply: async () => Number((await readAt('totalSupply')) ?? 0n),
2727
+ };
2728
+ const plan = await planResume(reader, legs);
2729
+ for (const line of plan.done)
2730
+ ok(line);
2731
+ for (const line of plan.todo)
2732
+ info(`will send — ${line}`);
2733
+ if (!plan.calls.length) {
2734
+ ok(`nothing missing — this contract's setup is already complete. ${dim('Confirm with `abx verify ' + target + '`.')}`);
2735
+ emit(jsonSafe({ command: 'deploy-code', resumed: target, chain: CHAIN, chainId: resolveChain(CHAIN).id, sent: false, complete: true, sentLegs: 0 }));
2736
+ console.log('');
2737
+ return;
2738
+ }
2739
+ const tx = prepareCodeSetup({
2740
+ contract: target,
2741
+ calls: plan.calls,
2742
+ chainId: resolveChain(CHAIN).id,
2743
+ chunkCount: plan.sending.chunkIndices.length,
2744
+ chunkBytes: plan.sending.chunkBytes,
2745
+ schemaKeys: plan.sending.schemaKeys,
2746
+ deps: plan.sending.deps ? deps.map((d) => d.display) : [],
2747
+ onchainUri: plan.sending.uri,
2748
+ });
2749
+ // Routed through the shared risk gate — the same choke point ownerops.ts's `runWrite` uses for
2750
+ // every owner-op: --dry-run preview / --confirm prompt / lane selection / signing, all one way.
2751
+ // This used to be its own hand-rolled copy of that exact shape (a dry-run print then confirmSend
2752
+ // then signTx, duplicated from ownerops.ts's `runWrite`); a deploy-family write is a write like
2753
+ // any other, so it takes the identical gate (the preview also gains the `to`/`owner` lines
2754
+ // `runWrite`'s preview always printed).
2755
+ if (isDryRun(flags)) {
2756
+ emit(jsonSafe({ command: 'deploy-code', resumed: target, chain: CHAIN, chainId: resolveChain(CHAIN).id, dryRun: true, sent: false, complete: false, sentLegs: plan.calls.length }));
2757
+ }
2758
+ const result = await gatedSend(() => tx, flags, { chainKey: CHAIN, expectedSigner: owner });
2759
+ if (!result) {
2760
+ if (isDryRun(flags))
2761
+ return;
2762
+ console.log(`\n${dim(' unsigned — broadcast it, then re-run `abx verify ' + target + '` to confirm the setup completed.')}\n`);
2763
+ emit(jsonSafe({ command: 'deploy-code', resumed: target, chain: CHAIN, chainId: resolveChain(CHAIN).id, sent: false, complete: false, sentLegs: plan.calls.length }));
2764
+ return;
2765
+ }
2766
+ ok(`${tx.summary}`);
2767
+ // Locals, not the outer clone/deployBlock: this branch never falls through to the deploy path, and
2768
+ // the authoritative scan floor is still the clone's CREATION block (getCode search), not this
2769
+ // repair tx's block — flooring the resolver above the deploy would hide the `code` field again.
2770
+ const resumedFloor = (await discoverDeployBlock(publicClient, target)) ?? result.blockNumber;
2771
+ emit(jsonSafe({
2772
+ command: 'deploy-code',
2773
+ resumed: target,
2774
+ address: target,
2775
+ chain: CHAIN,
2776
+ chainId: resolveChain(CHAIN).id,
2777
+ deployBlock: resumedFloor,
2778
+ sent: true,
2779
+ complete: true,
2780
+ sentLegs: plan.calls.length,
2781
+ txHash: result.txHash,
2782
+ }));
2783
+ step('Index');
2784
+ await registerAndIndexLocally(target, { 'from-block': resumedFloor.toString(), factory, label: name });
2785
+ console.log(`\n ${g('\u2713 setup finished')} \u2014 ${dim('confirm it resolves:')} ${bold(`abx verify ${target}`)}` +
2786
+ `${onChainUri ? dim(' (chain-complete + the on-chain tokenURI)') : ''}\n`);
2787
+ return;
2788
+ }
2789
+ let clone;
2790
+ let deployBlock;
2791
+ if (lane === 'unsigned') {
2792
+ // cold lane: deterministic deploy makes the whole sequence pre-computable — the clone
2793
+ // address is a pure function of (factory, salt), so setup + mints target it up front.
2794
+ const signer = flags.for;
2795
+ if (!signer)
2796
+ throw new Error('--unsigned needs --for <signer> — the salt guard and ownership are keyed to the signing address');
2797
+ const { clone: predicted, txs } = await preparedFor(signer);
2798
+ for (const tx of txs) {
2799
+ await signTx(() => tx, { lane, chainKey: CHAIN, yes: !!flags.yes, expectedSigner: signer });
2800
+ }
2801
+ console.log(`\n${dim(` unsigned — broadcast in order, then: abx add ${predicted} --factory ${factory} --from-block <deployBlock>`)}\n`);
2802
+ return;
2803
+ }
2804
+ if (lane === 'sign') {
2805
+ // wallet lane: one session approves the deploy + the setup multicall (+ mints ride the
2806
+ // multicall — msg.sender is preserved, so owner-auth holds). `total: approvals` (not a bare `2`)
2807
+ // — B2 found this hardcoded to 2 even though `preparedFor` sends just the ONE deploy tx whenever
2808
+ // setupLen is 0 (no chunks/schemas/deps/on-chain-uri legs/setup-carried mints); the sign page
2809
+ // would have shown "Transaction 1 of 2" and then silently never asked for a second. `approvals`
2810
+ // is computed with the exact same `setupLen` math `preparedFor`'s own tx-count uses, so it can't
2811
+ // drift from what this session actually sends.
2812
+ const session = await openWalletSession({ chainKey: CHAIN, expectedSigner: flags.for, total: approvals, port: flags.port ? Number(flags.port) : undefined, signUrlFile: flags['sign-url-file'] });
2813
+ try {
2814
+ const signer = await session.connect();
2815
+ const { clone: predicted, txs } = await preparedFor(signer);
2816
+ // The scan floor is the clone-CREATION block (txs[0]) — NOT the last tx. A wallet session can
2817
+ // span blocks (deploy at N, setup+mint at N+2), and the `code` field is written in the deploy
2818
+ // tx's init params; recording the last (mint) block floored the resolver ABOVE it, so it never
2819
+ // indexed the code field → "not a code project" on an otherwise-correct deploy.
2820
+ let first;
2821
+ for (const tx of txs) {
2822
+ const sent = await session.send(tx);
2823
+ if (first === undefined)
2824
+ first = sent.receipt.blockNumber;
2825
+ }
2826
+ clone = predicted;
2827
+ deployBlock = first ?? 0n;
2828
+ }
2829
+ finally {
2830
+ session.close();
2831
+ }
2832
+ }
2833
+ else {
2834
+ // hot lane: the env key signs the WHOLE sequence (deploy, then the setup multicall that
2835
+ // targets the clone the deploy just created) through one `makeHotSender` — nonce pinned once,
2836
+ // gas re-checked per tx against a target a prior tx in this run may just have created. See
2837
+ // `signHotSequence` (signer.ts) / `makeHotSender` (sdk execute.ts) for the read-after-write-lag
2838
+ // reasoning this used to be a hand-rolled loop for.
2839
+ const { account } = makeWalletClient({ chainKey: CHAIN });
2840
+ const { clone: predicted, txs } = await preparedFor(account.address);
2841
+ const results = await signHotSequence(txs, { chainKey: CHAIN, yes: !!flags.yes });
2842
+ clone = predicted;
2843
+ deployBlock = results[0]?.blockNumber ?? 0n;
2844
+ }
2845
+ // Authoritative floor: a receipt block can be wrong (a wallet session that spans blocks) or stale
2846
+ // (a re-run against a pre-existing deterministic clone reports THIS run's block, not the original —
2847
+ // exactly the retry case). discoverDeployBlock reads the clone's real creation block from chain
2848
+ // (getCode binary search) — the same helper `add` uses when no floor is known. It's the scan floor
2849
+ // that lets the resolver see the `code` field, so trust chain over the receipt.
2850
+ const discovered = await discoverDeployBlock(publicClient, clone);
2851
+ if (discovered !== null)
2852
+ deployBlock = discovered;
2853
+ ok(`SeriesCode live: ${clone} ${dim(`(from block ${deployBlock})`)}`);
2854
+ // Emitted here rather than at the end: a code deploy is TWO transactions, and if the second (setup)
2855
+ // fails, the address of the live-but-incomplete contract is the single most valuable thing a caller
2856
+ // can be told — it is exactly the input `--resume <address>` takes to finish the job.
2857
+ emit(jsonSafe({
2858
+ command: 'deploy-code',
2859
+ address: clone,
2860
+ chain: CHAIN,
2861
+ chainId: resolveChain(CHAIN).id,
2862
+ factory,
2863
+ deployBlock,
2864
+ name,
2865
+ symbol,
2866
+ onChainUri,
2867
+ }));
2868
+ step('Index');
2869
+ await registerAndIndexLocally(clone, { 'from-block': deployBlock.toString(), factory, label: name });
2870
+ if (onChainUri) {
2871
+ step('On-chain URI');
2872
+ ok(`tokenURI/contractURI resolve ON-CHAIN via the renderer ${metadataRenderer} — any RPC returns the metadata, no server in the graph`);
2873
+ if (hasProgram)
2874
+ info(`animation_url computes on-chain via the generator ${generator} — it enumerates this token's params from chain, so there is no key list to maintain`);
2875
+ else
2876
+ info(`renderer-only — image + attributes computed on-chain by your Solidity renderer(s); no animation_url · ${g('fully on-chain')}, zero dependency outside the EVM`);
2877
+ if (hasProgram && !codeDir) {
2878
+ const expectation = expectedChainComplete(deps, depChecks);
2879
+ info(`chain-complete expectation: ${expectation.expected === true ? g('yes') : expectation.expected === false ? 'no' : 'unknown'} — ${expectation.detail}`);
2880
+ }
2881
+ else if (codeDir) {
2882
+ info(`directory branch: no-server, not chain-complete — liveness rides the gateway; params ride the URL (8KB budget).`);
2883
+ }
2884
+ console.log(` ${bold(`abx verify ${clone}`)} ${dim('# reads onChainStatus (branch · chain-complete · unresolved refs · URL budget) + decodes tokenURI from chain')}`);
2885
+ }
2886
+ // Even fully on-chain, a code project still wants the resolver + effect runner for the KINDER
2887
+ // serving path (a live view at a real URL, and the thumbnail — the still is ALWAYS rendered
2888
+ // off-chain by the runner; the on-chain lane has no image shortcut). Without --onchain-uri the
2889
+ // resolver is load-bearing: keep it up (metadata + live view) AND the effect runner (thumbnail
2890
+ // + traits). Spell out both, and whether the resolver is local (serve here) or hosted
2891
+ // (register with `add --remote`).
2892
+ // The in-chain Solidity lane (image computed on-chain, no program): NOTHING runs. Skip every
2893
+ // resolver/thumbnail/runner instruction — those are for JS/rendered lanes and only confuse here.
2894
+ const fullyInChain = hasImageRenderer && !hasProgram;
2895
+ if (fullyInChain) {
2896
+ console.log(`\n${g('Fully on-chain — nothing to run.')} ${dim('image + traits are computed on-chain by your Solidity renderer(s); the tokenURI resolves from any RPC forever — no resolver, no bucket, no effect runner.')}`);
2897
+ console.log(`\n ${bold('Verify (from chain, zero servers):')} ${bold(`abx tokenuri ${clone}`)} ${dim('— decodes name + the on-chain SVG image + traits straight from the contract')}`);
2898
+ console.log(` ${dim('(a param change re-addresses the on-chain image automatically — the renderer reads it live; there is no still to re-render.)')}\n`);
2899
+ return;
2900
+ }
2901
+ const isRemoteBase = !/^https?:\/\/(localhost|127\.0\.0\.1)\b/i.test(baseUrl);
2902
+ console.log(`\n${g('Code project deployed.')} ${dim(onChainUri
2903
+ ? 'tokenURI is on-chain; a resolver remains the kinder serving path (live view + thumbnail publishing):'
2904
+ : 'It resolves through your resolver — keep these running:')}`);
2905
+ if (isRemoteBase)
2906
+ console.log(` ${bold(`abx add ${clone} --remote`)} ${dim('# register with the hosted resolver (a local deploy does NOT) — serves metadata + the live view')}`);
2907
+ else
2908
+ console.log(` ${bold('abx serve')} ${dim('# stand up the resolver (metadata + live view); a real launch needs a PUBLIC resolver, not localhost')}`);
2909
+ if (hasImageRenderer) {
2910
+ // Image is on-chain (a Solidity renderer) but there's still a JS animation/live view here — so no
2911
+ // still to render, but the resolver still serves the live view.
2912
+ console.log(`\n ${bold('Thumbnail')} ${dim('— computed ON-CHAIN by your Solidity image renderer; nothing to render or host.')}`);
2913
+ }
2914
+ else {
2915
+ // Rendering is a decision the creator should have made BEFORE deploy (the --dry-run readout + the
2916
+ // skill surface it up front); this is the reminder of how to ACT on the mode already chosen. The
2917
+ // still is rendered OFF-CHAIN — no runner ⇒ placeholder thumbnail.
2918
+ console.log(`\n ${bold('Thumbnail')} ${dim('— rendered off-chain; act on the mode you chose (auto-render needs a runner):')}`);
2919
+ console.log(` ${dim('• continuous auto-render')} ${dim('(recommended for a live/for-sale drop):')} ${bold('abx effects')} ${dim('runs it LOCALLY (in-process, background it) · ')}${bold(`abx deploy-effects --resolver-url ${baseUrl}`)} ${dim('scaffolds a HOSTED (fly/docker) runner')}`);
2920
+ console.log(` ${dim('• one-shot')} ${dim('(fixed supply):')} ${bold(`abx render ${clone}${isRemoteBase ? ' --remote' : ''}`)} ${dim('after mint — re-run for later mints / param changes')}`);
2921
+ console.log(` ${dim('• none:')} skip it — the live view still animates, but the marketplace thumbnail stays a placeholder SVG`);
2922
+ if (onChainUri && !hasPublicUrl) {
2923
+ console.log(` ${c.orange}⚠${c.reset} ${dim('no resolver = no chain-watcher: thumbnails are')} ${bold('backfill/manual')}${dim(' — re-run `abx render` after mints AND after each PostParam change (the on-chain animation updates live; the still does not auto-refresh). Want continuous/live thumbnails? Stand up an off-chain resolver — it is the watcher that auto-notifies the runner.')}`);
2924
+ }
2925
+ }
2926
+ if (dirUpload?.backend.id === 'arweave') {
2927
+ console.log(`\n ${c.orange}⚠${c.reset} ${bold('Arweave upload propagates with a delay.')} ${dim('Turbo settles the bundle over minutes (sometimes longer); until then the gateway 404s, so the live view and a render will fail. A render run now correctly reports the content is not servable yet and stores NOTHING (not a garbage 404 thumbnail) — this is expected. Re-run the render once the content is live (the effects service will pick it up on its next sweep).')}`);
2928
+ }
2929
+ console.log(`\n ${bold('Verify after minting token 0:')} ${bold(`abx verify ${clone}`)} ${dim('— confirms the thumbnail is a real render, not the placeholder')}`);
2930
+ console.log(` ${dim('live view:')} ${baseUrl}/a/${chainId}/${clone}/0 ${dim('· tokenURI:')} abx tokenuri ${clone}\n`);
2931
+ }
2932
+ /**
2933
+ * Resolve `--721c` into the InitParams `transferValidator`. Absent → `zeroAddress`: a plain
2934
+ * ERC-721, byte-for-byte the pre-721C deploy — no prompts, no output, no mention of 721C.
2935
+ * `--721c` / `--721c recommended` → the per-chain recommended validator (refused, naming the
2936
+ * chains that have one, when the manifest has no entry); `--721c 0x…` → checksum-validated AND
2937
+ * pre-checked for code on this chain (the factory would revert `InvalidTransferValidator()` —
2938
+ * surface it before any upload/staging/gas). When enrolling, prints the one plain statement of
2939
+ * what enforcement means — once, adapted to the chosen validator.
2940
+ */
2941
+ export async function resolveTransferValidatorFlag(flags, publicClient, dryRun) {
2942
+ const raw = flags['721c'];
2943
+ if (raw === undefined)
2944
+ return zeroAddress;
2945
+ const chainId = resolveChain(CHAIN).id;
2946
+ const validator = parseTransferValidatorValue(raw, { chainId, chainLabel: CHAIN });
2947
+ // Has-code precheck — for the recommended constant too (presence on THIS chain is the fact that
2948
+ // matters; a sandbox/private chain won't have it). A dry run tolerates an unreachable RPC (it
2949
+ // sends nothing); a real deploy refuses to enroll blind.
2950
+ let code;
2951
+ try {
2952
+ code = await publicClient.getCode({ address: validator });
2953
+ }
2954
+ catch (err) {
2955
+ if (!dryRun)
2956
+ throw new Error(`--721c: couldn't verify the validator has code at ${validator} (${err.message}) — refusing to enroll blind; retry when the RPC answers.`);
2957
+ code = null; // offline preview — note it below
2958
+ }
2959
+ if (code === null) {
2960
+ info(dim(`--721c: RPC unreachable, so the validator code check is deferred — a real deploy verifies ${validator} has code first.`));
2961
+ }
2962
+ else if (!code || code === '0x') {
2963
+ const rec = resolveRecommendedTransferValidator(chainId);
2964
+ throw new Error(`--721c: no contract code at ${validator} on ${CHAIN} — the deploy would revert InvalidTransferValidator(). ` +
2965
+ `A transfer validator must be a DEPLOYED contract on this chain` +
2966
+ (rec && rec !== validator ? ` (the known-good one: --721c recommended → ${rec})` : '') + `.`);
2967
+ }
2968
+ // The one plain statement — printed once, only when enrolling. Never appears un-enrolled.
2969
+ info(validator === resolveRecommendedTransferValidator(chainId)
2970
+ ? `ERC-721C enforcement: only owner-initiated transfers and OpenSea-authorized sales will transfer; other marketplaces/operators are blocked. Manage with \`abx set-transfer-validator\`.`
2971
+ : `ERC-721C enforcement: every non-mint transfer is checked by validator ${validator} — its policy decides which operators may transfer. Manage with \`abx set-transfer-validator\`.`);
2972
+ return validator;
2973
+ }
2974
+ //# sourceMappingURL=deploy.js.map