@clanker-chain/clanker-cli 2026.9.7 → 2026.9.8-2

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/lib/doctor.mjs ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * `clanker doctor` — local readiness checks (mise-doctor habit).
3
+ */
4
+
5
+ import { getAddress } from "viem";
6
+ import {
7
+ ANVIL_DEFAULT_ADDRESS,
8
+ isLocalRpc,
9
+ loadConfig,
10
+ loadOperator,
11
+ clankerHome,
12
+ } from "./profile.mjs";
13
+ import { detectSetupHints, formatSetupDetectTable } from "./setup-detect.mjs";
14
+ import { c, nextHint } from "./ui.mjs";
15
+
16
+ /**
17
+ * @param {{ home?: string, env?: NodeJS.ProcessEnv, openclawDir?: string, castBin?: string, spawn?: Function, fetchImpl?: typeof fetch }} [opts]
18
+ */
19
+ export async function runDoctorChecks(opts = {}) {
20
+ const env = opts.env ?? process.env;
21
+ const home = opts.home ?? clankerHome(env);
22
+ const hints = detectSetupHints({
23
+ home,
24
+ env,
25
+ openclawDir: opts.openclawDir,
26
+ castBin: opts.castBin,
27
+ spawn: opts.spawn,
28
+ });
29
+ const config = hints.config ?? loadConfig(home);
30
+ const operator = hints.operator ?? loadOperator(home);
31
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
32
+
33
+ /** @type {{ id: string, ok: boolean, level: 'pass'|'warn'|'fail', message: string }[]} */
34
+ const checks = [];
35
+
36
+ checks.push({
37
+ id: "config",
38
+ ok: Boolean(config),
39
+ level: config ? "pass" : "fail",
40
+ message: config
41
+ ? `config.json present (preset=${config.preset})`
42
+ : "config.json missing — run clanker setup",
43
+ });
44
+
45
+ const registry = config?.registryAddress;
46
+ const rpc = config?.chainRpcUrl ?? "";
47
+ checks.push({
48
+ id: "registry",
49
+ ok: Boolean(registry && /^0x[0-9a-fA-F]{40}$/.test(registry)),
50
+ level: registry && /^0x[0-9a-fA-F]{40}$/.test(registry) ? "pass" : "fail",
51
+ message:
52
+ registry && /^0x[0-9a-fA-F]{40}$/.test(registry)
53
+ ? `registry ${registry}`
54
+ : "registry missing — run clanker setup --preset sepolia (or set after local deploy)",
55
+ });
56
+
57
+ const hasOwner =
58
+ Boolean(operator?.owner) && /^0x[0-9a-fA-F]{40}$/.test(operator.owner);
59
+ const hasLabel = Boolean(operator?.label);
60
+ checks.push({
61
+ id: "operator",
62
+ ok: hasOwner && hasLabel,
63
+ level: hasOwner && hasLabel ? "pass" : "fail",
64
+ message:
65
+ hasOwner && hasLabel
66
+ ? `operator.json ${operator.label} · ${operator.owner}`
67
+ : "operator.json incomplete — run clanker setup",
68
+ });
69
+
70
+ if (hasOwner && rpc && !isLocalRpc(rpc)) {
71
+ const anvil =
72
+ getAddress(operator.owner).toLowerCase() ===
73
+ ANVIL_DEFAULT_ADDRESS.toLowerCase();
74
+ checks.push({
75
+ id: "anvil_public",
76
+ ok: !anvil,
77
+ level: anvil ? "fail" : "pass",
78
+ message: anvil
79
+ ? "owner is Anvil #0 on a public RPC — refuse for mutate; fix owner address"
80
+ : "owner is not Anvil #0",
81
+ });
82
+ }
83
+
84
+ const hasKey =
85
+ Boolean(operator?.key?.type === "keyFile" && operator.key.value) ||
86
+ Boolean(operator?.key?.type === "env" && operator.key.value) ||
87
+ Boolean(env.OPERATOR_PRIVATE_KEY);
88
+ checks.push({
89
+ id: "signing",
90
+ ok: true,
91
+ level: hasKey ? "pass" : "warn",
92
+ message: hasKey
93
+ ? "signing key pointer available (mint/revoke OK)"
94
+ : "read-only profile — whoami/bots OK; mint needs --key-file or OPERATOR_PRIVATE_KEY",
95
+ });
96
+
97
+ checks.push({
98
+ id: "foundry",
99
+ ok: true,
100
+ level: hints.foundryAvailable ? "pass" : "warn",
101
+ message: hints.foundryAvailable
102
+ ? `Foundry cast OK (${hints.foundryAccounts.length} account(s))`
103
+ : "Foundry cast not on PATH (optional)",
104
+ });
105
+
106
+ const authUrl = config?.mqttAuthServiceUrl;
107
+ if (authUrl && typeof fetchImpl === "function") {
108
+ const healthUrl = `${String(authUrl).replace(/\/$/, "")}/health`;
109
+ try {
110
+ const ac = new AbortController();
111
+ const t = setTimeout(() => ac.abort(), 5000);
112
+ const res = await fetchImpl(healthUrl, { signal: ac.signal });
113
+ clearTimeout(t);
114
+ if (res.ok) {
115
+ checks.push({
116
+ id: "mqtt_auth_health",
117
+ ok: true,
118
+ level: "pass",
119
+ message: `mqtt-auth health OK (${healthUrl})`,
120
+ });
121
+ } else {
122
+ checks.push({
123
+ id: "mqtt_auth_health",
124
+ ok: false,
125
+ level: "fail",
126
+ message: `mqtt-auth health HTTP ${res.status} (${healthUrl})`,
127
+ });
128
+ }
129
+ } catch (err) {
130
+ checks.push({
131
+ id: "mqtt_auth_health",
132
+ ok: false,
133
+ level: "warn",
134
+ message: `mqtt-auth health unreachable: ${err.message ?? err}`,
135
+ });
136
+ }
137
+ } else if (config) {
138
+ checks.push({
139
+ id: "mqtt_auth_health",
140
+ ok: true,
141
+ level: "warn",
142
+ message: "mqttAuthServiceUrl not set — skip hub health",
143
+ });
144
+ }
145
+
146
+ const readyWhoami = checks
147
+ .filter((ch) => ch.id === "config" || ch.id === "registry" || ch.id === "operator")
148
+ .every((ch) => ch.ok);
149
+ const readyMint = readyWhoami && hasKey &&
150
+ !checks.some((ch) => ch.id === "anvil_public" && !ch.ok);
151
+
152
+ return {
153
+ home,
154
+ hints,
155
+ checks,
156
+ readyWhoami,
157
+ readyMint,
158
+ ok: readyWhoami,
159
+ };
160
+ }
161
+
162
+ /**
163
+ * @param {string[]} argv
164
+ * @param {{ home?: string, env?: NodeJS.ProcessEnv }} [opts]
165
+ * @returns {Promise<{ exitCode: number, report: object }>}
166
+ */
167
+ export async function runDoctor(argv = [], opts = {}) {
168
+ const json = argv.includes("--json");
169
+ const report = await runDoctorChecks(opts);
170
+
171
+ if (json) {
172
+ console.log(
173
+ JSON.stringify(
174
+ {
175
+ ok: report.ok,
176
+ readyWhoami: report.readyWhoami,
177
+ readyMint: report.readyMint,
178
+ home: report.home,
179
+ checks: report.checks,
180
+ },
181
+ null,
182
+ 2,
183
+ ),
184
+ );
185
+ return { exitCode: report.ok ? 0 : 1, report };
186
+ }
187
+
188
+ console.log(c.bold("clanker doctor"));
189
+ console.log("");
190
+ console.log(formatSetupDetectTable(report.hints));
191
+ console.log("");
192
+ console.log(c.bold("Checks"));
193
+ for (const ch of report.checks) {
194
+ const mark =
195
+ ch.level === "pass"
196
+ ? c.green("pass")
197
+ : ch.level === "warn"
198
+ ? c.yellow("warn")
199
+ : c.red("fail");
200
+ console.log(` [${mark}] ${ch.message}`);
201
+ }
202
+ console.log("");
203
+ if (report.readyWhoami) {
204
+ console.log(c.green("Ready for: clanker whoami"));
205
+ } else {
206
+ console.log(c.red("Not ready for whoami"));
207
+ }
208
+ if (report.readyMint) {
209
+ console.log(c.green("Ready for: clanker operator mint / bot mint"));
210
+ } else {
211
+ console.log(c.dim("Mint/revoke: need signing key (and non-Anvil owner on public RPC)"));
212
+ }
213
+
214
+ if (!report.readyWhoami) {
215
+ nextHint(["clanker setup"]);
216
+ } else if (!report.readyMint) {
217
+ nextHint([
218
+ "clanker whoami",
219
+ "clanker setup --key-file ~/.clanker/op.key --force # to enable mint",
220
+ ]);
221
+ } else {
222
+ nextHint(["clanker whoami", "clanker bots"]);
223
+ }
224
+
225
+ return { exitCode: report.ok ? 0 : 1, report };
226
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Foundry cast helpers for setup (address resolve + key export).
3
+ * Never logs private keys.
4
+ */
5
+
6
+ import { mkdirSync, writeFileSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ import { spawnSync } from "node:child_process";
9
+ import { getAddress } from "viem";
10
+ import { privateKeyToAccount } from "viem/accounts";
11
+ import { normalizePrivateKey } from "./resolve.mjs";
12
+ import { parseCastWalletList } from "./setup-detect.mjs";
13
+
14
+ /**
15
+ * @param {{ castBin?: string, spawn?: typeof spawnSync }} [opts]
16
+ */
17
+ export function listFoundryAccounts(opts = {}) {
18
+ const spawn = opts.spawn ?? spawnSync;
19
+ const castBin = opts.castBin ?? "cast";
20
+ const result = spawn(castBin, ["wallet", "list"], {
21
+ encoding: "utf8",
22
+ shell: false,
23
+ });
24
+ if (result.error || result.status !== 0) {
25
+ return { available: false, accounts: [] };
26
+ }
27
+ return { available: true, accounts: parseCastWalletList(result.stdout ?? "") };
28
+ }
29
+
30
+ /**
31
+ * Resolve 0x address for a Foundry account name.
32
+ * Uses inherit stdio so unlock prompts work when interactive.
33
+ *
34
+ * @param {string} account
35
+ * @param {{ castBin?: string, spawn?: typeof spawnSync, inheritStdio?: boolean }} [opts]
36
+ * @returns {string} checksummed address
37
+ */
38
+ export function resolveFoundryAddress(account, opts = {}) {
39
+ const spawn = opts.spawn ?? spawnSync;
40
+ const castBin = opts.castBin ?? "cast";
41
+ const inherit = opts.inheritStdio !== false;
42
+ const result = spawn(castBin, ["wallet", "address", account], {
43
+ encoding: "utf8",
44
+ shell: false,
45
+ stdio: inherit ? ["inherit", "pipe", "inherit"] : "pipe",
46
+ });
47
+ if (result.error) {
48
+ throw new Error(`cast failed: ${result.error.message}`);
49
+ }
50
+ if (result.status !== 0) {
51
+ const err = (result.stderr || result.stdout || "").trim();
52
+ throw new Error(
53
+ err || `cast wallet address ${account} failed (status ${result.status})`,
54
+ );
55
+ }
56
+ const line = String(result.stdout ?? "")
57
+ .split(/\r?\n/)
58
+ .map((l) => l.trim())
59
+ .find((l) => /^0x[0-9a-fA-F]{40}$/.test(l));
60
+ if (!line) {
61
+ throw new Error(`Could not parse address from cast wallet address ${account}`);
62
+ }
63
+ return getAddress(line);
64
+ }
65
+
66
+ /**
67
+ * Export Foundry account private key to destPath (mode 0o600).
68
+ * Does not print the key. Unlock prompts via inherit stdio when interactive.
69
+ *
70
+ * @param {string} account
71
+ * @param {string} destPath
72
+ * @param {{ castBin?: string, spawn?: typeof spawnSync, inheritStdio?: boolean }} [opts]
73
+ * @returns {{ path: string, address: string }}
74
+ */
75
+ export function exportFoundryKey(account, destPath, opts = {}) {
76
+ const spawn = opts.spawn ?? spawnSync;
77
+ const castBin = opts.castBin ?? "cast";
78
+ const inherit = opts.inheritStdio !== false;
79
+ const result = spawn(castBin, ["wallet", "private-key", account], {
80
+ encoding: "utf8",
81
+ shell: false,
82
+ stdio: inherit ? ["inherit", "pipe", "inherit"] : "pipe",
83
+ });
84
+ if (result.error) {
85
+ throw new Error(`cast failed: ${result.error.message}`);
86
+ }
87
+ if (result.status !== 0) {
88
+ const err = (result.stderr || "").trim();
89
+ throw new Error(
90
+ err || `cast wallet private-key ${account} failed (status ${result.status})`,
91
+ );
92
+ }
93
+ const raw = String(result.stdout ?? "")
94
+ .split(/\r?\n/)
95
+ .map((l) => l.trim())
96
+ .find((l) => l.length > 0);
97
+ if (!raw) {
98
+ throw new Error("cast returned empty private key");
99
+ }
100
+ const key = normalizePrivateKey(raw);
101
+ const address = privateKeyToAccount(key).address;
102
+
103
+ mkdirSync(dirname(destPath), { recursive: true });
104
+ writeFileSync(destPath, `${key}\n`, { mode: 0o600 });
105
+ return { path: destPath, address };
106
+ }
107
+
108
+ /**
109
+ * Default operator key path under clanker home.
110
+ * @param {string} home
111
+ */
112
+ export function defaultOperatorKeyPath(home) {
113
+ return `${home.replace(/\/$/, "")}/op.key`;
114
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Merge / create ~/.openclaw/openclaw.json channels.mqtt from clanker profile.
3
+ */
4
+
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { harnessSnippet } from "./profile.mjs";
9
+
10
+ /** Current published OpenClaw plugin pins for closed-beta invite. */
11
+ export const OPENCLAW_PLUGIN_PIN = "2026.7.29";
12
+
13
+ /**
14
+ * @param {string} [openclawHome]
15
+ */
16
+ export function openclawConfigPath(openclawHome = join(homedir(), ".openclaw")) {
17
+ return join(openclawHome, "openclaw.json");
18
+ }
19
+
20
+ /**
21
+ * Ensure mqtt + mqtt-tools are in plugins.enabled.
22
+ * @param {object} cfg
23
+ */
24
+ export function ensureMqttPlugins(cfg) {
25
+ const out = { ...cfg };
26
+ const plugins = { ...(out.plugins ?? {}) };
27
+ const enabled = Array.isArray(plugins.enabled) ? [...plugins.enabled] : [];
28
+ for (const id of ["mqtt", "mqtt-tools"]) {
29
+ if (!enabled.includes(id)) enabled.push(id);
30
+ }
31
+ plugins.enabled = enabled;
32
+ out.plugins = plugins;
33
+ return out;
34
+ }
35
+
36
+ /**
37
+ * Wire channels.mqtt into openclaw.json.
38
+ *
39
+ * @param {{
40
+ * botId: string,
41
+ * operatorId: string,
42
+ * network: { rpc: string, registry: string|null, brokerUrl?: string, mqttAuthServiceUrl?: string },
43
+ * keyPath?: string|null,
44
+ * openclawHome?: string,
45
+ * }} opts
46
+ */
47
+ export function wireOpenClawMqtt(opts) {
48
+ const openclawHome = opts.openclawHome ?? join(homedir(), ".openclaw");
49
+ const cfgPath = openclawConfigPath(openclawHome);
50
+ mkdirSync(openclawHome, { recursive: true });
51
+
52
+ const mqtt = harnessSnippet({
53
+ botId: opts.botId,
54
+ operatorId: opts.operatorId,
55
+ network: opts.network,
56
+ });
57
+ if (!mqtt.registryAddress) {
58
+ mqtt.registryAddress = "0x0000000000000000000000000000000000000000";
59
+ }
60
+ if (opts.keyPath) {
61
+ mqtt.privateKeyFile = opts.keyPath;
62
+ }
63
+
64
+ let created = false;
65
+ let cfg;
66
+ if (!existsSync(cfgPath)) {
67
+ created = true;
68
+ cfg = {
69
+ plugins: { enabled: ["mqtt", "mqtt-tools"] },
70
+ channels: { mqtt },
71
+ };
72
+ } else {
73
+ cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
74
+ cfg = ensureMqttPlugins(cfg);
75
+ const channels = { ...(cfg.channels ?? {}) };
76
+ const prev = channels.mqtt && typeof channels.mqtt === "object" ? channels.mqtt : {};
77
+ channels.mqtt = { ...prev, ...mqtt };
78
+ cfg.channels = channels;
79
+ }
80
+
81
+ writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
82
+ return {
83
+ created,
84
+ path: cfgPath,
85
+ channelsMqtt: cfg.channels.mqtt,
86
+ pluginPin: OPENCLAW_PLUGIN_PIN,
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Human checklist lines after wiring.
92
+ * @param {{ botId: string, channelsMqtt: object, pluginPin: string }} opts
93
+ */
94
+ export function hubConnectChecklist(opts) {
95
+ const pin = opts.pluginPin ?? OPENCLAW_PLUGIN_PIN;
96
+ return [
97
+ "Your bot login is already wired — install plugins and restart OpenClaw.",
98
+ `openclaw plugins install @clanker-chain/mqtt-channel-plugin@${pin}`,
99
+ `openclaw plugins install @clanker-chain/mqtt-tools@${pin}`,
100
+ "Enable plugin ids mqtt + mqtt-tools (already set in openclaw.json if we wired it)",
101
+ `CONNECT broker: ${opts.channelsMqtt?.brokerUrl ?? "mqtts://mqtt.clanker-chain.com:8883"}`,
102
+ `Ask hub operator to allow bot_id "${opts.botId}" in france dmPolicy / allowFrom`,
103
+ "DM openclaw.france.prod-1 to smoke the mesh",
104
+ ];
105
+ }
106
+
107
+ /**
108
+ * Plain-language card: what the bot uses vs operator mint key.
109
+ * @param {{
110
+ * botId: string,
111
+ * operatorId: string,
112
+ * keyPath: string,
113
+ * openclawPath: string,
114
+ * created?: boolean,
115
+ * }} opts
116
+ * @returns {string[]}
117
+ */
118
+ export function botIdentityCard(opts) {
119
+ const openclawState = opts.created
120
+ ? `${opts.openclawPath} (channels.mqtt created)`
121
+ : `${opts.openclawPath} (channels.mqtt updated)`;
122
+ return [
123
+ "Bot identity (what OpenClaw uses to CONNECT):",
124
+ `bot id: ${opts.botId}`,
125
+ `operator: ${opts.operatorId}`,
126
+ `bot key: ${opts.keyPath}`,
127
+ `openclaw: ${openclawState}`,
128
+ "Do not give the bot ~/.clanker/op.key — that key is only for mint/transfer.",
129
+ ];
130
+ }
131
+
132
+ /**
133
+ * Structured bot_identity for --json mint output.
134
+ * @param {{ botId: string, keyPath: string, openclawConfigPath: string }} opts
135
+ */
136
+ export function botIdentityJson(opts) {
137
+ return {
138
+ botId: opts.botId,
139
+ keyPath: opts.keyPath,
140
+ openclawConfigPath: opts.openclawConfigPath,
141
+ warnOperatorKey:
142
+ "Do not give the bot ~/.clanker/op.key — that key is only for mint/transfer.",
143
+ };
144
+ }
145
+
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Consumer-path operator key helpers (generate local op.key).
3
+ * Never logs private keys.
4
+ */
5
+
6
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
9
+ import { defaultOperatorKeyPath } from "./foundry.mjs";
10
+
11
+ export { defaultOperatorKeyPath };
12
+
13
+ /** Coinbase Developer Platform faucet UI (Base Sepolia). */
14
+ export const BASE_SEPOLIA_FAUCET_URL =
15
+ "https://portal.cdp.coinbase.com/products/faucet";
16
+
17
+ /**
18
+ * Generate a new secp256k1 key and write it to destPath (mode 0o600).
19
+ * Does not print the key.
20
+ *
21
+ * @param {string} destPath
22
+ * @param {{ force?: boolean }} [opts]
23
+ * @returns {{ path: string, address: string }}
24
+ */
25
+ export function generateOperatorKeyFile(destPath, opts = {}) {
26
+ if (existsSync(destPath) && !opts.force) {
27
+ throw new Error(
28
+ `Key file already exists: ${destPath} (pass --force to overwrite)`,
29
+ );
30
+ }
31
+ const key = generatePrivateKey();
32
+ const address = privateKeyToAccount(key).address;
33
+ mkdirSync(dirname(destPath), { recursive: true });
34
+ writeFileSync(destPath, `${key}\n`, { mode: 0o600 });
35
+ return { path: destPath, address };
36
+ }
37
+
38
+ /**
39
+ * Plain-language next steps after creating an operator key on Sepolia.
40
+ * @param {{ address: string, label?: string }} opts
41
+ */
42
+ export function consumerFundHints(opts) {
43
+ const mintLabel = opts.label ? ` ${opts.label}` : "";
44
+ return [
45
+ `Your operator address is ${opts.address} — fund it with Base Sepolia ETH: ${BASE_SEPOLIA_FAUCET_URL}`,
46
+ "clanker doctor",
47
+ `clanker operator mint${mintLabel} --yes`,
48
+ "clanker bot mint <bot_label> --yes",
49
+ ];
50
+ }
package/lib/profile.mjs CHANGED
@@ -17,11 +17,14 @@ export const ANVIL_DEFAULT_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266
17
17
  export const SEPOLIA_REGISTRY = "0xD650467f9D7A20f37E55ec23Ca1c711598f97958";
18
18
 
19
19
  /**
20
- * Safe floor for Sepolia getLogs (before registry deploy). Documented so scans
21
- * stay cheap; bump only if you redeploy the registry earlier.
20
+ * Safe floor for Sepolia getLogs (before registry deploy). Use for full historical
21
+ * scans via --from-block; default preset uses the faster public-RPC floor below.
22
22
  */
23
23
  export const SEPOLIA_FROM_BLOCK = 35_000_000n;
24
24
 
25
+ /** Default Sepolia fromBlock for public RPC (faster whoami/bots scans). */
26
+ export const SEPOLIA_FAST_FROM_BLOCK = 46_000_000n;
27
+
25
28
  export const PRESETS = {
26
29
  local: {
27
30
  preset: "local",
@@ -37,7 +40,7 @@ export const PRESETS = {
37
40
  chainRpcUrl: "https://sepolia.base.org",
38
41
  brokerUrl: "mqtts://mqtt.clanker-chain.com:8883",
39
42
  mqttAuthServiceUrl: "https://mqtt-auth.clanker-chain.com",
40
- fromBlock: SEPOLIA_FROM_BLOCK,
43
+ fromBlock: SEPOLIA_FAST_FROM_BLOCK,
41
44
  },
42
45
  };
43
46
 
@@ -63,7 +66,7 @@ export function openclawKeysDir() {
63
66
 
64
67
  /**
65
68
  * @param {string} presetName
66
- * @param {{ force?: boolean, registryAddress?: string|null, home?: string }} [opts]
69
+ * @param {{ force?: boolean, registryAddress?: string|null, home?: string, fromBlock?: bigint|string|number }} [opts]
67
70
  */
68
71
  export function initProfile(presetName, opts = {}) {
69
72
  const name = String(presetName || "").toLowerCase();
@@ -79,13 +82,15 @@ export function initProfile(presetName, opts = {}) {
79
82
  }
80
83
  mkdirSync(home, { recursive: true });
81
84
  const preset = PRESETS[name];
85
+ const fromBlock =
86
+ opts.fromBlock != null ? BigInt(opts.fromBlock) : preset.fromBlock;
82
87
  const config = {
83
88
  preset: preset.preset,
84
89
  registryAddress: opts.registryAddress ?? preset.registryAddress,
85
90
  chainRpcUrl: preset.chainRpcUrl,
86
91
  brokerUrl: preset.brokerUrl,
87
92
  mqttAuthServiceUrl: preset.mqttAuthServiceUrl,
88
- fromBlock: preset.fromBlock.toString(),
93
+ fromBlock: fromBlock.toString(),
89
94
  };
90
95
  if (name === "local" && !config.registryAddress) {
91
96
  // Placeholder until chain deploy; operator must set after forge deploy.
@@ -120,8 +125,9 @@ export function loadOperator(home = clankerHome()) {
120
125
  }
121
126
 
122
127
  /**
123
- * Write operator profile (label + owner + key pointer). Never stores hex keys.
124
- * @param {{ label: string, owner: string, key?: { type: 'env'|'keyFile', value?: string } }} op
128
+ * Write operator profile (label + owner + optional key pointer). Never stores hex keys.
129
+ * Omit `key` (or pass null) for a read-only profile; mint/revoke still need a pointer later.
130
+ * @param {{ label: string, owner: string, key?: { type: 'env'|'keyFile', value?: string }|null }} op
125
131
  * @param {string} [home]
126
132
  */
127
133
  export function writeOperator(op, home = clankerHome()) {
@@ -130,8 +136,10 @@ export function writeOperator(op, home = clankerHome()) {
130
136
  const out = {
131
137
  label: op.label,
132
138
  owner: op.owner,
133
- key: op.key ?? { type: "env", value: "OPERATOR_PRIVATE_KEY" },
134
139
  };
140
+ if (op.key != null) {
141
+ out.key = op.key;
142
+ }
135
143
  writeFileSync(path, `${JSON.stringify(out, null, 2)}\n`, { mode: 0o600 });
136
144
  return { path, operator: out };
137
145
  }
@@ -182,7 +190,7 @@ export function resolveNetwork(argv = [], opts = {}) {
182
190
  }
183
191
 
184
192
  if (fromBlock == null) {
185
- fromBlock = isLocalRpc(rpc) ? 0n : SEPOLIA_FROM_BLOCK;
193
+ fromBlock = isLocalRpc(rpc) ? 0n : SEPOLIA_FAST_FROM_BLOCK;
186
194
  }
187
195
 
188
196
  return {
package/lib/resolve.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import { existsSync, readFileSync } from "node:fs";
6
+ import { getAddress } from "viem";
6
7
  import { privateKeyToAccount } from "viem/accounts";
7
8
  import {
8
9
  ANVIL_DEFAULT_PRIVATE_KEY,
@@ -174,8 +175,63 @@ export function resolveForRead(argv = [], opts = {}) {
174
175
  if (!network.registry || !/^0x[0-9a-fA-F]{40}$/.test(network.registry)) {
175
176
  throw new Error(
176
177
  "REGISTRY_ADDRESS or --registry is required (0x + 40 hex). " +
177
- "Run `clanker init --preset sepolia` or set registry after `clanker chain deploy`.",
178
+ "Run `clanker init --preset sepolia` or `clanker setup`, or set registry after deploy.",
178
179
  );
179
180
  }
180
181
  return network;
181
182
  }
183
+
184
+ /**
185
+ * Resolve address for read-only commands.
186
+ * Order: --address > signing key > operator.json owner > error (point at setup).
187
+ *
188
+ * @param {string[]} argv
189
+ * @param {{ home?: string, env?: NodeJS.ProcessEnv }} [opts]
190
+ * @returns {{ address: string, source: string, network: object }}
191
+ */
192
+ export function resolveReadIdentity(argv = [], opts = {}) {
193
+ const network = resolveForRead(argv, opts);
194
+ const home = opts.home ?? network.home;
195
+
196
+ let addressFlag = null;
197
+ for (let i = 0; i < argv.length; i += 1) {
198
+ if (argv[i] === "--address" && argv[i + 1] && !argv[i + 1].startsWith("--")) {
199
+ addressFlag = argv[++i];
200
+ }
201
+ }
202
+ if (addressFlag) {
203
+ return {
204
+ address: getAddress(addressFlag),
205
+ source: "--address",
206
+ network,
207
+ };
208
+ }
209
+
210
+ try {
211
+ const resolved = resolveOperatorKey(argv, { ...opts, home });
212
+ return {
213
+ address: resolved.address,
214
+ source: resolved.source,
215
+ network: resolved.network,
216
+ };
217
+ } catch (err) {
218
+ const msg = err?.message ?? String(err);
219
+ if (!/private key required|Anvil account #0|Key file not found|Profile keyFile/i.test(msg)) {
220
+ throw err;
221
+ }
222
+ }
223
+
224
+ const operator = loadOperator(home);
225
+ if (operator?.owner && /^0x[0-9a-fA-F]{40}$/.test(operator.owner)) {
226
+ return {
227
+ address: getAddress(operator.owner),
228
+ source: "profile owner",
229
+ network,
230
+ };
231
+ }
232
+
233
+ throw new Error(
234
+ "No operator identity configured. Run `clanker setup`, pass --address 0x…, " +
235
+ "or set a signing key (OPERATOR_PRIVATE_KEY / --key-file / operator.json key pointer).",
236
+ );
237
+ }