@artblocks/abx-cli 0.1.0-alpha.2 → 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.
- package/CHANGELOG.md +1762 -0
- package/assets/renderer-scaffold/src/interfaces/IAbxParams.sol +17 -0
- package/assets/renderer-scaffold/test/MyRenderer.t.sol +51 -2
- package/dist/commands/deploy.d.ts +206 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +4004 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/project.d.ts +241 -0
- package/dist/commands/project.d.ts.map +1 -0
- package/dist/commands/project.js +1044 -0
- package/dist/commands/project.js.map +1 -0
- package/dist/commands/reads.d.ts +64 -0
- package/dist/commands/reads.d.ts.map +1 -0
- package/dist/commands/reads.js +667 -0
- package/dist/commands/reads.js.map +1 -0
- package/dist/commands/scaffold.d.ts +71 -0
- package/dist/commands/scaffold.d.ts.map +1 -0
- package/dist/commands/scaffold.js +544 -0
- package/dist/commands/scaffold.js.map +1 -0
- package/dist/commands/service.d.ts +41 -0
- package/dist/commands/service.d.ts.map +1 -0
- package/dist/commands/service.js +618 -0
- package/dist/commands/service.js.map +1 -0
- package/dist/commands/storage.d.ts +37 -0
- package/dist/commands/storage.d.ts.map +1 -0
- package/dist/commands/storage.js +335 -0
- package/dist/commands/storage.js.map +1 -0
- package/dist/config.d.ts +91 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +244 -11
- package/dist/config.js.map +1 -1
- package/dist/conformance.d.ts +31 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +371 -0
- package/dist/conformance.js.map +1 -0
- package/dist/deps.d.ts +6 -39
- package/dist/deps.d.ts.map +1 -1
- package/dist/deps.js +4 -68
- package/dist/deps.js.map +1 -1
- package/dist/errors.d.ts +20 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +25 -0
- package/dist/errors.js.map +1 -0
- package/dist/flags.d.ts +41 -0
- package/dist/flags.d.ts.map +1 -1
- package/dist/flags.js +94 -1
- package/dist/flags.js.map +1 -1
- package/dist/jsonout.d.ts +37 -0
- package/dist/jsonout.d.ts.map +1 -0
- package/dist/jsonout.js +68 -0
- package/dist/jsonout.js.map +1 -0
- package/dist/kind.d.ts +48 -0
- package/dist/kind.d.ts.map +1 -0
- package/dist/kind.js +69 -0
- package/dist/kind.js.map +1 -0
- package/dist/main.js +509 -4708
- package/dist/main.js.map +1 -1
- package/dist/mintpage.d.ts +17 -2
- package/dist/mintpage.d.ts.map +1 -1
- package/dist/mintpage.js +214 -52
- package/dist/mintpage.js.map +1 -1
- package/dist/output.d.ts +148 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +634 -0
- package/dist/output.js.map +1 -0
- package/dist/ownerops.d.ts +132 -44
- package/dist/ownerops.d.ts.map +1 -1
- package/dist/ownerops.js +1067 -327
- package/dist/ownerops.js.map +1 -1
- package/dist/preview.d.ts +81 -0
- package/dist/preview.d.ts.map +1 -0
- package/dist/preview.js +535 -0
- package/dist/preview.js.map +1 -0
- package/dist/prompt.d.ts +17 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +19 -0
- package/dist/prompt.js.map +1 -0
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +10 -7
- package/dist/provision.js.map +1 -1
- package/dist/remote.d.ts +134 -53
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +352 -46
- package/dist/remote.js.map +1 -1
- package/dist/riskgate.d.ts +29 -0
- package/dist/riskgate.d.ts.map +1 -0
- package/dist/riskgate.js +91 -0
- package/dist/riskgate.js.map +1 -0
- package/dist/scaffold.d.ts +10 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +52 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/schema.d.ts +18 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +74 -23
- package/dist/schema.js.map +1 -1
- package/dist/served.d.ts +30 -0
- package/dist/served.d.ts.map +1 -0
- package/dist/served.js +112 -0
- package/dist/served.js.map +1 -0
- package/dist/signer.d.ts +13 -0
- package/dist/signer.d.ts.map +1 -1
- package/dist/signer.js +80 -14
- package/dist/signer.js.map +1 -1
- package/dist/update-check.d.ts +58 -4
- package/dist/update-check.d.ts.map +1 -1
- package/dist/update-check.js +92 -11
- package/dist/update-check.js.map +1 -1
- package/package.json +10 -6
- package/skill/SKILL.md +104 -60
- package/skill/reference/code-projects.md +87 -13
- package/skill/reference/creator-token.md +86 -0
- package/skill/reference/decisions.md +61 -0
- package/skill/reference/hosting.md +48 -15
- package/skill/reference/operating.md +29 -8
- package/skill/reference/setup.md +10 -3
- package/skill/reference/troubleshooting.md +31 -4
- package/dist/inspect.d.ts +0 -48
- package/dist/inspect.d.ts.map +0 -1
- package/dist/inspect.js +0 -184
- package/dist/inspect.js.map +0 -1
- package/dist/migrate.d.ts +0 -65
- package/dist/migrate.d.ts.map +0 -1
- package/dist/migrate.js +0 -180
- package/dist/migrate.js.map +0 -1
- package/dist/onchain-uri.d.ts +0 -97
- package/dist/onchain-uri.d.ts.map +0 -1
- package/dist/onchain-uri.js +0 -243
- package/dist/onchain-uri.js.map +0 -1
- package/dist/upload.d.ts +0 -28
- package/dist/upload.d.ts.map +0 -1
- package/dist/upload.js +0 -41
- package/dist/upload.js.map +0 -1
package/dist/ownerops.js
CHANGED
|
@@ -6,22 +6,29 @@
|
|
|
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,
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { DEP_RESOLUTION, parseDependencyRef, prepareLockDependencies, prepareRemoveLastDependency, prepareSetDependency, prepareSetDependencyRegistry, } 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
|
+
import { encodeScalarParam, encodeTag as encodeTagSdk, isAccepted, prepareConfigureTokenParam, prepareConfigureTokenParamData, prepareSetContractParam, prepareSetContractParamData, seriesCodeAbi, tryReadContract, } from '@artblocks/abx-sdk';
|
|
11
|
+
import { hasParamEnumeration } from '@artblocks/abx-sdk';
|
|
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
18
|
import { formatEther, getAddress, isAddress, parseEther, toHex, zeroAddress } from 'viem';
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
24
|
-
|
|
19
|
+
import { CHAIN, chainId, fixedPriceMinterAddress, fixedPriceMinter1155Address, localIndexer } from './config.js';
|
|
20
|
+
import { CliError } from './errors.js';
|
|
21
|
+
import { detectTokenKind } from './kind.js';
|
|
22
|
+
import { openWalletSession } from './signer.js';
|
|
23
|
+
import { gatedSend, laneFromFlags } from './riskgate.js';
|
|
24
|
+
import { withJson } from './jsonout.js';
|
|
25
|
+
import { resolveRemote, serviceClient } from './remote.js';
|
|
26
|
+
import { isDryRun, positionalArgs, unknownFlags } from './flags.js';
|
|
27
|
+
import { parseSchemaSpecs, describeSchema } from './schema.js';
|
|
28
|
+
// Re-exported for main.ts (the on-chain-vs-off-chain cost model + the compression-mode type now
|
|
29
|
+
// live in the SDK's staging.ts, layered on planChunks/planContentTxs — see the comment at their
|
|
30
|
+
// definition below).
|
|
31
|
+
export { ONCHAIN_PROJECT_SOFT_LIMIT };
|
|
25
32
|
// ── ANSI (local) ─────────────────────────────────────────────────────────────
|
|
26
33
|
const C = { reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m', green: '\x1b[38;5;115m', yellow: '\x1b[38;5;221m' };
|
|
27
34
|
const dim = (s) => `${C.dim}${s}${C.reset}`;
|
|
@@ -33,19 +40,30 @@ const bold = (s) => `${C.bold}${s}${C.reset}`;
|
|
|
33
40
|
// COMPLETE set — but it's COMPUTED by the resolver/renderer from the token's fields + effect
|
|
34
41
|
// outputs; it is never a field you set. These keys are that computed output, so setting them by
|
|
35
42
|
// hand would only pollute the manifest with a bogus entry (a real round-1 agent trap). Refuse them
|
|
36
|
-
// and point at the real verb.
|
|
37
|
-
|
|
43
|
+
// and point at the real verb. `abx_params` joins them for the same reason: it is COMPUTED from the
|
|
44
|
+
// token's on-chain param enumeration, so a hand-set field of that name would be a decoy sitting
|
|
45
|
+
// beside the real block. See specs/protocol/data-plane.md + specs/protocol/onchain-metadata.md.
|
|
46
|
+
const COMPUTED_FIELD_KEYS = new Set(['artifacts', 'abx_params', 'abx_provenance']);
|
|
38
47
|
export function assertSettableField(field) {
|
|
39
48
|
if (COMPUTED_FIELD_KEYS.has(field)) {
|
|
40
49
|
const what = field === 'artifacts'
|
|
41
50
|
? "the COMPUTED manifest (the complete list of this token's files), assembled by the resolver/renderer from your fields"
|
|
42
|
-
:
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
: field === 'abx_params'
|
|
52
|
+
? "the COMPUTED parameter block, enumerated from the token's on-chain params (set one with `abx configure-param`, declare one with `abx set-schema`)"
|
|
53
|
+
: 'the COMPUTED provenance list, assembled by the resolver/renderer';
|
|
54
|
+
throw new Error(`"${field}" is not a field you set — it's ${what}.` +
|
|
55
|
+
(field === 'abx_params'
|
|
56
|
+
? `\n Set a param instead:\n abx configure-param <address> <tokenId|-> <key> <value>\n (or attach a FILE under your own key: abx attach <address> <yourkey> <ipfs://… | ar://… | https://…>)`
|
|
57
|
+
: ` To attach a file so it appears in the manifest, pick your OWN key:\n abx attach <address> <yourkey> <ipfs://… | ar://… | https://…>`));
|
|
45
58
|
}
|
|
46
59
|
}
|
|
60
|
+
/** Whether a value fits the literal `bytes32` lane of a raw contract-param write (printable ASCII,
|
|
61
|
+
* ≤ 31 bytes). Anything longer takes the data path — one blob, its hash evented. */
|
|
62
|
+
function fitsLiteralBytes32(value) {
|
|
63
|
+
return value.length <= 31 && /^[\x20-\x7e]+$/.test(value);
|
|
64
|
+
}
|
|
47
65
|
/** Flags `abx attach` recognizes — anything else warns (non-fatal), so a silent no-op flag surfaces. */
|
|
48
|
-
const ATTACH_FLAGS = ['file', 'compress', 'collection', 'token', 'send', 'sign', 'unsigned', 'yes', 'dry-run', 'port', 'sign-url-file', 'remote'];
|
|
66
|
+
const ATTACH_FLAGS = ['file', 'compress', 'collection', 'token', 'send', 'sign', 'unsigned', 'yes', 'dry-run', 'confirm', 'port', 'sign-url-file', 'remote'];
|
|
49
67
|
/** Auto-detect the on-chain representation for an off-chain locator by its URI scheme. Returns null
|
|
50
68
|
* for anything that isn't a recognized durable/http locator (so `attach` can refuse it loudly
|
|
51
69
|
* rather than silently storing a bad value — the round-1 `--representation` guessing trap). */
|
|
@@ -59,21 +77,10 @@ export function representationForLocator(uri) {
|
|
|
59
77
|
return R.url;
|
|
60
78
|
return null;
|
|
61
79
|
}
|
|
62
|
-
function chainId() {
|
|
63
|
-
return resolveChain(CHAIN).id;
|
|
64
|
-
}
|
|
65
|
-
/** Resolve the signing lane from flags: --unsigned (cold) · --sign (wallet) · default hot. */
|
|
66
|
-
export function laneFromFlags(flags) {
|
|
67
|
-
if (flags.unsigned)
|
|
68
|
-
return 'unsigned';
|
|
69
|
-
if (flags.sign)
|
|
70
|
-
return 'sign';
|
|
71
|
-
return 'send';
|
|
72
|
-
}
|
|
73
80
|
function requireAddress(address, usage) {
|
|
74
81
|
if (!address || address.startsWith('--')) {
|
|
75
82
|
console.error(`usage: ${usage}\n`);
|
|
76
|
-
|
|
83
|
+
throw new CliError('', 1, true); // already printed above — see CliError's alreadyPrinted doc
|
|
77
84
|
}
|
|
78
85
|
return address;
|
|
79
86
|
}
|
|
@@ -81,55 +88,32 @@ function requireFlag(flags, name, usage) {
|
|
|
81
88
|
const v = flags[name];
|
|
82
89
|
if (!v || v === 'true') {
|
|
83
90
|
console.error(`missing --${name}\nusage: ${usage}\n`);
|
|
84
|
-
|
|
91
|
+
throw new CliError('', 1, true); // already printed above — see CliError's alreadyPrinted doc
|
|
85
92
|
}
|
|
86
93
|
return v;
|
|
87
94
|
}
|
|
88
|
-
/**
|
|
89
|
-
*
|
|
95
|
+
/**
|
|
96
|
+
* Every owner-op's send choke point. Returns the {@link SignResult} when a tx was broadcast, else
|
|
97
|
+
* `null` (dry run, or the cold lane which prints a tx instead of sending one).
|
|
98
|
+
*
|
|
99
|
+
* It used to return a bare boolean. It returns the result now because a caller sometimes needs the
|
|
100
|
+
* *receipt* — `abx mint --json` has to report which token id was actually minted, and the only
|
|
101
|
+
* authoritative answer is the Transfer log the mint emitted. Callers that only asked "did it send?"
|
|
102
|
+
* keep working: `null` is falsy, and a result object is truthy.
|
|
103
|
+
*
|
|
104
|
+
* The dry-run preview / `--confirm` prompt / lane selection live in {@link gatedSend} — the SAME
|
|
105
|
+
* choke point the deploy family's resumed-setup send routes through — so this is now just "gate,
|
|
106
|
+
* then re-index if the project is known."
|
|
107
|
+
*/
|
|
90
108
|
async function runWrite(address, provider, flags, expectedSigner) {
|
|
91
|
-
|
|
92
|
-
// owner-op, so --dry-run is uniform across them — it must never fall through to a real send just
|
|
93
|
-
// because the flag isn't wired per-command (a round-1 finding: `set-field --dry-run` silently
|
|
94
|
-
// reached the send lane and only stopped for lack of a key).
|
|
95
|
-
if (flags['dry-run']) {
|
|
96
|
-
const prepared = typeof provider === 'function' ? await provider(expectedSigner ?? zeroAddress) : provider;
|
|
97
|
-
console.log(`\n ${C.bold}◆ ${prepared.summary}${C.reset} ${dim('(dry run — nothing sent)')}`);
|
|
98
|
-
for (const [k, v] of Object.entries(prepared.fields))
|
|
99
|
-
console.log(` ${dim(k.padEnd(12))} ${v}`);
|
|
100
|
-
console.log(` ${dim('to'.padEnd(12))} ${prepared.to ?? dim('(contract deploy)')}`);
|
|
101
|
-
if (expectedSigner)
|
|
102
|
-
console.log(` ${dim('owner'.padEnd(12))} ${expectedSigner}`);
|
|
103
|
-
console.log(dim(`\n Re-run without --dry-run to send (lane: ${laneFromFlags(flags)}).\n`));
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
await assertChainId(CHAIN); // verify the RPC really is CHAIN before any irreversible write
|
|
107
|
-
const result = await signTx(provider, {
|
|
108
|
-
lane: laneFromFlags(flags),
|
|
109
|
-
chainKey: CHAIN,
|
|
110
|
-
expectedSigner,
|
|
111
|
-
yes: !!flags.yes,
|
|
112
|
-
port: flags.port ? Number(flags.port) : undefined,
|
|
113
|
-
signUrlFile: flags['sign-url-file'],
|
|
114
|
-
});
|
|
109
|
+
const result = await gatedSend(provider, flags, { chainKey: CHAIN, expectedSigner });
|
|
115
110
|
if (!result)
|
|
116
|
-
return
|
|
111
|
+
return null; // dry run, or the cold lane — nothing broadcast
|
|
117
112
|
await reindexIfKnown(address, flags);
|
|
118
|
-
return
|
|
119
|
-
}
|
|
120
|
-
/** Resolve a `--remote [url]` owner-op target: explicit URL, else ABX_PUBLIC_BASE_URL / ABX_RESOLVER_URL. */
|
|
121
|
-
function remoteResolverTarget(flags) {
|
|
122
|
-
const v = flags.remote;
|
|
123
|
-
if (v === undefined)
|
|
124
|
-
return null;
|
|
125
|
-
const base = v !== 'true' && v !== '' ? String(v) : (process.env.ABX_PUBLIC_BASE_URL ?? process.env.ABX_RESOLVER_URL);
|
|
126
|
-
if (!base) {
|
|
127
|
-
throw new Error('`--remote` needs a resolver URL: pass `--remote https://host` or set ABX_PUBLIC_BASE_URL in .env');
|
|
128
|
-
}
|
|
129
|
-
return base.replace(/\/+$/, '');
|
|
113
|
+
return result;
|
|
130
114
|
}
|
|
131
115
|
async function reindexIfKnown(address, flags) {
|
|
132
|
-
const indexer =
|
|
116
|
+
const indexer = localIndexer();
|
|
133
117
|
if (indexer.store.getRegistration(address)) {
|
|
134
118
|
const { state, elapsedMs } = await indexer.reindex(address);
|
|
135
119
|
console.log(` ${green('✓')} re-indexed ${state.name ?? address}: ${state.eventCount} events in ${elapsedMs}ms`);
|
|
@@ -137,46 +121,55 @@ async function reindexIfKnown(address, flags) {
|
|
|
137
121
|
else {
|
|
138
122
|
console.log(dim(` (not indexed by this node — run \`abx add ${address}\` then \`abx index\` to serve it)`));
|
|
139
123
|
}
|
|
140
|
-
//
|
|
124
|
+
// Remote-resolver nudge. A remote resolver serves from ITS OWN store, so the LOCAL reindex above
|
|
141
125
|
// never reaches it: after an owner-op — especially a PostParam change — it stays stale, and its
|
|
142
126
|
// effect runner never sees the new inputsHash, so the marketplace thumbnail never auto-updates.
|
|
143
|
-
// `--remote <url>` re-indexes the
|
|
144
|
-
// (ABX_EFFECTS_URL) → the automatic re-render. This is what makes a
|
|
145
|
-
// one-command change (no separate `abx index --remote` + no stale thumbnail).
|
|
146
|
-
|
|
127
|
+
// `--remote <name|url>` re-indexes the remote resolver (incremental), which pings its effect
|
|
128
|
+
// runner (ABX_EFFECTS_URL) → the automatic re-render. This is what makes a remote
|
|
129
|
+
// `configure-param` a one-command change (no separate `abx index --remote` + no stale thumbnail).
|
|
130
|
+
// Best-effort BY DESIGN: the signed tx already landed, so a missing token soft-skips (a hard
|
|
131
|
+
// error here would turn an on-chain success into a CLI failure) — worse only than stale-until-reindex.
|
|
132
|
+
const remote = resolveRemote(flags.remote, flags['remote-token']);
|
|
147
133
|
if (!remote)
|
|
148
134
|
return;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
console.log(dim(` (--remote: set ABX_RESOLVER_ADMIN_TOKEN in .env to nudge ${remote}; skipped — it will serve stale state until it re-indexes)`));
|
|
135
|
+
if (!remote.token) {
|
|
136
|
+
console.log(dim(` (--remote: set ${remote.tokenVar} in .env to nudge ${remote.url}; skipped — it will serve stale state until it re-indexes)`));
|
|
152
137
|
return;
|
|
153
138
|
}
|
|
154
139
|
try {
|
|
155
|
-
|
|
156
|
-
|
|
140
|
+
// no fromBlock ⇒ incremental nudge
|
|
141
|
+
const r = await serviceClient(remote).registerProject({ chainId: chainId(), address });
|
|
142
|
+
// A deferred nudge (202) is NOT waited on here: the signed tx has already landed, and blocking a
|
|
143
|
+
// completed owner-op behind someone else's backfill would be the wrong trade. Say where it got to.
|
|
144
|
+
if (isAccepted(r)) {
|
|
145
|
+
console.log(` ${green('✓')} nudged remote resolver ${dim(remote.url)}: ${r.project.status} — it will pick up the change (check: abx status ${address} --remote ${flags.remote})`);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
console.log(` ${green('✓')} nudged remote resolver ${dim(remote.url)}: ${r.project.eventCount} events (${r.mode}) — its effect runner picks up the change`);
|
|
149
|
+
}
|
|
157
150
|
}
|
|
158
151
|
catch (e) {
|
|
159
|
-
console.log(dim(` (couldn't nudge ${remote}: ${e.message} — run \`abx index ${address} --remote ${remote}\` to refresh it)`));
|
|
152
|
+
console.log(dim(` (couldn't nudge ${remote.url}: ${e.message} — run \`abx index ${address} --remote ${flags.remote}\` to refresh it)`));
|
|
160
153
|
}
|
|
161
154
|
}
|
|
162
155
|
async function read(address, functionName, args = []) {
|
|
163
156
|
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
157
|
+
const result = await tryReadContract(publicClient, { address, abi: oneOfOneImageAbi, functionName, args });
|
|
158
|
+
if (result !== undefined)
|
|
159
|
+
return result;
|
|
160
|
+
// A read that "returned no data" is usually not a real revert but an address with NO CONTRACT —
|
|
161
|
+
// previewing before deploy, a typo'd address, or the wrong chain. Every owner-op reads the
|
|
162
|
+
// owner/ownerOf here FIRST, so this one guard turns the opaque viem error into an actionable one
|
|
163
|
+
// across all of them. Only runs on the error path (zero cost when the contract exists).
|
|
164
|
+
await assertContractExists(address);
|
|
165
|
+
// The contract DOES exist → a genuine revert. tryReadContract already swallowed that error, so
|
|
166
|
+
// re-read once (uncaught) to surface it rather than a generic "read failed".
|
|
167
|
+
return (await publicClient.readContract({
|
|
168
|
+
address,
|
|
169
|
+
abi: oneOfOneImageAbi,
|
|
170
|
+
functionName: functionName,
|
|
171
|
+
args: args,
|
|
172
|
+
}));
|
|
180
173
|
}
|
|
181
174
|
/** Throw a clear, actionable error when `address` has no contract on the active chain — the common
|
|
182
175
|
* cause of an owner-op's raw `returned no data ("0x")`. No-op when code is present or unknowable. */
|
|
@@ -192,7 +185,8 @@ export async function assertContractExists(address) {
|
|
|
192
185
|
if (!code || code === '0x') {
|
|
193
186
|
throw new Error(`no contract at ${address} on ${CHAIN} — nothing to operate on. Owner ops run AFTER deploy: ` +
|
|
194
187
|
`deploy it first (e.g. abx deploy / deploy-series / deploy-code), or check the address and that ` +
|
|
195
|
-
`ABX_CHAIN='${CHAIN}' is the right network (
|
|
188
|
+
`ABX_CHAIN='${CHAIN}' is the right network — we asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}, and a ` +
|
|
189
|
+
`wrong-network endpoint reads as empty even when the address is right.`);
|
|
196
190
|
}
|
|
197
191
|
}
|
|
198
192
|
// ── configure-param ──────────────────────────────────────────────────────────
|
|
@@ -209,18 +203,35 @@ export async function assertContractExists(address) {
|
|
|
209
203
|
* (mirrors parseFlags). `rest.filter(r => !r.startsWith('--'))` was NOT enough: a flag's VALUE
|
|
210
204
|
* (e.g. the URL after `--remote`, or the path after `--file`) isn't `--`-prefixed, so it leaked
|
|
211
205
|
* into the positional value (`configure-param … #ff0000 --remote http://h` → value "#ff0000 http://h"). */
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
206
|
+
/**
|
|
207
|
+
* Encode a command-line value for a payload-typed param (`String` / `Bytes`).
|
|
208
|
+
*
|
|
209
|
+
* `String` is UTF-8 text — the literal characters are the value, which is what a user means.
|
|
210
|
+
*
|
|
211
|
+
* `Bytes` is NOT text, and the old code UTF-8'd whatever string it was handed. A tester passed
|
|
212
|
+
* base64 (the encoding the params docs mention — which describes the *canonical decode* a program
|
|
213
|
+
* receives, not what you type here), and 128 packed bytes were stored as 172 bytes of base64 ASCII.
|
|
214
|
+
* Nothing errored; the in-chain renderer read ASCII where it expected bytes and drew garbage. So a
|
|
215
|
+
* `Bytes` value must state its encoding: `0x…` hex, or `--file` for real binary. A bare string is
|
|
216
|
+
* refused rather than guessed at — there is no safe guess between "these characters" and "these
|
|
217
|
+
* bytes", and the failure is invisible until an artwork renders wrong.
|
|
218
|
+
*/
|
|
219
|
+
export function encodePayloadParam(typeName, valueInput, key) {
|
|
220
|
+
if (typeName === 'String')
|
|
221
|
+
return toHexSdk(new TextEncoder().encode(valueInput));
|
|
222
|
+
const v = valueInput.trim();
|
|
223
|
+
if (/^0x[0-9a-fA-F]*$/.test(v)) {
|
|
224
|
+
if (v.length % 2 !== 0) {
|
|
225
|
+
throw new Error(`--${key} hex value has an odd number of digits (${v.length - 2}) — a byte is two hex digits.`);
|
|
220
226
|
}
|
|
221
|
-
|
|
222
|
-
}
|
|
223
|
-
|
|
227
|
+
return v;
|
|
228
|
+
}
|
|
229
|
+
throw new Error(`"${key}" is a Bytes param, so its value must say what its bytes ARE — this looks like text.\n` +
|
|
230
|
+
` Pass 0x-prefixed hex: abx configure-param <addr> <id> ${key} 0x00112233…\n` +
|
|
231
|
+
` Or the bytes in a file: abx configure-param <addr> <id> ${key} --file ./payload.bin\n` +
|
|
232
|
+
` (Base64 is how a Bytes param is DECODED for your program — not how you write it here. ` +
|
|
233
|
+
`Storing base64 text would put ASCII on-chain where a renderer expects bytes, silently. ` +
|
|
234
|
+
`To store these literal characters on purpose, declare the key as String instead.)`);
|
|
224
235
|
}
|
|
225
236
|
export async function cmdConfigureParam(address, rest, flags) {
|
|
226
237
|
const usage = 'abx configure-param <address> <tokenId|-> <key> <value> [--file <path>] [--remote [url]] [--sign|--unsigned] (tokenId "-" = contract scope, schema-less keys only)';
|
|
@@ -229,7 +240,8 @@ export async function cmdConfigureParam(address, rest, flags) {
|
|
|
229
240
|
const valueInput = valueParts.join(' ');
|
|
230
241
|
if (!tokenIdRaw || !key || (!valueInput && !flags.file)) {
|
|
231
242
|
console.error(`usage: ${usage}\n`);
|
|
232
|
-
process.
|
|
243
|
+
process.exitCode = 1;
|
|
244
|
+
return;
|
|
233
245
|
}
|
|
234
246
|
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
235
247
|
const schema = (await publicClient.readContract({
|
|
@@ -240,8 +252,9 @@ export async function cmdConfigureParam(address, rest, flags) {
|
|
|
240
252
|
}));
|
|
241
253
|
const [exists, paramTypeIdx, , , , , , selectOptions] = schema;
|
|
242
254
|
// Contract scope (`-`): the raw owner setters (`setContractParam[Data]`) — the write path of
|
|
243
|
-
// well-known contract params like `
|
|
244
|
-
//
|
|
255
|
+
// well-known contract params like `display.gateway`. Schema-less keys only: a schema'd key closes
|
|
256
|
+
// the raw path on-chain (`SchemaGoverned`) and is per-token by design. No key is special here: a
|
|
257
|
+
// contract param is enumerated on-chain like any other and lands in tokenData for every token.
|
|
245
258
|
if (tokenIdRaw === '-') {
|
|
246
259
|
if (exists) {
|
|
247
260
|
throw new Error(`"${key}" is schema-governed — it's a per-token PostParam (abx configure-param ${contract} <tokenId> ${key} …). ` +
|
|
@@ -254,9 +267,9 @@ export async function cmdConfigureParam(address, rest, flags) {
|
|
|
254
267
|
await runWrite(contract, prepareSetContractParamData({ contract, key, data, chainId: cid }), flags, owner);
|
|
255
268
|
return;
|
|
256
269
|
}
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
if (
|
|
270
|
+
// Printable ASCII ≤ 31 chars rides as a literal readable bytes32; anything longer takes the
|
|
271
|
+
// data path (one blob, hash evented).
|
|
272
|
+
if (fitsLiteralBytes32(valueInput)) {
|
|
260
273
|
console.log(` ${key} (contract scope) ← "${valueInput}" ${dim('(literal bytes32; owner-only raw setter)')}`);
|
|
261
274
|
await runWrite(contract, prepareSetContractParam({ contract, key, value: encodeTagSdk(valueInput), display: valueInput, chainId: cid }), flags, owner);
|
|
262
275
|
}
|
|
@@ -268,16 +281,21 @@ export async function cmdConfigureParam(address, rest, flags) {
|
|
|
268
281
|
}
|
|
269
282
|
const tokenId = BigInt(tokenIdRaw);
|
|
270
283
|
if (!exists) {
|
|
271
|
-
throw new Error(`no PostParam schema for "${key}" on ${contract}.
|
|
272
|
-
`abx deploy-code … --schema ${key}:<Type>:<Auth
|
|
273
|
-
`
|
|
284
|
+
throw new Error(`no PostParam schema for "${key}" on ${contract}. Declare one — at deploy with ` +
|
|
285
|
+
`abx deploy-code … --schema ${key}:<Type>:<Auth>, or right now on the live contract with ` +
|
|
286
|
+
`abx set-schema ${contract} --schema ${key}:<Type>:<Auth> (e.g. ${key}:HexColor:TokenOwner).`);
|
|
274
287
|
}
|
|
275
288
|
const typeName = ['Bool', 'Select', 'Uint256Range', 'Int256Range', 'DecimalRange', 'HexColor', 'Timestamp', 'String', 'Bytes'][paramTypeIdx];
|
|
276
289
|
let sent;
|
|
277
290
|
if (typeName === 'String' || typeName === 'Bytes') {
|
|
278
291
|
const data = flags.file
|
|
279
292
|
? toHexSdk(new Uint8Array(readFileSync(flags.file)))
|
|
280
|
-
:
|
|
293
|
+
: encodePayloadParam(typeName, valueInput, key);
|
|
294
|
+
const bytes = (data.length - 2) / 2;
|
|
295
|
+
// Echo the DECODED byte count. The old code echoed the string's length, which is precisely how a
|
|
296
|
+
// wrong encoding announced itself and was missed: 128 packed bytes passed as base64 printed
|
|
297
|
+
// "172 bytes".
|
|
298
|
+
console.log(` ${key} (${typeName}) ← ${bytes} bytes ${dim(flags.file ? '(file contents, verbatim)' : typeName === 'Bytes' ? '(decoded from hex)' : '(UTF-8 text)')}`);
|
|
281
299
|
sent = await runWrite(contract, prepareConfigureTokenParamData({ contract, tokenId, key, data, chainId: chainId() }), flags);
|
|
282
300
|
}
|
|
283
301
|
else {
|
|
@@ -285,22 +303,7 @@ export async function cmdConfigureParam(address, rest, flags) {
|
|
|
285
303
|
console.log(` ${key} (${typeName}) ← ${display} ${dim('(canonical encode; the chain enforces schema + auth)')}`);
|
|
286
304
|
sent = await runWrite(contract, prepareConfigureTokenParam({ contract, tokenId, key, value, display, chainId: chainId() }), flags);
|
|
287
305
|
}
|
|
288
|
-
// params.keys drift nudge (best-effort, never fails the command): a project on the on-chain
|
|
289
|
-
// URI lane enumerates its param surface in the `params.keys` contract param — the canonical
|
|
290
|
-
// generator reads it to build on-chain tokenData. A configured key that isn't listed is
|
|
291
|
-
// silently OMITTED there (byte-parity with the resolver breaks), so say it, with the fix.
|
|
292
306
|
if (sent) {
|
|
293
|
-
try {
|
|
294
|
-
const csv = await readParamsKeys(publicClient, contract);
|
|
295
|
-
const fixed = paramsKeysNudge(csv, key);
|
|
296
|
-
if (fixed !== null) {
|
|
297
|
-
console.log(` ${yellow('⚠')} params.keys doesn't list "${key}" — the on-chain generator's tokenData will omit it. Fix:\n` +
|
|
298
|
-
` ${bold(`abx configure-param ${contract} - params.keys ${fixed}`)}`);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
catch {
|
|
302
|
-
/* nudge is advisory — an RPC hiccup or a non-Params contract must never fail the write */
|
|
303
|
-
}
|
|
304
307
|
// What happens to the IMAGE depends on how it's produced. If the `image` field is computed
|
|
305
308
|
// on-chain (a `renderer` representation — the in-chain SVG lane), it re-tints AUTOMATICALLY: the
|
|
306
309
|
// renderer reads the param live, so tokenURI's image is already updated, nothing to re-render.
|
|
@@ -373,8 +376,12 @@ export async function cmdSetParamHooks(address, flags) {
|
|
|
373
376
|
}
|
|
374
377
|
catch (err) {
|
|
375
378
|
await assertContractExists(contract); // no contract at all → the actionable no-contract error
|
|
376
|
-
|
|
377
|
-
|
|
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.`);
|
|
378
385
|
}
|
|
379
386
|
const cur = { configure: current[0], augment: current[1], transfer: current[2] };
|
|
380
387
|
const label = (a) => (eqAddr(a, zeroAddress) ? dim('none') : a);
|
|
@@ -421,7 +428,8 @@ export async function cmdSetDependency(address, rest, flags) {
|
|
|
421
428
|
const [indexRaw, refRaw] = positionalArgs(rest);
|
|
422
429
|
if (indexRaw === undefined || !refRaw) {
|
|
423
430
|
console.error(`usage: ${usage}\n`);
|
|
424
|
-
process.
|
|
431
|
+
process.exitCode = 1;
|
|
432
|
+
return;
|
|
425
433
|
}
|
|
426
434
|
if (!/^\d+$/.test(indexRaw))
|
|
427
435
|
throw new Error(`<index> must be a non-negative integer (got '${indexRaw}') — the list is dense: index ≤ dependencyCount`);
|
|
@@ -445,7 +453,8 @@ export async function cmdSetDependencyRegistry(address, rest, flags) {
|
|
|
445
453
|
const [raw] = positionalArgs(rest);
|
|
446
454
|
if (!raw) {
|
|
447
455
|
console.error(`usage: ${usage}\n`);
|
|
448
|
-
process.
|
|
456
|
+
process.exitCode = 1;
|
|
457
|
+
return;
|
|
449
458
|
}
|
|
450
459
|
if (raw !== 'none' && !/^0x[0-9a-fA-F]{40}$/.test(raw)) {
|
|
451
460
|
throw new Error(`<registry> must be an address (0x + 40 hex) or 'none' to clear; got '${raw}'`);
|
|
@@ -462,30 +471,128 @@ export async function cmdLockDependencies(address, flags) {
|
|
|
462
471
|
console.log(dim(' note: locking the dependency set is permanent and irreversible (list AND registry pointer freeze).'));
|
|
463
472
|
await runWrite(contract, prepareLockDependencies({ contract, chainId: chainId() }), flags, owner);
|
|
464
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
|
+
}
|
|
465
506
|
// ── mint ─────────────────────────────────────────────────────────────────────
|
|
466
507
|
// Issue a token — a deferred mint (deploy → warm resolver → mint) or a primary sale
|
|
467
508
|
// (mint straight to the buyer). For a 1/1 this is the one-shot token #0. For a Series
|
|
468
509
|
// it mints the next sequential token id (metadata = token id): bare (one), or `--count <n>`
|
|
469
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.
|
|
470
514
|
export async function cmdMint(address, flags) {
|
|
471
|
-
const usage = 'abx mint <address> [--to 0x…] [--count <n>] [--sign|--unsigned]';
|
|
515
|
+
const usage = 'abx mint <address> [--to 0x…] [--count <n> | --token-id <id> --amount <n>] [--json] [--sign|--unsigned]';
|
|
472
516
|
const contract = requireAddress(address, usage);
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
517
|
+
// `--json`: the TOKEN ID is the value a program came for, and it was previously only obtainable by
|
|
518
|
+
// regex-scraping coloured prose (B19). It is read from the mint's own Transfer logs rather than by
|
|
519
|
+
// re-reading `nextTokenId` afterwards — a concurrent mint would make that answer wrong, and a
|
|
520
|
+
// number that is usually right is worse than no number.
|
|
521
|
+
return withJson(flags, async () => {
|
|
522
|
+
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
523
|
+
const kind = await detectTokenKind(publicClient, contract);
|
|
524
|
+
const owner = await read(contract, 'owner');
|
|
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
|
+
}
|
|
564
|
+
let tx;
|
|
565
|
+
let count = 1n;
|
|
566
|
+
if (flags.count !== undefined && flags.count !== 'true') {
|
|
567
|
+
count = BigInt(flags.count);
|
|
568
|
+
console.log(dim(` minting ${count} Series tokens in order → ${to}`));
|
|
569
|
+
tx = prepareSeriesMintMany({ contract, to, count, chainId: chainId() });
|
|
570
|
+
}
|
|
571
|
+
else {
|
|
572
|
+
// Bare mint: the `mint(address)` selector is shared by the 1/1 (token #0) and a
|
|
573
|
+
// Series (next sequential token) — one path serves both.
|
|
574
|
+
console.log(dim(` minting token → ${to}${flags.to ? '' : ' (owner — pass --to for a buyer)'}`));
|
|
575
|
+
tx = prepareMint({ contract, to, chainId: chainId() });
|
|
576
|
+
}
|
|
577
|
+
const result = await runWrite(contract, tx, flags, owner);
|
|
578
|
+
const tokenIds = result ? await mintedTokenIds(makePublicClient({ chainKey: CHAIN }), contract, result.txHash) : [];
|
|
579
|
+
if (result && tokenIds.length) {
|
|
580
|
+
console.log(` ${green('✓')} minted token${tokenIds.length > 1 ? 's' : ''} ${bold(tokenIds.map((t) => '#' + t).join(', '))} → ${to}`);
|
|
581
|
+
}
|
|
582
|
+
console.log(dim(` next: \`abx refresh ${contract}\` so marketplaces pick up the new token.`));
|
|
583
|
+
return {
|
|
584
|
+
contract,
|
|
585
|
+
chainId: chainId(),
|
|
586
|
+
to,
|
|
587
|
+
// `sent: false` is the dry-run and cold-lane answer — an empty tokenIds with no explanation
|
|
588
|
+
// would read as a failed mint.
|
|
589
|
+
sent: !!result,
|
|
590
|
+
txHash: result?.txHash ?? null,
|
|
591
|
+
blockNumber: result ? String(result.blockNumber) : null,
|
|
592
|
+
tokenIds,
|
|
593
|
+
requestedCount: Number(count),
|
|
594
|
+
};
|
|
595
|
+
});
|
|
489
596
|
}
|
|
490
597
|
// ── series owner ops (minter set · supply cap · primary payee) ────────────────
|
|
491
598
|
// The multi-token knobs. All owner-only; each runs through the same signing lane +
|
|
@@ -508,6 +615,67 @@ export async function cmdSetMaxInvocations(address, flags) {
|
|
|
508
615
|
const owner = await read(contract, 'owner');
|
|
509
616
|
await runWrite(contract, prepareSetMaxInvocations({ contract, maxInvocations: max, chainId: chainId() }), flags, owner);
|
|
510
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
|
+
}
|
|
511
679
|
/** Pause minting — restrict it to the owner (config/reserves) until unpaused. */
|
|
512
680
|
export async function cmdPause(address, flags) {
|
|
513
681
|
const contract = requireAddress(address, 'abx pause <address> [--sign|--unsigned]');
|
|
@@ -551,15 +719,29 @@ export async function cmdSetPrimaryPayee(address, flags) {
|
|
|
551
719
|
// marketplaces fetch the on-chain tokenURI; if the resolver wasn't warm they cache
|
|
552
720
|
// a miss until refreshed. We emit ERC-4906 on URI changes (4906-aware marketplaces
|
|
553
721
|
// auto-refresh) — this is the manual fallback for the rest, and after the genesis mint.
|
|
554
|
-
|
|
555
|
-
|
|
722
|
+
/**
|
|
723
|
+
* OpenSea's own chain slugs, keyed by ours. They are NOT our keys (`base-sepolia` is `base_sepolia`
|
|
724
|
+
* there) and not derivable from viem, so the map is unavoidable — but a chain missing from it must
|
|
725
|
+
* produce NO link rather than a wrong one, which is what `osChain ?? CHAIN` used to do.
|
|
726
|
+
*
|
|
727
|
+
* It previously listed only `sepolia` and `mainnet`, so the CLI's own DEFAULT chain (`base-sepolia`)
|
|
728
|
+
* fell through to the raw key: the refresh POST went to `/chain/base-sepolia/…` (a slug OpenSea does
|
|
729
|
+
* not know) and the printed link pointed at **mainnet** `opensea.io` for a testnet token. Same shape
|
|
730
|
+
* as the hardcoded explorer table that once sent every Base Sepolia link to Etherscan — hence
|
|
731
|
+
* `testnet` now comes from the chain registry instead of a second hand-maintained set.
|
|
732
|
+
*/
|
|
733
|
+
const OPENSEA_CHAIN = { 'base-sepolia': 'base_sepolia', sepolia: 'sepolia' };
|
|
556
734
|
export async function cmdRefresh(address, flags) {
|
|
557
735
|
const contract = requireAddress(address, 'abx refresh <address> [--token 0]');
|
|
558
736
|
const tokenId = flags.token ?? '0';
|
|
559
|
-
const
|
|
560
|
-
const
|
|
737
|
+
const chain = resolveChain(CHAIN);
|
|
738
|
+
const osChain = OPENSEA_CHAIN[CHAIN];
|
|
739
|
+
const explorer = chain.blockExplorers?.default?.url ?? '';
|
|
561
740
|
const apiKey = process.env.OPENSEA_API_KEY;
|
|
562
|
-
if (
|
|
741
|
+
if (!osChain) {
|
|
742
|
+
console.log(dim(` no OpenSea slug known for '${CHAIN}' — skipping the OpenSea refresh (a guessed slug 404s, and a guessed link would point at the wrong network).`));
|
|
743
|
+
}
|
|
744
|
+
else if (apiKey) {
|
|
563
745
|
const url = `https://api.opensea.io/api/v2/chain/${osChain}/contract/${contract}/nfts/${tokenId}/refresh`;
|
|
564
746
|
try {
|
|
565
747
|
const res = await fetch(url, { method: 'POST', headers: { 'x-api-key': apiKey, accept: 'application/json' } });
|
|
@@ -575,17 +757,43 @@ export async function cmdRefresh(address, flags) {
|
|
|
575
757
|
else {
|
|
576
758
|
console.log(dim(' no OPENSEA_API_KEY set — open these and click “Refresh metadata”:'));
|
|
577
759
|
}
|
|
578
|
-
|
|
579
|
-
|
|
760
|
+
// `testnet` from the chain registry (viem), never a local set — that is the drift this bug was.
|
|
761
|
+
const osBase = chain.testnet ? 'https://testnets.opensea.io' : 'https://opensea.io';
|
|
762
|
+
if (osChain)
|
|
763
|
+
console.log(` ${dim('OpenSea ')}${osBase}/assets/${osChain}/${contract}/${tokenId}`);
|
|
580
764
|
if (explorer)
|
|
581
765
|
console.log(` ${dim('Etherscan')} ${explorer}/token/${contract}?a=${tokenId}`);
|
|
582
766
|
console.log(dim(' (ERC-4906 already pings 4906-aware marketplaces on URI changes; this covers the genesis mint + the rest.)'));
|
|
583
767
|
}
|
|
584
768
|
// ── transfer ─────────────────────────────────────────────────────────────────
|
|
585
769
|
export async function cmdTransfer(address, flags) {
|
|
586
|
-
const
|
|
587
|
-
const
|
|
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);
|
|
588
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
|
+
}
|
|
589
797
|
const from = await read(contract, 'ownerOf', [tokenId]);
|
|
590
798
|
const tx = prepareTransfer({ contract, from, to, tokenId, chainId: chainId() });
|
|
591
799
|
await runWrite(contract, tx, flags, from);
|
|
@@ -648,126 +856,195 @@ export async function cmdSetRoyalty(address, flags) {
|
|
|
648
856
|
}
|
|
649
857
|
await runWrite(contract, prepareSetRoyalty({ contract, receiver, bps, chainId: chainId() }), flags, owner);
|
|
650
858
|
}
|
|
859
|
+
// ── ERC-721C (creator token) — the transfer-validator surface ─────────────────
|
|
860
|
+
// Enrollment is a DEPLOY-TIME decision (`--721c` on the deploy commands) and permanent in both
|
|
861
|
+
// directions: an unenrolled token can never gain a validator, an enrolled one never sheds the
|
|
862
|
+
// standard. Within an enrolled token the owner re-points or suspends (zero) the validator freely.
|
|
863
|
+
/** The chain keys the manifest recommends a transfer validator for — for refusal messages. */
|
|
864
|
+
function chainsWithRecommendedValidator() {
|
|
865
|
+
const ids = new Set(Object.keys(RECOMMENDED_TRANSFER_VALIDATOR).map(Number));
|
|
866
|
+
return KNOWN_CHAIN_KEYS.filter((k) => ids.has(resolveChain(k).id)).join(', ');
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Parse a transfer-validator choice — the shared grammar of the deploy flag (`--721c`) and the
|
|
870
|
+
* owner op (`abx set-transfer-validator`):
|
|
871
|
+
* - `recommended` (or a bare `--721c`) → the per-chain recommended validator, refusing on a
|
|
872
|
+
* chain the manifest has no entry for (naming the chains that do — never guess one);
|
|
873
|
+
* - a `0x…` address → checksum-validated (EIP-55) and returned as its canonical form;
|
|
874
|
+
* - `none`/zero → `allowNone` decides: the owner op suspends with it; the DEPLOY flag refuses
|
|
875
|
+
* it (a zero validator at deploy never enrolls — that is already the default, so someone
|
|
876
|
+
* passing it either wants plain ERC-721 (drop the flag) or mistakenly believes "enrolled but
|
|
877
|
+
* suspended" is a deploy-time state — it isn't).
|
|
878
|
+
* Pure (no RPC) — callers do the has-code precheck themselves. Exported for the regression test.
|
|
879
|
+
*/
|
|
880
|
+
export function parseTransferValidatorValue(raw, opts) {
|
|
881
|
+
const s = raw.trim().toLowerCase();
|
|
882
|
+
if (s === 'true' || s === '' || s === 'recommended') {
|
|
883
|
+
const rec = resolveRecommendedTransferValidator(opts.chainId);
|
|
884
|
+
if (!rec) {
|
|
885
|
+
throw new Error(`no recommended transfer validator is known for '${opts.chainLabel}' (chainId ${opts.chainId}) — ` +
|
|
886
|
+
`chains with one: ${chainsWithRecommendedValidator() || '(none shipped)'}. ` +
|
|
887
|
+
`Pass an explicit validator address instead (it must be a deployed contract on this chain).`);
|
|
888
|
+
}
|
|
889
|
+
return rec;
|
|
890
|
+
}
|
|
891
|
+
if (s === 'none' || s === 'zero' || s === '0' || s === '0x0' || s === zeroAddress) {
|
|
892
|
+
if (opts.allowNone)
|
|
893
|
+
return zeroAddress;
|
|
894
|
+
throw new Error(`a zero transfer validator never enrolls — a plain (unenrolled) token is already the default, so drop --721c. ` +
|
|
895
|
+
`("enrolled but suspended" is not a deploy-time state: enroll with a real validator, then suspend ` +
|
|
896
|
+
`with \`abx set-transfer-validator <address> none\`.)`);
|
|
897
|
+
}
|
|
898
|
+
const trimmed = raw.trim();
|
|
899
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(trimmed)) {
|
|
900
|
+
throw new Error(`transfer validator must be 'recommended'${opts.allowNone ? ", 'none'," : ''} or a 0x address (0x + 40 hex); got '${raw}'`);
|
|
901
|
+
}
|
|
902
|
+
// `isAddress` strict-validates the EIP-55 checksum of a mixed-case address (all-lowercase carries
|
|
903
|
+
// no checksum and passes); `getAddress` alone only NORMALIZES — it would silently accept a
|
|
904
|
+
// mis-cased paste, which is exactly the transposition this check exists to catch.
|
|
905
|
+
if (!isAddress(trimmed, { strict: true })) {
|
|
906
|
+
throw new Error(`transfer validator address failed its EIP-55 checksum: '${raw}' — paste it exactly (or all-lowercase).`);
|
|
907
|
+
}
|
|
908
|
+
return getAddress(trimmed);
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* `abx set-transfer-validator <address> <0x…|none|recommended>` — re-point an ENROLLED (ERC-721C)
|
|
912
|
+
* collection's transfer validator, or suspend enforcement with `none` (address(0); the token
|
|
913
|
+
* STAYS enrolled). Refuses up front, before any signing: a plain ERC-721 (enrollment is
|
|
914
|
+
* deploy-time-only — the contract would revert `NotCreatorToken()`), and a codeless validator
|
|
915
|
+
* (would revert `InvalidTransferValidator()`). Owner-only, any lane, guards `--dry-run`.
|
|
916
|
+
*/
|
|
917
|
+
export async function cmdSetTransferValidator(address, rest, flags) {
|
|
918
|
+
const usage = 'abx set-transfer-validator <address> <0x…|none|recommended> [--sign|--unsigned] [--dry-run]';
|
|
919
|
+
const contract = requireAddress(address, usage);
|
|
920
|
+
const [raw] = positionalArgs(rest);
|
|
921
|
+
if (!raw) {
|
|
922
|
+
console.error(`usage: ${usage}\n`);
|
|
923
|
+
process.exitCode = 1;
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
const cid = chainId();
|
|
927
|
+
const validator = parseTransferValidatorValue(raw, { chainId: cid, chainLabel: CHAIN, allowNone: true });
|
|
928
|
+
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
929
|
+
// Enrollment guard FIRST — a read, not a send. An unenrolled token would revert
|
|
930
|
+
// `NotCreatorToken()`; refuse with the real story instead of letting the chain say it in hex.
|
|
931
|
+
// (readCreatorTokenStatus is defensive, so check the contract exists first — a typo'd address
|
|
932
|
+
// must not read as "plain ERC-721".)
|
|
933
|
+
await assertContractExists(contract);
|
|
934
|
+
const status = await readCreatorTokenStatus(publicClient, contract);
|
|
935
|
+
if (!status.enrolled) {
|
|
936
|
+
throw new Error(`${contract} did not enroll as a creator token — 721C/1155C enrollment is a deploy-time decision. ` +
|
|
937
|
+
`Enrollment can never be added to a live collection; if enforcement is required, redeploy with ` +
|
|
938
|
+
`--721c recommended (or --721c 0x…) on the deploy command.`);
|
|
939
|
+
}
|
|
940
|
+
// Codeless-validator guard: the contract refuses a non-zero validator with no code
|
|
941
|
+
// (`InvalidTransferValidator()`) — surface it before gas is spent.
|
|
942
|
+
if (validator !== zeroAddress) {
|
|
943
|
+
let code;
|
|
944
|
+
try {
|
|
945
|
+
code = await publicClient.getCode({ address: validator });
|
|
946
|
+
}
|
|
947
|
+
catch (err) {
|
|
948
|
+
throw new Error(`couldn't verify the validator has code at ${validator} (${err.message}) — refusing to re-point blind; retry when the RPC answers.`);
|
|
949
|
+
}
|
|
950
|
+
if (!code || code === '0x') {
|
|
951
|
+
const rec = resolveRecommendedTransferValidator(cid);
|
|
952
|
+
throw new Error(`no contract code at ${validator} on ${CHAIN} — the token would revert InvalidTransferValidator(). ` +
|
|
953
|
+
`A transfer validator must be a DEPLOYED contract on this chain` +
|
|
954
|
+
(rec ? ` (the recommended one: \`abx set-transfer-validator ${contract} recommended\` → ${rec})` : '') + `.`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (eqAddr(status.validator, validator)) {
|
|
958
|
+
console.log(dim(` no change — the validator is already ${validator === zeroAddress ? 'suspended (0x0)' : validator}. Nothing sent.`));
|
|
959
|
+
return;
|
|
960
|
+
}
|
|
961
|
+
const label = (a) => (eqAddr(a, zeroAddress) ? 'suspended (0x0)' : a);
|
|
962
|
+
console.log(dim(` transfer validator: ${label(status.validator)} → ${label(validator)}${validator === zeroAddress ? ' — transfers go unvalidated until one is set again (the token stays enrolled)' : ''}`));
|
|
963
|
+
const owner = await read(contract, 'owner');
|
|
964
|
+
await runWrite(contract, prepareSetTransferValidator({ contract, validator, chainId: cid }), flags, owner);
|
|
965
|
+
}
|
|
966
|
+
// ── multi-chunk on-chain content (the `reader` path) ─────────────────────────
|
|
967
|
+
// The plan math (chunk count + tx shape), the cost-model constants, and the actual
|
|
968
|
+
// plan→resolve-store→stage→encode orchestration all live in the SDK's staging.ts, layered on
|
|
969
|
+
// planChunks/planContentTxs/stageContent/encodeReader/ensureChunkStore — see its module doc. What
|
|
970
|
+
// stays here is Node-only (reading the file, gzip) plus the narration: every console.log below is
|
|
971
|
+
// this file's onEvent handler for the SDK's `StagingEvent`, so the lines are unchanged from before
|
|
972
|
+
// the move.
|
|
651
973
|
export function parseCompress(v) {
|
|
652
974
|
const c = (v ?? 'none').toLowerCase();
|
|
653
975
|
if (c === 'none' || c === 'fastlz' || c === 'gzip')
|
|
654
976
|
return c;
|
|
655
977
|
throw new Error(`--compress must be none | fastlz | gzip (got '${v}')`);
|
|
656
978
|
}
|
|
979
|
+
/** The chunk-store bootstrap narration shared by every content-staging path below — one place so
|
|
980
|
+
* the lines are identical whether the store is resolved for a single field ({@link putContentOnChain}
|
|
981
|
+
* → the SDK's `stageFieldContent`) or once up front for a whole batch ({@link ensureChunkStore}). */
|
|
982
|
+
function printChunkStoreEvent(e) {
|
|
983
|
+
if (e.kind === 'stale')
|
|
984
|
+
console.log(yellow(` configured chunk store ${e.address} is a stale deployment (no writeContent) — deploying a current one`));
|
|
985
|
+
else if (e.kind === 'canonical')
|
|
986
|
+
console.log(dim(` using the canonical chunk store at its deterministic address ${e.address}`));
|
|
987
|
+
else if (e.kind === 'deploying')
|
|
988
|
+
console.log(dim(' deploying the canonical multi-chunk content store (AbxChunkStore) — CREATE2…'));
|
|
989
|
+
else {
|
|
990
|
+
console.log(` ${green('✓')} chunk store ${e.address}`);
|
|
991
|
+
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)`));
|
|
992
|
+
}
|
|
993
|
+
}
|
|
657
994
|
/**
|
|
658
995
|
* The chain's shared multi-chunk store/reader; deploy it once if missing, signing through
|
|
659
996
|
* `send` (the env key on the hot lane, or the connected wallet on the wallet lane) — the
|
|
660
|
-
* store is ownerless, so any funded signer can stand it up.
|
|
997
|
+
* store is ownerless, so any funded signer can stand it up. Used directly only by
|
|
998
|
+
* {@link stageImageFieldsBatch} (resolving ONE store up front for every token); a single-field
|
|
999
|
+
* stage ({@link putContentOnChain}) resolves its store through the SDK's `stageFieldContent`
|
|
1000
|
+
* instead, which calls the very same SDK `ensureChunkStore` internally.
|
|
661
1001
|
*/
|
|
662
1002
|
async function ensureChunkStore(send, override) {
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
|
|
1003
|
+
// The resolution logic lives in the SDK (`ensureChunkStore`) so an SDK integrator bootstraps
|
|
1004
|
+
// identically instead of hand-rolling the "is this store capable?" guard — forget it and an
|
|
1005
|
+
// incapable store fails deep inside a mint, after transactions have landed. The CLI keeps only the
|
|
1006
|
+
// narration: the SDK reports progress through `onEvent` rather than printing.
|
|
666
1007
|
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
667
|
-
|
|
668
|
-
const code = await publicClient.getCode({ address: known });
|
|
669
|
-
if (code && code !== '0x') {
|
|
670
|
-
// Has code, but is it the *current* store? A pre-`writeContent` deployment passes a
|
|
671
|
-
// bare code check yet reverts the staging call — verify the ABI, don't just assume.
|
|
672
|
-
if (await storeSupportsWriteContent(publicClient, known))
|
|
673
|
-
return known;
|
|
674
|
-
console.log(yellow(` configured chunk store ${known} is a stale deployment (no writeContent) — deploying a current one`));
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
// No usable listed store. The canonical store is CREATE2-deterministic, so it may already exist at
|
|
678
|
-
// its predicted address — deployed by the forge script (or a prior lazy deploy) with the manifest
|
|
679
|
-
// not yet updated. Check there before deploying: self-healing, and never a duplicate at a random
|
|
680
|
-
// CREATE address. `deployChunkStore` (below) also lands at exactly this address.
|
|
681
|
-
const predicted = predictChunkStore();
|
|
682
|
-
if (!known || known.toLowerCase() !== predicted.toLowerCase()) {
|
|
683
|
-
const pcode = await publicClient.getCode({ address: predicted });
|
|
684
|
-
if (pcode && pcode !== '0x' && (await storeSupportsWriteContent(publicClient, predicted))) {
|
|
685
|
-
console.log(dim(` using the canonical chunk store at its deterministic address ${predicted}`));
|
|
686
|
-
return predicted;
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
console.log(dim(' deploying the canonical multi-chunk content store (AbxChunkStore) — CREATE2…'));
|
|
690
|
-
const { chunkStore } = await deployChunkStore(send);
|
|
691
|
-
console.log(` ${green('✓')} chunk store ${chunkStore}`);
|
|
692
|
-
console.log(dim(` not in the shipped manifest for ${CHAIN} — to reuse it set ABX_CHUNK_STORE=${chunkStore} (or add it to packages/sdk/src/deployments.ts)`));
|
|
693
|
-
return chunkStore;
|
|
1008
|
+
return sdkEnsureChunkStore(publicClient, send, { chainId: chainId(), override, onEvent: printChunkStoreEvent });
|
|
694
1009
|
}
|
|
695
1010
|
/**
|
|
696
|
-
* The hot-lane staging signer: the env key signs + broadcasts each chunk-store write
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
1011
|
+
* The hot-lane staging signer: the env key signs + broadcasts each chunk-store write, via the
|
|
1012
|
+
* SDK's `makeHotSender` (pinned nonce, pinned gas, typed revert). Built lazily so commands that
|
|
1013
|
+
* never stage on-chain don't require a key. The wallet lane passes a session-backed sender
|
|
1014
|
+
* instead (see {@link openWalletSession}); the cold lane can't stage interactively, so it's
|
|
1015
|
+
* rejected up front by the deploy/set-field commands.
|
|
700
1016
|
*/
|
|
701
1017
|
export function envStagingSender() {
|
|
702
1018
|
const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
|
|
703
1019
|
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
704
|
-
return
|
|
705
|
-
const hash = await wallet.sendTransaction({
|
|
706
|
-
account,
|
|
707
|
-
chain: wallet.chain,
|
|
708
|
-
to: tx.to ?? undefined,
|
|
709
|
-
data: tx.data,
|
|
710
|
-
value: 0n,
|
|
711
|
-
});
|
|
712
|
-
return publicClient.waitForTransactionReceipt({ hash });
|
|
713
|
-
};
|
|
1020
|
+
return makeHotSender({ wallet, account, publicClient });
|
|
714
1021
|
}
|
|
715
1022
|
/** Wallet-lane staging signer: each chunk-store write is approved in the human's browser
|
|
716
|
-
* wallet through the open {@link WalletSession}, sharing one connection with the final tx.
|
|
1023
|
+
* wallet through the open {@link WalletSession}, sharing one connection with the final tx. A
|
|
1024
|
+
* {@link PreparedTx} already carries everything `session.send` needs, so this is a thin adapter. */
|
|
717
1025
|
export function sessionStagingSender(session) {
|
|
718
|
-
|
|
719
|
-
return async (tx) => (await session.send({
|
|
720
|
-
op: 'stage',
|
|
721
|
-
to: tx.to,
|
|
722
|
-
data: tx.data,
|
|
723
|
-
value: '0x0',
|
|
724
|
-
chainId: id,
|
|
725
|
-
summary: tx.summary,
|
|
726
|
-
fields: tx.fields,
|
|
727
|
-
})).receipt;
|
|
1026
|
+
return async (tx) => (await session.send(tx)).receipt;
|
|
728
1027
|
}
|
|
729
|
-
/**
|
|
730
|
-
* Put `content` on-chain as SSTORE2 chunks behind the shared reader, and return the field's
|
|
731
|
-
* `(representation, value)`. `fastlz` compresses per chunk (reader decodes on read → stays
|
|
732
|
-
* on-chain renderable); `gzip` compresses the whole content (off-chain decode → the
|
|
733
|
-
* `reader-gzip` representation, not on-chain renderable).
|
|
734
|
-
*
|
|
735
|
-
* Staging is one atomic `writeContent` when the content fits a single tx's gas, else
|
|
736
|
-
* gas-bounded chunk-write `multicall`s plus a final manifest write — {@link stageContent}
|
|
737
|
-
* picks, and we print the tx plan up front so the human knows the count before signing.
|
|
738
|
-
* These store writes use the env key (the data contracts are ownerless); the owner only
|
|
739
|
-
* signs the field set that references the manifest.
|
|
740
|
-
*/
|
|
741
|
-
/**
|
|
742
|
-
* Pure staging plan for a piece of content — chunk count + transaction shape — with NO
|
|
743
|
-
* chain writes, no signer, no `ensureChunkStore`. Shared by {@link putContentOnChain}
|
|
744
|
-
* (which then actually stages) and the deploy dry-run preview, so the count the human is
|
|
745
|
-
* told up front is the same one the real staging will produce. `chunkSize` defaults to the
|
|
746
|
-
* SDK's {@link planChunks} default (currently 22 KB), so e.g. a 2.8 KB SVG is 1 chunk.
|
|
747
|
-
*/
|
|
748
|
-
// On-chain image storage costs ~200 gas/byte (SSTORE2 code deposit) + per-chunk tx overhead, so it
|
|
749
|
-
// is cost-effective ONLY for tiny art. Past these soft limits, off-chain (Arweave pay-once, or a
|
|
750
|
-
// hosted resolver + IPFS) is FAR cheaper — on-chain's remaining edge is self-resolution, not cost.
|
|
751
|
-
// Per-file limit ≈ one 22 KB storage chunk; project limit caps the whole collection's gas.
|
|
752
|
-
export const ONCHAIN_IMAGE_SOFT_LIMIT = 24 * 1024; // per file
|
|
753
|
-
export const ONCHAIN_PROJECT_SOFT_LIMIT = 256 * 1024; // whole collection
|
|
754
1028
|
const kb = (n) => `${n < 10 * 1024 ? (n / 1024).toFixed(1) : Math.round(n / 1024)}KB`;
|
|
755
1029
|
/** Warn (loudly) when content is too big for on-chain to be the cost-sane choice. Returns true if it warned. */
|
|
756
1030
|
export function warnIfLargeForOnChain(bytesLen, label) {
|
|
757
|
-
if (bytesLen
|
|
1031
|
+
if (!exceedsOnchainSoftLimit(bytesLen))
|
|
758
1032
|
return false;
|
|
759
1033
|
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.`));
|
|
760
1034
|
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.`));
|
|
761
1035
|
return true;
|
|
762
1036
|
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Pure staging plan for a piece of content — chunk count + transaction shape — with NO chain
|
|
1039
|
+
* writes, no signer, no `ensureChunkStore`. The CLI-facing wrapper over the SDK's pure
|
|
1040
|
+
* {@link planStagedContent}: `gzip` is a Node-only transform, so it happens here (not in the SDK —
|
|
1041
|
+
* see staging.ts's module doc) before handing the result off to the SDK's chunk/tx-shape math.
|
|
1042
|
+
* Shared by {@link putContentOnChain} (which then actually stages) and the deploy dry-run preview,
|
|
1043
|
+
* so the count the human is told up front is the same one the real staging will produce.
|
|
1044
|
+
*/
|
|
763
1045
|
export function computeContentPlan(bytes, compress) {
|
|
764
1046
|
const content = compress === 'gzip' ? new Uint8Array(gzipSync(bytes)) : new Uint8Array(bytes);
|
|
765
|
-
|
|
766
|
-
const planned = planChunks(content, { fastlz });
|
|
767
|
-
const plan = planContentTxs(planned);
|
|
768
|
-
const representation = compress === 'gzip' ? R.readerGzip : R.reader;
|
|
769
|
-
const stagedBytes = planned.reduce((n, c) => n + c.data.length, 0);
|
|
770
|
-
return { content, fastlz, chunks: planned.length, stagedBytes, plan, representation };
|
|
1047
|
+
return planStagedContent(content, compress);
|
|
771
1048
|
}
|
|
772
1049
|
/** Human-readable size line for a content plan, e.g. `2812B → 1 chunk [fastlz 2812→904B]`. */
|
|
773
1050
|
function planSizeLine(bytes, p, compress) {
|
|
@@ -789,15 +1066,43 @@ function planTxLine(p, finalLabel) {
|
|
|
789
1066
|
? `plan: 1 staging tx (atomic writeContent) + ${finalLabel} = 2 transactions`
|
|
790
1067
|
: `plan: ${p.plan.batches.length} chunk-batch tx(s) + 1 manifest tx + ${finalLabel} = ${p.plan.txCount + 1} transactions`;
|
|
791
1068
|
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Put `bytes` on-chain as SSTORE2 chunks behind the shared reader, and return the field's
|
|
1071
|
+
* `(representation, value)`. `fastlz` compresses per chunk (reader decodes on read → stays
|
|
1072
|
+
* on-chain renderable); `gzip` compresses the whole content (off-chain decode → the
|
|
1073
|
+
* `reader-gzip` representation, not on-chain renderable) — done here, not in the SDK's
|
|
1074
|
+
* `stageFieldContent` (a Node-only transform; see staging.ts's module doc).
|
|
1075
|
+
*
|
|
1076
|
+
* Staging is one atomic `writeContent` when the content fits a single tx's gas, else
|
|
1077
|
+
* gas-bounded chunk-write `multicall`s plus a final manifest write — the SDK picks, and we
|
|
1078
|
+
* print the tx plan up front (via its `onEvent`) so the human knows the count before signing.
|
|
1079
|
+
* These store writes use the env key (the data contracts are ownerless); the owner only
|
|
1080
|
+
* signs the field set that references the manifest.
|
|
1081
|
+
*/
|
|
792
1082
|
async function putContentOnChain(bytes, compress, field, send, finalLabel = '1 owner field-set', store) {
|
|
793
|
-
const p = computeContentPlan(bytes, compress);
|
|
794
1083
|
warnIfLargeForOnChain(bytes.length, `'${field}'`);
|
|
795
|
-
const
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
1084
|
+
const content = compress === 'gzip' ? new Uint8Array(gzipSync(bytes)) : new Uint8Array(bytes);
|
|
1085
|
+
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
1086
|
+
return stageFieldContent({
|
|
1087
|
+
content,
|
|
1088
|
+
compress,
|
|
1089
|
+
field,
|
|
1090
|
+
send,
|
|
1091
|
+
publicClient,
|
|
1092
|
+
chainId: chainId(),
|
|
1093
|
+
store,
|
|
1094
|
+
onEvent: (e) => {
|
|
1095
|
+
if (e.kind === 'chunk-store')
|
|
1096
|
+
printChunkStoreEvent(e.event);
|
|
1097
|
+
else if (e.kind === 'planned') {
|
|
1098
|
+
console.log(dim(` '${field}' on-chain: ${planSizeLine(bytes.length, e.contentPlan, compress)} via reader ${e.store}`));
|
|
1099
|
+
console.log(dim(` ${planTxLine(e.contentPlan, finalLabel)}`));
|
|
1100
|
+
}
|
|
1101
|
+
else {
|
|
1102
|
+
console.log(dim(` staged in ${e.txHashes.length} tx(s); manifest → ${e.manifest}`));
|
|
1103
|
+
}
|
|
1104
|
+
},
|
|
1105
|
+
});
|
|
801
1106
|
}
|
|
802
1107
|
/**
|
|
803
1108
|
* Dry-run preview of staging an image fully on-chain — the REAL chunk + transaction plan,
|
|
@@ -845,6 +1150,55 @@ export async function stageImageFieldsBatch(imagePaths, compress, send, storeOve
|
|
|
845
1150
|
}
|
|
846
1151
|
return { fields, store };
|
|
847
1152
|
}
|
|
1153
|
+
// ── the generator repoint guard ───────────────────────────────────────────────
|
|
1154
|
+
// The canonical AbxGenerator reads a token's param surface FROM CHAIN (`tokenParamKeys` /
|
|
1155
|
+
// `contractParamKeys`). A LEGACY implementation — deployed before enumeration shipped — has neither
|
|
1156
|
+
// getter, so the generator finds nothing: every configured param vanishes from tokenData and from
|
|
1157
|
+
// the live view, silently, behind a tokenURI that still looks perfectly healthy. That is the exact
|
|
1158
|
+
// failure class this toolkit refuses rather than warns about, so pointing a legacy token at the
|
|
1159
|
+
// current generator is REFUSED. (Repointing the metadata RENDERER is safe and unguarded: a v4
|
|
1160
|
+
// renderer on a legacy token just emits no params block.)
|
|
1161
|
+
/** The field-renderer address a `--value` names: the canonical `abi.encode(address)` (32 bytes),
|
|
1162
|
+
* or a bare 20-byte address. Null when it is neither. */
|
|
1163
|
+
function fieldRendererTarget(value) {
|
|
1164
|
+
const hex = value.trim();
|
|
1165
|
+
if (!/^0x[0-9a-fA-F]*$/.test(hex))
|
|
1166
|
+
return null;
|
|
1167
|
+
if (hex.length === 42)
|
|
1168
|
+
return getAddress(hex);
|
|
1169
|
+
if (hex.length !== 66)
|
|
1170
|
+
return null;
|
|
1171
|
+
try {
|
|
1172
|
+
return decodeFieldRenderer(hex);
|
|
1173
|
+
}
|
|
1174
|
+
catch {
|
|
1175
|
+
return null;
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
export async function assertGeneratorRepointable(contract, field, flags,
|
|
1179
|
+
/** Injected for tests; the real probe is one eth_call. */
|
|
1180
|
+
probe = (c) => hasParamEnumeration(makePublicClient({ chainKey: CHAIN }), c)) {
|
|
1181
|
+
if (flags.representation !== R.renderer || !flags.value || flags.value === 'true')
|
|
1182
|
+
return;
|
|
1183
|
+
const target = fieldRendererTarget(flags.value);
|
|
1184
|
+
const generator = resolveGenerator(chainId());
|
|
1185
|
+
// Only the CANONICAL generator is guarded — any other field renderer is the creator's own contract
|
|
1186
|
+
// and none of our business.
|
|
1187
|
+
if (!target || !generator || target.toLowerCase() !== generator.toLowerCase())
|
|
1188
|
+
return;
|
|
1189
|
+
// The `owner` read has already succeeded by the time this runs, so the RPC is proven reachable:
|
|
1190
|
+
// a failing probe here means the getter is absent, not that the node is down.
|
|
1191
|
+
if (await probe(contract))
|
|
1192
|
+
return;
|
|
1193
|
+
throw new Error(`refusing to point ${field} at the canonical generator ${generator} — ${contract} does not expose the param\n` +
|
|
1194
|
+
` enumeration surface (tokenParamKeys/contractParamKeys), so it is a LEGACY implementation. The current\n` +
|
|
1195
|
+
` generator reads params FROM CHAIN, so on this token it would read NOTHING: every configured param would\n` +
|
|
1196
|
+
` silently disappear from tokenData, the live view, and every render — with a tokenURI that still looks fine.\n\n` +
|
|
1197
|
+
` Two honest options:\n` +
|
|
1198
|
+
` • stay on the generator this project already uses (pass that address as --value; it reads the project's\n` +
|
|
1199
|
+
` params.keys list, which is how it has always worked here), or\n` +
|
|
1200
|
+
` • redeploy the project with the current \`abx deploy-code\` — new projects enumerate on-chain and need no list.`);
|
|
1201
|
+
}
|
|
848
1202
|
// ── set-field ──────────────────────────────────────────────────────────────--
|
|
849
1203
|
// Set an on-chain metadata field. `--field` is what (e.g. image, description),
|
|
850
1204
|
// `--representation` is how it's carried (default `inline` for --text, `keccak256`
|
|
@@ -859,10 +1213,11 @@ export async function cmdSetField(address, flags) {
|
|
|
859
1213
|
const collection = !!flags.collection;
|
|
860
1214
|
const lane = laneFromFlags(flags);
|
|
861
1215
|
const owner = await read(contract, 'owner');
|
|
1216
|
+
await assertGeneratorRepointable(contract, field, flags); // legacy impl + the current generator = params silently invisible
|
|
862
1217
|
const staging = !!(flags.file && flags.file !== 'true'); // large content ON-CHAIN via chunk store/reader
|
|
863
1218
|
// --file + --dry-run: preview the on-chain staging plan and store/send NOTHING. (The locator/text
|
|
864
1219
|
// path flows through runWrite, which previews there; staging must short-circuit before any upload.)
|
|
865
|
-
if (staging && flags
|
|
1220
|
+
if (staging && isDryRun(flags)) {
|
|
866
1221
|
const bytes = readFileSync(resolvePath(flags.file));
|
|
867
1222
|
const p = computeContentPlan(bytes, parseCompress(flags.compress));
|
|
868
1223
|
console.log(`\n ${bold('◆ set-field ' + field)} ${dim('(dry run — nothing staged or sent)')}`);
|
|
@@ -938,9 +1293,25 @@ export async function cmdSetField(address, flags) {
|
|
|
938
1293
|
* - computed keys (`artifacts`, `abx_provenance`) are refused — they're the manifest, not inputs.
|
|
939
1294
|
* Scope: token (`--token`, default 0) or `--collection`. Any signing lane. Owner-only.
|
|
940
1295
|
*/
|
|
1296
|
+
/**
|
|
1297
|
+
* Attach one or more artifacts. Several `<key> <uri>` pairs in one invocation become **one
|
|
1298
|
+
* transaction**.
|
|
1299
|
+
*
|
|
1300
|
+
* That batching is a safety fix, not a convenience (backlog B20). The CLI's most-documented flow —
|
|
1301
|
+
* mint, then attach each artifact, then refresh — sent one transaction per step with no all-or-nothing
|
|
1302
|
+
* boundary, so a failure partway through left a permanently half-written token that cannot be
|
|
1303
|
+
* un-minted. An integrator hit exactly this and folded 8 operations into 1 transaction using the SDK's
|
|
1304
|
+
* `batchOps`, which the CLI already shipped and did not use. Now it does: N pairs are one
|
|
1305
|
+
* `multicall`, so either every artifact lands or none does.
|
|
1306
|
+
*
|
|
1307
|
+
* A single pair passes through `batchOps` untouched, so the one-artifact case sends the identical
|
|
1308
|
+
* transaction it always did.
|
|
1309
|
+
*/
|
|
941
1310
|
export async function cmdAttach(rest, flags) {
|
|
942
|
-
const usage = 'abx attach <address> <key> <ipfs://… | ar://… | https://…> [--file <path>] [--collection | --token 0] [--sign|--unsigned] [--dry-run]';
|
|
943
|
-
const [address,
|
|
1311
|
+
const usage = 'abx attach <address> <key> <ipfs://… | ar://… | https://…> [<key> <uri> …] [--file <path>] [--collection | --token 0] [--sign|--unsigned] [--dry-run]';
|
|
1312
|
+
const [address, ...pairArgs] = positionalArgs(rest);
|
|
1313
|
+
const key = pairArgs[0];
|
|
1314
|
+
const uri = pairArgs[1];
|
|
944
1315
|
// Warn (never throw) on an unrecognized flag — a typo or a hopeful `--mime-type` otherwise no-ops
|
|
945
1316
|
// INVISIBLY (round-1: an agent passed `--mime-type` and it was silently swallowed). mimeType is
|
|
946
1317
|
// declared from the URL extension, not a flag; say so.
|
|
@@ -955,13 +1326,15 @@ export async function cmdAttach(rest, flags) {
|
|
|
955
1326
|
const contract = requireAddress(address, usage);
|
|
956
1327
|
if (!key) {
|
|
957
1328
|
console.error(`usage: ${usage}\n the <key> is the name your file appears under in the manifest (e.g. print, certificate, stems, readme).\n`);
|
|
958
|
-
process.
|
|
1329
|
+
process.exitCode = 1;
|
|
1330
|
+
return;
|
|
959
1331
|
}
|
|
960
1332
|
assertSettableField(key);
|
|
961
1333
|
const hasFile = !!(flags.file && flags.file !== 'true');
|
|
962
1334
|
if (!uri && !hasFile) {
|
|
963
1335
|
console.error(`usage: ${usage}\n pass a locator URI (ipfs://… / ar://… / https://…) or --file <path> to store bytes on-chain.\n`);
|
|
964
|
-
process.
|
|
1336
|
+
process.exitCode = 1;
|
|
1337
|
+
return;
|
|
965
1338
|
}
|
|
966
1339
|
if (uri && hasFile)
|
|
967
1340
|
throw new Error('pass EITHER a locator URI OR --file <path>, not both.');
|
|
@@ -975,40 +1348,133 @@ export async function cmdAttach(rest, flags) {
|
|
|
975
1348
|
// Locator path (the common case): auto-detect the representation, refuse an unrecognized scheme
|
|
976
1349
|
// loudly (never silently store a bad value) — fail fast before any RPC. Derive the declared
|
|
977
1350
|
// mimeType from the extension.
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
1351
|
+
// Every `<key> <uri>` pair, validated BEFORE anything is sent — a batch that would revert partway
|
|
1352
|
+
// is exactly what this command now exists to prevent, so a bad locator in pair 5 must stop pair 1.
|
|
1353
|
+
if (pairArgs.length % 2 !== 0) {
|
|
1354
|
+
throw new Error(`attach takes <key> <uri> PAIRS; got ${pairArgs.length} positional argument(s) after the address. ` +
|
|
1355
|
+
`Last one seen: "${pairArgs[pairArgs.length - 1]}".`);
|
|
1356
|
+
}
|
|
1357
|
+
const pairs = [];
|
|
1358
|
+
for (let i = 0; i < pairArgs.length; i += 2) {
|
|
1359
|
+
const k = pairArgs[i];
|
|
1360
|
+
const u = pairArgs[i + 1];
|
|
1361
|
+
assertSettableField(k);
|
|
1362
|
+
const rep = representationForLocator(u);
|
|
1363
|
+
if (!rep) {
|
|
1364
|
+
throw new Error(`"${u}" isn't a recognized file locator. Use ipfs://… (pinned/IPFS), ar://… (Arweave), or https://… . ` +
|
|
1365
|
+
'To store literal text or raw bytes on-chain instead, use `abx set-field`.');
|
|
1366
|
+
}
|
|
1367
|
+
pairs.push({ key: k, uri: u, representation: rep, mimeType: contentTypeFromPath(u) });
|
|
1368
|
+
}
|
|
1369
|
+
// A key repeated within one batch would have the later write silently win — the same
|
|
1370
|
+
// full-column-upsert hazard that bit `register` and `lock-field`. Refuse instead.
|
|
1371
|
+
const dupes = pairs.map((p) => p.key).filter((k, i, a) => a.indexOf(k) !== i);
|
|
1372
|
+
if (dupes.length) {
|
|
1373
|
+
throw new Error(`the same key appears twice in one batch: ${[...new Set(dupes)].join(', ')}. ` +
|
|
1374
|
+
'Each field holds ONE active value, so the last write would silently win — attach them separately if that is really what you want.');
|
|
982
1375
|
}
|
|
1376
|
+
const representation = pairs[0].representation;
|
|
983
1377
|
const owner = await read(contract, 'owner');
|
|
984
|
-
const
|
|
985
|
-
|
|
1378
|
+
for (const p of pairs) {
|
|
1379
|
+
if (p.mimeType === 'application/octet-stream') {
|
|
1380
|
+
console.log(yellow(' ⚠ ') +
|
|
1381
|
+
dim(`no file extension in "${p.uri.slice(0, 64)}" → declared type will be application/octet-stream. `) +
|
|
1382
|
+
dim('Point the URI at the file itself (…/master.tiff, …/coa.pdf) so collectors get the right type.'));
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
const mimeType = pairs[0].mimeType;
|
|
1386
|
+
for (const p of pairs) {
|
|
1387
|
+
console.log(` attaching ${bold(p.key)} ${dim(`(${p.mimeType}, ${p.representation})`)} to ${scope}: ${dim(p.uri)}`);
|
|
1388
|
+
}
|
|
1389
|
+
// Where it surfaces. This used to be one dim line, and it read as a footnote rather than as a
|
|
1390
|
+
// dependency: an integrator attached five audio stems to a fully-on-chain token, paid to store
|
|
1391
|
+
// them, and found `tokenURI` listed none of them — "paid for, stored on-chain, and invisible".
|
|
1392
|
+
// The on-chain renderer deliberately omits locator-represented artifacts (they duplicate no
|
|
1393
|
+
// on-chain type information — see specs/protocol/data-plane.md), so the artifacts manifest comes
|
|
1394
|
+
// from a RESOLVER. When the project has no resolver baked in, that is not a footnote, it is the
|
|
1395
|
+
// difference between a feature working and not existing, so say it as a warning.
|
|
1396
|
+
const uriBase = await read(contract, 'tokenURIBase').catch(() => '');
|
|
1397
|
+
const artifactsPath = `/t/${chainId()}/${contract}/${flags.token ?? '0'}`;
|
|
1398
|
+
if (uriBase && uriBase.trim() !== '') {
|
|
1399
|
+
console.log(dim(` → listed in this project's resolver artifacts (${artifactsPath} and /data/<key>); a bare on-chain tokenURI carries reserved fields plus abx_params only.`));
|
|
1400
|
+
}
|
|
1401
|
+
else {
|
|
986
1402
|
console.log(yellow(' ⚠ ') +
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
//
|
|
993
|
-
|
|
994
|
-
const
|
|
995
|
-
? prepareSetContractField({ contract, field: key, representation, value: toHex(uri), chainId: chainId() })
|
|
996
|
-
: prepareSetTokenField({ contract, tokenId: BigInt(flags.token ?? '0'), field: key, representation, value: toHex(uri), chainId: chainId() });
|
|
997
|
-
const
|
|
1403
|
+
`this project resolves ON-CHAIN (no resolver base baked in), and the on-chain document carries reserved fields plus the computed ${bold('abx_params')} block only — ` +
|
|
1404
|
+
`so ${pairs.map((p) => bold(p.key)).join(', ')} will NOT appear in ${bold('tokenURI')}. The bytes are stored and provable, but nothing surfaces them to a marketplace or wallet. ` +
|
|
1405
|
+
dim('(Configured params DO appear on-chain — attachments are the surface that needs a resolver.)'));
|
|
1406
|
+
console.log(dim(` to make attached artifacts visible, point the project at a resolver (${bold('abx deploy-resolver')}, or a managed one via ${bold('abx add <addr> --remote <name>')}) — it serves the listing at ${artifactsPath}.`));
|
|
1407
|
+
}
|
|
1408
|
+
// ONE transaction for the whole set. `batchOps` folds a same-target run into a `multicall` and
|
|
1409
|
+
// passes a lone op through untouched, so a single attach is byte-identical to before.
|
|
1410
|
+
const ops = pairs.map((p) => collection
|
|
1411
|
+
? prepareSetContractField({ contract, field: p.key, representation: p.representation, value: toHex(p.uri), chainId: chainId() })
|
|
1412
|
+
: prepareSetTokenField({ contract, tokenId: BigInt(flags.token ?? '0'), field: p.key, representation: p.representation, value: toHex(p.uri), chainId: chainId() }));
|
|
1413
|
+
const batched = batchOps(ops);
|
|
1414
|
+
if (batched.length !== 1) {
|
|
1415
|
+
// Defensive: every op here targets the same contract and carries no value, so batchOps must
|
|
1416
|
+
// return exactly one tx. If that ever changes, fail loudly rather than send a partial set.
|
|
1417
|
+
throw new Error(`attach expected to batch ${ops.length} op(s) into one transaction, got ${batched.length}`);
|
|
1418
|
+
}
|
|
1419
|
+
if (pairs.length > 1) {
|
|
1420
|
+
console.log(dim(` ${pairs.length} artifacts → ONE transaction (all-or-nothing: a revert lands none of them, so no half-written token).`));
|
|
1421
|
+
}
|
|
1422
|
+
const sent = await runWrite(contract, batched[0], flags, owner);
|
|
998
1423
|
if (sent) {
|
|
999
1424
|
const id = flags.token ?? '0';
|
|
1000
|
-
|
|
1425
|
+
const names = pairs.map((p) => bold(p.key)).join(', ');
|
|
1426
|
+
console.log(` ${green('✓')} attached — ${names} join${pairs.length > 1 ? '' : 's'} ${scope}'s ${bold('artifacts')} manifest (stored on-chain, anchored).\n` +
|
|
1001
1427
|
dim(` verify (a resolver serves the complete listing): `) +
|
|
1002
|
-
`
|
|
1003
|
-
dim(` ${collection ? '' : `→ artifacts[].key "${key}"; /data
|
|
1004
|
-
dim(` (The complete file listing is a resolver surface — the bare on-chain tokenURI enumerates reserved fields
|
|
1428
|
+
`abx tokenuri ${contract}${id === '0' ? '' : ` --token ${id}`} --fetch` +
|
|
1429
|
+
dim(` ${collection ? '' : `→ artifacts[].key ${pairs.map((p) => `"${p.key}"`).join(', ')}; /data/<key> fetches each`}\n`) +
|
|
1430
|
+
dim(` (The complete file listing is a resolver surface — the bare on-chain tokenURI enumerates reserved fields plus abx_params. Params never need a resolver; attachments do.)\n`));
|
|
1005
1431
|
}
|
|
1006
1432
|
}
|
|
1007
1433
|
// ── lock-field ───────────────────────────────────────────────────────────────
|
|
1434
|
+
/**
|
|
1435
|
+
* Refuse `lock-field` on a name that is a declared PARAM key.
|
|
1436
|
+
*
|
|
1437
|
+
* Fields and params are two separate namespaces that may share a name, and `lock-field` only ever
|
|
1438
|
+
* locks the *field*. A tester welded `grid` — a `Bytes` param holding the artwork — with
|
|
1439
|
+
* `lock-field --field grid`, got "permanent", got `tokenFieldLocked(0,"grid") == true`, and then
|
|
1440
|
+
* overwrote the artwork with `configure-param` on the next call. Every individual statement the CLI
|
|
1441
|
+
* made was true; together they promised a protection that did not exist. Permanence is the pitch, so
|
|
1442
|
+
* this refuses rather than warns, and names the mechanism that actually welds a param.
|
|
1443
|
+
*/
|
|
1444
|
+
async function refuseIfParamKey(contract, field) {
|
|
1445
|
+
let exists = false;
|
|
1446
|
+
try {
|
|
1447
|
+
const schema = (await makePublicClient({ chainKey: CHAIN }).readContract({
|
|
1448
|
+
address: contract,
|
|
1449
|
+
abi: seriesCodeAbi,
|
|
1450
|
+
functionName: 'paramSchema',
|
|
1451
|
+
args: [encodeTagSdk(field)],
|
|
1452
|
+
}));
|
|
1453
|
+
exists = !!schema[0];
|
|
1454
|
+
}
|
|
1455
|
+
catch {
|
|
1456
|
+
// No param surface at all (a 1/1 or plain Series) — nothing to confuse the name with.
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
if (!exists)
|
|
1460
|
+
return;
|
|
1461
|
+
throw new Error(`"${field}" is a declared PostParam key on ${contract}, and lock-field does NOT lock params — ` +
|
|
1462
|
+
`it locks the metadata FIELD of the same name. They are separate namespaces, so this would have ` +
|
|
1463
|
+
`reported "permanent" while configure-param stayed free to overwrite the value.\n` +
|
|
1464
|
+
` To weld the param, lock its schema instead:\n` +
|
|
1465
|
+
` abx set-schema ${contract} --schema ${field}:<Type>:<Auth>:lock=now\n` +
|
|
1466
|
+
` (after that every configure-param on "${field}" reverts ParamLockExpired — check with ` +
|
|
1467
|
+
`\`abx inspect ${contract}\`.)\n` +
|
|
1468
|
+
` If you really did mean the metadata field "${field}" and not the param, re-run with --force-field.`);
|
|
1469
|
+
}
|
|
1008
1470
|
export async function cmdLockField(address, flags) {
|
|
1009
1471
|
const contract = requireAddress(address, 'abx lock-field <address> --field <name> [--collection | --token 0] [--sign|--unsigned]');
|
|
1010
1472
|
const field = requireFlag(flags, 'field', 'abx lock-field <address> --field <name>');
|
|
1011
1473
|
const collection = !!flags.collection;
|
|
1474
|
+
// The metadata field and a same-named param are different things; only an explicit --force-field
|
|
1475
|
+
// says "yes, I mean the field". See refuseIfParamKey.
|
|
1476
|
+
if (flags['force-field'] === undefined)
|
|
1477
|
+
await refuseIfParamKey(contract, field);
|
|
1012
1478
|
const owner = await read(contract, 'owner');
|
|
1013
1479
|
console.log(dim(` note: locking the '${field}' field is permanent and irreversible (freezes all its representations).`));
|
|
1014
1480
|
const tx = collection
|
|
@@ -1069,15 +1535,16 @@ async function readSeries(token, fn, args = []) {
|
|
|
1069
1535
|
args: args,
|
|
1070
1536
|
}));
|
|
1071
1537
|
}
|
|
1072
|
-
|
|
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 = []) {
|
|
1073
1541
|
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
1074
|
-
|
|
1075
|
-
address:
|
|
1076
|
-
abi:
|
|
1077
|
-
functionName:
|
|
1078
|
-
args:
|
|
1542
|
+
return (await publicClient.readContract({
|
|
1543
|
+
address: token,
|
|
1544
|
+
abi: oneOfOneEditionAbi,
|
|
1545
|
+
functionName: fn,
|
|
1546
|
+
args: args,
|
|
1079
1547
|
}));
|
|
1080
|
-
return { configured, paymentToken, price, allocation, sold };
|
|
1081
1548
|
}
|
|
1082
1549
|
/** Resolve the shared minter for the chain (flag → env → manifest), or deploy it if none exists.
|
|
1083
1550
|
* It's ownerless, so any funded signer can stand it up — the sibling of `ensureChunkStore`. */
|
|
@@ -1101,38 +1568,51 @@ async function ensureFixedPriceMinter(override) {
|
|
|
1101
1568
|
}
|
|
1102
1569
|
}
|
|
1103
1570
|
const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
|
|
1571
|
+
const send = makeHotSender({ wallet, account, publicClient });
|
|
1104
1572
|
console.log(dim(' deploying the canonical fixed-price minter (shared, ownerless) — CREATE2…'));
|
|
1105
|
-
const { minter, txHash } = await deployFixedPriceMinter(
|
|
1573
|
+
const { minter, txHash } = await deployFixedPriceMinter(send, publicClient);
|
|
1106
1574
|
console.log(` ${green('✓')} fixed-price minter ${minter}`);
|
|
1107
1575
|
console.log(dim(` tx ${txHash}`));
|
|
1108
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)`));
|
|
1109
1577
|
return minter;
|
|
1110
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
|
+
}
|
|
1111
1607
|
/** A minter write (configure/buy). No re-index — the sale lives on the minter, not the token
|
|
1112
|
-
* projection; the token's own state is unchanged by a sale config.
|
|
1608
|
+
* projection; the token's own state is unchanged by a sale config.
|
|
1609
|
+
*
|
|
1610
|
+
* Used to hand-roll its own dry-run guard — "the minter path does not go through runWrite, so it
|
|
1611
|
+
* needs its OWN" — which is exactly the duplication {@link gatedSend} exists to end: this now shares
|
|
1612
|
+
* the identical choke point `runWrite` does, so `minter configure`/`minter buy` also pick up
|
|
1613
|
+
* `--confirm` (previously only the deploy family had it) with no extra code here. */
|
|
1113
1614
|
async function runMinterWrite(provider, flags, expectedSigner) {
|
|
1114
|
-
|
|
1115
|
-
// through runWrite, so it needs its OWN dry-run guard — without it, `minter configure --dry-run`
|
|
1116
|
-
// silently signs + broadcasts a real sale (a critical footgun: a mainnet creator "previewing"
|
|
1117
|
-
// their sale would send it). Mirrors runWrite's guard.
|
|
1118
|
-
if (flags['dry-run']) {
|
|
1119
|
-
const prepared = typeof provider === 'function' ? await provider(expectedSigner ?? zeroAddress) : provider;
|
|
1120
|
-
console.log(`\n ${bold('◆ ' + prepared.summary)} ${dim('(dry run — nothing sent)')}`);
|
|
1121
|
-
for (const [k, v] of Object.entries(prepared.fields))
|
|
1122
|
-
console.log(` ${dim(k.padEnd(12))} ${v}`);
|
|
1123
|
-
console.log(` ${dim('to'.padEnd(12))} ${prepared.to ?? dim('(contract deploy)')}`);
|
|
1124
|
-
console.log(dim(`\n Re-run without --dry-run to send (lane: ${laneFromFlags(flags)}).\n`));
|
|
1125
|
-
return;
|
|
1126
|
-
}
|
|
1127
|
-
await assertChainId(CHAIN);
|
|
1128
|
-
await signTx(provider, {
|
|
1129
|
-
lane: laneFromFlags(flags),
|
|
1130
|
-
chainKey: CHAIN,
|
|
1131
|
-
expectedSigner,
|
|
1132
|
-
yes: !!flags.yes,
|
|
1133
|
-
port: flags.port ? Number(flags.port) : undefined,
|
|
1134
|
-
signUrlFile: flags['sign-url-file'],
|
|
1135
|
-
});
|
|
1615
|
+
await gatedSend(provider, flags, { chainKey: CHAIN, expectedSigner });
|
|
1136
1616
|
}
|
|
1137
1617
|
/** Parse the sale price: `--price-raw <units>` (exact base units) or the friendly `--price <ether>`
|
|
1138
1618
|
* (18-decimals). ETH sales use `--price`; non-18-dp ERC-20s should use `--price-raw`. */
|
|
@@ -1142,30 +1622,83 @@ function parseSalePrice(flags, usage) {
|
|
|
1142
1622
|
if (flags.price && flags.price !== 'true')
|
|
1143
1623
|
return parseEther(flags.price);
|
|
1144
1624
|
console.error(`missing --price <ether> or --price-raw <units>\nusage: ${usage}\n`);
|
|
1145
|
-
|
|
1625
|
+
throw new CliError('', 1, true); // already printed above — see CliError's alreadyPrinted doc
|
|
1146
1626
|
}
|
|
1147
1627
|
/**
|
|
1148
1628
|
* `abx minter configure <token> --price <eth> --allocation <n> [--erc20 0x…]` — set the fixed-price
|
|
1149
1629
|
* sale for a project on the shared minter. Defers to the token owner. Resolves (or deploys) the
|
|
1150
1630
|
* shared minter, then reports the two remaining grants the owner still needs (assign the minter on
|
|
1151
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.
|
|
1152
1636
|
*/
|
|
1153
1637
|
export async function cmdMinterConfigure(address, flags) {
|
|
1154
|
-
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]';
|
|
1155
1639
|
const token = requireAddress(address, usage);
|
|
1640
|
+
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
1641
|
+
const kind = await detectTokenKind(publicClient, token);
|
|
1156
1642
|
const owner = await read(token, 'owner');
|
|
1157
1643
|
const erc20 = flags.erc20 && flags.erc20 !== 'true' ? flags.erc20 : zeroAddress;
|
|
1158
1644
|
const allocation = BigInt(requireFlag(flags, 'allocation', usage));
|
|
1159
1645
|
const price = parseSalePrice(flags, usage);
|
|
1160
|
-
|
|
1161
|
-
//
|
|
1162
|
-
|
|
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.
|
|
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
|
+
}
|
|
1163
1697
|
const knownMinter = fixedPriceMinterAddress(flags['minter-contract']);
|
|
1164
1698
|
if (dryRun && !knownMinter) {
|
|
1165
1699
|
console.log(yellow(` ⚠ no shared minter deployed on ${CHAIN} yet — a real run deploys it once (a separate tx) before configuring.`));
|
|
1166
1700
|
}
|
|
1167
1701
|
const minter = dryRun ? (knownMinter ?? zeroAddress) : await ensureFixedPriceMinter(flags['minter-contract']);
|
|
1168
|
-
const isEth = erc20 === zeroAddress;
|
|
1169
1702
|
// Sanity-check the allocation against what the contract can actually mint. A creator who sets
|
|
1170
1703
|
// --allocation 100 on a 16-supply Series would only ever sell the remainder (maxInvocations binds
|
|
1171
1704
|
// tighter than the minter's allocation) and discover it when mint #16 reverts. Warn loudly — but
|
|
@@ -1203,19 +1736,67 @@ export async function cmdMinterConfigure(address, flags) {
|
|
|
1203
1736
|
console.log(dim(` buyers then run: abx minter buy ${token}`));
|
|
1204
1737
|
}
|
|
1205
1738
|
/** `abx minter show <token>` — the sale terms + the token's readiness (assigned? payee? paused?).
|
|
1206
|
-
* 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). */
|
|
1207
1741
|
export async function cmdMinterShow(address, flags) {
|
|
1208
|
-
const
|
|
1742
|
+
const usage = 'abx minter show <token> [--token-id <n> (editions)] [--minter-contract 0x…]';
|
|
1743
|
+
const token = requireAddress(address, usage);
|
|
1209
1744
|
// Refuse a nonexistent contract like the sibling owner-ops (state/unpause/minter configure) do —
|
|
1210
1745
|
// otherwise the minter singleton returns zero-config for the unconfigured token and we print a
|
|
1211
1746
|
// plausible-but-fake `paused: no (open)` readout for an address that has no project at all.
|
|
1212
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
|
+
}
|
|
1213
1794
|
const minter = fixedPriceMinterAddress(flags['minter-contract']);
|
|
1214
1795
|
if (!minter) {
|
|
1215
1796
|
console.log(yellow(` no shared minter known for ${CHAIN} — configure a sale (deploys it) or set ABX_FIXED_PRICE_MINTER`));
|
|
1216
1797
|
return;
|
|
1217
1798
|
}
|
|
1218
|
-
const sale = await
|
|
1799
|
+
const sale = await readSaleConfig(publicClient, minter, token);
|
|
1219
1800
|
const [assignedMinter, payee, paused, max, supply] = await Promise.all([
|
|
1220
1801
|
readSeries(token, 'minter').catch(() => zeroAddress),
|
|
1221
1802
|
readSeries(token, 'primaryPayee').catch(() => zeroAddress),
|
|
@@ -1247,14 +1828,46 @@ export async function cmdMinterShow(address, flags) {
|
|
|
1247
1828
|
console.log('');
|
|
1248
1829
|
}
|
|
1249
1830
|
/** `abx minter buy <token> [--to 0x…]` — buy one token through the shared minter (public; any
|
|
1250
|
-
* 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`. */
|
|
1251
1834
|
export async function cmdMinterBuy(address, flags) {
|
|
1252
|
-
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]';
|
|
1253
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
|
+
}
|
|
1254
1867
|
const minter = fixedPriceMinterAddress(flags['minter-contract']);
|
|
1255
1868
|
if (!minter)
|
|
1256
1869
|
throw new Error(`no shared minter known for ${CHAIN} — configure a sale first, or set ABX_FIXED_PRICE_MINTER`);
|
|
1257
|
-
const sale = await
|
|
1870
|
+
const sale = await readSaleConfig(publicClient, minter, token);
|
|
1258
1871
|
if (!sale.configured)
|
|
1259
1872
|
throw new Error(`no sale configured for ${token} — run: abx minter configure ${token} …`);
|
|
1260
1873
|
const isEth = sale.paymentToken === zeroAddress;
|
|
@@ -1267,4 +1880,131 @@ export async function cmdMinterBuy(address, flags) {
|
|
|
1267
1880
|
await runMinterWrite(preparePurchase({ minter, token, to, value, chainId: chainId() }), flags);
|
|
1268
1881
|
console.log(dim(` next: \`abx refresh ${token}\` so marketplaces pick up the new token.`));
|
|
1269
1882
|
}
|
|
1883
|
+
// ── PostParam schemas, after deploy ──────────────────────────────────────────
|
|
1884
|
+
// `setParamSchema` is owner-gated with no deploy-time restriction, so a project's param surface was
|
|
1885
|
+
// never actually frozen at deploy — the toolkit just had no way to reach it, which read to creators
|
|
1886
|
+
// as a protocol limitation ("we must guess every param up front, or redeploy and lose the address").
|
|
1887
|
+
// Two commands close that: `set-schema` (attach or replace one key) and `retire-param` (stop all
|
|
1888
|
+
// further writes, the closest thing to a delete the protocol has).
|
|
1889
|
+
/** Values already stored under a key can be stranded by a schema change — the contract does NOT
|
|
1890
|
+
* re-validate them. Compare old vs new and name what would break, so the guard can refuse. */
|
|
1891
|
+
export function strandingRisks(before, after) {
|
|
1892
|
+
const risks = [];
|
|
1893
|
+
const typeName = (i) => PARAM_TYPES[i] ?? String(i);
|
|
1894
|
+
if (before.paramType !== after.paramType) {
|
|
1895
|
+
risks.push(`type ${typeName(before.paramType)} → ${typeName(after.paramType)} (a stored value keeps its old encoding)`);
|
|
1896
|
+
}
|
|
1897
|
+
const dropped = before.selectOptions.filter((o) => !after.selectOptions.includes(o));
|
|
1898
|
+
if (before.selectOptions.length && dropped.length) {
|
|
1899
|
+
risks.push(`Select option(s) removed: ${dropped.join(', ')} (a token already set to one keeps it)`);
|
|
1900
|
+
}
|
|
1901
|
+
// Narrowing either bound can strand a value that sat inside the old range.
|
|
1902
|
+
const asInt = (h) => (PARAM_TYPES[after.paramType] === 'Int256Range' ? BigInt.asIntN(256, BigInt(h)) : BigInt(h));
|
|
1903
|
+
if (before.min !== after.min && asInt(after.min) > asInt(before.min))
|
|
1904
|
+
risks.push(`min raised (${asInt(before.min)} → ${asInt(after.min)})`);
|
|
1905
|
+
if (before.max !== after.max && asInt(before.max) !== 0n && asInt(after.max) < asInt(before.max)) {
|
|
1906
|
+
risks.push(`max lowered (${asInt(before.max)} → ${asInt(after.max)})`);
|
|
1907
|
+
}
|
|
1908
|
+
return risks;
|
|
1909
|
+
}
|
|
1910
|
+
/** `abx set-schema <address> --schema key:Type:Auth[:lock=<when>]` — attach or replace ONE key's
|
|
1911
|
+
* on-chain schema, any time in a project's life. Owner-only. */
|
|
1912
|
+
export async function cmdSetSchema(address, flags) {
|
|
1913
|
+
const usage = 'abx set-schema <address> --schema key:Type:Auth[:lock=<when>] [--force] [--dry-run] [--sign|--unsigned]';
|
|
1914
|
+
const contract = requireAddress(address, usage);
|
|
1915
|
+
const specs = parseSchemaSpecs(flags.schema);
|
|
1916
|
+
if (specs.length !== 1) {
|
|
1917
|
+
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`);
|
|
1918
|
+
process.exitCode = 1;
|
|
1919
|
+
return;
|
|
1920
|
+
}
|
|
1921
|
+
const next = specs[0];
|
|
1922
|
+
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
1923
|
+
const owner = await read(contract, 'owner');
|
|
1924
|
+
const before = await readParamSchema(publicClient, contract, next.key);
|
|
1925
|
+
if (!before.exists) {
|
|
1926
|
+
console.log(` ${next.key} ${dim('— new governed key')} ${describeSchema(next)}`);
|
|
1927
|
+
}
|
|
1928
|
+
else {
|
|
1929
|
+
// The upsert hazard: this is a FULL row write, so anything not restated is overwritten. Show
|
|
1930
|
+
// both sides, and refuse a change that could strand values unless the caller insists.
|
|
1931
|
+
console.log(` ${next.key} ${dim('— replacing an existing schema')}`);
|
|
1932
|
+
console.log(` ${dim('before')} ${describeSchema(onChainToParsed(next.key, before))}`);
|
|
1933
|
+
console.log(` ${dim('after ')} ${describeSchema(next)}`);
|
|
1934
|
+
const risks = strandingRisks(before, next);
|
|
1935
|
+
if (risks.length && flags.force === undefined) {
|
|
1936
|
+
throw new Error(`refusing to replace "${next.key}" — this change can strand values already stored under it:\n` +
|
|
1937
|
+
risks.map((r) => ` • ${r}`).join('\n') +
|
|
1938
|
+
`\n\n The contract does NOT re-validate stored values against a new schema, so affected tokens would keep\n` +
|
|
1939
|
+
` values their own schema no longer allows. Re-run with --force if that is what you intend.`);
|
|
1940
|
+
}
|
|
1941
|
+
// With --force, say what is being overridden. Silently applying a value-stranding change is the
|
|
1942
|
+
// one outcome worse than refusing it: the operator gets no record of which tokens they may have
|
|
1943
|
+
// just invalidated, and neither does anyone reading the terminal afterwards.
|
|
1944
|
+
if (risks.length) {
|
|
1945
|
+
console.log(` ${C.yellow}⚠${C.reset} ${bold('--force')} — applying a change that can strand stored values:`);
|
|
1946
|
+
for (const r of risks)
|
|
1947
|
+
console.log(` • ${r}`);
|
|
1948
|
+
console.log(dim(` any token already holding a value for "${next.key}" keeps it, now outside what its schema allows.`));
|
|
1949
|
+
}
|
|
1950
|
+
if (before.lockAfter && !next.lockAfter) {
|
|
1951
|
+
console.log(` ${C.yellow}⚠${C.reset} the existing lock (${new Date(before.lockAfter * 1000).toISOString().slice(0, 19)}Z) is being REMOVED — restate it with :lock= to keep it.`);
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
// ONE op. A schema write used to need a `params.keys` companion write in the same tx to keep the
|
|
1955
|
+
// on-chain generator's key list in step; the generator now enumerates params from the token
|
|
1956
|
+
// itself, so the schema write is the whole change.
|
|
1957
|
+
await runWrite(contract, prepareSetParamSchema({
|
|
1958
|
+
contract,
|
|
1959
|
+
key: next.key,
|
|
1960
|
+
paramType: next.paramType,
|
|
1961
|
+
auth: next.auth,
|
|
1962
|
+
authAddress: next.authAddress,
|
|
1963
|
+
lockAfter: next.lockAfter,
|
|
1964
|
+
min: next.min,
|
|
1965
|
+
max: next.max,
|
|
1966
|
+
selectOptions: next.selectOptions,
|
|
1967
|
+
chainId: chainId(),
|
|
1968
|
+
display: describeSchema(next),
|
|
1969
|
+
}), flags, owner);
|
|
1970
|
+
}
|
|
1971
|
+
/** Render an on-chain schema through the same formatter the CLI uses for a parsed one. */
|
|
1972
|
+
function onChainToParsed(key, s) {
|
|
1973
|
+
return {
|
|
1974
|
+
key,
|
|
1975
|
+
paramType: s.paramType,
|
|
1976
|
+
auth: s.auth,
|
|
1977
|
+
authAddress: s.authAddress,
|
|
1978
|
+
lockAfter: s.lockAfter,
|
|
1979
|
+
min: s.min,
|
|
1980
|
+
max: s.max,
|
|
1981
|
+
selectOptions: s.selectOptions,
|
|
1982
|
+
};
|
|
1983
|
+
}
|
|
1984
|
+
/** `abx retire-param <address> <key>` — permanently stop further writes to a PostParam. */
|
|
1985
|
+
export async function cmdRetireParam(address, rest, flags) {
|
|
1986
|
+
const usage = 'abx retire-param <address> <key> [--dry-run] [--sign|--unsigned]';
|
|
1987
|
+
const contract = requireAddress(address, usage);
|
|
1988
|
+
const [key] = positionalArgs(rest);
|
|
1989
|
+
if (!key) {
|
|
1990
|
+
console.error(`usage: ${usage}\n`);
|
|
1991
|
+
process.exitCode = 1;
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
1995
|
+
const owner = await read(contract, 'owner');
|
|
1996
|
+
const current = await readParamSchema(publicClient, contract, key);
|
|
1997
|
+
if (!current.exists)
|
|
1998
|
+
throw new Error(`no PostParam schema for "${key}" on ${contract} — nothing to retire.`);
|
|
1999
|
+
if (current.lockAfter && current.lockAfter < Math.floor(Date.now() / 1000)) {
|
|
2000
|
+
console.log(` ${C.green}✓${C.reset} "${key}" is already retired (locked ${new Date(current.lockAfter * 1000).toISOString().slice(0, 19)}Z). Nothing to do.`);
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
console.log(` ${key} ${dim(describeSchema(onChainToParsed(key, current)))}`);
|
|
2004
|
+
console.log(` ${dim('after this: every write reverts ParamLockExpired — permanently, with no way back.')}`);
|
|
2005
|
+
console.log(` ${dim('the schema stays (a key can never be un-governed), and any value ALREADY stored stays and keeps serving.')}`);
|
|
2006
|
+
// Read-modify-write: carry every field forward and change only the lock. Composing a fresh schema
|
|
2007
|
+
// here would silently reset type/auth/bounds/options — the upsert clobber this command exists to avoid.
|
|
2008
|
+
await runWrite(contract, prepareRetireParam({ contract, key, current, chainId: chainId() }), flags, owner);
|
|
2009
|
+
}
|
|
1270
2010
|
//# sourceMappingURL=ownerops.js.map
|