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

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 (44) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/dist/commands/deploy.d.ts +32 -7
  3. package/dist/commands/deploy.d.ts.map +1 -1
  4. package/dist/commands/deploy.js +1079 -49
  5. package/dist/commands/deploy.js.map +1 -1
  6. package/dist/commands/project.d.ts +107 -0
  7. package/dist/commands/project.d.ts.map +1 -1
  8. package/dist/commands/project.js +141 -16
  9. package/dist/commands/project.js.map +1 -1
  10. package/dist/commands/reads.d.ts.map +1 -1
  11. package/dist/commands/reads.js +52 -20
  12. package/dist/commands/reads.js.map +1 -1
  13. package/dist/commands/scaffold.d.ts +13 -11
  14. package/dist/commands/scaffold.d.ts.map +1 -1
  15. package/dist/commands/scaffold.js +73 -37
  16. package/dist/commands/scaffold.js.map +1 -1
  17. package/dist/config.d.ts +8 -0
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +18 -1
  20. package/dist/config.js.map +1 -1
  21. package/dist/kind.d.ts +48 -0
  22. package/dist/kind.d.ts.map +1 -0
  23. package/dist/kind.js +69 -0
  24. package/dist/kind.js.map +1 -0
  25. package/dist/main.js +107 -23
  26. package/dist/main.js.map +1 -1
  27. package/dist/mintpage.d.ts +14 -1
  28. package/dist/mintpage.d.ts.map +1 -1
  29. package/dist/mintpage.js +166 -4
  30. package/dist/mintpage.js.map +1 -1
  31. package/dist/output.d.ts +11 -0
  32. package/dist/output.d.ts.map +1 -1
  33. package/dist/output.js +133 -3
  34. package/dist/output.js.map +1 -1
  35. package/dist/ownerops.d.ts +33 -2
  36. package/dist/ownerops.d.ts.map +1 -1
  37. package/dist/ownerops.js +356 -19
  38. package/dist/ownerops.js.map +1 -1
  39. package/package.json +6 -6
  40. package/skill/SKILL.md +5 -3
  41. package/skill/reference/creator-token.md +40 -25
  42. package/skill/reference/hosting.md +1 -1
  43. package/skill/reference/operating.md +20 -0
  44. package/skill/reference/troubleshooting.md +7 -0
package/dist/ownerops.js CHANGED
@@ -6,7 +6,7 @@
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, 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';
9
+ import { assertChainId, ensureChunkStore as sdkEnsureChunkStore, predictFixedPriceMinter, predictFixedPriceMinter1155, makeHotSender, makePublicClient, makeWalletClient, oneOfOneImageAbi, oneOfOneEditionAbi, seriesImageAbi, deployFixedPriceMinter, deployFixedPriceMinter1155, prepareConfigureSale, prepareConfigureSale1155, preparePurchase, preparePurchase1155, readSaleConfig, readSaleConfig1155, prepareEditionMint, prepareEditionTransfer, prepareSetMaxSupply, preparePingURI, 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
10
  import { encodeScalarParam, encodeTag as encodeTagSdk, isAccepted, prepareConfigureTokenParam, prepareConfigureTokenParamData, prepareSetContractParam, prepareSetContractParamData, seriesCodeAbi, tryReadContract, } from '@artblocks/abx-sdk';
11
11
  import { hasParamEnumeration } from '@artblocks/abx-sdk';
12
12
  import { decodeFieldRenderer, resolveGenerator, DEP_RESOLUTION, parseDependencyRef, prepareLockDependencies, prepareRemoveLastDependency, prepareSetDependency, prepareSetDependencyRegistry, } from '@artblocks/abx-sdk';
@@ -16,8 +16,9 @@ import { readFileSync } from 'node:fs';
16
16
  import { basename, resolve as resolvePath } from 'node:path';
17
17
  import { gzipSync } from 'node:zlib';
18
18
  import { formatEther, getAddress, isAddress, parseEther, toHex, zeroAddress } from 'viem';
19
- import { CHAIN, chainId, fixedPriceMinterAddress, localIndexer } from './config.js';
19
+ import { CHAIN, chainId, fixedPriceMinterAddress, fixedPriceMinter1155Address, localIndexer } from './config.js';
20
20
  import { CliError } from './errors.js';
21
+ import { detectTokenKind } from './kind.js';
21
22
  import { openWalletSession } from './signer.js';
22
23
  import { gatedSend, laneFromFlags } from './riskgate.js';
23
24
  import { withJson } from './jsonout.js';
@@ -375,8 +376,12 @@ export async function cmdSetParamHooks(address, flags) {
375
376
  }
376
377
  catch (err) {
377
378
  await assertContractExists(contract); // no contract at all → the actionable no-contract error
378
- throw new Error(`${contract} exposes no param hooksthe configure/augment/transfer hooks are a SeriesCode ` +
379
- `(PostParams) feature. A 1/1 or a plain Series has no configurable params to hook.`);
379
+ // Name the ACTUAL kind rather than assuming 721 a 1/1-edition or EditionImage has no param
380
+ // hooks either (only EditionCode composes ConfigurableParams, mirroring SeriesCode), so the old
381
+ // blanket "a 1/1 or a plain Series" message misnamed an edition target's real kind.
382
+ const kind = await detectTokenKind(publicClient, contract);
383
+ throw new Error(`${contract} exposes no param hooks — the configure/augment/transfer hooks are a SeriesCode/EditionCode ` +
384
+ `(PostParams) feature. ${kind.label} has no configurable params to hook.`);
380
385
  }
381
386
  const cur = { configure: current[0], augment: current[1], transfer: current[2] };
382
387
  const label = (a) => (eqAddr(a, zeroAddress) ? dim('none') : a);
@@ -466,21 +471,96 @@ export async function cmdLockDependencies(address, flags) {
466
471
  console.log(dim(' note: locking the dependency set is permanent and irreversible (list AND registry pointer freeze).'));
467
472
  await runWrite(contract, prepareLockDependencies({ contract, chainId: chainId() }), flags, owner);
468
473
  }
474
+ // ── editions: shared small parsers ────────────────────────────────────────────
475
+ /** A non-negative-integer edition flag (`--token-id`, `--amount`, …) — the shared parse +
476
+ * bound-check so every edition command that takes "an id" or "a count" rejects the same way. */
477
+ export function parseEditionCountFlag(raw, flag) {
478
+ if (raw === 'true' || !/^\d+$/.test(raw.trim()))
479
+ throw new Error(`--${flag} must be a non-negative integer; got '${raw}'.`);
480
+ return BigInt(raw.trim());
481
+ }
482
+ /** `--token-ids <csv|range>` — the shape `ping-uri` (and any future batch-by-id command) takes: a
483
+ * comma-separated list of ids and/or `lo-hi` ranges (`0-9,20,25-30`), de-duplicated and sorted. */
484
+ export function parseTokenIdRange(raw) {
485
+ const ids = new Set();
486
+ for (const part of raw.split(',').map((s) => s.trim()).filter(Boolean)) {
487
+ const range = part.match(/^(\d+)-(\d+)$/);
488
+ if (range) {
489
+ const [lo, hi] = [BigInt(range[1]), BigInt(range[2])];
490
+ if (lo > hi)
491
+ throw new Error(`--token-ids: range '${part}' has lo > hi.`);
492
+ for (let i = lo; i <= hi; i++)
493
+ ids.add(i);
494
+ }
495
+ else if (/^\d+$/.test(part)) {
496
+ ids.add(BigInt(part));
497
+ }
498
+ else {
499
+ throw new Error(`--token-ids: '${part}' isn't a token id or a 'lo-hi' range.`);
500
+ }
501
+ }
502
+ if (!ids.size)
503
+ throw new Error("--token-ids needs at least one id (e.g. '0,1,2' or '0-99').");
504
+ return [...ids].sort((a, b) => Number(a - b));
505
+ }
469
506
  // ── mint ─────────────────────────────────────────────────────────────────────
470
507
  // Issue a token — a deferred mint (deploy → warm resolver → mint) or a primary sale
471
508
  // (mint straight to the buyer). For a 1/1 this is the one-shot token #0. For a Series
472
509
  // it mints the next sequential token id (metadata = token id): bare (one), or `--count <n>`
473
510
  // (n in order). Owner or an authorized minter signs.
511
+ // For an EDITION (OneOfOneEdition/EditionImage/EditionCode) this mints copies of ONE id:
512
+ // --token-id (required unless the target is a 1/1-edition, whose id space is fixed to {0}) +
513
+ // --amount (default 1). --count is the 721 Series primitive and is refused on an edition.
474
514
  export async function cmdMint(address, flags) {
475
- const usage = 'abx mint <address> [--to 0x…] [--count <n>] [--json] [--sign|--unsigned]';
515
+ const usage = 'abx mint <address> [--to 0x…] [--count <n> | --token-id <id> --amount <n>] [--json] [--sign|--unsigned]';
476
516
  const contract = requireAddress(address, usage);
477
517
  // `--json`: the TOKEN ID is the value a program came for, and it was previously only obtainable by
478
518
  // regex-scraping coloured prose (B19). It is read from the mint's own Transfer logs rather than by
479
519
  // re-reading `nextTokenId` afterwards — a concurrent mint would make that answer wrong, and a
480
520
  // number that is usually right is worse than no number.
481
521
  return withJson(flags, async () => {
522
+ const publicClient = makePublicClient({ chainKey: CHAIN });
523
+ const kind = await detectTokenKind(publicClient, contract);
482
524
  const owner = await read(contract, 'owner');
483
525
  const to = flags.to ?? owner; // default: pre-mint to the admin
526
+ if (kind.isEdition) {
527
+ // `--count` is the 721 Series primitive (N sequential ids); an edition mint always names its
528
+ // id and an amount — there is no "next in order" to count through. Refuse, don't silently
529
+ // reinterpret it as something else.
530
+ if (flags.count !== undefined) {
531
+ throw new Error(`--count is a 721 Series flag (mint N tokens in order) — ${contract} is a ${kind.label} (edition). Use --token-id <id> --amount <n> instead.`);
532
+ }
533
+ // OneOfOneEdition's id space is fixed to {0}, so --token-id defaults there; EditionImage/
534
+ // EditionCode ids are caller-named artworks with no sensible default — require it.
535
+ if (kind.kind !== '1of1-edition' && flags['token-id'] === undefined) {
536
+ throw new Error(`--token-id is required on a ${kind.label} — its ids are caller-named artworks, not a single default. ` +
537
+ `See existing ids with \`abx tokens ${contract}\`.`);
538
+ }
539
+ const tokenId = flags['token-id'] !== undefined ? parseEditionCountFlag(flags['token-id'], 'token-id') : 0n;
540
+ const amount = flags.amount !== undefined ? parseEditionCountFlag(flags.amount, 'amount') : 1n;
541
+ console.log(dim(` minting ${amount} cop${amount === 1n ? 'y' : 'ies'} of #${tokenId} → ${to}${flags.to ? '' : ' (owner — pass --to for a buyer)'}`));
542
+ const tx = prepareEditionMint({ contract, to, tokenId, amount, chainId: chainId() });
543
+ const result = await runWrite(contract, tx, flags, owner);
544
+ if (result)
545
+ console.log(` ${green('✓')} minted ${amount} cop${amount === 1n ? 'y' : 'ies'} of ${bold('#' + tokenId)} → ${to}`);
546
+ console.log(dim(` next: \`abx refresh ${contract}\` so marketplaces pick up the change.`));
547
+ return {
548
+ contract,
549
+ chainId: chainId(),
550
+ to,
551
+ sent: !!result,
552
+ txHash: result?.txHash ?? null,
553
+ blockNumber: result ? String(result.blockNumber) : null,
554
+ tokenIds: [tokenId.toString()],
555
+ amount: amount.toString(),
556
+ };
557
+ }
558
+ // 721 path (unchanged): --token-id/--amount have no meaning here — refuse rather than silently
559
+ // ignore (the membrane rule: enforce, don't warn).
560
+ if (flags['token-id'] !== undefined || flags.amount !== undefined) {
561
+ throw new Error(`--token-id/--amount are edition-only (ERC-1155 copies) — ${contract} is a ${kind.label} (721). ` +
562
+ `Mint the next token with \`abx mint ${contract}\`${kind.kind !== '1of1' ? ' (or --count N for several in order)' : ''}.`);
563
+ }
484
564
  let tx;
485
565
  let count = 1n;
486
566
  if (flags.count !== undefined && flags.count !== 'true') {
@@ -535,6 +615,67 @@ export async function cmdSetMaxInvocations(address, flags) {
535
615
  const owner = await read(contract, 'owner');
536
616
  await runWrite(contract, prepareSetMaxInvocations({ contract, maxInvocations: max, chainId: chainId() }), flags, owner);
537
617
  }
618
+ // ── set-max-supply (editions only) — the per-id twin of set-max-invocations ──────────────────────
619
+ /**
620
+ * `abx set-max-supply <address> --token-id <n> --cap <n>` — owner overrides ONE id's ERC-1155
621
+ * supply cap (Edition Supply extension). Monotonically non-increasing once used for that id — the
622
+ * chain enforces it (`MaxSupplyIncreaseForbidden`/`MaxSupplyBelowFloor`), and `--cap open` is
623
+ * refused HERE, before any gas: the contract's own "0" means "closed forever" once an id has been
624
+ * overridden (not "reopen to uncapped" — `EditionSupply.sol`'s own doc calls this out), so accepting
625
+ * `open` as an alias for 0 would silently promise the opposite of what it does on a re-run.
626
+ */
627
+ export async function cmdSetMaxSupply(address, flags) {
628
+ const usage = 'abx set-max-supply <address> --token-id <n> --cap <n> [--sign|--unsigned]';
629
+ const contract = requireAddress(address, usage);
630
+ const publicClient = makePublicClient({ chainKey: CHAIN });
631
+ const kind = await detectTokenKind(publicClient, contract);
632
+ if (!kind.isEdition) {
633
+ throw new Error(`set-max-supply is edition-only (a per-id ERC-1155 cap) — ${contract} is a ${kind.label} (721). Use \`abx set-max-invocations\` instead (the whole-project cap).`);
634
+ }
635
+ const tokenId = parseEditionCountFlag(requireFlag(flags, 'token-id', usage), 'token-id');
636
+ const capRaw = requireFlag(flags, 'cap', usage);
637
+ if (capRaw.trim().toLowerCase() === 'open') {
638
+ throw new Error(`--cap open is refused: once you set a cap for #${tokenId} it can only DECREASE (never increase back to uncapped) — ` +
639
+ `"open" would ask for exactly the increase the chain forbids. If this id has never been overridden, it's already open ` +
640
+ `(the --copies default from deploy) — there's nothing to set. To lower an existing cap, pass the number.`);
641
+ }
642
+ const cap = parseEditionCountFlag(capRaw, 'cap');
643
+ const owner = await read(contract, 'owner');
644
+ console.log(dim(` #${tokenId} supply cap → ${cap}`));
645
+ await runWrite(contract, prepareSetMaxSupply({ contract, tokenId, cap, chainId: chainId() }), flags, owner);
646
+ }
647
+ // ── ping-uri (editions only) — permissionless URI re-emission ────────────────────────────────────
648
+ /** How many ids one `pingURI` call carries per transaction — large enough that a normal collection
649
+ * fits in one tx, small enough that a genuinely huge id list doesn't build one unbounded call. */
650
+ const PING_URI_CHUNK_SIZE = 200;
651
+ /**
652
+ * `abx ping-uri <address> --token-ids <csv|range>` — re-emit the native ERC-1155 `URI` event for
653
+ * the given ids after a contract-wide re-point (`set-token-uri`/`set-renderer`), so marketplaces/
654
+ * indexers that only honor the native event re-index. Permissionless (anyone may call it — it only
655
+ * re-emits already-public, current truth), so there is no owner check and no `expectedSigner` to
656
+ * pin; whichever signer the active lane resolves sends it. Chunks a large id list into several
657
+ * transactions, each through the SAME `--dry-run`/`--confirm` gate as any other write.
658
+ */
659
+ export async function cmdPingUri(address, flags) {
660
+ const usage = 'abx ping-uri <address> --token-ids <csv|range> [--sign|--unsigned]';
661
+ const contract = requireAddress(address, usage);
662
+ const publicClient = makePublicClient({ chainKey: CHAIN });
663
+ const kind = await detectTokenKind(publicClient, contract);
664
+ if (!kind.isEdition) {
665
+ throw new Error(`ping-uri is edition-only — it re-emits the ERC-1155 URI event, which a ${kind.label} (721) doesn't have ` +
666
+ `(721 emits ERC-4906 MetadataUpdate automatically on a URI change — nothing to re-emit by hand).`);
667
+ }
668
+ const ids = parseTokenIdRange(requireFlag(flags, 'token-ids', usage));
669
+ const chunks = [];
670
+ for (let i = 0; i < ids.length; i += PING_URI_CHUNK_SIZE)
671
+ chunks.push(ids.slice(i, i + PING_URI_CHUNK_SIZE));
672
+ console.log(dim(` re-emitting URI for ${ids.length} id${ids.length === 1 ? '' : 's'} — permissionless (no owner check) — ${chunks.length} tx${chunks.length === 1 ? '' : '(s)'} of up to ${PING_URI_CHUNK_SIZE} id(s) each`));
673
+ for (const [i, chunk] of chunks.entries()) {
674
+ if (chunks.length > 1)
675
+ console.log(dim(` chunk ${i + 1}/${chunks.length}: ${chunk.length} id(s) (#${chunk[0]}–#${chunk[chunk.length - 1]})`));
676
+ await runWrite(contract, preparePingURI({ contract, tokenIds: chunk, chainId: chainId() }), flags);
677
+ }
678
+ }
538
679
  /** Pause minting — restrict it to the owner (config/reserves) until unpaused. */
539
680
  export async function cmdPause(address, flags) {
540
681
  const contract = requireAddress(address, 'abx pause <address> [--sign|--unsigned]');
@@ -626,9 +767,33 @@ export async function cmdRefresh(address, flags) {
626
767
  }
627
768
  // ── transfer ─────────────────────────────────────────────────────────────────
628
769
  export async function cmdTransfer(address, flags) {
629
- const contract = requireAddress(address, 'abx transfer <address> --to 0x… [--token 0] [--sign|--unsigned]');
630
- const to = requireFlag(flags, 'to', 'abx transfer <address> --to 0x…');
770
+ const usage = 'abx transfer <address> --to 0x… [--token 0] [--amount <n> --from 0x… (editions only)] [--sign|--unsigned]';
771
+ const contract = requireAddress(address, usage);
772
+ const to = requireFlag(flags, 'to', usage);
631
773
  const tokenId = BigInt(flags.token ?? '0');
774
+ const publicClient = makePublicClient({ chainKey: CHAIN });
775
+ const kind = await detectTokenKind(publicClient, contract);
776
+ if (kind.isEdition) {
777
+ const amount = flags.amount !== undefined ? parseEditionCountFlag(flags.amount, 'amount') : 1n;
778
+ // Unlike a 721 (one holder, read on-chain via ownerOf), an edition id can have MANY concurrent
779
+ // holders — there is no single "the owner" to move copies from, so the holder must be named.
780
+ const fromRaw = flags.from;
781
+ if (!fromRaw || fromRaw === 'true') {
782
+ throw new Error(`--from 0x.. is required on a ${kind.label} — an edition id can have many concurrent holders, so ` +
783
+ `there is no single current owner to read on-chain (unlike a 721). Pass the holder's address.`);
784
+ }
785
+ if (!isAddress(fromRaw))
786
+ throw new Error(`--from must be a 0x address; got '${fromRaw}'.`);
787
+ const from = getAddress(fromRaw);
788
+ const tx = prepareEditionTransfer({ contract, from, to, tokenId, amount, chainId: chainId() });
789
+ await runWrite(contract, tx, flags, from);
790
+ return;
791
+ }
792
+ // 721 path (unchanged): --amount has no meaning — a token transfers as a whole. Refuse rather
793
+ // than silently ignore.
794
+ if (flags.amount !== undefined) {
795
+ throw new Error(`--amount is edition-only (ERC-1155 copies) — ${contract} is a ${kind.label} (721), where a token transfers as a whole. Drop --amount.`);
796
+ }
632
797
  const from = await read(contract, 'ownerOf', [tokenId]);
633
798
  const tx = prepareTransfer({ contract, from, to, tokenId, chainId: chainId() });
634
799
  await runWrite(contract, tx, flags, from);
@@ -726,7 +891,7 @@ export function parseTransferValidatorValue(raw, opts) {
726
891
  if (s === 'none' || s === 'zero' || s === '0' || s === '0x0' || s === zeroAddress) {
727
892
  if (opts.allowNone)
728
893
  return zeroAddress;
729
- throw new Error(`a zero transfer validator never enrolls — plain ERC-721 is already the default, so drop --721c. ` +
894
+ throw new Error(`a zero transfer validator never enrolls — a plain (unenrolled) token is already the default, so drop --721c. ` +
730
895
  `("enrolled but suspended" is not a deploy-time state: enroll with a real validator, then suspend ` +
731
896
  `with \`abx set-transfer-validator <address> none\`.)`);
732
897
  }
@@ -768,7 +933,7 @@ export async function cmdSetTransferValidator(address, rest, flags) {
768
933
  await assertContractExists(contract);
769
934
  const status = await readCreatorTokenStatus(publicClient, contract);
770
935
  if (!status.enrolled) {
771
- throw new Error(`${contract} is a plain ERC-721 — 721C is a deploy-time decision, and this collection didn't enroll. ` +
936
+ throw new Error(`${contract} did not enroll as a creator token — 721C/1155C enrollment is a deploy-time decision. ` +
772
937
  `Enrollment can never be added to a live collection; if enforcement is required, redeploy with ` +
773
938
  `--721c recommended (or --721c 0x…) on the deploy command.`);
774
939
  }
@@ -1370,6 +1535,17 @@ async function readSeries(token, fn, args = []) {
1370
1535
  args: args,
1371
1536
  }));
1372
1537
  }
1538
+ /** The edition twin of {@link readSeries} — reads a per-id getter (`totalSupply(id)`,
1539
+ * `maxSupply(id)`, `uri(id)`, …) via the narrowest-common-superset edition ABI. */
1540
+ async function readEdition(token, fn, args = []) {
1541
+ const publicClient = makePublicClient({ chainKey: CHAIN });
1542
+ return (await publicClient.readContract({
1543
+ address: token,
1544
+ abi: oneOfOneEditionAbi,
1545
+ functionName: fn,
1546
+ args: args,
1547
+ }));
1548
+ }
1373
1549
  /** Resolve the shared minter for the chain (flag → env → manifest), or deploy it if none exists.
1374
1550
  * It's ownerless, so any funded signer can stand it up — the sibling of `ensureChunkStore`. */
1375
1551
  async function ensureFixedPriceMinter(override) {
@@ -1400,6 +1576,34 @@ async function ensureFixedPriceMinter(override) {
1400
1576
  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)`));
1401
1577
  return minter;
1402
1578
  }
1579
+ /** The edition twin of {@link ensureFixedPriceMinter} — resolves (or deploys) the chain's shared
1580
+ * `AbxFixedPriceMinter1155`, the Minter spine's per-`(token, id)` sale singleton. */
1581
+ async function ensureFixedPriceMinter1155(override) {
1582
+ const known = fixedPriceMinter1155Address(override);
1583
+ const publicClient = makePublicClient({ chainKey: CHAIN });
1584
+ if (known) {
1585
+ const code = await publicClient.getCode({ address: known });
1586
+ if (code && code !== '0x')
1587
+ return known;
1588
+ console.log(yellow(` configured edition minter ${known} has no code on ${CHAIN} — deploying a fresh one`));
1589
+ }
1590
+ const predicted = predictFixedPriceMinter1155();
1591
+ if (!known || known.toLowerCase() !== predicted.toLowerCase()) {
1592
+ const pcode = await publicClient.getCode({ address: predicted });
1593
+ if (pcode && pcode !== '0x') {
1594
+ console.log(dim(` using the canonical fixed-price edition minter at its deterministic address ${predicted}`));
1595
+ return predicted;
1596
+ }
1597
+ }
1598
+ const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
1599
+ const send = makeHotSender({ wallet, account, publicClient });
1600
+ console.log(dim(' deploying the canonical fixed-price edition minter (shared, ownerless) — CREATE2…'));
1601
+ const { minter, txHash } = await deployFixedPriceMinter1155(send, publicClient);
1602
+ console.log(` ${green('✓')} fixed-price edition minter ${minter}`);
1603
+ console.log(dim(` tx ${txHash}`));
1604
+ console.log(dim(` not in the shipped manifest for ${CHAIN} — to reuse it set ${bold(`ABX_FIXED_PRICE_MINTER_1155=${minter}`)} (or add it to packages/sdk/src/deployments.ts)`));
1605
+ return minter;
1606
+ }
1403
1607
  /** A minter write (configure/buy). No re-index — the sale lives on the minter, not the token
1404
1608
  * projection; the token's own state is unchanged by a sale config.
1405
1609
  *
@@ -1425,23 +1629,76 @@ function parseSalePrice(flags, usage) {
1425
1629
  * sale for a project on the shared minter. Defers to the token owner. Resolves (or deploys) the
1426
1630
  * shared minter, then reports the two remaining grants the owner still needs (assign the minter on
1427
1631
  * the token, set a primary payee) and whether the token is paused.
1632
+ *
1633
+ * On an EDITION (OneOfOneEdition/EditionImage/EditionCode), `--token-id` is REQUIRED (sales are
1634
+ * keyed `(token, id)` — a per-artwork price, not one project-wide sale) and the sale routes to the
1635
+ * shared `AbxFixedPriceMinter1155` instead. Refused on a 721 target.
1428
1636
  */
1429
1637
  export async function cmdMinterConfigure(address, flags) {
1430
- const usage = 'abx minter configure <token> (--price <eth> | --price-raw <units>) --allocation <n> [--erc20 0x…] [--minter-contract 0x…] [--sign|--unsigned]';
1638
+ const usage = 'abx minter configure <token> (--price <eth> | --price-raw <units>) --allocation <n> [--erc20 0x…] [--token-id <n> (editions)] [--minter-contract 0x…] [--sign|--unsigned]';
1431
1639
  const token = requireAddress(address, usage);
1640
+ const publicClient = makePublicClient({ chainKey: CHAIN });
1641
+ const kind = await detectTokenKind(publicClient, token);
1432
1642
  const owner = await read(token, 'owner');
1433
1643
  const erc20 = flags.erc20 && flags.erc20 !== 'true' ? flags.erc20 : zeroAddress;
1434
1644
  const allocation = BigInt(requireFlag(flags, 'allocation', usage));
1435
1645
  const price = parseSalePrice(flags, usage);
1436
- // On --dry-run resolve the minter WITHOUT deploying — ensureFixedPriceMinter would deploy the
1437
- // shared singleton if absent, and a preview must never send. Fall back to a label if none exists.
1646
+ const isEth = erc20 === zeroAddress;
1647
+ // On --dry-run resolve the minter WITHOUT deploying ensure* would deploy the shared singleton
1648
+ // if absent, and a preview must never send. Fall back to a label if none exists.
1438
1649
  const dryRun = isDryRun(flags);
1650
+ if (kind.isEdition) {
1651
+ const tokenId = parseEditionCountFlag(requireFlag(flags, 'token-id', usage), 'token-id');
1652
+ const knownMinter = fixedPriceMinter1155Address(flags['minter-contract']);
1653
+ if (dryRun && !knownMinter) {
1654
+ console.log(yellow(` ⚠ no shared edition minter deployed on ${CHAIN} yet — a real run deploys it once (a separate tx) before configuring.`));
1655
+ }
1656
+ const minter = dryRun ? (knownMinter ?? zeroAddress) : await ensureFixedPriceMinter1155(flags['minter-contract']);
1657
+ // Sanity-check the allocation against what THIS id can still mint. `maxSupply(id) === 0` reads
1658
+ // as "open" (the un-overridden --copies default, or an id nobody has ever capped) — the same
1659
+ // convention `tokens.ts`'s TokenRow.maxSupply documents; see set-max-supply's own note on why a
1660
+ // bare 0 can't be told apart from "explicitly closed" without more than this one read.
1661
+ const [maxSupply, supplyNow] = await Promise.all([
1662
+ readEdition(token, 'maxSupply', [tokenId]).catch(() => 0n),
1663
+ readEdition(token, 'totalSupply', [tokenId]).catch(() => 0n),
1664
+ ]);
1665
+ const remaining = maxSupply > 0n ? (maxSupply > supplyNow ? maxSupply - supplyNow : 0n) : null;
1666
+ if (remaining !== null && allocation > remaining) {
1667
+ console.log(yellow(` ⚠ allocation ${allocation} exceeds the ${remaining} #${tokenId} can still mint `) +
1668
+ dim(`(cap ${maxSupply} − ${supplyNow} minted). Only ${remaining} will actually sell.`));
1669
+ console.log(dim(` To sell ${allocation}, raise this id's cap is not possible (caps only decrease) — set --allocation ${remaining} (or less, to hold reserves).`));
1670
+ }
1671
+ console.log(dim(` configuring sale on ${minter}`));
1672
+ console.log(dim(` token ${token} · id #${tokenId}`));
1673
+ console.log(dim(` price ${isEth ? `${formatEther(price)} ETH` : `${price} units of ${erc20}`} / copy`));
1674
+ console.log(dim(` allocation ${allocation}`));
1675
+ await runMinterWrite(prepareConfigureSale1155({ minter, token, tokenId, paymentToken: erc20, price, allocation, chainId: chainId() }), flags, owner);
1676
+ const [assignedMinter, payee, paused] = await Promise.all([
1677
+ read(token, 'minter').catch(() => zeroAddress),
1678
+ read(token, 'primaryPayee').catch(() => zeroAddress),
1679
+ read(token, 'paused').catch(() => false),
1680
+ ]);
1681
+ const assigned = assignedMinter.toLowerCase() === minter.toLowerCase();
1682
+ console.log('');
1683
+ console.log(assigned ? dim(' ✓ minter is assigned on the token') : yellow(` ⚠ assign this minter on the token: abx set-minter ${token} --minter ${minter}`));
1684
+ if (payee === zeroAddress)
1685
+ console.log(yellow(` ⚠ set a primary payee (sales revert without one): abx set-primary-payee ${token} --payee 0x…`));
1686
+ else
1687
+ console.log(dim(` ✓ proceeds → ${payee}`));
1688
+ if (paused)
1689
+ console.log(yellow(` • token is paused — open the sale when ready: abx unpause ${token}`));
1690
+ console.log(dim(` buyers then run: abx minter buy ${token} --token-id ${tokenId} --quantity <n>`));
1691
+ return;
1692
+ }
1693
+ // 721 path (unchanged): --token-id has no meaning — a 721 sale is one project-wide price.
1694
+ if (flags['token-id'] !== undefined) {
1695
+ throw new Error(`--token-id is edition-only (per-(token,id) sales) — ${token} is a ${kind.label} (721), sold as a single project-wide sale. Drop --token-id.`);
1696
+ }
1439
1697
  const knownMinter = fixedPriceMinterAddress(flags['minter-contract']);
1440
1698
  if (dryRun && !knownMinter) {
1441
1699
  console.log(yellow(` ⚠ no shared minter deployed on ${CHAIN} yet — a real run deploys it once (a separate tx) before configuring.`));
1442
1700
  }
1443
1701
  const minter = dryRun ? (knownMinter ?? zeroAddress) : await ensureFixedPriceMinter(flags['minter-contract']);
1444
- const isEth = erc20 === zeroAddress;
1445
1702
  // Sanity-check the allocation against what the contract can actually mint. A creator who sets
1446
1703
  // --allocation 100 on a 16-supply Series would only ever sell the remainder (maxInvocations binds
1447
1704
  // tighter than the minter's allocation) and discover it when mint #16 reverts. Warn loudly — but
@@ -1479,19 +1736,67 @@ export async function cmdMinterConfigure(address, flags) {
1479
1736
  console.log(dim(` buyers then run: abx minter buy ${token}`));
1480
1737
  }
1481
1738
  /** `abx minter show <token>` — the sale terms + the token's readiness (assigned? payee? paused?).
1482
- * Read-only; the agent runs it to see exactly what's configured before/after a sale. */
1739
+ * Read-only; the agent runs it to see exactly what's configured before/after a sale. On an
1740
+ * edition, `--token-id` is required and the readout is per-id (copies, not a whole-contract cap). */
1483
1741
  export async function cmdMinterShow(address, flags) {
1484
- const token = requireAddress(address, 'abx minter show <token> [--minter-contract 0x…]');
1742
+ const usage = 'abx minter show <token> [--token-id <n> (editions)] [--minter-contract 0x…]';
1743
+ const token = requireAddress(address, usage);
1485
1744
  // Refuse a nonexistent contract like the sibling owner-ops (state/unpause/minter configure) do —
1486
1745
  // otherwise the minter singleton returns zero-config for the unconfigured token and we print a
1487
1746
  // plausible-but-fake `paused: no (open)` readout for an address that has no project at all.
1488
1747
  await assertContractExists(token);
1748
+ const publicClient = makePublicClient({ chainKey: CHAIN });
1749
+ const kind = await detectTokenKind(publicClient, token);
1750
+ if (kind.isEdition) {
1751
+ const tokenId = parseEditionCountFlag(requireFlag(flags, 'token-id', usage), 'token-id');
1752
+ const minter = fixedPriceMinter1155Address(flags['minter-contract']);
1753
+ if (!minter) {
1754
+ console.log(yellow(` no shared edition minter known for ${CHAIN} — configure a sale (deploys it) or set ABX_FIXED_PRICE_MINTER_1155`));
1755
+ return;
1756
+ }
1757
+ const sale = await readSaleConfig1155(publicClient, minter, token, tokenId);
1758
+ // Every read here goes through the EDITION ABI: the generic `read()` is the 721 1/1 ABI,
1759
+ // which has no minter()/primaryPayee() at all — a swallowed client-side throw there printed
1760
+ // a false "not assigned / no payee" for every correctly-configured edition (caught live by
1761
+ // the Sepolia fixture sweep, 2026-08-05).
1762
+ const [assignedMinter, payee, paused, maxSupply, supply] = await Promise.all([
1763
+ readEdition(token, 'minter').catch(() => zeroAddress),
1764
+ readEdition(token, 'primaryPayee').catch(() => zeroAddress),
1765
+ readEdition(token, 'paused').catch(() => false),
1766
+ readEdition(token, 'maxSupply', [tokenId]).catch(() => 0n),
1767
+ readEdition(token, 'totalSupply', [tokenId]).catch(() => 0n),
1768
+ ]);
1769
+ const isEth = sale.paymentToken === zeroAddress;
1770
+ const assigned = assignedMinter.toLowerCase() === minter.toLowerCase();
1771
+ console.log(bold(`\n minter sale — ${token} #${tokenId}`));
1772
+ console.log(` shared minter: ${minter}`);
1773
+ if (!sale.configured) {
1774
+ console.log(yellow(` not configured — abx minter configure ${token} --token-id ${tokenId} --price <eth> --allocation <n>`));
1775
+ }
1776
+ else {
1777
+ console.log(` price: ${isEth ? `${formatEther(sale.price)} ETH` : `${sale.price} units of ${sale.paymentToken}`}`);
1778
+ console.log(` allocation: ${sale.sold}/${sale.allocation} sold`);
1779
+ }
1780
+ console.log(` assigned on token: ${assigned ? green('yes') : yellow(`no — abx set-minter ${token} --minter ${minter}`)}`);
1781
+ console.log(` primary payee: ${payee === zeroAddress ? yellow('none — abx set-primary-payee …') : payee}`);
1782
+ console.log(` paused: ${paused ? yellow(`yes — abx unpause ${token}`) : green('no (open)')}`);
1783
+ console.log(` copies: ${supply}${maxSupply > 0n ? `/${maxSupply}` : dim(' (open — no cap)')}`);
1784
+ const remaining = maxSupply > 0n ? (maxSupply > supply ? maxSupply - supply : 0n) : null;
1785
+ if (sale.configured && remaining !== null && sale.allocation > remaining) {
1786
+ console.log(yellow(` ⚠ allocation ${sale.allocation} exceeds the ${remaining} still mintable for #${tokenId} (cap ${maxSupply} − ${supply} minted) — only ${remaining} can actually sell.`));
1787
+ }
1788
+ console.log('');
1789
+ return;
1790
+ }
1791
+ if (flags['token-id'] !== undefined) {
1792
+ throw new Error(`--token-id is edition-only — ${token} is a ${kind.label} (721). Drop --token-id.`);
1793
+ }
1489
1794
  const minter = fixedPriceMinterAddress(flags['minter-contract']);
1490
1795
  if (!minter) {
1491
1796
  console.log(yellow(` no shared minter known for ${CHAIN} — configure a sale (deploys it) or set ABX_FIXED_PRICE_MINTER`));
1492
1797
  return;
1493
1798
  }
1494
- const sale = await readSaleConfig(makePublicClient({ chainKey: CHAIN }), minter, token);
1799
+ const sale = await readSaleConfig(publicClient, minter, token);
1495
1800
  const [assignedMinter, payee, paused, max, supply] = await Promise.all([
1496
1801
  readSeries(token, 'minter').catch(() => zeroAddress),
1497
1802
  readSeries(token, 'primaryPayee').catch(() => zeroAddress),
@@ -1523,14 +1828,46 @@ export async function cmdMinterShow(address, flags) {
1523
1828
  console.log('');
1524
1829
  }
1525
1830
  /** `abx minter buy <token> [--to 0x…]` — buy one token through the shared minter (public; any
1526
- * funded signer). ETH sales attach the price; ERC-20 sales need a prior approval to the minter. */
1831
+ * funded signer). ETH sales attach the price; ERC-20 sales need a prior approval to the minter.
1832
+ * On an edition, `--token-id` is required and `--quantity` (default 1) buys several copies in the
1833
+ * same purchase — the total ETH attached is `price × quantity`. */
1527
1834
  export async function cmdMinterBuy(address, flags) {
1528
- const usage = 'abx minter buy <token> [--to 0x…] [--minter-contract 0x…] [--sign|--unsigned]';
1835
+ const usage = 'abx minter buy <token> [--to 0x…] [--token-id <n> --quantity <n> (editions)] [--minter-contract 0x…] [--sign|--unsigned]';
1529
1836
  const token = requireAddress(address, usage);
1837
+ const publicClient = makePublicClient({ chainKey: CHAIN });
1838
+ const kind = await detectTokenKind(publicClient, token);
1839
+ if (kind.isEdition) {
1840
+ const tokenId = parseEditionCountFlag(requireFlag(flags, 'token-id', usage), 'token-id');
1841
+ const quantity = flags.quantity !== undefined ? parseEditionCountFlag(flags.quantity, 'quantity') : 1n;
1842
+ if (quantity === 0n)
1843
+ throw new Error('--quantity must be at least 1 (0 copies is not a purchase).');
1844
+ const minter = fixedPriceMinter1155Address(flags['minter-contract']);
1845
+ if (!minter)
1846
+ throw new Error(`no shared edition minter known for ${CHAIN} — configure a sale first, or set ABX_FIXED_PRICE_MINTER_1155`);
1847
+ const sale = await readSaleConfig1155(publicClient, minter, token, tokenId);
1848
+ if (!sale.configured)
1849
+ throw new Error(`no sale configured for ${token} #${tokenId} — run: abx minter configure ${token} --token-id ${tokenId} …`);
1850
+ const isEth = sale.paymentToken === zeroAddress;
1851
+ const to = flags.to && flags.to !== 'true' ? flags.to : undefined;
1852
+ // Payment math: price × quantity, computed ONCE and shown in the confirm/dry-run readout below
1853
+ // (via runMinterWrite → gatedSend's own preview) — the exact ETH the tx attaches.
1854
+ const value = isEth ? sale.price * quantity : 0n;
1855
+ if (!isEth) {
1856
+ console.log(yellow(` ERC-20 sale: the buyer must have approved ${minter} to spend ${sale.price * quantity} units of ${sale.paymentToken} first (else the tx reverts).`));
1857
+ }
1858
+ console.log(dim(` buying ${quantity} cop${quantity === 1n ? 'y' : 'ies'} of #${tokenId}${to ? ` → ${to}` : ' → signer'} for ` +
1859
+ `${isEth ? `${formatEther(value)} ETH (${formatEther(sale.price)} × ${quantity})` : `${value} units (${sale.price} × ${quantity})`}`));
1860
+ await runMinterWrite(preparePurchase1155({ minter, token, tokenId, quantity, to, value, chainId: chainId() }), flags);
1861
+ console.log(dim(` next: \`abx refresh ${token}\` so marketplaces pick up the change.`));
1862
+ return;
1863
+ }
1864
+ if (flags['token-id'] !== undefined || flags.quantity !== undefined) {
1865
+ throw new Error(`--token-id/--quantity are edition-only — ${token} is a ${kind.label} (721), where a sale buys exactly one token. Drop them.`);
1866
+ }
1530
1867
  const minter = fixedPriceMinterAddress(flags['minter-contract']);
1531
1868
  if (!minter)
1532
1869
  throw new Error(`no shared minter known for ${CHAIN} — configure a sale first, or set ABX_FIXED_PRICE_MINTER`);
1533
- const sale = await readSaleConfig(makePublicClient({ chainKey: CHAIN }), minter, token);
1870
+ const sale = await readSaleConfig(publicClient, minter, token);
1534
1871
  if (!sale.configured)
1535
1872
  throw new Error(`no sale configured for ${token} — run: abx minter configure ${token} …`);
1536
1873
  const isEth = sale.paymentToken === zeroAddress;