@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
package/dist/ownerops.js CHANGED
@@ -6,24 +6,28 @@
6
6
  * served state reflects the change. The agent picks the lane; the human only
7
7
  * approves (wallet lane) or it's the env key (hot lane).
8
8
  */
9
- import { assertChainId, ensureChunkStore as sdkEnsureChunkStore, predictFixedPriceMinter, encodeReader, makePublicClient, makeWalletClient, oneOfOneImageAbi, seriesImageAbi, abxFixedPriceMinterAbi, deployFixedPriceMinter, prepareConfigureSale, preparePurchase, planChunks, planContentTxs, prepareLockContractField, prepareLockContractURI, prepareLockTokenField, prepareLockTokenURI, prepareMint, prepareSeriesMintMany, prepareSetMinter, prepareSetMaxInvocations, prepareSetPrimaryPayee, prepareSetPaused, prepareSetContractField, prepareSetContractURIBase, prepareSetContractURIOverride, prepareSetContractURIRenderer, prepareSetParamHooks, prepareSetParamSchema, prepareRetireParam, readParamSchema, PARAM_TYPES, prepareSetRoyalty, prepareSetTokenField, batchOps, prepareSetTokenURIBase, prepareSetTokenURIOverride, prepareSetTokenURIRenderer, prepareSetTransferValidator, prepareTransfer, prepareTransferOwnership, readCreatorTokenStatus, resolveRecommendedTransferValidator, RECOMMENDED_TRANSFER_VALIDATOR, KNOWN_CHAIN_KEYS, resolveChain, resolveRpcUrl, redactRpcUrl, DEFAULT_CHAIN_KEY, stageContent, encodeTag, METADATA_FIELD as F, METADATA_REPRESENTATION as R, } from '@artblocks/abx-sdk';
10
- import { SelfHostIndexer } from '@artblocks/abx-indexer';
11
- import { encodeScalarParam, encodeTag as encodeTagSdk, isAccepted, prepareConfigureTokenParam, prepareConfigureTokenParamData, prepareSetContractParam, prepareSetContractParamData, seriesCodeAbi, } from '@artblocks/abx-sdk';
12
- import { hasParamEnumeration } from './onchain-uri.js';
9
+ import { assertChainId, ensureChunkStore as sdkEnsureChunkStore, predictFixedPriceMinter, makeHotSender, makePublicClient, makeWalletClient, oneOfOneImageAbi, seriesImageAbi, deployFixedPriceMinter, prepareConfigureSale, preparePurchase, readSaleConfig, prepareLockContractField, prepareLockContractURI, prepareLockTokenField, prepareLockTokenURI, prepareMint, mintedTokenIds, prepareSeriesMintMany, prepareSetMinter, prepareSetMaxInvocations, prepareSetPrimaryPayee, prepareSetPaused, prepareSetContractField, prepareSetContractURIBase, prepareSetContractURIOverride, prepareSetContractURIRenderer, prepareSetParamHooks, prepareSetParamSchema, prepareRetireParam, readParamSchema, PARAM_TYPES, prepareSetRoyalty, prepareSetTokenField, batchOps, prepareSetTokenURIBase, prepareSetTokenURIOverride, prepareSetTokenURIRenderer, prepareSetTransferValidator, prepareTransfer, prepareTransferOwnership, readCreatorTokenStatus, resolveRecommendedTransferValidator, RECOMMENDED_TRANSFER_VALIDATOR, KNOWN_CHAIN_KEYS, resolveChain, resolveRpcUrl, redactRpcUrl, encodeTag, METADATA_FIELD as F, METADATA_REPRESENTATION as R, stageFieldContent, planStagedContent, exceedsOnchainSoftLimit, ONCHAIN_IMAGE_SOFT_LIMIT, ONCHAIN_PROJECT_SOFT_LIMIT, } from '@artblocks/abx-sdk';
10
+ import { encodeScalarParam, encodeTag as encodeTagSdk, isAccepted, prepareConfigureTokenParam, prepareConfigureTokenParamData, prepareSetContractParam, prepareSetContractParamData, seriesCodeAbi, tryReadContract, } from '@artblocks/abx-sdk';
11
+ import { hasParamEnumeration } from '@artblocks/abx-sdk';
13
12
  import { decodeFieldRenderer, resolveGenerator, DEP_RESOLUTION, parseDependencyRef, prepareLockDependencies, prepareRemoveLastDependency, prepareSetDependency, prepareSetDependencyRegistry, } from '@artblocks/abx-sdk';
14
13
  import { contentTypeFromPath } from '@artblocks/abx-storage';
15
14
  import { toHex as toHexSdk } from 'viem';
16
15
  import { readFileSync } from 'node:fs';
17
16
  import { basename, resolve as resolvePath } from 'node:path';
18
17
  import { gzipSync } from 'node:zlib';
19
- import { decodeEventLog, formatEther, getAddress, isAddress, parseEther, toHex, zeroAddress } from 'viem';
20
- import { fixedPriceMinterAddress } from './config.js';
21
- import { openWalletSession, signTx } from './signer.js';
18
+ import { formatEther, getAddress, isAddress, parseEther, toHex, zeroAddress } from 'viem';
19
+ import { CHAIN, chainId, fixedPriceMinterAddress, localIndexer } from './config.js';
20
+ import { CliError } from './errors.js';
21
+ import { openWalletSession } from './signer.js';
22
+ import { gatedSend, laneFromFlags } from './riskgate.js';
22
23
  import { withJson } from './jsonout.js';
23
24
  import { resolveRemote, serviceClient } from './remote.js';
24
- import { positionalArgs, unknownFlags } from './flags.js';
25
+ import { isDryRun, positionalArgs, unknownFlags } from './flags.js';
25
26
  import { parseSchemaSpecs, describeSchema } from './schema.js';
26
- const CHAIN = process.env.ABX_CHAIN ?? DEFAULT_CHAIN_KEY; // base-sepolia default — MUST match main.ts/config.ts (a stale 'sepolia' here silently ran every owner-op on the wrong chain)
27
+ // Re-exported for main.ts (the on-chain-vs-off-chain cost model + the compression-mode type now
28
+ // live in the SDK's staging.ts, layered on planChunks/planContentTxs — see the comment at their
29
+ // definition below).
30
+ export { ONCHAIN_PROJECT_SOFT_LIMIT };
27
31
  // ── ANSI (local) ─────────────────────────────────────────────────────────────
28
32
  const C = { reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m', green: '\x1b[38;5;115m', yellow: '\x1b[38;5;221m' };
29
33
  const dim = (s) => `${C.dim}${s}${C.reset}`;
@@ -58,7 +62,7 @@ function fitsLiteralBytes32(value) {
58
62
  return value.length <= 31 && /^[\x20-\x7e]+$/.test(value);
59
63
  }
60
64
  /** Flags `abx attach` recognizes — anything else warns (non-fatal), so a silent no-op flag surfaces. */
61
- const ATTACH_FLAGS = ['file', 'compress', 'collection', 'token', 'send', 'sign', 'unsigned', 'yes', 'dry-run', 'port', 'sign-url-file', 'remote'];
65
+ const ATTACH_FLAGS = ['file', 'compress', 'collection', 'token', 'send', 'sign', 'unsigned', 'yes', 'dry-run', 'confirm', 'port', 'sign-url-file', 'remote'];
62
66
  /** Auto-detect the on-chain representation for an off-chain locator by its URI scheme. Returns null
63
67
  * for anything that isn't a recognized durable/http locator (so `attach` can refuse it loudly
64
68
  * rather than silently storing a bad value — the round-1 `--representation` guessing trap). */
@@ -72,21 +76,10 @@ export function representationForLocator(uri) {
72
76
  return R.url;
73
77
  return null;
74
78
  }
75
- function chainId() {
76
- return resolveChain(CHAIN).id;
77
- }
78
- /** Resolve the signing lane from flags: --unsigned (cold) · --sign (wallet) · default hot. */
79
- export function laneFromFlags(flags) {
80
- if (flags.unsigned)
81
- return 'unsigned';
82
- if (flags.sign)
83
- return 'sign';
84
- return 'send';
85
- }
86
79
  function requireAddress(address, usage) {
87
80
  if (!address || address.startsWith('--')) {
88
81
  console.error(`usage: ${usage}\n`);
89
- process.exit(1);
82
+ throw new CliError('', 1, true); // already printed above — see CliError's alreadyPrinted doc
90
83
  }
91
84
  return address;
92
85
  }
@@ -94,12 +87,10 @@ function requireFlag(flags, name, usage) {
94
87
  const v = flags[name];
95
88
  if (!v || v === 'true') {
96
89
  console.error(`missing --${name}\nusage: ${usage}\n`);
97
- process.exit(1);
90
+ throw new CliError('', 1, true); // already printed above — see CliError's alreadyPrinted doc
98
91
  }
99
92
  return v;
100
93
  }
101
- /** Run a prepared write through the chosen lane, then re-index if the project is known.
102
- * Returns whether anything was BROADCAST (false on the cold lane — the tx was only printed). */
103
94
  /**
104
95
  * Every owner-op's send choke point. Returns the {@link SignResult} when a tx was broadcast, else
105
96
  * `null` (dry run, or the cold lane which prints a tx instead of sending one).
@@ -108,39 +99,20 @@ function requireFlag(flags, name, usage) {
108
99
  * *receipt* — `abx mint --json` has to report which token id was actually minted, and the only
109
100
  * authoritative answer is the Transfer log the mint emitted. Callers that only asked "did it send?"
110
101
  * keep working: `null` is falsy, and a result object is truthy.
102
+ *
103
+ * The dry-run preview / `--confirm` prompt / lane selection live in {@link gatedSend} — the SAME
104
+ * choke point the deploy family's resumed-setup send routes through — so this is now just "gate,
105
+ * then re-index if the project is known."
111
106
  */
112
107
  async function runWrite(address, provider, flags, expectedSigner) {
113
- // --dry-run: preview the exact tx and send NOTHING. This is the shared choke point for every
114
- // owner-op, so --dry-run is uniform across them — it must never fall through to a real send just
115
- // because the flag isn't wired per-command (a round-1 finding: `set-field --dry-run` silently
116
- // reached the send lane and only stopped for lack of a key).
117
- if (flags['dry-run']) {
118
- const prepared = typeof provider === 'function' ? await provider(expectedSigner ?? zeroAddress) : provider;
119
- console.log(`\n ${C.bold}◆ ${prepared.summary}${C.reset} ${dim('(dry run — nothing sent)')}`);
120
- for (const [k, v] of Object.entries(prepared.fields))
121
- console.log(` ${dim(k.padEnd(12))} ${v}`);
122
- console.log(` ${dim('to'.padEnd(12))} ${prepared.to ?? dim('(contract deploy)')}`);
123
- if (expectedSigner)
124
- console.log(` ${dim('owner'.padEnd(12))} ${expectedSigner}`);
125
- console.log(dim(`\n Re-run without --dry-run to send (lane: ${laneFromFlags(flags)}).\n`));
126
- return null;
127
- }
128
- await assertChainId(CHAIN); // verify the RPC really is CHAIN before any irreversible write
129
- const result = await signTx(provider, {
130
- lane: laneFromFlags(flags),
131
- chainKey: CHAIN,
132
- expectedSigner,
133
- yes: !!flags.yes,
134
- port: flags.port ? Number(flags.port) : undefined,
135
- signUrlFile: flags['sign-url-file'],
136
- });
108
+ const result = await gatedSend(provider, flags, { chainKey: CHAIN, expectedSigner });
137
109
  if (!result)
138
- return null; // cold lane — nothing broadcast
110
+ return null; // dry run, or the cold lane — nothing broadcast
139
111
  await reindexIfKnown(address, flags);
140
112
  return result;
141
113
  }
142
114
  async function reindexIfKnown(address, flags) {
143
- const indexer = new SelfHostIndexer();
115
+ const indexer = localIndexer();
144
116
  if (indexer.store.getRegistration(address)) {
145
117
  const { state, elapsedMs } = await indexer.reindex(address);
146
118
  console.log(` ${green('✓')} re-indexed ${state.name ?? address}: ${state.eventCount} events in ${elapsedMs}ms`);
@@ -181,22 +153,22 @@ async function reindexIfKnown(address, flags) {
181
153
  }
182
154
  async function read(address, functionName, args = []) {
183
155
  const publicClient = makePublicClient({ chainKey: CHAIN });
184
- try {
185
- return (await publicClient.readContract({
186
- address,
187
- abi: oneOfOneImageAbi,
188
- functionName: functionName,
189
- args: args,
190
- }));
191
- }
192
- catch (err) {
193
- // A read that "returned no data" is usually not a real revert but an address with NO CONTRACT —
194
- // previewing before deploy, a typo'd address, or the wrong chain. Every owner-op reads the
195
- // owner/ownerOf here FIRST, so this one guard turns the opaque viem error into an actionable one
196
- // across all of them. Only runs on the error path (zero cost when the contract exists).
197
- await assertContractExists(address);
198
- throw err; // the contract DOES exist → surface the original error (a genuine revert)
199
- }
156
+ const result = await tryReadContract(publicClient, { address, abi: oneOfOneImageAbi, functionName, args });
157
+ if (result !== undefined)
158
+ return result;
159
+ // A read that "returned no data" is usually not a real revert but an address with NO CONTRACT —
160
+ // previewing before deploy, a typo'd address, or the wrong chain. Every owner-op reads the
161
+ // owner/ownerOf here FIRST, so this one guard turns the opaque viem error into an actionable one
162
+ // across all of them. Only runs on the error path (zero cost when the contract exists).
163
+ await assertContractExists(address);
164
+ // The contract DOES exist → a genuine revert. tryReadContract already swallowed that error, so
165
+ // re-read once (uncaught) to surface it rather than a generic "read failed".
166
+ return (await publicClient.readContract({
167
+ address,
168
+ abi: oneOfOneImageAbi,
169
+ functionName: functionName,
170
+ args: args,
171
+ }));
200
172
  }
201
173
  /** Throw a clear, actionable error when `address` has no contract on the active chain — the common
202
174
  * cause of an owner-op's raw `returned no data ("0x")`. No-op when code is present or unknowable. */
@@ -267,7 +239,8 @@ export async function cmdConfigureParam(address, rest, flags) {
267
239
  const valueInput = valueParts.join(' ');
268
240
  if (!tokenIdRaw || !key || (!valueInput && !flags.file)) {
269
241
  console.error(`usage: ${usage}\n`);
270
- process.exit(1);
242
+ process.exitCode = 1;
243
+ return;
271
244
  }
272
245
  const publicClient = makePublicClient({ chainKey: CHAIN });
273
246
  const schema = (await publicClient.readContract({
@@ -450,7 +423,8 @@ export async function cmdSetDependency(address, rest, flags) {
450
423
  const [indexRaw, refRaw] = positionalArgs(rest);
451
424
  if (indexRaw === undefined || !refRaw) {
452
425
  console.error(`usage: ${usage}\n`);
453
- process.exit(1);
426
+ process.exitCode = 1;
427
+ return;
454
428
  }
455
429
  if (!/^\d+$/.test(indexRaw))
456
430
  throw new Error(`<index> must be a non-negative integer (got '${indexRaw}') — the list is dense: index ≤ dependencyCount`);
@@ -474,7 +448,8 @@ export async function cmdSetDependencyRegistry(address, rest, flags) {
474
448
  const [raw] = positionalArgs(rest);
475
449
  if (!raw) {
476
450
  console.error(`usage: ${usage}\n`);
477
- process.exit(1);
451
+ process.exitCode = 1;
452
+ return;
478
453
  }
479
454
  if (raw !== 'none' && !/^0x[0-9a-fA-F]{40}$/.test(raw)) {
480
455
  throw new Error(`<registry> must be an address (0x + 40 hex) or 'none' to clear; got '${raw}'`);
@@ -520,7 +495,7 @@ export async function cmdMint(address, flags) {
520
495
  tx = prepareMint({ contract, to, chainId: chainId() });
521
496
  }
522
497
  const result = await runWrite(contract, tx, flags, owner);
523
- const tokenIds = result ? await mintedTokenIds(contract, result.txHash) : [];
498
+ const tokenIds = result ? await mintedTokenIds(makePublicClient({ chainKey: CHAIN }), contract, result.txHash) : [];
524
499
  if (result && tokenIds.length) {
525
500
  console.log(` ${green('✓')} minted token${tokenIds.length > 1 ? 's' : ''} ${bold(tokenIds.map((t) => '#' + t).join(', '))} → ${to}`);
526
501
  }
@@ -539,43 +514,6 @@ export async function cmdMint(address, flags) {
539
514
  };
540
515
  });
541
516
  }
542
- /**
543
- * The token ids a mint actually created, decoded from its receipt's `Transfer(from=0x0)` logs.
544
- *
545
- * Authoritative by construction: the ids come from the transaction that minted them, so a
546
- * `--count N` batch reports all N and a concurrent mint elsewhere cannot skew the answer. Returns
547
- * `[]` rather than throwing if the receipt can't be read — a mint that landed on-chain must not be
548
- * reported as failed because a follow-up read hiccuped.
549
- */
550
- async function mintedTokenIds(contract, txHash) {
551
- try {
552
- const publicClient = makePublicClient({ chainKey: CHAIN });
553
- const receipt = await publicClient.getTransactionReceipt({ hash: txHash });
554
- const ids = [];
555
- for (const log of receipt.logs) {
556
- if (log.address.toLowerCase() !== contract.toLowerCase())
557
- continue;
558
- try {
559
- const decoded = decodeEventLog({ abi: oneOfOneImageAbi, data: log.data, topics: log.topics });
560
- if (decoded.eventName !== 'Transfer')
561
- continue;
562
- const args = decoded.args;
563
- if (args.from && args.from !== zeroAddress)
564
- continue; // a transfer, not a mint
565
- const id = args.id ?? args.tokenId;
566
- if (id !== undefined)
567
- ids.push(id.toString());
568
- }
569
- catch {
570
- continue; // a log from another interface on the same contract
571
- }
572
- }
573
- return ids.sort((a, b) => Number(BigInt(a) - BigInt(b)));
574
- }
575
- catch {
576
- return [];
577
- }
578
- }
579
517
  // ── series owner ops (minter set · supply cap · primary payee) ────────────────
580
518
  // The multi-token knobs. All owner-only; each runs through the same signing lane +
581
519
  // re-index as every other owner op.
@@ -817,7 +755,8 @@ export async function cmdSetTransferValidator(address, rest, flags) {
817
755
  const [raw] = positionalArgs(rest);
818
756
  if (!raw) {
819
757
  console.error(`usage: ${usage}\n`);
820
- process.exit(1);
758
+ process.exitCode = 1;
759
+ return;
821
760
  }
822
761
  const cid = chainId();
823
762
  const validator = parseTransferValidatorValue(raw, { chainId: cid, chainLabel: CHAIN, allowNone: true });
@@ -859,16 +798,41 @@ export async function cmdSetTransferValidator(address, rest, flags) {
859
798
  const owner = await read(contract, 'owner');
860
799
  await runWrite(contract, prepareSetTransferValidator({ contract, validator, chainId: cid }), flags, owner);
861
800
  }
801
+ // ── multi-chunk on-chain content (the `reader` path) ─────────────────────────
802
+ // The plan math (chunk count + tx shape), the cost-model constants, and the actual
803
+ // plan→resolve-store→stage→encode orchestration all live in the SDK's staging.ts, layered on
804
+ // planChunks/planContentTxs/stageContent/encodeReader/ensureChunkStore — see its module doc. What
805
+ // stays here is Node-only (reading the file, gzip) plus the narration: every console.log below is
806
+ // this file's onEvent handler for the SDK's `StagingEvent`, so the lines are unchanged from before
807
+ // the move.
862
808
  export function parseCompress(v) {
863
809
  const c = (v ?? 'none').toLowerCase();
864
810
  if (c === 'none' || c === 'fastlz' || c === 'gzip')
865
811
  return c;
866
812
  throw new Error(`--compress must be none | fastlz | gzip (got '${v}')`);
867
813
  }
814
+ /** The chunk-store bootstrap narration shared by every content-staging path below — one place so
815
+ * the lines are identical whether the store is resolved for a single field ({@link putContentOnChain}
816
+ * → the SDK's `stageFieldContent`) or once up front for a whole batch ({@link ensureChunkStore}). */
817
+ function printChunkStoreEvent(e) {
818
+ if (e.kind === 'stale')
819
+ console.log(yellow(` configured chunk store ${e.address} is a stale deployment (no writeContent) — deploying a current one`));
820
+ else if (e.kind === 'canonical')
821
+ console.log(dim(` using the canonical chunk store at its deterministic address ${e.address}`));
822
+ else if (e.kind === 'deploying')
823
+ console.log(dim(' deploying the canonical multi-chunk content store (AbxChunkStore) — CREATE2…'));
824
+ else {
825
+ console.log(` ${green('✓')} chunk store ${e.address}`);
826
+ console.log(dim(` not in the shipped manifest for ${CHAIN} — to reuse it set ABX_CHUNK_STORE=${e.address} (or add it to packages/sdk/src/deployments.ts)`));
827
+ }
828
+ }
868
829
  /**
869
830
  * The chain's shared multi-chunk store/reader; deploy it once if missing, signing through
870
831
  * `send` (the env key on the hot lane, or the connected wallet on the wallet lane) — the
871
- * store is ownerless, so any funded signer can stand it up.
832
+ * store is ownerless, so any funded signer can stand it up. Used directly only by
833
+ * {@link stageImageFieldsBatch} (resolving ONE store up front for every token); a single-field
834
+ * stage ({@link putContentOnChain}) resolves its store through the SDK's `stageFieldContent`
835
+ * instead, which calls the very same SDK `ensureChunkStore` internally.
872
836
  */
873
837
  async function ensureChunkStore(send, override) {
874
838
  // The resolution logic lives in the SDK (`ensureChunkStore`) so an SDK integrator bootstraps
@@ -876,99 +840,46 @@ async function ensureChunkStore(send, override) {
876
840
  // incapable store fails deep inside a mint, after transactions have landed. The CLI keeps only the
877
841
  // narration: the SDK reports progress through `onEvent` rather than printing.
878
842
  const publicClient = makePublicClient({ chainKey: CHAIN });
879
- return sdkEnsureChunkStore(publicClient, send, {
880
- chainId: chainId(),
881
- override,
882
- onEvent: (e) => {
883
- if (e.kind === 'stale')
884
- console.log(yellow(` configured chunk store ${e.address} is a stale deployment (no writeContent) — deploying a current one`));
885
- else if (e.kind === 'canonical')
886
- console.log(dim(` using the canonical chunk store at its deterministic address ${e.address}`));
887
- else if (e.kind === 'deploying')
888
- console.log(dim(' deploying the canonical multi-chunk content store (AbxChunkStore) — CREATE2…'));
889
- else {
890
- console.log(` ${green('✓')} chunk store ${e.address}`);
891
- console.log(dim(` not in the shipped manifest for ${CHAIN} — to reuse it set ABX_CHUNK_STORE=${e.address} (or add it to packages/sdk/src/deployments.ts)`));
892
- }
893
- },
894
- });
843
+ return sdkEnsureChunkStore(publicClient, send, { chainId: chainId(), override, onEvent: printChunkStoreEvent });
895
844
  }
896
845
  /**
897
- * The hot-lane staging signer: the env key signs + broadcasts each chunk-store write. Built
898
- * lazily so commands that never stage on-chain don't require a key. The wallet lane passes a
899
- * session-backed sender instead (see {@link openWalletSession}); the cold lane can't stage
900
- * interactively, so it's rejected up front by the deploy/set-field commands.
846
+ * The hot-lane staging signer: the env key signs + broadcasts each chunk-store write, via the
847
+ * SDK's `makeHotSender` (pinned nonce, pinned gas, typed revert). Built lazily so commands that
848
+ * never stage on-chain don't require a key. The wallet lane passes a session-backed sender
849
+ * instead (see {@link openWalletSession}); the cold lane can't stage interactively, so it's
850
+ * rejected up front by the deploy/set-field commands.
901
851
  */
902
852
  export function envStagingSender() {
903
853
  const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
904
854
  const publicClient = makePublicClient({ chainKey: CHAIN });
905
- return async (tx) => {
906
- const hash = await wallet.sendTransaction({
907
- account,
908
- chain: wallet.chain,
909
- to: tx.to ?? undefined,
910
- data: tx.data,
911
- value: 0n,
912
- });
913
- return publicClient.waitForTransactionReceipt({ hash });
914
- };
855
+ return makeHotSender({ wallet, account, publicClient });
915
856
  }
916
857
  /** Wallet-lane staging signer: each chunk-store write is approved in the human's browser
917
- * wallet through the open {@link WalletSession}, sharing one connection with the final tx. */
858
+ * wallet through the open {@link WalletSession}, sharing one connection with the final tx. A
859
+ * {@link PreparedTx} already carries everything `session.send` needs, so this is a thin adapter. */
918
860
  export function sessionStagingSender(session) {
919
- const id = chainId();
920
- return async (tx) => (await session.send({
921
- op: 'stage',
922
- to: tx.to,
923
- data: tx.data,
924
- value: '0x0',
925
- chainId: id,
926
- summary: tx.summary,
927
- fields: tx.fields,
928
- })).receipt;
861
+ return async (tx) => (await session.send(tx)).receipt;
929
862
  }
930
- /**
931
- * Put `content` on-chain as SSTORE2 chunks behind the shared reader, and return the field's
932
- * `(representation, value)`. `fastlz` compresses per chunk (reader decodes on read → stays
933
- * on-chain renderable); `gzip` compresses the whole content (off-chain decode → the
934
- * `reader-gzip` representation, not on-chain renderable).
935
- *
936
- * Staging is one atomic `writeContent` when the content fits a single tx's gas, else
937
- * gas-bounded chunk-write `multicall`s plus a final manifest write — {@link stageContent}
938
- * picks, and we print the tx plan up front so the human knows the count before signing.
939
- * These store writes use the env key (the data contracts are ownerless); the owner only
940
- * signs the field set that references the manifest.
941
- */
942
- /**
943
- * Pure staging plan for a piece of content — chunk count + transaction shape — with NO
944
- * chain writes, no signer, no `ensureChunkStore`. Shared by {@link putContentOnChain}
945
- * (which then actually stages) and the deploy dry-run preview, so the count the human is
946
- * told up front is the same one the real staging will produce. `chunkSize` defaults to the
947
- * SDK's {@link planChunks} default (currently 22 KB), so e.g. a 2.8 KB SVG is 1 chunk.
948
- */
949
- // On-chain image storage costs ~200 gas/byte (SSTORE2 code deposit) + per-chunk tx overhead, so it
950
- // is cost-effective ONLY for tiny art. Past these soft limits, off-chain (Arweave pay-once, or a
951
- // hosted resolver + IPFS) is FAR cheaper — on-chain's remaining edge is self-resolution, not cost.
952
- // Per-file limit ≈ one 22 KB storage chunk; project limit caps the whole collection's gas.
953
- export const ONCHAIN_IMAGE_SOFT_LIMIT = 24 * 1024; // per file
954
- export const ONCHAIN_PROJECT_SOFT_LIMIT = 256 * 1024; // whole collection
955
863
  const kb = (n) => `${n < 10 * 1024 ? (n / 1024).toFixed(1) : Math.round(n / 1024)}KB`;
956
864
  /** Warn (loudly) when content is too big for on-chain to be the cost-sane choice. Returns true if it warned. */
957
865
  export function warnIfLargeForOnChain(bytesLen, label) {
958
- if (bytesLen <= ONCHAIN_IMAGE_SOFT_LIMIT)
866
+ if (!exceedsOnchainSoftLimit(bytesLen))
959
867
  return false;
960
868
  console.log(yellow(` ⚠ ${label} is ${kb(bytesLen)} — large for ON-CHAIN storage (~200 gas/byte). Past ~${kb(ONCHAIN_IMAGE_SOFT_LIMIT)}/file, on-chain is FAR more expensive than off-chain, not cheaper.`));
961
869
  console.log(dim(` For art this size prefer off-chain: --backend arweave (pay-once, permanent) or a hosted resolver + IPFS. Use on-chain only if self-resolution is a hard requirement.`));
962
870
  return true;
963
871
  }
872
+ /**
873
+ * Pure staging plan for a piece of content — chunk count + transaction shape — with NO chain
874
+ * writes, no signer, no `ensureChunkStore`. The CLI-facing wrapper over the SDK's pure
875
+ * {@link planStagedContent}: `gzip` is a Node-only transform, so it happens here (not in the SDK —
876
+ * see staging.ts's module doc) before handing the result off to the SDK's chunk/tx-shape math.
877
+ * Shared by {@link putContentOnChain} (which then actually stages) and the deploy dry-run preview,
878
+ * so the count the human is told up front is the same one the real staging will produce.
879
+ */
964
880
  export function computeContentPlan(bytes, compress) {
965
881
  const content = compress === 'gzip' ? new Uint8Array(gzipSync(bytes)) : new Uint8Array(bytes);
966
- const fastlz = compress === 'fastlz';
967
- const planned = planChunks(content, { fastlz });
968
- const plan = planContentTxs(planned);
969
- const representation = compress === 'gzip' ? R.readerGzip : R.reader;
970
- const stagedBytes = planned.reduce((n, c) => n + c.data.length, 0);
971
- return { content, fastlz, chunks: planned.length, stagedBytes, plan, representation };
882
+ return planStagedContent(content, compress);
972
883
  }
973
884
  /** Human-readable size line for a content plan, e.g. `2812B → 1 chunk [fastlz 2812→904B]`. */
974
885
  function planSizeLine(bytes, p, compress) {
@@ -990,15 +901,43 @@ function planTxLine(p, finalLabel) {
990
901
  ? `plan: 1 staging tx (atomic writeContent) + ${finalLabel} = 2 transactions`
991
902
  : `plan: ${p.plan.batches.length} chunk-batch tx(s) + 1 manifest tx + ${finalLabel} = ${p.plan.txCount + 1} transactions`;
992
903
  }
904
+ /**
905
+ * Put `bytes` on-chain as SSTORE2 chunks behind the shared reader, and return the field's
906
+ * `(representation, value)`. `fastlz` compresses per chunk (reader decodes on read → stays
907
+ * on-chain renderable); `gzip` compresses the whole content (off-chain decode → the
908
+ * `reader-gzip` representation, not on-chain renderable) — done here, not in the SDK's
909
+ * `stageFieldContent` (a Node-only transform; see staging.ts's module doc).
910
+ *
911
+ * Staging is one atomic `writeContent` when the content fits a single tx's gas, else
912
+ * gas-bounded chunk-write `multicall`s plus a final manifest write — the SDK picks, and we
913
+ * print the tx plan up front (via its `onEvent`) so the human knows the count before signing.
914
+ * These store writes use the env key (the data contracts are ownerless); the owner only
915
+ * signs the field set that references the manifest.
916
+ */
993
917
  async function putContentOnChain(bytes, compress, field, send, finalLabel = '1 owner field-set', store) {
994
- const p = computeContentPlan(bytes, compress);
995
918
  warnIfLargeForOnChain(bytes.length, `'${field}'`);
996
- const resolvedStore = store ?? (await ensureChunkStore(send));
997
- console.log(dim(` '${field}' on-chain: ${planSizeLine(bytes.length, p, compress)} via reader ${resolvedStore}`));
998
- console.log(dim(` ${planTxLine(p, finalLabel)}`));
999
- const { manifest, txHashes } = await stageContent(send, { store: resolvedStore, content: p.content, fastlz: p.fastlz });
1000
- console.log(dim(` staged in ${txHashes.length} tx(s); manifest → ${manifest}`));
1001
- return { value: encodeReader(resolvedStore, manifest), representation: p.representation };
919
+ const content = compress === 'gzip' ? new Uint8Array(gzipSync(bytes)) : new Uint8Array(bytes);
920
+ const publicClient = makePublicClient({ chainKey: CHAIN });
921
+ return stageFieldContent({
922
+ content,
923
+ compress,
924
+ field,
925
+ send,
926
+ publicClient,
927
+ chainId: chainId(),
928
+ store,
929
+ onEvent: (e) => {
930
+ if (e.kind === 'chunk-store')
931
+ printChunkStoreEvent(e.event);
932
+ else if (e.kind === 'planned') {
933
+ console.log(dim(` '${field}' on-chain: ${planSizeLine(bytes.length, e.contentPlan, compress)} via reader ${e.store}`));
934
+ console.log(dim(` ${planTxLine(e.contentPlan, finalLabel)}`));
935
+ }
936
+ else {
937
+ console.log(dim(` staged in ${e.txHashes.length} tx(s); manifest → ${e.manifest}`));
938
+ }
939
+ },
940
+ });
1002
941
  }
1003
942
  /**
1004
943
  * Dry-run preview of staging an image fully on-chain — the REAL chunk + transaction plan,
@@ -1113,7 +1052,7 @@ export async function cmdSetField(address, flags) {
1113
1052
  const staging = !!(flags.file && flags.file !== 'true'); // large content ON-CHAIN via chunk store/reader
1114
1053
  // --file + --dry-run: preview the on-chain staging plan and store/send NOTHING. (The locator/text
1115
1054
  // path flows through runWrite, which previews there; staging must short-circuit before any upload.)
1116
- if (staging && flags['dry-run']) {
1055
+ if (staging && isDryRun(flags)) {
1117
1056
  const bytes = readFileSync(resolvePath(flags.file));
1118
1057
  const p = computeContentPlan(bytes, parseCompress(flags.compress));
1119
1058
  console.log(`\n ${bold('◆ set-field ' + field)} ${dim('(dry run — nothing staged or sent)')}`);
@@ -1222,13 +1161,15 @@ export async function cmdAttach(rest, flags) {
1222
1161
  const contract = requireAddress(address, usage);
1223
1162
  if (!key) {
1224
1163
  console.error(`usage: ${usage}\n the <key> is the name your file appears under in the manifest (e.g. print, certificate, stems, readme).\n`);
1225
- process.exit(1);
1164
+ process.exitCode = 1;
1165
+ return;
1226
1166
  }
1227
1167
  assertSettableField(key);
1228
1168
  const hasFile = !!(flags.file && flags.file !== 'true');
1229
1169
  if (!uri && !hasFile) {
1230
1170
  console.error(`usage: ${usage}\n pass a locator URI (ipfs://… / ar://… / https://…) or --file <path> to store bytes on-chain.\n`);
1231
- process.exit(1);
1171
+ process.exitCode = 1;
1172
+ return;
1232
1173
  }
1233
1174
  if (uri && hasFile)
1234
1175
  throw new Error('pass EITHER a locator URI OR --file <path>, not both.');
@@ -1429,16 +1370,6 @@ async function readSeries(token, fn, args = []) {
1429
1370
  args: args,
1430
1371
  }));
1431
1372
  }
1432
- async function readSale(minter, token) {
1433
- const publicClient = makePublicClient({ chainKey: CHAIN });
1434
- const [configured, paymentToken, price, allocation, sold] = (await publicClient.readContract({
1435
- address: minter,
1436
- abi: abxFixedPriceMinterAbi,
1437
- functionName: 'sales',
1438
- args: [token],
1439
- }));
1440
- return { configured, paymentToken, price, allocation, sold };
1441
- }
1442
1373
  /** Resolve the shared minter for the chain (flag → env → manifest), or deploy it if none exists.
1443
1374
  * It's ownerless, so any funded signer can stand it up — the sibling of `ensureChunkStore`. */
1444
1375
  async function ensureFixedPriceMinter(override) {
@@ -1461,38 +1392,23 @@ async function ensureFixedPriceMinter(override) {
1461
1392
  }
1462
1393
  }
1463
1394
  const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
1395
+ const send = makeHotSender({ wallet, account, publicClient });
1464
1396
  console.log(dim(' deploying the canonical fixed-price minter (shared, ownerless) — CREATE2…'));
1465
- const { minter, txHash } = await deployFixedPriceMinter(wallet, account, publicClient);
1397
+ const { minter, txHash } = await deployFixedPriceMinter(send, publicClient);
1466
1398
  console.log(` ${green('✓')} fixed-price minter ${minter}`);
1467
1399
  console.log(dim(` tx ${txHash}`));
1468
1400
  console.log(dim(` not in the shipped manifest for ${CHAIN} — to reuse it set ${bold(`ABX_FIXED_PRICE_MINTER=${minter}`)} (or add it to packages/sdk/src/deployments.ts)`));
1469
1401
  return minter;
1470
1402
  }
1471
1403
  /** A minter write (configure/buy). No re-index — the sale lives on the minter, not the token
1472
- * projection; the token's own state is unchanged by a sale config. */
1404
+ * projection; the token's own state is unchanged by a sale config.
1405
+ *
1406
+ * Used to hand-roll its own dry-run guard — "the minter path does not go through runWrite, so it
1407
+ * needs its OWN" — which is exactly the duplication {@link gatedSend} exists to end: this now shares
1408
+ * the identical choke point `runWrite` does, so `minter configure`/`minter buy` also pick up
1409
+ * `--confirm` (previously only the deploy family had it) with no extra code here. */
1473
1410
  async function runMinterWrite(provider, flags, expectedSigner) {
1474
- // --dry-run: preview the sale-config / purchase tx and send NOTHING. The minter path does not go
1475
- // through runWrite, so it needs its OWN dry-run guard — without it, `minter configure --dry-run`
1476
- // silently signs + broadcasts a real sale (a critical footgun: a mainnet creator "previewing"
1477
- // their sale would send it). Mirrors runWrite's guard.
1478
- if (flags['dry-run']) {
1479
- const prepared = typeof provider === 'function' ? await provider(expectedSigner ?? zeroAddress) : provider;
1480
- console.log(`\n ${bold('◆ ' + prepared.summary)} ${dim('(dry run — nothing sent)')}`);
1481
- for (const [k, v] of Object.entries(prepared.fields))
1482
- console.log(` ${dim(k.padEnd(12))} ${v}`);
1483
- console.log(` ${dim('to'.padEnd(12))} ${prepared.to ?? dim('(contract deploy)')}`);
1484
- console.log(dim(`\n Re-run without --dry-run to send (lane: ${laneFromFlags(flags)}).\n`));
1485
- return;
1486
- }
1487
- await assertChainId(CHAIN);
1488
- await signTx(provider, {
1489
- lane: laneFromFlags(flags),
1490
- chainKey: CHAIN,
1491
- expectedSigner,
1492
- yes: !!flags.yes,
1493
- port: flags.port ? Number(flags.port) : undefined,
1494
- signUrlFile: flags['sign-url-file'],
1495
- });
1411
+ await gatedSend(provider, flags, { chainKey: CHAIN, expectedSigner });
1496
1412
  }
1497
1413
  /** Parse the sale price: `--price-raw <units>` (exact base units) or the friendly `--price <ether>`
1498
1414
  * (18-decimals). ETH sales use `--price`; non-18-dp ERC-20s should use `--price-raw`. */
@@ -1502,7 +1418,7 @@ function parseSalePrice(flags, usage) {
1502
1418
  if (flags.price && flags.price !== 'true')
1503
1419
  return parseEther(flags.price);
1504
1420
  console.error(`missing --price <ether> or --price-raw <units>\nusage: ${usage}\n`);
1505
- process.exit(1);
1421
+ throw new CliError('', 1, true); // already printed above — see CliError's alreadyPrinted doc
1506
1422
  }
1507
1423
  /**
1508
1424
  * `abx minter configure <token> --price <eth> --allocation <n> [--erc20 0x…]` — set the fixed-price
@@ -1519,7 +1435,7 @@ export async function cmdMinterConfigure(address, flags) {
1519
1435
  const price = parseSalePrice(flags, usage);
1520
1436
  // On --dry-run resolve the minter WITHOUT deploying — ensureFixedPriceMinter would deploy the
1521
1437
  // shared singleton if absent, and a preview must never send. Fall back to a label if none exists.
1522
- const dryRun = !!flags['dry-run'];
1438
+ const dryRun = isDryRun(flags);
1523
1439
  const knownMinter = fixedPriceMinterAddress(flags['minter-contract']);
1524
1440
  if (dryRun && !knownMinter) {
1525
1441
  console.log(yellow(` ⚠ no shared minter deployed on ${CHAIN} yet — a real run deploys it once (a separate tx) before configuring.`));
@@ -1575,7 +1491,7 @@ export async function cmdMinterShow(address, flags) {
1575
1491
  console.log(yellow(` no shared minter known for ${CHAIN} — configure a sale (deploys it) or set ABX_FIXED_PRICE_MINTER`));
1576
1492
  return;
1577
1493
  }
1578
- const sale = await readSale(minter, token);
1494
+ const sale = await readSaleConfig(makePublicClient({ chainKey: CHAIN }), minter, token);
1579
1495
  const [assignedMinter, payee, paused, max, supply] = await Promise.all([
1580
1496
  readSeries(token, 'minter').catch(() => zeroAddress),
1581
1497
  readSeries(token, 'primaryPayee').catch(() => zeroAddress),
@@ -1614,7 +1530,7 @@ export async function cmdMinterBuy(address, flags) {
1614
1530
  const minter = fixedPriceMinterAddress(flags['minter-contract']);
1615
1531
  if (!minter)
1616
1532
  throw new Error(`no shared minter known for ${CHAIN} — configure a sale first, or set ABX_FIXED_PRICE_MINTER`);
1617
- const sale = await readSale(minter, token);
1533
+ const sale = await readSaleConfig(makePublicClient({ chainKey: CHAIN }), minter, token);
1618
1534
  if (!sale.configured)
1619
1535
  throw new Error(`no sale configured for ${token} — run: abx minter configure ${token} …`);
1620
1536
  const isEth = sale.paymentToken === zeroAddress;
@@ -1662,7 +1578,8 @@ export async function cmdSetSchema(address, flags) {
1662
1578
  const specs = parseSchemaSpecs(flags.schema);
1663
1579
  if (specs.length !== 1) {
1664
1580
  console.error(`usage: ${usage}\n\n One key per call — a schema write is a full-row upsert, so batching them hides which one changed.\n`);
1665
- process.exit(1);
1581
+ process.exitCode = 1;
1582
+ return;
1666
1583
  }
1667
1584
  const next = specs[0];
1668
1585
  const publicClient = makePublicClient({ chainKey: CHAIN });
@@ -1734,7 +1651,8 @@ export async function cmdRetireParam(address, rest, flags) {
1734
1651
  const [key] = positionalArgs(rest);
1735
1652
  if (!key) {
1736
1653
  console.error(`usage: ${usage}\n`);
1737
- process.exit(1);
1654
+ process.exitCode = 1;
1655
+ return;
1738
1656
  }
1739
1657
  const publicClient = makePublicClient({ chainKey: CHAIN });
1740
1658
  const owner = await read(contract, 'owner');