@artblocks/abx-cli 0.1.0-alpha.0
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/LICENSE +21 -0
- package/assets/renderer-scaffold/README.md +59 -0
- package/assets/renderer-scaffold/foundry.toml +15 -0
- package/assets/renderer-scaffold/remappings.txt +3 -0
- package/assets/renderer-scaffold/script/Deploy.s.sol +23 -0
- package/assets/renderer-scaffold/src/MyRenderer.sol +123 -0
- package/assets/renderer-scaffold/src/MyTraits.sol +75 -0
- package/assets/renderer-scaffold/src/interfaces/IAbxFieldRenderer.sol +32 -0
- package/assets/renderer-scaffold/src/interfaces/IAbxParams.sol +26 -0
- package/assets/renderer-scaffold/test/MyRenderer.t.sol +110 -0
- package/dist/config.d.ts +69 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +184 -0
- package/dist/config.js.map +1 -0
- package/dist/deps.d.ts +46 -0
- package/dist/deps.d.ts.map +1 -0
- package/dist/deps.js +90 -0
- package/dist/deps.js.map +1 -0
- package/dist/flags.d.ts +25 -0
- package/dist/flags.d.ts.map +1 -0
- package/dist/flags.js +34 -0
- package/dist/flags.js.map +1 -0
- package/dist/inspect.d.ts +48 -0
- package/dist/inspect.d.ts.map +1 -0
- package/dist/inspect.js +184 -0
- package/dist/inspect.js.map +1 -0
- package/dist/main.d.ts +3 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/main.js +5102 -0
- package/dist/main.js.map +1 -0
- package/dist/migrate.d.ts +65 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +180 -0
- package/dist/migrate.js.map +1 -0
- package/dist/mintpage.d.ts +46 -0
- package/dist/mintpage.d.ts.map +1 -0
- package/dist/mintpage.js +461 -0
- package/dist/mintpage.js.map +1 -0
- package/dist/onchain-uri.d.ts +97 -0
- package/dist/onchain-uri.d.ts.map +1 -0
- package/dist/onchain-uri.js +243 -0
- package/dist/onchain-uri.js.map +1 -0
- package/dist/ownerops.d.ts +195 -0
- package/dist/ownerops.d.ts.map +1 -0
- package/dist/ownerops.js +1270 -0
- package/dist/ownerops.js.map +1 -0
- package/dist/provision.d.ts +86 -0
- package/dist/provision.d.ts.map +1 -0
- package/dist/provision.js +372 -0
- package/dist/provision.js.map +1 -0
- package/dist/remote.d.ts +58 -0
- package/dist/remote.d.ts.map +1 -0
- package/dist/remote.js +54 -0
- package/dist/remote.js.map +1 -0
- package/dist/schema.d.ts +15 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +125 -0
- package/dist/schema.js.map +1 -0
- package/dist/series-traits.d.ts +30 -0
- package/dist/series-traits.d.ts.map +1 -0
- package/dist/series-traits.js +103 -0
- package/dist/series-traits.js.map +1 -0
- package/dist/signer.d.ts +80 -0
- package/dist/signer.d.ts.map +1 -0
- package/dist/signer.js +520 -0
- package/dist/signer.js.map +1 -0
- package/dist/upload.d.ts +28 -0
- package/dist/upload.d.ts.map +1 -0
- package/dist/upload.js +41 -0
- package/dist/upload.js.map +1 -0
- package/package.json +55 -0
- package/skill/SKILL.md +304 -0
- package/skill/reference/code-projects.md +211 -0
- package/skill/reference/hosting.md +138 -0
- package/skill/reference/operating.md +116 -0
- package/skill/reference/setup.md +36 -0
- package/skill/reference/troubleshooting.md +28 -0
package/dist/ownerops.js
ADDED
|
@@ -0,0 +1,1270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner operations — the writes that *operate* a project after it's born:
|
|
3
|
+
* sell/transfer, re-point its resolver, change royalties, update or freeze
|
|
4
|
+
* content, hand over admin. Each builds an unsigned tx with the SDK and runs it
|
|
5
|
+
* through the signing harness (hot / wallet / cold lane), then re-indexes so the
|
|
6
|
+
* served state reflects the change. The agent picks the lane; the human only
|
|
7
|
+
* approves (wallet lane) or it's the env key (hot lane).
|
|
8
|
+
*/
|
|
9
|
+
import { assertChainId, deployChunkStore, predictChunkStore, predictFixedPriceMinter, encodeReader, makePublicClient, makeWalletClient, oneOfOneImageAbi, seriesImageAbi, abxFixedPriceMinterAbi, deployFixedPriceMinter, prepareConfigureSale, preparePurchase, planChunks, planContentTxs, prepareLockContractField, prepareLockContractURI, prepareLockTokenField, prepareLockTokenURI, prepareMint, prepareSeriesMintMany, prepareSetMinter, prepareSetMaxInvocations, prepareSetPrimaryPayee, prepareSetPaused, prepareSetContractField, prepareSetContractURIBase, prepareSetContractURIOverride, prepareSetContractURIRenderer, prepareSetParamHooks, prepareSetRoyalty, prepareSetTokenField, prepareSetTokenURIBase, prepareSetTokenURIOverride, prepareSetTokenURIRenderer, prepareTransfer, prepareTransferOwnership, resolveChain, DEFAULT_CHAIN_KEY, stageContent, storeSupportsWriteContent, encodeTag, METADATA_FIELD as F, METADATA_REPRESENTATION as R, } from '@artblocks/abx-sdk';
|
|
10
|
+
import { SelfHostIndexer } from '@artblocks/abx-indexer';
|
|
11
|
+
import { encodeScalarParam, encodeTag as encodeTagSdk, prepareConfigureTokenParam, prepareConfigureTokenParamData, prepareSetContractParam, prepareSetContractParamData, seriesCodeAbi, } from '@artblocks/abx-sdk';
|
|
12
|
+
import { paramsKeysIsLiteral, paramsKeysNudge, readParamsKeys } from './onchain-uri.js';
|
|
13
|
+
import { DEP_RESOLUTION, parseDependencyRef, prepareLockDependencies, prepareRemoveLastDependency, prepareSetDependency, prepareSetDependencyRegistry, } from '@artblocks/abx-sdk';
|
|
14
|
+
import { contentTypeFromPath } from '@artblocks/abx-storage';
|
|
15
|
+
import { toHex as toHexSdk } from 'viem';
|
|
16
|
+
import { readFileSync } from 'node:fs';
|
|
17
|
+
import { basename, resolve as resolvePath } from 'node:path';
|
|
18
|
+
import { gzipSync } from 'node:zlib';
|
|
19
|
+
import { formatEther, getAddress, isAddress, parseEther, toHex, zeroAddress } from 'viem';
|
|
20
|
+
import { chunkStoreAddress, fixedPriceMinterAddress } from './config.js';
|
|
21
|
+
import { openWalletSession, signTx } from './signer.js';
|
|
22
|
+
import { remoteAddProject } from './remote.js';
|
|
23
|
+
import { unknownFlags } from './flags.js';
|
|
24
|
+
const CHAIN = process.env.ABX_CHAIN ?? DEFAULT_CHAIN_KEY; // base-sepolia default — MUST match main.ts/config.ts (a stale 'sepolia' here silently ran every owner-op on the wrong chain)
|
|
25
|
+
// ── ANSI (local) ─────────────────────────────────────────────────────────────
|
|
26
|
+
const C = { reset: '\x1b[0m', dim: '\x1b[2m', bold: '\x1b[1m', green: '\x1b[38;5;115m', yellow: '\x1b[38;5;221m' };
|
|
27
|
+
const dim = (s) => `${C.dim}${s}${C.reset}`;
|
|
28
|
+
const green = (s) => `${C.green}${s}${C.reset}`;
|
|
29
|
+
const yellow = (s) => `${C.yellow}${s}${C.reset}`;
|
|
30
|
+
const bold = (s) => `${C.bold}${s}${C.reset}`;
|
|
31
|
+
// ── data plane: attach files, and the keys you can't set by hand ──────────────
|
|
32
|
+
// A token anchors named, typed files ("artifacts"). The served JSON's `artifacts` list is the
|
|
33
|
+
// COMPLETE set — but it's COMPUTED by the resolver/renderer from the token's fields + effect
|
|
34
|
+
// outputs; it is never a field you set. These keys are that computed output, so setting them by
|
|
35
|
+
// 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. See specs/protocol/data-plane.md.
|
|
37
|
+
const COMPUTED_FIELD_KEYS = new Set(['artifacts', 'abx_provenance']);
|
|
38
|
+
export function assertSettableField(field) {
|
|
39
|
+
if (COMPUTED_FIELD_KEYS.has(field)) {
|
|
40
|
+
const what = field === 'artifacts'
|
|
41
|
+
? "the COMPUTED manifest (the complete list of this token's files), assembled by the resolver/renderer from your fields"
|
|
42
|
+
: 'the COMPUTED provenance list, assembled by the resolver/renderer';
|
|
43
|
+
throw new Error(`"${field}" is not a field you set — it's ${what}. To attach a file so it appears in the manifest, ` +
|
|
44
|
+
`pick your OWN key:\n abx attach <address> <yourkey> <ipfs://… | ar://… | https://…>`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** 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'];
|
|
49
|
+
/** Auto-detect the on-chain representation for an off-chain locator by its URI scheme. Returns null
|
|
50
|
+
* for anything that isn't a recognized durable/http locator (so `attach` can refuse it loudly
|
|
51
|
+
* rather than silently storing a bad value — the round-1 `--representation` guessing trap). */
|
|
52
|
+
export function representationForLocator(uri) {
|
|
53
|
+
const u = uri.trim();
|
|
54
|
+
if (/^ipfs:\/\//i.test(u))
|
|
55
|
+
return R.ipfs;
|
|
56
|
+
if (/^ar:\/\//i.test(u))
|
|
57
|
+
return R.arweave;
|
|
58
|
+
if (/^https?:\/\//i.test(u))
|
|
59
|
+
return R.url;
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
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
|
+
function requireAddress(address, usage) {
|
|
74
|
+
if (!address || address.startsWith('--')) {
|
|
75
|
+
console.error(`usage: ${usage}\n`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
return address;
|
|
79
|
+
}
|
|
80
|
+
function requireFlag(flags, name, usage) {
|
|
81
|
+
const v = flags[name];
|
|
82
|
+
if (!v || v === 'true') {
|
|
83
|
+
console.error(`missing --${name}\nusage: ${usage}\n`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
return v;
|
|
87
|
+
}
|
|
88
|
+
/** Run a prepared write through the chosen lane, then re-index if the project is known.
|
|
89
|
+
* Returns whether anything was BROADCAST (false on the cold lane — the tx was only printed). */
|
|
90
|
+
async function runWrite(address, provider, flags, expectedSigner) {
|
|
91
|
+
// --dry-run: preview the exact tx and send NOTHING. This is the shared choke point for every
|
|
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
|
+
});
|
|
115
|
+
if (!result)
|
|
116
|
+
return false; // cold lane — nothing broadcast
|
|
117
|
+
await reindexIfKnown(address, flags);
|
|
118
|
+
return true;
|
|
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(/\/+$/, '');
|
|
130
|
+
}
|
|
131
|
+
async function reindexIfKnown(address, flags) {
|
|
132
|
+
const indexer = new SelfHostIndexer();
|
|
133
|
+
if (indexer.store.getRegistration(address)) {
|
|
134
|
+
const { state, elapsedMs } = await indexer.reindex(address);
|
|
135
|
+
console.log(` ${green('✓')} re-indexed ${state.name ?? address}: ${state.eventCount} events in ${elapsedMs}ms`);
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
console.log(dim(` (not indexed by this node — run \`abx add ${address}\` then \`abx index\` to serve it)`));
|
|
139
|
+
}
|
|
140
|
+
// Hosted-resolver nudge. A hosted resolver serves from ITS OWN store, so the LOCAL reindex above
|
|
141
|
+
// never reaches it: after an owner-op — especially a PostParam change — it stays stale, and its
|
|
142
|
+
// effect runner never sees the new inputsHash, so the marketplace thumbnail never auto-updates.
|
|
143
|
+
// `--remote <url>` re-indexes the hosted resolver (incremental), which pings its effect runner
|
|
144
|
+
// (ABX_EFFECTS_URL) → the automatic re-render. This is what makes a hosted `configure-param` a
|
|
145
|
+
// one-command change (no separate `abx index --remote` + no stale thumbnail).
|
|
146
|
+
const remote = remoteResolverTarget(flags);
|
|
147
|
+
if (!remote)
|
|
148
|
+
return;
|
|
149
|
+
const token = process.env.ABX_RESOLVER_ADMIN_TOKEN;
|
|
150
|
+
if (!token) {
|
|
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)`));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
const r = await remoteAddProject(remote, token, { address }); // no fromBlock ⇒ incremental nudge
|
|
156
|
+
console.log(` ${green('✓')} nudged hosted resolver ${dim(remote)}: ${r.project.eventCount} events (${r.mode}) — its effect runner picks up the change`);
|
|
157
|
+
}
|
|
158
|
+
catch (e) {
|
|
159
|
+
console.log(dim(` (couldn't nudge ${remote}: ${e.message} — run \`abx index ${address} --remote ${remote}\` to refresh it)`));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function read(address, functionName, args = []) {
|
|
163
|
+
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
164
|
+
try {
|
|
165
|
+
return (await publicClient.readContract({
|
|
166
|
+
address,
|
|
167
|
+
abi: oneOfOneImageAbi,
|
|
168
|
+
functionName: functionName,
|
|
169
|
+
args: args,
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
// A read that "returned no data" is usually not a real revert but an address with NO CONTRACT —
|
|
174
|
+
// previewing before deploy, a typo'd address, or the wrong chain. Every owner-op reads the
|
|
175
|
+
// owner/ownerOf here FIRST, so this one guard turns the opaque viem error into an actionable one
|
|
176
|
+
// across all of them. Only runs on the error path (zero cost when the contract exists).
|
|
177
|
+
await assertContractExists(address);
|
|
178
|
+
throw err; // the contract DOES exist → surface the original error (a genuine revert)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** Throw a clear, actionable error when `address` has no contract on the active chain — the common
|
|
182
|
+
* cause of an owner-op's raw `returned no data ("0x")`. No-op when code is present or unknowable. */
|
|
183
|
+
export async function assertContractExists(address) {
|
|
184
|
+
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
185
|
+
let code;
|
|
186
|
+
try {
|
|
187
|
+
code = await publicClient.getCode({ address });
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return; // can't check (RPC issue) → let the caller's original error surface
|
|
191
|
+
}
|
|
192
|
+
if (!code || code === '0x') {
|
|
193
|
+
throw new Error(`no contract at ${address} on ${CHAIN} — nothing to operate on. Owner ops run AFTER deploy: ` +
|
|
194
|
+
`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 (a wrong-chain address reads as empty).`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// ── configure-param ──────────────────────────────────────────────────────────
|
|
199
|
+
/**
|
|
200
|
+
* `abx configure-param <address> <tokenId> <key> <value>` — set a schema-governed
|
|
201
|
+
* PostParam through the typed path, any lane. Reads the key's on-chain schema first
|
|
202
|
+
* and canonically ENCODES the human input per its type (`#rrggbb`, decimals ×1e10,
|
|
203
|
+
* Select by label, …); `String`/`Bytes` schemas take the value as UTF-8 (or
|
|
204
|
+
* `--file <path>` for bytes) via the data path. The signer must satisfy the schema's
|
|
205
|
+
* auth (Artist = contract owner, TokenOwner — delegate.xyz honored — or the named
|
|
206
|
+
* address); the chain enforces it either way.
|
|
207
|
+
*/
|
|
208
|
+
/** Positional args only — drops `--flags` AND the single token each value-taking flag consumes
|
|
209
|
+
* (mirrors parseFlags). `rest.filter(r => !r.startsWith('--'))` was NOT enough: a flag's VALUE
|
|
210
|
+
* (e.g. the URL after `--remote`, or the path after `--file`) isn't `--`-prefixed, so it leaked
|
|
211
|
+
* into the positional value (`configure-param … #ff0000 --remote http://h` → value "#ff0000 http://h"). */
|
|
212
|
+
function positionalArgs(args) {
|
|
213
|
+
const out = [];
|
|
214
|
+
for (let i = 0; i < args.length; i++) {
|
|
215
|
+
const a = args[i];
|
|
216
|
+
if (a.startsWith('--')) {
|
|
217
|
+
if (a.indexOf('=') === -1 && args[i + 1] && !args[i + 1].startsWith('--'))
|
|
218
|
+
i++; // this flag consumes the next token
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
out.push(a);
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
export async function cmdConfigureParam(address, rest, flags) {
|
|
226
|
+
const usage = 'abx configure-param <address> <tokenId|-> <key> <value> [--file <path>] [--remote [url]] [--sign|--unsigned] (tokenId "-" = contract scope, schema-less keys only)';
|
|
227
|
+
const contract = requireAddress(address, usage);
|
|
228
|
+
const [tokenIdRaw, key, ...valueParts] = positionalArgs(rest);
|
|
229
|
+
const valueInput = valueParts.join(' ');
|
|
230
|
+
if (!tokenIdRaw || !key || (!valueInput && !flags.file)) {
|
|
231
|
+
console.error(`usage: ${usage}\n`);
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
235
|
+
const schema = (await publicClient.readContract({
|
|
236
|
+
address: contract,
|
|
237
|
+
abi: seriesCodeAbi,
|
|
238
|
+
functionName: 'paramSchema',
|
|
239
|
+
args: [encodeTagSdk(key)],
|
|
240
|
+
}));
|
|
241
|
+
const [exists, paramTypeIdx, , , , , , selectOptions] = schema;
|
|
242
|
+
// Contract scope (`-`): the raw owner setters (`setContractParam[Data]`) — the write path of
|
|
243
|
+
// well-known contract params like `params.keys` / `display.gateway`. Schema-less keys only: a
|
|
244
|
+
// schema'd key closes the raw path on-chain (`SchemaGoverned`) and is per-token by design.
|
|
245
|
+
if (tokenIdRaw === '-') {
|
|
246
|
+
if (exists) {
|
|
247
|
+
throw new Error(`"${key}" is schema-governed — it's a per-token PostParam (abx configure-param ${contract} <tokenId> ${key} …). ` +
|
|
248
|
+
'Contract-scope raw params are owner-set and schema-less.');
|
|
249
|
+
}
|
|
250
|
+
const owner = await read(contract, 'owner');
|
|
251
|
+
const cid = chainId();
|
|
252
|
+
if (flags.file && flags.file !== 'true') {
|
|
253
|
+
const data = toHexSdk(new Uint8Array(readFileSync(flags.file)));
|
|
254
|
+
await runWrite(contract, prepareSetContractParamData({ contract, key, data, chainId: cid }), flags, owner);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// Same encoding rule as deploy-code's params.keys leg: printable ASCII ≤ 31 chars rides as a
|
|
258
|
+
// literal readable bytes32; anything longer takes the data path (one blob, hash evented).
|
|
259
|
+
if (paramsKeysIsLiteral(valueInput)) {
|
|
260
|
+
console.log(` ${key} (contract scope) ← "${valueInput}" ${dim('(literal bytes32; owner-only raw setter)')}`);
|
|
261
|
+
await runWrite(contract, prepareSetContractParam({ contract, key, value: encodeTagSdk(valueInput), display: valueInput, chainId: cid }), flags, owner);
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
console.log(` ${key} (contract scope) ← ${valueInput.length} chars ${dim('(data-backed; owner-only raw setter)')}`);
|
|
265
|
+
await runWrite(contract, prepareSetContractParamData({ contract, key, data: toHexSdk(new TextEncoder().encode(valueInput)), chainId: cid }), flags, owner);
|
|
266
|
+
}
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const tokenId = BigInt(tokenIdRaw);
|
|
270
|
+
if (!exists) {
|
|
271
|
+
throw new Error(`no PostParam schema for "${key}" on ${contract}. Schemas are declared at DEPLOY: ` +
|
|
272
|
+
`abx deploy-code … --schema ${key}:<Type>:<Auth> (e.g. ${key}:HexColor:TokenOwner). ` +
|
|
273
|
+
`Adding a param to an already-deployed contract isn't a CLI command yet.`);
|
|
274
|
+
}
|
|
275
|
+
const typeName = ['Bool', 'Select', 'Uint256Range', 'Int256Range', 'DecimalRange', 'HexColor', 'Timestamp', 'String', 'Bytes'][paramTypeIdx];
|
|
276
|
+
let sent;
|
|
277
|
+
if (typeName === 'String' || typeName === 'Bytes') {
|
|
278
|
+
const data = flags.file
|
|
279
|
+
? toHexSdk(new Uint8Array(readFileSync(flags.file)))
|
|
280
|
+
: toHexSdk(new TextEncoder().encode(valueInput));
|
|
281
|
+
sent = await runWrite(contract, prepareConfigureTokenParamData({ contract, tokenId, key, data, chainId: chainId() }), flags);
|
|
282
|
+
}
|
|
283
|
+
else {
|
|
284
|
+
const { value, display } = encodeScalarParam(typeName, valueInput, [...selectOptions]);
|
|
285
|
+
console.log(` ${key} (${typeName}) ← ${display} ${dim('(canonical encode; the chain enforces schema + auth)')}`);
|
|
286
|
+
sent = await runWrite(contract, prepareConfigureTokenParam({ contract, tokenId, key, value, display, chainId: chainId() }), flags);
|
|
287
|
+
}
|
|
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
|
+
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
|
+
// What happens to the IMAGE depends on how it's produced. If the `image` field is computed
|
|
305
|
+
// on-chain (a `renderer` representation — the in-chain SVG lane), it re-tints AUTOMATICALLY: the
|
|
306
|
+
// renderer reads the param live, so tokenURI's image is already updated, nothing to re-render.
|
|
307
|
+
// Otherwise the image is an off-chain rendered STILL that re-addresses to a new inputsHash → it's
|
|
308
|
+
// stale until re-rendered (a running resolver+runner or `--remote` auto-catches it; a no-resolver
|
|
309
|
+
// drop has no watcher → re-render by hand). This is the "I changed palette, why is it stale?" moment.
|
|
310
|
+
if (!flags.remote) {
|
|
311
|
+
let imageOnChain = false;
|
|
312
|
+
try {
|
|
313
|
+
const res = (await publicClient.readContract({ address: contract, abi: seriesCodeAbi, functionName: 'contractField', args: [encodeTag('image')] }));
|
|
314
|
+
imageOnChain = String(res[0]).toLowerCase() === String(encodeTag('renderer')).toLowerCase();
|
|
315
|
+
}
|
|
316
|
+
catch { /* advisory — never fail the write over a field read */ }
|
|
317
|
+
if (imageOnChain) {
|
|
318
|
+
console.log(` ${dim('the on-chain image re-tints automatically from this param — nothing to re-render (the renderer reads it live; tokenURI is already updated).')}`);
|
|
319
|
+
}
|
|
320
|
+
else {
|
|
321
|
+
console.log(` ${dim('live view updates now; the thumbnail still is stale until re-rendered (no resolver watcher = no auto-refresh):')} ${bold(`abx render ${contract} ${tokenIdRaw}`)}`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// ── set-param-hooks ────────────────────────────────────────────────────────────
|
|
327
|
+
const HOOK_ROLES = ['configure', 'augment', 'transfer'];
|
|
328
|
+
const HOOK_NOTE = {
|
|
329
|
+
configure: 'write-time veto/validator (a configure tx reverts if it reverts)',
|
|
330
|
+
augment: 'read-time derivation folded into tokenData (live view reads chain per view)',
|
|
331
|
+
transfer: 'ownership-change lifecycle, best-effort (a revert never blocks the transfer)',
|
|
332
|
+
};
|
|
333
|
+
const eqAddr = (a, b) => a.toLowerCase() === b.toLowerCase();
|
|
334
|
+
/** Parse a hook-address flag: a 0x address, or `none`/`zero`/`0`/`0x0` to clear that role. */
|
|
335
|
+
export function parseHookAddress(role, v) {
|
|
336
|
+
const s = v.trim().toLowerCase();
|
|
337
|
+
if (v === 'true' || v === '')
|
|
338
|
+
throw new Error(`--${role} needs a value: a 0x address, or "none" to clear it`);
|
|
339
|
+
if (s === 'none' || s === 'zero' || s === '0' || s === '0x0' || s === zeroAddress)
|
|
340
|
+
return zeroAddress;
|
|
341
|
+
if (!isAddress(v))
|
|
342
|
+
throw new Error(`--${role} must be a 0x address or "none" (to clear it) — got '${v}'`);
|
|
343
|
+
return getAddress(v);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* `abx set-param-hooks <address> [--configure 0x|none] [--augment 0x|none] [--transfer 0x|none] [--clear]`
|
|
347
|
+
* — wire (or clear) a SeriesCode project's three param-lifecycle hooks. The contract has NO per-hook
|
|
348
|
+
* setter (`setParamHooks` writes all three at once), so this READS the current trio (`paramHooks()`)
|
|
349
|
+
* and re-sends it with your changes applied: omit a role to KEEP it, pass an address to set it, or
|
|
350
|
+
* `none` to clear it (`--clear` clears all three). Owner-only, any lane, guards `--dry-run`. SeriesCode
|
|
351
|
+
* only — a 1/1 or plain Series has no configurable params to hook. Bare (no flags) prints the current
|
|
352
|
+
* hooks and does nothing.
|
|
353
|
+
*/
|
|
354
|
+
export async function cmdSetParamHooks(address, flags) {
|
|
355
|
+
const usage = 'abx set-param-hooks <address> [--configure 0x|none] [--augment 0x|none] [--transfer 0x|none] [--clear] [--sign|--unsigned] [--dry-run]';
|
|
356
|
+
const contract = requireAddress(address, usage);
|
|
357
|
+
const clearAll = flags.clear !== undefined;
|
|
358
|
+
const roleFlags = HOOK_ROLES.filter((r) => flags[r] !== undefined);
|
|
359
|
+
if (clearAll && roleFlags.length) {
|
|
360
|
+
// Refuse the contradictory combo rather than silently pick one (enforce, don't warn).
|
|
361
|
+
throw new Error(`--clear clears ALL three hooks; don't combine it with --${roleFlags.join('/--')}. Use either --clear, or per-role flags.`);
|
|
362
|
+
}
|
|
363
|
+
// Read the current trio FIRST — this doubles as the SeriesCode guard (paramHooks() exists only on
|
|
364
|
+
// the ConfigurableParams extension; a 1/1 / plain Series reverts "no data").
|
|
365
|
+
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
366
|
+
let current;
|
|
367
|
+
try {
|
|
368
|
+
current = (await publicClient.readContract({
|
|
369
|
+
address: contract,
|
|
370
|
+
abi: seriesCodeAbi,
|
|
371
|
+
functionName: 'paramHooks',
|
|
372
|
+
}));
|
|
373
|
+
}
|
|
374
|
+
catch (err) {
|
|
375
|
+
await assertContractExists(contract); // no contract at all → the actionable no-contract error
|
|
376
|
+
throw new Error(`${contract} exposes no param hooks — the configure/augment/transfer hooks are a SeriesCode ` +
|
|
377
|
+
`(PostParams) feature. A 1/1 or a plain Series has no configurable params to hook.`);
|
|
378
|
+
}
|
|
379
|
+
const cur = { configure: current[0], augment: current[1], transfer: current[2] };
|
|
380
|
+
const label = (a) => (eqAddr(a, zeroAddress) ? dim('none') : a);
|
|
381
|
+
// Bare invocation: show the current hooks, mutate nothing.
|
|
382
|
+
if (!clearAll && roleFlags.length === 0) {
|
|
383
|
+
console.log(` param hooks on ${contract}:`);
|
|
384
|
+
for (const r of HOOK_ROLES)
|
|
385
|
+
console.log(` ${r.padEnd(10)} ${label(cur[r])} ${dim(`(${HOOK_NOTE[r]})`)}`);
|
|
386
|
+
console.log(dim(`\n set one with e.g. \`${usage}\``));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
// Read-modify-write: preserve each omitted role; a huge win is that changing ONE hook can't
|
|
390
|
+
// silently zero the other two (the contract's all-at-once setter would, if fed blindly).
|
|
391
|
+
const next = {
|
|
392
|
+
configure: clearAll ? zeroAddress : flags.configure !== undefined ? parseHookAddress('configure', flags.configure) : cur.configure,
|
|
393
|
+
augment: clearAll ? zeroAddress : flags.augment !== undefined ? parseHookAddress('augment', flags.augment) : cur.augment,
|
|
394
|
+
transfer: clearAll ? zeroAddress : flags.transfer !== undefined ? parseHookAddress('transfer', flags.transfer) : cur.transfer,
|
|
395
|
+
};
|
|
396
|
+
if (HOOK_ROLES.every((r) => eqAddr(next[r], cur[r]))) {
|
|
397
|
+
console.log(dim(' no change — the hooks already match. Nothing sent.'));
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
console.log(` param hooks on ${contract}:`);
|
|
401
|
+
for (const r of HOOK_ROLES) {
|
|
402
|
+
const changed = !eqAddr(cur[r], next[r]);
|
|
403
|
+
console.log(` ${r.padEnd(10)} ${changed ? `${label(cur[r])} → ${label(next[r])}` : `${label(cur[r])} ${dim('(unchanged)')}`}`);
|
|
404
|
+
}
|
|
405
|
+
const owner = await read(contract, 'owner');
|
|
406
|
+
await runWrite(contract, prepareSetParamHooks({ contract, configureHook: next.configure, augmentHook: next.augment, transferHook: next.transfer, chainId: chainId() }), flags, owner);
|
|
407
|
+
}
|
|
408
|
+
// ── dependencies (code projects) ──────────────────────────────────────────────
|
|
409
|
+
// The ordered library declarations of a template-mode code project (Dependencies
|
|
410
|
+
// extension) — index 0 = the runtime, by convention; the list stays dense. Owner-only;
|
|
411
|
+
// each runs through the same signing lanes + re-index as every other owner op.
|
|
412
|
+
/**
|
|
413
|
+
* `abx set-dependency <address> <index> <ref>` — declare/replace the dependency at an
|
|
414
|
+
* index. The ref auto-detects: `name@version` (e.g. `p5@1.0.0` — resolves through the
|
|
415
|
+
* collection's soft registry pointer) or `0x…` (an on-chain data contract, read directly).
|
|
416
|
+
* The chain enforces density (index ≤ dependencyCount) and the lock.
|
|
417
|
+
*/
|
|
418
|
+
export async function cmdSetDependency(address, rest, flags) {
|
|
419
|
+
const usage = 'abx set-dependency <address> <index> <ref> (ref: name@version | 0x<data-contract>) [--sign|--unsigned]';
|
|
420
|
+
const contract = requireAddress(address, usage);
|
|
421
|
+
const [indexRaw, refRaw] = positionalArgs(rest);
|
|
422
|
+
if (indexRaw === undefined || !refRaw) {
|
|
423
|
+
console.error(`usage: ${usage}\n`);
|
|
424
|
+
process.exit(1);
|
|
425
|
+
}
|
|
426
|
+
if (!/^\d+$/.test(indexRaw))
|
|
427
|
+
throw new Error(`<index> must be a non-negative integer (got '${indexRaw}') — the list is dense: index ≤ dependencyCount`);
|
|
428
|
+
const dep = parseDependencyRef(refRaw);
|
|
429
|
+
console.log(dim(` dependency [${indexRaw}] ← ${dep.display} (${dep.resolution === DEP_RESOLUTION.registry ? 'registry name@version — resolves through the registry pointer' : 'on-chain data contract — read directly'})${indexRaw === '0' ? ' · index 0 = the runtime, by convention' : ''}`));
|
|
430
|
+
const owner = await read(contract, 'owner');
|
|
431
|
+
await runWrite(contract, prepareSetDependency({ contract, index: BigInt(indexRaw), resolution: dep.resolution, ref: dep.ref, display: dep.display, chainId: chainId() }), flags, owner);
|
|
432
|
+
}
|
|
433
|
+
/** `abx remove-last-dependency <address>` — pop the LAST dependency (the list stays dense;
|
|
434
|
+
* to replace one in place, `set-dependency` at its index instead). */
|
|
435
|
+
export async function cmdRemoveLastDependency(address, flags) {
|
|
436
|
+
const contract = requireAddress(address, 'abx remove-last-dependency <address> [--sign|--unsigned]');
|
|
437
|
+
const owner = await read(contract, 'owner');
|
|
438
|
+
await runWrite(contract, prepareRemoveLastDependency({ contract, chainId: chainId() }), flags, owner);
|
|
439
|
+
}
|
|
440
|
+
/** `abx set-dependency-registry <address> <registry>` — point (or clear, with `none`) the
|
|
441
|
+
* soft, non-validating registry pointer the project's `name@version` refs resolve through. */
|
|
442
|
+
export async function cmdSetDependencyRegistry(address, rest, flags) {
|
|
443
|
+
const usage = 'abx set-dependency-registry <address> <0x…|none> [--sign|--unsigned]';
|
|
444
|
+
const contract = requireAddress(address, usage);
|
|
445
|
+
const [raw] = positionalArgs(rest);
|
|
446
|
+
if (!raw) {
|
|
447
|
+
console.error(`usage: ${usage}\n`);
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
if (raw !== 'none' && !/^0x[0-9a-fA-F]{40}$/.test(raw)) {
|
|
451
|
+
throw new Error(`<registry> must be an address (0x + 40 hex) or 'none' to clear; got '${raw}'`);
|
|
452
|
+
}
|
|
453
|
+
const registry = (raw === 'none' ? zeroAddress : raw);
|
|
454
|
+
console.log(dim(' the pointer is SOFT and non-validating — it disambiguates resolution; the declared refs stay either way.'));
|
|
455
|
+
const owner = await read(contract, 'owner');
|
|
456
|
+
await runWrite(contract, prepareSetDependencyRegistry({ contract, registry, chainId: chainId() }), flags, owner);
|
|
457
|
+
}
|
|
458
|
+
/** `abx lock-dependencies <address>` — freeze the dependency set (list + registry pointer) forever. */
|
|
459
|
+
export async function cmdLockDependencies(address, flags) {
|
|
460
|
+
const contract = requireAddress(address, 'abx lock-dependencies <address> [--sign|--unsigned]');
|
|
461
|
+
const owner = await read(contract, 'owner');
|
|
462
|
+
console.log(dim(' note: locking the dependency set is permanent and irreversible (list AND registry pointer freeze).'));
|
|
463
|
+
await runWrite(contract, prepareLockDependencies({ contract, chainId: chainId() }), flags, owner);
|
|
464
|
+
}
|
|
465
|
+
// ── mint ─────────────────────────────────────────────────────────────────────
|
|
466
|
+
// Issue a token — a deferred mint (deploy → warm resolver → mint) or a primary sale
|
|
467
|
+
// (mint straight to the buyer). For a 1/1 this is the one-shot token #0. For a Series
|
|
468
|
+
// it mints the next sequential token id (metadata = token id): bare (one), or `--count <n>`
|
|
469
|
+
// (n in order). Owner or an authorized minter signs.
|
|
470
|
+
export async function cmdMint(address, flags) {
|
|
471
|
+
const usage = 'abx mint <address> [--to 0x…] [--count <n>] [--sign|--unsigned]';
|
|
472
|
+
const contract = requireAddress(address, usage);
|
|
473
|
+
const owner = await read(contract, 'owner');
|
|
474
|
+
const to = flags.to ?? owner; // default: pre-mint to the admin
|
|
475
|
+
let tx;
|
|
476
|
+
if (flags.count !== undefined && flags.count !== 'true') {
|
|
477
|
+
const count = BigInt(flags.count);
|
|
478
|
+
console.log(dim(` minting ${count} Series tokens in order → ${to}`));
|
|
479
|
+
tx = prepareSeriesMintMany({ contract, to, count, chainId: chainId() });
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
// Bare mint: the `mint(address)` selector is shared by the 1/1 (token #0) and a
|
|
483
|
+
// Series (next sequential token) — one path serves both.
|
|
484
|
+
console.log(dim(` minting token → ${to}${flags.to ? '' : ' (owner — pass --to for a buyer)'}`));
|
|
485
|
+
tx = prepareMint({ contract, to, chainId: chainId() });
|
|
486
|
+
}
|
|
487
|
+
await runWrite(contract, tx, flags, owner);
|
|
488
|
+
console.log(dim(` next: \`abx refresh ${contract}\` so marketplaces pick up the new token.`));
|
|
489
|
+
}
|
|
490
|
+
// ── series owner ops (minter set · supply cap · primary payee) ────────────────
|
|
491
|
+
// The multi-token knobs. All owner-only; each runs through the same signing lane +
|
|
492
|
+
// re-index as every other owner op.
|
|
493
|
+
/** Set (or clear, with `--minter none`) the single authorized minter — a drop/auction/router
|
|
494
|
+
* contract. Setting a new one atomically replaces any previous minter. */
|
|
495
|
+
export async function cmdSetMinter(address, flags) {
|
|
496
|
+
const usage = 'abx set-minter <address> --minter 0x…|none [--sign|--unsigned]';
|
|
497
|
+
const contract = requireAddress(address, usage);
|
|
498
|
+
const raw = requireFlag(flags, 'minter', usage);
|
|
499
|
+
const minter = (raw === 'none' ? zeroAddress : raw);
|
|
500
|
+
const owner = await read(contract, 'owner');
|
|
501
|
+
await runWrite(contract, prepareSetMinter({ contract, minter, chainId: chainId() }), flags, owner);
|
|
502
|
+
}
|
|
503
|
+
/** Lower the supply cap (monotonic — e.g. close an open edition early at `--max <totalSupply>`). */
|
|
504
|
+
export async function cmdSetMaxInvocations(address, flags) {
|
|
505
|
+
const usage = 'abx set-max-invocations <address> --max <N> [--sign|--unsigned]';
|
|
506
|
+
const contract = requireAddress(address, usage);
|
|
507
|
+
const max = BigInt(requireFlag(flags, 'max', usage));
|
|
508
|
+
const owner = await read(contract, 'owner');
|
|
509
|
+
await runWrite(contract, prepareSetMaxInvocations({ contract, maxInvocations: max, chainId: chainId() }), flags, owner);
|
|
510
|
+
}
|
|
511
|
+
/** Pause minting — restrict it to the owner (config/reserves) until unpaused. */
|
|
512
|
+
export async function cmdPause(address, flags) {
|
|
513
|
+
const contract = requireAddress(address, 'abx pause <address> [--sign|--unsigned]');
|
|
514
|
+
const owner = await read(contract, 'owner');
|
|
515
|
+
await runWrite(contract, prepareSetPaused({ contract, paused: true, chainId: chainId() }), flags, owner);
|
|
516
|
+
}
|
|
517
|
+
/** Unpause minting — open it to the authorized minter (go live). */
|
|
518
|
+
export async function cmdUnpause(address, flags) {
|
|
519
|
+
const contract = requireAddress(address, 'abx unpause <address> [--sign|--unsigned]');
|
|
520
|
+
const owner = await read(contract, 'owner');
|
|
521
|
+
await runWrite(contract, prepareSetPaused({ contract, paused: false, chainId: chainId() }), flags, owner);
|
|
522
|
+
}
|
|
523
|
+
// ── authorship + rights deploy fields (parity with --description / --external-url) ───────────
|
|
524
|
+
/** The optional authorship + rights deploy flags, each paired with the reserved collection-field
|
|
525
|
+
* key it writes. Set at deploy → baked as on-chain **inline collection** fields (same slot the
|
|
526
|
+
* `--description` / `--external-url` identity writes use); unset → nothing written. Collection
|
|
527
|
+
* scope, so one value covers the whole project — they project into `contractURI` on the resolver
|
|
528
|
+
* AND the on-chain renderer. Works for every token type (1/1 · Series · code). */
|
|
529
|
+
export const AUTHORSHIP_DEPLOY_FIELDS = [
|
|
530
|
+
['artist', F.artist],
|
|
531
|
+
['display-notes', F.displayNotes],
|
|
532
|
+
['artist-links', F.artistLinks],
|
|
533
|
+
['license', F.license],
|
|
534
|
+
];
|
|
535
|
+
/** Build the on-chain (inline) collection fields for whichever authorship/rights deploy flags are
|
|
536
|
+
* set — pure, so a deploy path just spreads the result into its `contractFields`. */
|
|
537
|
+
export function authorshipContractFields(flags) {
|
|
538
|
+
return AUTHORSHIP_DEPLOY_FIELDS.flatMap(([flag, field]) => flags[flag] ? [{ field: encodeTag(field), representation: encodeTag(R.inline), value: toHex(String(flags[flag])) }] : []);
|
|
539
|
+
}
|
|
540
|
+
/** Set (or clear, with `--payee none`) the primary-sale payout destination. */
|
|
541
|
+
export async function cmdSetPrimaryPayee(address, flags) {
|
|
542
|
+
const usage = 'abx set-primary-payee <address> --payee 0x…|none [--sign|--unsigned]';
|
|
543
|
+
const contract = requireAddress(address, usage);
|
|
544
|
+
const raw = requireFlag(flags, 'payee', usage);
|
|
545
|
+
const payee = (raw === 'none' ? zeroAddress : raw);
|
|
546
|
+
const owner = await read(contract, 'owner');
|
|
547
|
+
await runWrite(contract, prepareSetPrimaryPayee({ contract, payee, chainId: chainId() }), flags, owner);
|
|
548
|
+
}
|
|
549
|
+
// ── refresh ──────────────────────────────────────────────────────────────────
|
|
550
|
+
// Ask marketplaces to (re)index a token's metadata. The instant a mint lands,
|
|
551
|
+
// marketplaces fetch the on-chain tokenURI; if the resolver wasn't warm they cache
|
|
552
|
+
// a miss until refreshed. We emit ERC-4906 on URI changes (4906-aware marketplaces
|
|
553
|
+
// auto-refresh) — this is the manual fallback for the rest, and after the genesis mint.
|
|
554
|
+
const OPENSEA_CHAIN = { sepolia: 'sepolia', mainnet: 'ethereum' };
|
|
555
|
+
const TESTNET_CHAINS = new Set(['sepolia']);
|
|
556
|
+
export async function cmdRefresh(address, flags) {
|
|
557
|
+
const contract = requireAddress(address, 'abx refresh <address> [--token 0]');
|
|
558
|
+
const tokenId = flags.token ?? '0';
|
|
559
|
+
const osChain = OPENSEA_CHAIN[CHAIN] ?? CHAIN;
|
|
560
|
+
const explorer = resolveChain(CHAIN).blockExplorers?.default?.url ?? '';
|
|
561
|
+
const apiKey = process.env.OPENSEA_API_KEY;
|
|
562
|
+
if (apiKey) {
|
|
563
|
+
const url = `https://api.opensea.io/api/v2/chain/${osChain}/contract/${contract}/nfts/${tokenId}/refresh`;
|
|
564
|
+
try {
|
|
565
|
+
const res = await fetch(url, { method: 'POST', headers: { 'x-api-key': apiKey, accept: 'application/json' } });
|
|
566
|
+
if (res.ok)
|
|
567
|
+
console.log(` ${green('✓')} asked OpenSea to refresh ${contract} #${tokenId}`);
|
|
568
|
+
else
|
|
569
|
+
console.log(dim(` OpenSea API ${res.status} ${res.statusText} — use the page link below and click “Refresh metadata”.`));
|
|
570
|
+
}
|
|
571
|
+
catch (err) {
|
|
572
|
+
console.log(dim(` OpenSea API unreachable (${err.message}) — use the links below.`));
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
console.log(dim(' no OPENSEA_API_KEY set — open these and click “Refresh metadata”:'));
|
|
577
|
+
}
|
|
578
|
+
const osBase = TESTNET_CHAINS.has(CHAIN) ? 'https://testnets.opensea.io' : 'https://opensea.io';
|
|
579
|
+
console.log(` ${dim('OpenSea ')}${osBase}/assets/${osChain}/${contract}/${tokenId}`);
|
|
580
|
+
if (explorer)
|
|
581
|
+
console.log(` ${dim('Etherscan')} ${explorer}/token/${contract}?a=${tokenId}`);
|
|
582
|
+
console.log(dim(' (ERC-4906 already pings 4906-aware marketplaces on URI changes; this covers the genesis mint + the rest.)'));
|
|
583
|
+
}
|
|
584
|
+
// ── transfer ─────────────────────────────────────────────────────────────────
|
|
585
|
+
export async function cmdTransfer(address, flags) {
|
|
586
|
+
const contract = requireAddress(address, 'abx transfer <address> --to 0x… [--token 0] [--sign|--unsigned]');
|
|
587
|
+
const to = requireFlag(flags, 'to', 'abx transfer <address> --to 0x…');
|
|
588
|
+
const tokenId = BigInt(flags.token ?? '0');
|
|
589
|
+
const from = await read(contract, 'ownerOf', [tokenId]);
|
|
590
|
+
const tx = prepareTransfer({ contract, from, to, tokenId, chainId: chainId() });
|
|
591
|
+
await runWrite(contract, tx, flags, from);
|
|
592
|
+
}
|
|
593
|
+
// ── set-token-uri ──────────────────────────────────────────────────────────--
|
|
594
|
+
// `--uri <base>` re-points the resolver BASE (the contract derives {base}/{chainId}/{address}/{tokenId}).
|
|
595
|
+
// `--override <uri> [--token <id>]` pins ONE token to a fixed locator (e.g. ipfs://) — the rare,
|
|
596
|
+
// deliberate escape out of the spec; pass an empty string to clear it.
|
|
597
|
+
export async function cmdSetTokenUri(address, flags) {
|
|
598
|
+
const usage = 'abx set-token-uri <address> (--uri <base> | --override <uri> [--token <id>]) [--sign|--unsigned]';
|
|
599
|
+
const contract = requireAddress(address, usage);
|
|
600
|
+
const owner = await read(contract, 'owner');
|
|
601
|
+
if (flags.override !== undefined) {
|
|
602
|
+
const tokenId = flags.token ? Number(flags.token) : 0;
|
|
603
|
+
await runWrite(contract, prepareSetTokenURIOverride({ contract, tokenId, uri: flags.override, chainId: chainId() }), flags, owner);
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const base = requireFlag(flags, 'uri', usage);
|
|
607
|
+
await runWrite(contract, prepareSetTokenURIBase({ contract, base, chainId: chainId() }), flags, owner);
|
|
608
|
+
}
|
|
609
|
+
// ── set-contract-uri ─────────────────────────────────────────────────────────
|
|
610
|
+
// `--uri <base>` re-points the collection base; `--override <uri>` pins a fixed locator.
|
|
611
|
+
export async function cmdSetContractUri(address, flags) {
|
|
612
|
+
const usage = 'abx set-contract-uri <address> (--uri <base> | --override <uri>) [--sign|--unsigned]';
|
|
613
|
+
const contract = requireAddress(address, usage);
|
|
614
|
+
const owner = await read(contract, 'owner');
|
|
615
|
+
if (flags.override !== undefined) {
|
|
616
|
+
await runWrite(contract, prepareSetContractURIOverride({ contract, uri: flags.override, chainId: chainId() }), flags, owner);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
const base = requireFlag(flags, 'uri', usage);
|
|
620
|
+
await runWrite(contract, prepareSetContractURIBase({ contract, base, chainId: chainId() }), flags, owner);
|
|
621
|
+
}
|
|
622
|
+
// ── set-royalty ──────────────────────────────────────────────────────────────
|
|
623
|
+
/** Parse + bound-check a royalty `--bps` (basis points, 0–10000 = 0–100%). Throws a plain-language
|
|
624
|
+
* error on a non-integer or out-of-range value instead of letting it hit the chain (or a cryptic
|
|
625
|
+
* ABI read). Exported for the regression test. */
|
|
626
|
+
export function parseRoyaltyBps(raw) {
|
|
627
|
+
const bps = Number(raw);
|
|
628
|
+
if (!Number.isInteger(bps) || bps < 0 || bps > 10000) {
|
|
629
|
+
throw new Error(`--bps must be a whole number 0–10000 (100 = 1%, 750 = 7.5%, 10000 = 100%); got '${raw}'.`);
|
|
630
|
+
}
|
|
631
|
+
return bps;
|
|
632
|
+
}
|
|
633
|
+
export async function cmdSetRoyalty(address, flags) {
|
|
634
|
+
const contract = requireAddress(address, 'abx set-royalty <address> --bps <0-10000> [--receiver 0x…] [--sign|--unsigned]');
|
|
635
|
+
const bps = parseRoyaltyBps(requireFlag(flags, 'bps', 'abx set-royalty <address> --bps <0-10000>'));
|
|
636
|
+
const owner = await read(contract, 'owner');
|
|
637
|
+
// Default the receiver to the CURRENT on-chain receiver so `--bps` alone just changes the rate.
|
|
638
|
+
// Read it via the ERC-2981 `royaltyInfo` view (there is NO bare `royaltyReceiver()` getter —
|
|
639
|
+
// reading one threw "not found on ABI", which broke `set-royalty --bps` without `--receiver`).
|
|
640
|
+
// Fall back to the owner if it can't be read or is unset (a zero receiver would revert on set).
|
|
641
|
+
let receiver = flags.receiver && flags.receiver !== 'true' ? flags.receiver : undefined;
|
|
642
|
+
if (!receiver) {
|
|
643
|
+
receiver = await read(contract, 'royaltyInfo', [0n, 10000n])
|
|
644
|
+
.then((r) => r[0])
|
|
645
|
+
.catch(() => owner);
|
|
646
|
+
if (!receiver || receiver === zeroAddress)
|
|
647
|
+
receiver = owner;
|
|
648
|
+
}
|
|
649
|
+
await runWrite(contract, prepareSetRoyalty({ contract, receiver, bps, chainId: chainId() }), flags, owner);
|
|
650
|
+
}
|
|
651
|
+
export function parseCompress(v) {
|
|
652
|
+
const c = (v ?? 'none').toLowerCase();
|
|
653
|
+
if (c === 'none' || c === 'fastlz' || c === 'gzip')
|
|
654
|
+
return c;
|
|
655
|
+
throw new Error(`--compress must be none | fastlz | gzip (got '${v}')`);
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* The chain's shared multi-chunk store/reader; deploy it once if missing, signing through
|
|
659
|
+
* `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.
|
|
661
|
+
*/
|
|
662
|
+
async function ensureChunkStore(send, override) {
|
|
663
|
+
// Resolve from the shipped manifest (flag → ABX_CHUNK_STORE → manifest); on a chain with no
|
|
664
|
+
// entry (or a stale deployment), deploy a current one and tell the operator how to reuse it.
|
|
665
|
+
const known = chunkStoreAddress(override);
|
|
666
|
+
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
667
|
+
if (known) {
|
|
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;
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* The hot-lane staging signer: the env key signs + broadcasts each chunk-store write. Built
|
|
697
|
+
* lazily so commands that never stage on-chain don't require a key. The wallet lane passes a
|
|
698
|
+
* session-backed sender instead (see {@link openWalletSession}); the cold lane can't stage
|
|
699
|
+
* interactively, so it's rejected up front by the deploy/set-field commands.
|
|
700
|
+
*/
|
|
701
|
+
export function envStagingSender() {
|
|
702
|
+
const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
|
|
703
|
+
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
704
|
+
return async (tx) => {
|
|
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
|
+
};
|
|
714
|
+
}
|
|
715
|
+
/** 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. */
|
|
717
|
+
export function sessionStagingSender(session) {
|
|
718
|
+
const id = chainId();
|
|
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;
|
|
728
|
+
}
|
|
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
|
+
const kb = (n) => `${n < 10 * 1024 ? (n / 1024).toFixed(1) : Math.round(n / 1024)}KB`;
|
|
755
|
+
/** Warn (loudly) when content is too big for on-chain to be the cost-sane choice. Returns true if it warned. */
|
|
756
|
+
export function warnIfLargeForOnChain(bytesLen, label) {
|
|
757
|
+
if (bytesLen <= ONCHAIN_IMAGE_SOFT_LIMIT)
|
|
758
|
+
return false;
|
|
759
|
+
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
|
+
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
|
+
return true;
|
|
762
|
+
}
|
|
763
|
+
export function computeContentPlan(bytes, compress) {
|
|
764
|
+
const content = compress === 'gzip' ? new Uint8Array(gzipSync(bytes)) : new Uint8Array(bytes);
|
|
765
|
+
const fastlz = compress === 'fastlz';
|
|
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 };
|
|
771
|
+
}
|
|
772
|
+
/** Human-readable size line for a content plan, e.g. `2812B → 1 chunk [fastlz 2812→904B]`. */
|
|
773
|
+
function planSizeLine(bytes, p, compress) {
|
|
774
|
+
const savings = compress === 'fastlz'
|
|
775
|
+
? `fastlz ${bytes}→${p.stagedBytes}B`
|
|
776
|
+
: compress === 'gzip'
|
|
777
|
+
? `gzip ${bytes}→${p.stagedBytes}B (off-chain decode)`
|
|
778
|
+
: 'no compression';
|
|
779
|
+
return `${bytes}B → ${p.chunks} chunk(s) [${savings}]`;
|
|
780
|
+
}
|
|
781
|
+
/**
|
|
782
|
+
* The transaction-count line for a content plan, given what the FINAL (owner) tx is —
|
|
783
|
+
* a separate `set-field` after staging, or the `deploy` that bakes the reader field in.
|
|
784
|
+
* Staging itself is 1 atomic `writeContent` (content fits one tx's gas) or N gas-bounded
|
|
785
|
+
* chunk-batch txs + 1 manifest tx.
|
|
786
|
+
*/
|
|
787
|
+
function planTxLine(p, finalLabel) {
|
|
788
|
+
return p.plan.mode === 'single'
|
|
789
|
+
? `plan: 1 staging tx (atomic writeContent) + ${finalLabel} = 2 transactions`
|
|
790
|
+
: `plan: ${p.plan.batches.length} chunk-batch tx(s) + 1 manifest tx + ${finalLabel} = ${p.plan.txCount + 1} transactions`;
|
|
791
|
+
}
|
|
792
|
+
async function putContentOnChain(bytes, compress, field, send, finalLabel = '1 owner field-set', store) {
|
|
793
|
+
const p = computeContentPlan(bytes, compress);
|
|
794
|
+
warnIfLargeForOnChain(bytes.length, `'${field}'`);
|
|
795
|
+
const resolvedStore = store ?? (await ensureChunkStore(send));
|
|
796
|
+
console.log(dim(` '${field}' on-chain: ${planSizeLine(bytes.length, p, compress)} via reader ${resolvedStore}`));
|
|
797
|
+
console.log(dim(` ${planTxLine(p, finalLabel)}`));
|
|
798
|
+
const { manifest, txHashes } = await stageContent(send, { store: resolvedStore, content: p.content, fastlz: p.fastlz });
|
|
799
|
+
console.log(dim(` staged in ${txHashes.length} tx(s); manifest → ${manifest}`));
|
|
800
|
+
return { value: encodeReader(resolvedStore, manifest), representation: p.representation };
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Dry-run preview of staging an image fully on-chain — the REAL chunk + transaction plan,
|
|
804
|
+
* computed without writing anything (so `abx deploy --dry-run --onchain-image` reports the
|
|
805
|
+
* true count instead of leaving the agent to guess). The final tx here is the deploy that
|
|
806
|
+
* bakes in the reader field, so the count is staging tx(s) + the deploy.
|
|
807
|
+
*/
|
|
808
|
+
export function previewImageStaging(imagePath, compress) {
|
|
809
|
+
const bytes = readFileSync(resolvePath(imagePath));
|
|
810
|
+
const p = computeContentPlan(bytes, compress);
|
|
811
|
+
warnIfLargeForOnChain(bytes.length, basename(resolvePath(imagePath)));
|
|
812
|
+
return (`would stage ${basename(resolvePath(imagePath))} on-chain (chunk store): ` +
|
|
813
|
+
`${planSizeLine(bytes.length, p, compress)} as ${p.representation}; ` +
|
|
814
|
+
`${planTxLine(p, '1 deploy (bakes the reader field + mints)')}`);
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Stage an image file fully on-chain (chunk store) and return the `reader`-backed
|
|
818
|
+
* `image` field to bake into a deploy's `InitParams.tokenFields`. The staging writes
|
|
819
|
+
* are ownerless (env key) and happen before the deploy, so "deploy a 50 kB on-chain
|
|
820
|
+
* image" is just those staging tx(s) + the single deploy — no post-deploy `set-field`.
|
|
821
|
+
* The manifest is deployer-independent, so this is computed once and reused for any signer.
|
|
822
|
+
*/
|
|
823
|
+
export async function stageImageField(imagePath, compress, send) {
|
|
824
|
+
const bytes = readFileSync(resolvePath(imagePath));
|
|
825
|
+
const { value, representation } = await putContentOnChain(bytes, compress, 'image', send, '1 deploy (bakes the reader field + mints)');
|
|
826
|
+
return {
|
|
827
|
+
field: { field: encodeTag(F.image), representation: encodeTag(representation), value },
|
|
828
|
+
note: `image: ${bytes.length}B staged ON-CHAIN via reader (${representation}) — baked into the deploy`,
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Stage MANY images on-chain against a **single, shared** chunk store — the batch form of
|
|
833
|
+
* {@link stageImageField} for a Series. The store is resolved/deployed **once** (not per token),
|
|
834
|
+
* then every file is staged against it and returned as its own `reader`-backed `image` field.
|
|
835
|
+
* Each field still points at its own manifest, so each token's artwork is independent; they just
|
|
836
|
+
* share the store contract. Returns the fields in input order (token order).
|
|
837
|
+
*/
|
|
838
|
+
export async function stageImageFieldsBatch(imagePaths, compress, send, storeOverride) {
|
|
839
|
+
const store = await ensureChunkStore(send, storeOverride);
|
|
840
|
+
const fields = [];
|
|
841
|
+
for (const imagePath of imagePaths) {
|
|
842
|
+
const bytes = readFileSync(resolvePath(imagePath));
|
|
843
|
+
const { value, representation } = await putContentOnChain(bytes, compress, 'image', send, '1 deploy (bakes the reader fields + mints)', store);
|
|
844
|
+
fields.push({ field: encodeTag(F.image), representation: encodeTag(representation), value });
|
|
845
|
+
}
|
|
846
|
+
return { fields, store };
|
|
847
|
+
}
|
|
848
|
+
// ── set-field ──────────────────────────────────────────────────────────────--
|
|
849
|
+
// Set an on-chain metadata field. `--field` is what (e.g. image, description),
|
|
850
|
+
// `--representation` is how it's carried (default `inline` for --text, `keccak256`
|
|
851
|
+
// for --value). `--text "…"` stores literal UTF-8; `--value 0x..` stores raw bytes.
|
|
852
|
+
// `--collection` targets the collection scope; otherwise token `--token` (default 0).
|
|
853
|
+
// The resolver prefers on-chain over off-chain. Owner-only.
|
|
854
|
+
export async function cmdSetField(address, flags) {
|
|
855
|
+
const contract = requireAddress(address, 'abx set-field <address> --field <name> (--text "…" | --value 0x…) [--representation <rep>] [--collection | --token 0]');
|
|
856
|
+
const field = requireFlag(flags, 'field', 'abx set-field <address> --field <name> (--text … | --value 0x… | --file <path>)');
|
|
857
|
+
assertSettableField(field); // `artifacts`/`abx_provenance` are computed, not settable → point at `abx attach`
|
|
858
|
+
await assertChainId(CHAIN); // verify network up front — this op may stage on-chain content before the field tx
|
|
859
|
+
const collection = !!flags.collection;
|
|
860
|
+
const lane = laneFromFlags(flags);
|
|
861
|
+
const owner = await read(contract, 'owner');
|
|
862
|
+
const staging = !!(flags.file && flags.file !== 'true'); // large content ON-CHAIN via chunk store/reader
|
|
863
|
+
// --file + --dry-run: preview the on-chain staging plan and store/send NOTHING. (The locator/text
|
|
864
|
+
// path flows through runWrite, which previews there; staging must short-circuit before any upload.)
|
|
865
|
+
if (staging && flags['dry-run']) {
|
|
866
|
+
const bytes = readFileSync(resolvePath(flags.file));
|
|
867
|
+
const p = computeContentPlan(bytes, parseCompress(flags.compress));
|
|
868
|
+
console.log(`\n ${bold('◆ set-field ' + field)} ${dim('(dry run — nothing staged or sent)')}`);
|
|
869
|
+
console.log(` ${dim('scope'.padEnd(12))} ${collection ? 'collection' : `token #${flags.token ?? '0'}`}`);
|
|
870
|
+
console.log(` ${planSizeLine(bytes.length, p, parseCompress(flags.compress))}`);
|
|
871
|
+
console.log(dim(`\n Re-run without --dry-run to stage on-chain (lane: ${lane}).\n`));
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
const buildTx = (value, representation) => collection
|
|
875
|
+
? prepareSetContractField({ contract, field, representation, value, chainId: chainId() })
|
|
876
|
+
: prepareSetTokenField({ contract, tokenId: BigInt(flags.token ?? '0'), field, representation, value, chainId: chainId() });
|
|
877
|
+
// On-chain staging (--file) is a SEQUENCE — chunk write(s) → manifest → the field-set that
|
|
878
|
+
// references it — where each tx's receipt feeds the next, so it can't be signed offline.
|
|
879
|
+
if (staging && lane === 'unsigned') {
|
|
880
|
+
throw new Error('Staging on-chain content (--file) needs interactive signing — each chunk tx feeds the next, ' +
|
|
881
|
+
"so it can't run on the cold lane (--unsigned). Use the hot lane (a funded key) or --sign (browser wallet).");
|
|
882
|
+
}
|
|
883
|
+
// Wallet lane + on-chain staging: ONE sign session signs every chunk write AND the field-set,
|
|
884
|
+
// so the human connects once and approves each step (the env key is never needed).
|
|
885
|
+
if (staging && lane === 'sign') {
|
|
886
|
+
const compress = parseCompress(flags.compress);
|
|
887
|
+
const bytes = readFileSync(resolvePath(flags.file));
|
|
888
|
+
const p = computeContentPlan(bytes, compress);
|
|
889
|
+
const stagingTxs = p.plan.mode === 'single' ? 1 : p.plan.txCount;
|
|
890
|
+
const session = await openWalletSession({
|
|
891
|
+
chainKey: CHAIN,
|
|
892
|
+
expectedSigner: owner,
|
|
893
|
+
total: stagingTxs + 1,
|
|
894
|
+
port: flags.port ? Number(flags.port) : undefined,
|
|
895
|
+
signUrlFile: flags['sign-url-file'],
|
|
896
|
+
});
|
|
897
|
+
try {
|
|
898
|
+
await session.connect();
|
|
899
|
+
const { value, representation } = await putContentOnChain(bytes, compress, field, sessionStagingSender(session));
|
|
900
|
+
await session.send(buildTx(value, representation));
|
|
901
|
+
}
|
|
902
|
+
finally {
|
|
903
|
+
session.close();
|
|
904
|
+
}
|
|
905
|
+
await reindexIfKnown(contract, flags);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
// Hot lane (or no staging): compute the value, then sign the single field-set tx as usual.
|
|
909
|
+
let value;
|
|
910
|
+
let representation;
|
|
911
|
+
if (staging) {
|
|
912
|
+
const compress = parseCompress(flags.compress);
|
|
913
|
+
({ value, representation } = await putContentOnChain(readFileSync(resolvePath(flags.file)), compress, field, envStagingSender()));
|
|
914
|
+
}
|
|
915
|
+
else if (flags.text && flags.text !== 'true') {
|
|
916
|
+
value = toHex(flags.text);
|
|
917
|
+
representation = flags.representation ?? R.inline;
|
|
918
|
+
console.log(dim(` inline on-chain '${field}'${collection ? ' (collection)' : ` on token #${flags.token ?? '0'}`}: ${JSON.stringify(flags.text.slice(0, 60))}${flags.text.length > 60 ? '…' : ''}`));
|
|
919
|
+
}
|
|
920
|
+
else {
|
|
921
|
+
value = requireFlag(flags, 'value', 'abx set-field <address> --field <name> --text … | --value 0x…');
|
|
922
|
+
representation = flags.representation ?? R.keccak256;
|
|
923
|
+
}
|
|
924
|
+
await runWrite(contract, buildTx(value, representation), flags, owner);
|
|
925
|
+
}
|
|
926
|
+
// ── attach (the data-plane verb: put a named file on a token) ─────────────────
|
|
927
|
+
/**
|
|
928
|
+
* `abx attach <address> <key> <uri>` — attach a named, typed file to a token (or the collection),
|
|
929
|
+
* so it appears in the token's served `artifacts` manifest as `{key, mimeType, uri}`. This is the
|
|
930
|
+
* creator-facing verb for the token data plane (specs/protocol/data-plane.md); it's a thin,
|
|
931
|
+
* correct-by-construction wrapper over `set-field`:
|
|
932
|
+
* - the on-chain representation is AUTO-DETECTED from the URI scheme (ipfs:// · ar:// · https://)
|
|
933
|
+
* — no `--representation` to guess wrong;
|
|
934
|
+
* - `--file <path>` instead of a URI stores tiny bytes ON-CHAIN (SSTORE2), for a small file with
|
|
935
|
+
* no external host (≈200 gas/byte — locators are the norm for anything non-trivial);
|
|
936
|
+
* - the declared `mimeType` comes from the file EXTENSION in the URI (`…/master.tiff` → image/tiff),
|
|
937
|
+
* so give the file a real extension or it lands as `application/octet-stream`;
|
|
938
|
+
* - computed keys (`artifacts`, `abx_provenance`) are refused — they're the manifest, not inputs.
|
|
939
|
+
* Scope: token (`--token`, default 0) or `--collection`. Any signing lane. Owner-only.
|
|
940
|
+
*/
|
|
941
|
+
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, key, uri] = positionalArgs(rest);
|
|
944
|
+
// Warn (never throw) on an unrecognized flag — a typo or a hopeful `--mime-type` otherwise no-ops
|
|
945
|
+
// INVISIBLY (round-1: an agent passed `--mime-type` and it was silently swallowed). mimeType is
|
|
946
|
+
// declared from the URL extension, not a flag; say so.
|
|
947
|
+
for (const f of unknownFlags(flags, ATTACH_FLAGS)) {
|
|
948
|
+
const hint = f.includes('mime')
|
|
949
|
+
? ' mimeType is declared from the file EXTENSION in the URI, not a flag.'
|
|
950
|
+
: f === 'for'
|
|
951
|
+
? ' --for is a deploy flag (it pins the future owner); an owner-op signs as the on-chain owner automatically — just connect that wallet.'
|
|
952
|
+
: '';
|
|
953
|
+
console.log(yellow(' ⚠ ') + dim(`unrecognized flag --${f} (ignored).${hint}`));
|
|
954
|
+
}
|
|
955
|
+
const contract = requireAddress(address, usage);
|
|
956
|
+
if (!key) {
|
|
957
|
+
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.exit(1);
|
|
959
|
+
}
|
|
960
|
+
assertSettableField(key);
|
|
961
|
+
const hasFile = !!(flags.file && flags.file !== 'true');
|
|
962
|
+
if (!uri && !hasFile) {
|
|
963
|
+
console.error(`usage: ${usage}\n pass a locator URI (ipfs://… / ar://… / https://…) or --file <path> to store bytes on-chain.\n`);
|
|
964
|
+
process.exit(1);
|
|
965
|
+
}
|
|
966
|
+
if (uri && hasFile)
|
|
967
|
+
throw new Error('pass EITHER a locator URI OR --file <path>, not both.');
|
|
968
|
+
const collection = !!flags.collection;
|
|
969
|
+
const scope = collection ? 'the collection' : `token #${flags.token ?? '0'}`;
|
|
970
|
+
// --file: reuse set-field's on-chain staging path (tiny files only). Delegate rather than
|
|
971
|
+
// re-implement the chunk sequence.
|
|
972
|
+
if (hasFile) {
|
|
973
|
+
return cmdSetField(address, { ...flags, field: key });
|
|
974
|
+
}
|
|
975
|
+
// Locator path (the common case): auto-detect the representation, refuse an unrecognized scheme
|
|
976
|
+
// loudly (never silently store a bad value) — fail fast before any RPC. Derive the declared
|
|
977
|
+
// mimeType from the extension.
|
|
978
|
+
const representation = representationForLocator(uri);
|
|
979
|
+
if (!representation) {
|
|
980
|
+
throw new Error(`"${uri}" isn't a recognized file locator. Use ipfs://… (pinned/IPFS), ar://… (Arweave), or https://… . ` +
|
|
981
|
+
'To store literal text or raw bytes on-chain instead, use `abx set-field`.');
|
|
982
|
+
}
|
|
983
|
+
const owner = await read(contract, 'owner');
|
|
984
|
+
const mimeType = contentTypeFromPath(uri);
|
|
985
|
+
if (mimeType === 'application/octet-stream') {
|
|
986
|
+
console.log(yellow(' ⚠ ') +
|
|
987
|
+
dim(`no file extension in "${uri.slice(0, 64)}" → declared type will be application/octet-stream. `) +
|
|
988
|
+
dim('Point the URI at the file itself (…/master.tiff, …/coa.pdf) so collectors get the right type.'));
|
|
989
|
+
}
|
|
990
|
+
console.log(` attaching ${bold(key)} ${dim(`(${mimeType}, ${representation})`)} to ${scope}: ${dim(uri)}`);
|
|
991
|
+
// Where it surfaces — say it on the preview path too (not just post-send), so a creator/agent
|
|
992
|
+
// doesn't expect an attached custom key in a bare `abx tokenuri` (round-2 finding: they did).
|
|
993
|
+
console.log(dim(` → appears in a resolver's artifacts listing (/t/${chainId()}/${contract}/${flags.token ?? '0'} and /data/${key}); a bare on-chain tokenURI shows reserved fields only.`));
|
|
994
|
+
const buildTx = () => collection
|
|
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 sent = await runWrite(contract, buildTx(), flags, owner);
|
|
998
|
+
if (sent) {
|
|
999
|
+
const id = flags.token ?? '0';
|
|
1000
|
+
console.log(` ${green('✓')} attached — ${bold(key)} joins ${scope}'s ${bold('artifacts')} manifest (stored on-chain, anchored).\n` +
|
|
1001
|
+
dim(` verify (a resolver serves the complete listing): `) +
|
|
1002
|
+
`curl <your-resolver>/t/${chainId()}/${contract}/${id}` +
|
|
1003
|
+
dim(` ${collection ? '' : `→ artifacts[].key "${key}"; /data/${key} fetches it`}\n`) +
|
|
1004
|
+
dim(` (The complete file listing is a resolver surface — the bare on-chain tokenURI enumerates reserved fields only.)\n`));
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
// ── lock-field ───────────────────────────────────────────────────────────────
|
|
1008
|
+
export async function cmdLockField(address, flags) {
|
|
1009
|
+
const contract = requireAddress(address, 'abx lock-field <address> --field <name> [--collection | --token 0] [--sign|--unsigned]');
|
|
1010
|
+
const field = requireFlag(flags, 'field', 'abx lock-field <address> --field <name>');
|
|
1011
|
+
const collection = !!flags.collection;
|
|
1012
|
+
const owner = await read(contract, 'owner');
|
|
1013
|
+
console.log(dim(` note: locking the '${field}' field is permanent and irreversible (freezes all its representations).`));
|
|
1014
|
+
const tx = collection
|
|
1015
|
+
? prepareLockContractField({ contract, field, chainId: chainId() })
|
|
1016
|
+
: prepareLockTokenField({ contract, tokenId: BigInt(flags.token ?? '0'), field, chainId: chainId() });
|
|
1017
|
+
await runWrite(contract, tx, flags, owner);
|
|
1018
|
+
}
|
|
1019
|
+
// ── set-renderer (toggle on-chain URI resolution) ─────────────────────────────
|
|
1020
|
+
/** Point a token's URI resolution at an on-chain renderer (or clear it with `--off`).
|
|
1021
|
+
* `flags.renderer` is the resolved address (main.ts defaults it to the canonical
|
|
1022
|
+
* renderer). With it set, `tokenURI`/`contractURI` are assembled on-chain from the
|
|
1023
|
+
* fields — no resolver needed. Scope: token (default) or `--collection`. */
|
|
1024
|
+
export async function cmdSetRenderer(address, flags) {
|
|
1025
|
+
const contract = requireAddress(address, 'abx set-renderer <address> [--collection] [--off | --renderer 0x..] [--sign|--unsigned]');
|
|
1026
|
+
const collection = !!flags.collection;
|
|
1027
|
+
const renderer = (flags.off ? zeroAddress : (flags.renderer ?? zeroAddress));
|
|
1028
|
+
const owner = await read(contract, 'owner');
|
|
1029
|
+
console.log(dim(renderer === zeroAddress
|
|
1030
|
+
? ` clearing the on-chain renderer → ${collection ? 'contractURI' : 'tokenURI'} resolves off-chain again.`
|
|
1031
|
+
: ` ${collection ? 'contractURI' : 'tokenURI'} will resolve ON-CHAIN via ${renderer} (assembled from fields).`));
|
|
1032
|
+
const tx = collection
|
|
1033
|
+
? prepareSetContractURIRenderer({ contract, renderer, chainId: chainId() })
|
|
1034
|
+
: prepareSetTokenURIRenderer({ contract, renderer, chainId: chainId() });
|
|
1035
|
+
await runWrite(contract, tx, flags, owner);
|
|
1036
|
+
}
|
|
1037
|
+
// ── lock-uri (freeze the URI config) ──────────────────────────────────────────
|
|
1038
|
+
/** Freeze a scope's URI config (pointer + renderer) forever. With the fields also
|
|
1039
|
+
* locked, the resolved metadata is provably immutable — the on-chain twin of a
|
|
1040
|
+
* frozen `tokenURI`. Scope: token (default) or `--collection`. */
|
|
1041
|
+
export async function cmdLockUri(address, flags) {
|
|
1042
|
+
const contract = requireAddress(address, 'abx lock-uri <address> [--collection] [--sign|--unsigned]');
|
|
1043
|
+
const collection = !!flags.collection;
|
|
1044
|
+
const owner = await read(contract, 'owner');
|
|
1045
|
+
console.log(dim(` note: locking the ${collection ? 'contractURI' : 'tokenURI'} config is permanent — pointer + renderer can never change again.`));
|
|
1046
|
+
const tx = collection
|
|
1047
|
+
? prepareLockContractURI({ contract, chainId: chainId() })
|
|
1048
|
+
: prepareLockTokenURI({ contract, chainId: chainId() });
|
|
1049
|
+
await runWrite(contract, tx, flags, owner);
|
|
1050
|
+
}
|
|
1051
|
+
// ── set-admin (transfer contract ownership) ──────────────────────────────────
|
|
1052
|
+
export async function cmdSetAdmin(address, flags) {
|
|
1053
|
+
const contract = requireAddress(address, 'abx set-admin <address> --to 0x… [--sign|--unsigned]');
|
|
1054
|
+
const newOwner = requireFlag(flags, 'to', 'abx set-admin <address> --to 0x…');
|
|
1055
|
+
const owner = await read(contract, 'owner');
|
|
1056
|
+
await runWrite(contract, prepareTransferOwnership({ contract, newOwner, chainId: chainId() }), flags, owner);
|
|
1057
|
+
}
|
|
1058
|
+
// ── fixed-price minter (the Minter spine) ─────────────────────────────────────
|
|
1059
|
+
// A shared, ownerless, multi-tenant sale singleton. `configure` defers to the token owner;
|
|
1060
|
+
// `buy` is public. Sale config is keyed by token address; proceeds route to the token's
|
|
1061
|
+
// primaryPayee; the token's Paused extension is the on/off switch. See specs/protocol/minter-spine.md.
|
|
1062
|
+
/** A Series read (minter/primaryPayee/paused/…) via the Series ABI (the 1/1 ABI lacks these). */
|
|
1063
|
+
async function readSeries(token, fn, args = []) {
|
|
1064
|
+
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
1065
|
+
return (await publicClient.readContract({
|
|
1066
|
+
address: token,
|
|
1067
|
+
abi: seriesImageAbi,
|
|
1068
|
+
functionName: fn,
|
|
1069
|
+
args: args,
|
|
1070
|
+
}));
|
|
1071
|
+
}
|
|
1072
|
+
async function readSale(minter, token) {
|
|
1073
|
+
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
1074
|
+
const [configured, paymentToken, price, allocation, sold] = (await publicClient.readContract({
|
|
1075
|
+
address: minter,
|
|
1076
|
+
abi: abxFixedPriceMinterAbi,
|
|
1077
|
+
functionName: 'sales',
|
|
1078
|
+
args: [token],
|
|
1079
|
+
}));
|
|
1080
|
+
return { configured, paymentToken, price, allocation, sold };
|
|
1081
|
+
}
|
|
1082
|
+
/** Resolve the shared minter for the chain (flag → env → manifest), or deploy it if none exists.
|
|
1083
|
+
* It's ownerless, so any funded signer can stand it up — the sibling of `ensureChunkStore`. */
|
|
1084
|
+
async function ensureFixedPriceMinter(override) {
|
|
1085
|
+
const known = fixedPriceMinterAddress(override);
|
|
1086
|
+
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
1087
|
+
if (known) {
|
|
1088
|
+
const code = await publicClient.getCode({ address: known });
|
|
1089
|
+
if (code && code !== '0x')
|
|
1090
|
+
return known;
|
|
1091
|
+
console.log(yellow(` configured minter ${known} has no code on ${CHAIN} — deploying a fresh one`));
|
|
1092
|
+
}
|
|
1093
|
+
// CREATE2-deterministic: it may already exist at its predicted address (forge script deployed it,
|
|
1094
|
+
// manifest not yet updated). Check there before deploying — self-healing, never a duplicate.
|
|
1095
|
+
const predicted = predictFixedPriceMinter();
|
|
1096
|
+
if (!known || known.toLowerCase() !== predicted.toLowerCase()) {
|
|
1097
|
+
const pcode = await publicClient.getCode({ address: predicted });
|
|
1098
|
+
if (pcode && pcode !== '0x') {
|
|
1099
|
+
console.log(dim(` using the canonical fixed-price minter at its deterministic address ${predicted}`));
|
|
1100
|
+
return predicted;
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
const { wallet, account } = makeWalletClient({ chainKey: CHAIN });
|
|
1104
|
+
console.log(dim(' deploying the canonical fixed-price minter (shared, ownerless) — CREATE2…'));
|
|
1105
|
+
const { minter, txHash } = await deployFixedPriceMinter(wallet, account, publicClient);
|
|
1106
|
+
console.log(` ${green('✓')} fixed-price minter ${minter}`);
|
|
1107
|
+
console.log(dim(` tx ${txHash}`));
|
|
1108
|
+
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
|
+
return minter;
|
|
1110
|
+
}
|
|
1111
|
+
/** 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. */
|
|
1113
|
+
async function runMinterWrite(provider, flags, expectedSigner) {
|
|
1114
|
+
// --dry-run: preview the sale-config / purchase tx and send NOTHING. The minter path does not go
|
|
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
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
/** Parse the sale price: `--price-raw <units>` (exact base units) or the friendly `--price <ether>`
|
|
1138
|
+
* (18-decimals). ETH sales use `--price`; non-18-dp ERC-20s should use `--price-raw`. */
|
|
1139
|
+
function parseSalePrice(flags, usage) {
|
|
1140
|
+
if (flags['price-raw'] && flags['price-raw'] !== 'true')
|
|
1141
|
+
return BigInt(flags['price-raw']);
|
|
1142
|
+
if (flags.price && flags.price !== 'true')
|
|
1143
|
+
return parseEther(flags.price);
|
|
1144
|
+
console.error(`missing --price <ether> or --price-raw <units>\nusage: ${usage}\n`);
|
|
1145
|
+
process.exit(1);
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* `abx minter configure <token> --price <eth> --allocation <n> [--erc20 0x…]` — set the fixed-price
|
|
1149
|
+
* sale for a project on the shared minter. Defers to the token owner. Resolves (or deploys) the
|
|
1150
|
+
* shared minter, then reports the two remaining grants the owner still needs (assign the minter on
|
|
1151
|
+
* the token, set a primary payee) and whether the token is paused.
|
|
1152
|
+
*/
|
|
1153
|
+
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]';
|
|
1155
|
+
const token = requireAddress(address, usage);
|
|
1156
|
+
const owner = await read(token, 'owner');
|
|
1157
|
+
const erc20 = flags.erc20 && flags.erc20 !== 'true' ? flags.erc20 : zeroAddress;
|
|
1158
|
+
const allocation = BigInt(requireFlag(flags, 'allocation', usage));
|
|
1159
|
+
const price = parseSalePrice(flags, usage);
|
|
1160
|
+
// On --dry-run resolve the minter WITHOUT deploying — ensureFixedPriceMinter would deploy the
|
|
1161
|
+
// shared singleton if absent, and a preview must never send. Fall back to a label if none exists.
|
|
1162
|
+
const dryRun = !!flags['dry-run'];
|
|
1163
|
+
const knownMinter = fixedPriceMinterAddress(flags['minter-contract']);
|
|
1164
|
+
if (dryRun && !knownMinter) {
|
|
1165
|
+
console.log(yellow(` ⚠ no shared minter deployed on ${CHAIN} yet — a real run deploys it once (a separate tx) before configuring.`));
|
|
1166
|
+
}
|
|
1167
|
+
const minter = dryRun ? (knownMinter ?? zeroAddress) : await ensureFixedPriceMinter(flags['minter-contract']);
|
|
1168
|
+
const isEth = erc20 === zeroAddress;
|
|
1169
|
+
// Sanity-check the allocation against what the contract can actually mint. A creator who sets
|
|
1170
|
+
// --allocation 100 on a 16-supply Series would only ever sell the remainder (maxInvocations binds
|
|
1171
|
+
// tighter than the minter's allocation) and discover it when mint #16 reverts. Warn loudly — but
|
|
1172
|
+
// don't refuse: allocation is a cap, and holding reserves (allocating < remaining) is legitimate.
|
|
1173
|
+
const [maxInv, supplyNow] = await Promise.all([
|
|
1174
|
+
readSeries(token, 'maxInvocations').catch(() => 0n),
|
|
1175
|
+
readSeries(token, 'totalSupply').catch(() => 0n),
|
|
1176
|
+
]);
|
|
1177
|
+
const remaining = maxInv > supplyNow ? maxInv - supplyNow : 0n;
|
|
1178
|
+
if (maxInv > 0n && allocation > remaining) {
|
|
1179
|
+
console.log(yellow(` ⚠ allocation ${allocation} exceeds the ${remaining} this contract can still mint `) +
|
|
1180
|
+
dim(`(maxInvocations ${maxInv} − ${supplyNow} minted). The token's cap binds tighter, so only ${remaining} will actually sell.`));
|
|
1181
|
+
console.log(dim(` To sell ${allocation}, redeploy a Series with a higher --max; otherwise set --allocation ${remaining} (or less, to hold reserves).`));
|
|
1182
|
+
}
|
|
1183
|
+
console.log(dim(` configuring sale on ${minter}`));
|
|
1184
|
+
console.log(dim(` token ${token}`));
|
|
1185
|
+
console.log(dim(` price ${isEth ? `${formatEther(price)} ETH` : `${price} units of ${erc20}`} / token`));
|
|
1186
|
+
console.log(dim(` allocation ${allocation}`));
|
|
1187
|
+
await runMinterWrite(prepareConfigureSale({ minter, token, paymentToken: erc20, price, allocation, chainId: chainId() }), flags, owner);
|
|
1188
|
+
// The two grants + the pause switch — surface what's still needed to actually sell.
|
|
1189
|
+
const [assignedMinter, payee, paused] = await Promise.all([
|
|
1190
|
+
readSeries(token, 'minter').catch(() => zeroAddress),
|
|
1191
|
+
readSeries(token, 'primaryPayee').catch(() => zeroAddress),
|
|
1192
|
+
readSeries(token, 'paused').catch(() => false),
|
|
1193
|
+
]);
|
|
1194
|
+
const assigned = assignedMinter.toLowerCase() === minter.toLowerCase();
|
|
1195
|
+
console.log('');
|
|
1196
|
+
console.log(assigned ? dim(' ✓ minter is assigned on the token') : yellow(` ⚠ assign this minter on the token: abx set-minter ${token} --minter ${minter}`));
|
|
1197
|
+
if (payee === zeroAddress)
|
|
1198
|
+
console.log(yellow(` ⚠ set a primary payee (sales revert without one): abx set-primary-payee ${token} --payee 0x…`));
|
|
1199
|
+
else
|
|
1200
|
+
console.log(dim(` ✓ proceeds → ${payee}`));
|
|
1201
|
+
if (paused)
|
|
1202
|
+
console.log(yellow(` • token is paused — open the sale when ready: abx unpause ${token}`));
|
|
1203
|
+
console.log(dim(` buyers then run: abx minter buy ${token}`));
|
|
1204
|
+
}
|
|
1205
|
+
/** `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. */
|
|
1207
|
+
export async function cmdMinterShow(address, flags) {
|
|
1208
|
+
const token = requireAddress(address, 'abx minter show <token> [--minter-contract 0x…]');
|
|
1209
|
+
// Refuse a nonexistent contract like the sibling owner-ops (state/unpause/minter configure) do —
|
|
1210
|
+
// otherwise the minter singleton returns zero-config for the unconfigured token and we print a
|
|
1211
|
+
// plausible-but-fake `paused: no (open)` readout for an address that has no project at all.
|
|
1212
|
+
await assertContractExists(token);
|
|
1213
|
+
const minter = fixedPriceMinterAddress(flags['minter-contract']);
|
|
1214
|
+
if (!minter) {
|
|
1215
|
+
console.log(yellow(` no shared minter known for ${CHAIN} — configure a sale (deploys it) or set ABX_FIXED_PRICE_MINTER`));
|
|
1216
|
+
return;
|
|
1217
|
+
}
|
|
1218
|
+
const sale = await readSale(minter, token);
|
|
1219
|
+
const [assignedMinter, payee, paused, max, supply] = await Promise.all([
|
|
1220
|
+
readSeries(token, 'minter').catch(() => zeroAddress),
|
|
1221
|
+
readSeries(token, 'primaryPayee').catch(() => zeroAddress),
|
|
1222
|
+
readSeries(token, 'paused').catch(() => false),
|
|
1223
|
+
readSeries(token, 'maxInvocations').catch(() => 0n),
|
|
1224
|
+
readSeries(token, 'totalSupply').catch(() => 0n),
|
|
1225
|
+
]);
|
|
1226
|
+
const isEth = sale.paymentToken === zeroAddress;
|
|
1227
|
+
const assigned = assignedMinter.toLowerCase() === minter.toLowerCase();
|
|
1228
|
+
console.log(bold(`\n minter sale — ${token}`));
|
|
1229
|
+
console.log(` shared minter: ${minter}`);
|
|
1230
|
+
if (!sale.configured) {
|
|
1231
|
+
console.log(yellow(` not configured — abx minter configure ${token} --price <eth> --allocation <n>`));
|
|
1232
|
+
}
|
|
1233
|
+
else {
|
|
1234
|
+
console.log(` price: ${isEth ? `${formatEther(sale.price)} ETH` : `${sale.price} units of ${sale.paymentToken}`}`);
|
|
1235
|
+
console.log(` allocation: ${sale.sold}/${sale.allocation} sold`);
|
|
1236
|
+
}
|
|
1237
|
+
console.log(` assigned on token: ${assigned ? green('yes') : yellow(`no — abx set-minter ${token} --minter ${minter}`)}`);
|
|
1238
|
+
console.log(` primary payee: ${payee === zeroAddress ? yellow('none — abx set-primary-payee …') : payee}`);
|
|
1239
|
+
console.log(` paused: ${paused ? yellow(`yes — abx unpause ${token}`) : green('no (open)')}`);
|
|
1240
|
+
console.log(` supply: ${supply}/${max}`);
|
|
1241
|
+
// Readiness must also flag an allocation that overshoots what the contract can still mint — the
|
|
1242
|
+
// same check `minter configure` does, surfaced here (this is the command billed as "check state").
|
|
1243
|
+
const remaining = max > supply ? max - supply : 0n;
|
|
1244
|
+
if (sale.configured && max > 0n && sale.allocation > remaining) {
|
|
1245
|
+
console.log(yellow(` ⚠ allocation ${sale.allocation} exceeds the ${remaining} still mintable (cap ${max} − ${supply} minted) — only ${remaining} can actually sell.`));
|
|
1246
|
+
}
|
|
1247
|
+
console.log('');
|
|
1248
|
+
}
|
|
1249
|
+
/** `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. */
|
|
1251
|
+
export async function cmdMinterBuy(address, flags) {
|
|
1252
|
+
const usage = 'abx minter buy <token> [--to 0x…] [--minter-contract 0x…] [--sign|--unsigned]';
|
|
1253
|
+
const token = requireAddress(address, usage);
|
|
1254
|
+
const minter = fixedPriceMinterAddress(flags['minter-contract']);
|
|
1255
|
+
if (!minter)
|
|
1256
|
+
throw new Error(`no shared minter known for ${CHAIN} — configure a sale first, or set ABX_FIXED_PRICE_MINTER`);
|
|
1257
|
+
const sale = await readSale(minter, token);
|
|
1258
|
+
if (!sale.configured)
|
|
1259
|
+
throw new Error(`no sale configured for ${token} — run: abx minter configure ${token} …`);
|
|
1260
|
+
const isEth = sale.paymentToken === zeroAddress;
|
|
1261
|
+
const to = flags.to && flags.to !== 'true' ? flags.to : undefined;
|
|
1262
|
+
const value = isEth ? sale.price : 0n;
|
|
1263
|
+
if (!isEth) {
|
|
1264
|
+
console.log(yellow(` ERC-20 sale: the buyer must have approved ${minter} to spend ${sale.price} units of ${sale.paymentToken} first (else the tx reverts).`));
|
|
1265
|
+
}
|
|
1266
|
+
console.log(dim(` buying 1 token${to ? ` → ${to}` : ' → signer'} for ${isEth ? `${formatEther(sale.price)} ETH` : `${sale.price} units`}`));
|
|
1267
|
+
await runMinterWrite(preparePurchase({ minter, token, to, value, chainId: chainId() }), flags);
|
|
1268
|
+
console.log(dim(` next: \`abx refresh ${token}\` so marketplaces pick up the new token.`));
|
|
1269
|
+
}
|
|
1270
|
+
//# sourceMappingURL=ownerops.js.map
|