@clanker-chain/clanker-cli 2026.9.10 → 2026.9.12

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/bin/clanker.mjs CHANGED
@@ -26,6 +26,11 @@ import {
26
26
  import { resolveForRead, resolveOperatorKey, resolveReadIdentity } from "../lib/resolve.mjs";
27
27
  import { runSetup } from "../lib/setup.mjs";
28
28
  import { runDoctor } from "../lib/doctor.mjs";
29
+ import { runFund } from "../lib/fund.mjs";
30
+ import {
31
+ assessMintBudget,
32
+ formatEthTrim,
33
+ } from "../lib/mint-budget.mjs";
29
34
  import {
30
35
  botIdentityCard,
31
36
  botIdentityJson,
@@ -166,6 +171,7 @@ function usage() {
166
171
 
167
172
  Usage:
168
173
  clanker setup [--preset sepolia|local] [--operator <label>] [--address 0x…] [--key-file path] [--force]
174
+ clanker fund [--no-open] [--timeout ms] [--json]
169
175
  clanker doctor [--json]
170
176
  clanker init --preset sepolia|local [--force]
171
177
  clanker whoami [--json] [--operator <label>] [--address 0x…] [--with-bots]
@@ -191,12 +197,12 @@ Profile:
191
197
  ~/.clanker/operator.json label + owner + optional key pointer (never raw hex)
192
198
  ~/.clanker/keys/ bot keys (also written to ~/.openclaw/keys/)
193
199
 
194
- Humans: \`clanker setup\` then \`clanker doctor\` / \`whoami\`.
200
+ Humans: \`clanker setup\` then \`clanker fund\` / \`doctor\` / \`whoami\`.
195
201
  Pairing (Policy): both operators run \`clanker pair add\` before DMs deliver on the hub.
196
202
  Mutates print a plan and confirm unless --yes or --json.
197
203
  whoami is fast by default; pass --with-bots to enrich child bots (or use \`clanker bots\`).
198
204
 
199
- See docs/operator-cli.md and docs/trust-model.md.
205
+ See docs/operator-cli.md, docs/prerequisites.md, and docs/trust-model.md.
200
206
  `);
201
207
  }
202
208
 
@@ -410,6 +416,7 @@ async function main() {
410
416
  because: "setup could not write a valid local profile",
411
417
  try: [
412
418
  "clanker setup --preset sepolia --operator org.you --address 0x… --yes --force",
419
+ "clanker fund",
413
420
  "clanker doctor",
414
421
  ],
415
422
  });
@@ -417,6 +424,22 @@ async function main() {
417
424
  return;
418
425
  }
419
426
 
427
+ if (cmd === "fund") {
428
+ try {
429
+ const { exitCode } = await runFund(rest);
430
+ process.exit(exitCode);
431
+ } catch (err) {
432
+ exitCliError({
433
+ error: err.message,
434
+ because: "fund needs a Sepolia profile with an owner address",
435
+ try: [
436
+ "clanker setup --preset sepolia --operator org.you --generate-key --yes --force",
437
+ "clanker fund",
438
+ ],
439
+ });
440
+ }
441
+ }
442
+
420
443
  if (cmd === "doctor") {
421
444
  const { exitCode } = await runDoctor(rest);
422
445
  process.exit(exitCode);
@@ -556,8 +579,9 @@ async function main() {
556
579
  process.exit(1);
557
580
  }
558
581
  let planRows;
582
+ let preview;
559
583
  try {
560
- const preview = resolveOperatorKey(flags);
584
+ preview = resolveOperatorKey(flags);
561
585
  planRows = [
562
586
  ["action", "registerOperator"],
563
587
  ["label", label],
@@ -573,10 +597,42 @@ async function main() {
573
597
  try: [
574
598
  "export OPERATOR_PRIVATE_KEY=0x…",
575
599
  "clanker operator mint " + label + " --key-file ~/.clanker/op.key --yes",
600
+ "clanker fund",
576
601
  "clanker doctor",
577
602
  ],
578
603
  });
579
604
  }
605
+ try {
606
+ if (preview.network.registry) {
607
+ const pub = await publicClientFromRpc(preview.network.rpc);
608
+ const budget = await assessMintBudget({
609
+ pub,
610
+ registry: preview.network.registry,
611
+ owner: preview.address,
612
+ rpc: preview.network.rpc,
613
+ operatorLabel: label,
614
+ mode: "operator",
615
+ });
616
+ planRows.push(
617
+ ["fee", `${formatEthTrim(budget.operatorFeeWei)} ETH`],
618
+ ["balance", `${formatEthTrim(budget.balanceWei)} ETH`],
619
+ [
620
+ "needed",
621
+ `${formatEthTrim(budget.neededWei)} ETH (fee + gas cushion)`,
622
+ ],
623
+ );
624
+ if (!budget.funded) {
625
+ exitCliError({
626
+ error: `insufficient ETH: have ${formatEthTrim(budget.balanceWei)}, need ${formatEthTrim(budget.neededWei)}`,
627
+ because: "mint sends the exact registry fee plus gas",
628
+ try: ["clanker fund", "clanker doctor"],
629
+ });
630
+ }
631
+ }
632
+ } catch (err) {
633
+ // Non-fatal if RPC read fails before confirm; mint will still fail clearly.
634
+ planRows.push(["balance", `(could not check: ${err.message ?? err})`]);
635
+ }
580
636
  const ok = await confirmPlan(flags, planRows, `Mint operator ${label}?`);
581
637
  if (!ok) process.exit(0);
582
638
  const result = await chainMintOperator(label, flags);
@@ -677,10 +733,11 @@ async function main() {
677
733
  process.exit(1);
678
734
  }
679
735
  let operatorLabel = operatorArg ?? flagValue(flags, "--operator") ?? null;
736
+ let preview = null;
680
737
  if (!operatorLabel) {
681
738
  try {
682
- const { address, network } = resolveOperatorKey(flags);
683
- const inferred = await resolveOperatorLabel(flags, address, network);
739
+ preview = resolveOperatorKey(flags);
740
+ const inferred = await resolveOperatorLabel(flags, preview.address, preview.network);
684
741
  operatorLabel = inferred.label;
685
742
  } catch (err) {
686
743
  exitCliError({
@@ -693,13 +750,60 @@ async function main() {
693
750
  });
694
751
  }
695
752
  }
753
+ if (!preview) {
754
+ try {
755
+ preview = resolveOperatorKey(flags);
756
+ } catch (err) {
757
+ exitCliError({
758
+ error: err.message,
759
+ because: "bot mint needs a signing key on this network",
760
+ try: [
761
+ `clanker bot mint ${botLabel} ${operatorLabel} --key-file ~/.clanker/op.key --yes`,
762
+ "clanker fund",
763
+ ],
764
+ });
765
+ }
766
+ }
767
+ const planRows = [
768
+ ["action", "registerBot"],
769
+ ["bot", botLabel],
770
+ ["operator", operatorLabel],
771
+ ["owner", preview.address],
772
+ ["registry", preview.network.registry ?? "(none)"],
773
+ ];
774
+ try {
775
+ if (preview.network.registry) {
776
+ const pub = await publicClientFromRpc(preview.network.rpc);
777
+ const budget = await assessMintBudget({
778
+ pub,
779
+ registry: preview.network.registry,
780
+ owner: preview.address,
781
+ rpc: preview.network.rpc,
782
+ operatorLabel,
783
+ mode: "bot",
784
+ });
785
+ planRows.push(
786
+ ["fee", `${formatEthTrim(budget.botFeeWei)} ETH`],
787
+ ["balance", `${formatEthTrim(budget.balanceWei)} ETH`],
788
+ [
789
+ "needed",
790
+ `${formatEthTrim(budget.neededWei)} ETH (fee + gas cushion)`,
791
+ ],
792
+ );
793
+ if (!budget.funded) {
794
+ exitCliError({
795
+ error: `insufficient ETH: have ${formatEthTrim(budget.balanceWei)}, need ${formatEthTrim(budget.neededWei)}`,
796
+ because: "mint sends the exact registry fee plus gas",
797
+ try: ["clanker fund", "clanker doctor"],
798
+ });
799
+ }
800
+ }
801
+ } catch (err) {
802
+ planRows.push(["balance", `(could not check: ${err.message ?? err})`]);
803
+ }
696
804
  const ok = await confirmPlan(
697
805
  flags,
698
- [
699
- ["action", "registerBot"],
700
- ["bot", botLabel],
701
- ["operator", operatorLabel],
702
- ],
806
+ planRows,
703
807
  `Mint bot ${botLabel} under ${operatorLabel}?`,
704
808
  );
705
809
  if (!ok) process.exit(0);
@@ -19,6 +19,11 @@ export const clankerIdentityAbi = [
19
19
  "name": "_feeRecipient",
20
20
  "type": "address",
21
21
  "internalType": "address"
22
+ },
23
+ {
24
+ "name": "_priorRegistry",
25
+ "type": "address",
26
+ "internalType": "address"
22
27
  }
23
28
  ],
24
29
  "stateMutability": "nonpayable"
@@ -176,6 +181,19 @@ export const clankerIdentityAbi = [
176
181
  ],
177
182
  "stateMutability": "view"
178
183
  },
184
+ {
185
+ "type": "function",
186
+ "name": "priorRegistry",
187
+ "inputs": [],
188
+ "outputs": [
189
+ {
190
+ "name": "",
191
+ "type": "address",
192
+ "internalType": "address"
193
+ }
194
+ ],
195
+ "stateMutability": "view"
196
+ },
179
197
  {
180
198
  "type": "function",
181
199
  "name": "proposeOperatorTransfer",
package/lib/doctor.mjs CHANGED
@@ -11,10 +11,25 @@ import {
11
11
  clankerHome,
12
12
  } from "./profile.mjs";
13
13
  import { detectSetupHints, formatSetupDetectTable } from "./setup-detect.mjs";
14
+ import { publicClientFromRpc } from "./identity-query.mjs";
15
+ import {
16
+ assessMintBudget,
17
+ budgetToJson,
18
+ formatBudgetSummary,
19
+ } from "./mint-budget.mjs";
14
20
  import { c, nextHint } from "./ui.mjs";
15
21
 
16
22
  /**
17
- * @param {{ home?: string, env?: NodeJS.ProcessEnv, openclawDir?: string, castBin?: string, spawn?: Function, fetchImpl?: typeof fetch }} [opts]
23
+ * @param {{
24
+ * home?: string,
25
+ * env?: NodeJS.ProcessEnv,
26
+ * openclawDir?: string,
27
+ * castBin?: string,
28
+ * spawn?: Function,
29
+ * fetchImpl?: typeof fetch,
30
+ * publicClient?: { readContract: Function, getBalance: Function },
31
+ * skipBalance?: boolean,
32
+ * }} [opts]
18
33
  */
19
34
  export async function runDoctorChecks(opts = {}) {
20
35
  const env = opts.env ?? process.env;
@@ -103,6 +118,59 @@ export async function runDoctorChecks(opts = {}) {
103
118
  : "Foundry cast not on PATH (optional)",
104
119
  });
105
120
 
121
+ /** @type {object|null} */
122
+ let budget = null;
123
+
124
+ const canCheckBalance =
125
+ !opts.skipBalance &&
126
+ hasOwner &&
127
+ registry &&
128
+ /^0x[0-9a-fA-F]{40}$/.test(registry) &&
129
+ rpc;
130
+
131
+ if (canCheckBalance) {
132
+ try {
133
+ if (isLocalRpc(rpc)) {
134
+ budget = await assessMintBudget({
135
+ pub: { readContract: async () => 0n, getBalance: async () => 0n },
136
+ registry,
137
+ owner: getAddress(operator.owner),
138
+ rpc,
139
+ operatorLabel: operator.label,
140
+ });
141
+ checks.push({
142
+ id: "balance",
143
+ ok: true,
144
+ level: "pass",
145
+ message: formatBudgetSummary(budget),
146
+ });
147
+ } else {
148
+ const pub =
149
+ opts.publicClient ?? (await publicClientFromRpc(rpc));
150
+ budget = await assessMintBudget({
151
+ pub,
152
+ registry,
153
+ owner: getAddress(operator.owner),
154
+ rpc,
155
+ operatorLabel: operator.label,
156
+ });
157
+ checks.push({
158
+ id: "balance",
159
+ ok: budget.funded,
160
+ level: budget.funded ? "pass" : "fail",
161
+ message: formatBudgetSummary(budget),
162
+ });
163
+ }
164
+ } catch (err) {
165
+ checks.push({
166
+ id: "balance",
167
+ ok: false,
168
+ level: "warn",
169
+ message: `balance check skipped: ${err.message ?? err}`,
170
+ });
171
+ }
172
+ }
173
+
106
174
  const authUrl = config?.mqttAuthServiceUrl;
107
175
  if (authUrl && typeof fetchImpl === "function") {
108
176
  const healthUrl = `${String(authUrl).replace(/\/$/, "")}/health`;
@@ -146,8 +214,12 @@ export async function runDoctorChecks(opts = {}) {
146
214
  const readyWhoami = checks
147
215
  .filter((ch) => ch.id === "config" || ch.id === "registry" || ch.id === "operator")
148
216
  .every((ch) => ch.ok);
149
- const readyMint = readyWhoami && hasKey &&
150
- !checks.some((ch) => ch.id === "anvil_public" && !ch.ok);
217
+ const balanceOk = !checks.some((ch) => ch.id === "balance" && ch.level === "fail");
218
+ const readyMint =
219
+ readyWhoami &&
220
+ hasKey &&
221
+ !checks.some((ch) => ch.id === "anvil_public" && !ch.ok) &&
222
+ balanceOk;
151
223
 
152
224
  return {
153
225
  home,
@@ -156,17 +228,19 @@ export async function runDoctorChecks(opts = {}) {
156
228
  readyWhoami,
157
229
  readyMint,
158
230
  ok: readyWhoami,
231
+ budget,
159
232
  };
160
233
  }
161
234
 
162
235
  /**
163
236
  * @param {string[]} argv
164
- * @param {{ home?: string, env?: NodeJS.ProcessEnv }} [opts]
237
+ * @param {{ home?: string, env?: NodeJS.ProcessEnv, publicClient?: object, skipBalance?: boolean }} [opts]
165
238
  * @returns {Promise<{ exitCode: number, report: object }>}
166
239
  */
167
240
  export async function runDoctor(argv = [], opts = {}) {
168
241
  const json = argv.includes("--json");
169
242
  const report = await runDoctorChecks(opts);
243
+ const budgetJson = report.budget ? budgetToJson(report.budget) : null;
170
244
 
171
245
  if (json) {
172
246
  console.log(
@@ -177,6 +251,15 @@ export async function runDoctor(argv = [], opts = {}) {
177
251
  readyMint: report.readyMint,
178
252
  home: report.home,
179
253
  checks: report.checks,
254
+ ...(budgetJson
255
+ ? {
256
+ balanceWei: budgetJson.balanceWei,
257
+ neededWei: budgetJson.neededWei,
258
+ shortfallWei: budgetJson.shortfallWei,
259
+ claimsNeeded: budgetJson.claimsNeeded,
260
+ budget: budgetJson,
261
+ }
262
+ : {}),
180
263
  },
181
264
  null,
182
265
  2,
@@ -207,12 +290,16 @@ export async function runDoctor(argv = [], opts = {}) {
207
290
  }
208
291
  if (report.readyMint) {
209
292
  console.log(c.green("Ready for: clanker operator mint / bot mint"));
293
+ } else if (report.budget && !report.budget.funded && !report.budget.local) {
294
+ console.log(c.dim("Mint: fund the operator address first (clanker fund)"));
210
295
  } else {
211
296
  console.log(c.dim("Mint/revoke: need signing key (and non-Anvil owner on public RPC)"));
212
297
  }
213
298
 
214
299
  if (!report.readyWhoami) {
215
300
  nextHint(["clanker setup"]);
301
+ } else if (report.budget && !report.budget.funded && !report.budget.local) {
302
+ nextHint(["clanker fund", "clanker doctor"]);
216
303
  } else if (!report.readyMint) {
217
304
  nextHint([
218
305
  "clanker whoami",
package/lib/fund.mjs ADDED
@@ -0,0 +1,250 @@
1
+ /**
2
+ * `clanker fund` — print mint ETH budget, open faucet, poll until funded.
3
+ */
4
+
5
+ import { spawn } from "node:child_process";
6
+ import { getAddress } from "viem";
7
+ import { clankerHome, loadConfig, loadOperator, isLocalRpc } from "./profile.mjs";
8
+ import { publicClientFromRpc } from "./identity-query.mjs";
9
+ import {
10
+ assessMintBudget,
11
+ budgetToJson,
12
+ formatBudgetSummary,
13
+ formatEthTrim,
14
+ } from "./mint-budget.mjs";
15
+ import { c, nextHint } from "./ui.mjs";
16
+
17
+ export const DEFAULT_FUND_TIMEOUT_MS = 5 * 60 * 1000;
18
+ export const DEFAULT_FUND_POLL_MS = 5000;
19
+
20
+ /**
21
+ * Best-effort open URL in the default browser. Never throws.
22
+ * @param {string} url
23
+ * @param {{ platform?: string, spawnImpl?: typeof spawn }} [opts]
24
+ * @returns {Promise<boolean>}
25
+ */
26
+ export async function openUrl(url, opts = {}) {
27
+ const platform = opts.platform ?? process.platform;
28
+ const spawnImpl = opts.spawnImpl ?? spawn;
29
+ try {
30
+ if (platform === "darwin") {
31
+ spawnImpl("open", [url], { stdio: "ignore", detached: true }).unref();
32
+ return true;
33
+ }
34
+ if (platform === "win32") {
35
+ spawnImpl("cmd", ["/c", "start", "", url], {
36
+ stdio: "ignore",
37
+ detached: true,
38
+ }).unref();
39
+ return true;
40
+ }
41
+ spawnImpl("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * @param {string[]} argv
50
+ */
51
+ export function parseFundFlags(argv) {
52
+ let timeoutMs = DEFAULT_FUND_TIMEOUT_MS;
53
+ let pollMs = DEFAULT_FUND_POLL_MS;
54
+ let noOpen = false;
55
+ let json = false;
56
+ for (let i = 0; i < argv.length; i += 1) {
57
+ const a = argv[i];
58
+ if (a === "--no-open") noOpen = true;
59
+ else if (a === "--json") json = true;
60
+ else if (a === "--timeout" && argv[i + 1]) {
61
+ timeoutMs = Number(argv[++i]);
62
+ } else if (a === "--poll" && argv[i + 1]) {
63
+ pollMs = Number(argv[++i]);
64
+ }
65
+ }
66
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
67
+ throw new Error("--timeout must be a non-negative number (ms)");
68
+ }
69
+ if (!Number.isFinite(pollMs) || pollMs < 100) {
70
+ throw new Error("--poll must be >= 100 (ms)");
71
+ }
72
+ return { timeoutMs, pollMs, noOpen, json };
73
+ }
74
+
75
+ /**
76
+ * @param {string[]} argv
77
+ * @param {{
78
+ * home?: string,
79
+ * env?: NodeJS.ProcessEnv,
80
+ * publicClient?: { readContract: Function, getBalance: Function },
81
+ * openUrlImpl?: typeof openUrl,
82
+ * sleep?: (ms: number) => Promise<void>,
83
+ * now?: () => number,
84
+ * }} [opts]
85
+ */
86
+ export async function runFund(argv = [], opts = {}) {
87
+ const flags = parseFundFlags(argv);
88
+ const env = opts.env ?? process.env;
89
+ const home = opts.home ?? clankerHome(env);
90
+ const config = loadConfig(home);
91
+ const operator = loadOperator(home);
92
+ const sleep =
93
+ opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
94
+ const now = opts.now ?? (() => Date.now());
95
+ const openUrlImpl = opts.openUrlImpl ?? openUrl;
96
+
97
+ if (!config) {
98
+ throw new Error("config.json missing — run clanker setup");
99
+ }
100
+ if (!operator?.owner || !/^0x[0-9a-fA-F]{40}$/.test(operator.owner)) {
101
+ throw new Error("operator.json incomplete — run clanker setup");
102
+ }
103
+
104
+ const rpc = config.chainRpcUrl ?? "";
105
+ const registry = config.registryAddress;
106
+ const owner = getAddress(operator.owner);
107
+
108
+ if (isLocalRpc(rpc)) {
109
+ const payload = {
110
+ ok: true,
111
+ funded: true,
112
+ local: true,
113
+ owner,
114
+ message: "Anvil is prefunded — no faucet needed",
115
+ };
116
+ if (flags.json) {
117
+ console.log(JSON.stringify(payload, null, 2));
118
+ } else {
119
+ console.log(c.bold("clanker fund"));
120
+ console.log("");
121
+ console.log(c.green("Anvil is prefunded — no faucet needed."));
122
+ nextHint(["clanker doctor", "clanker whoami"]);
123
+ }
124
+ return { exitCode: 0, ...payload };
125
+ }
126
+
127
+ if (!registry || !/^0x[0-9a-fA-F]{40}$/.test(registry)) {
128
+ throw new Error(
129
+ "registry missing — run clanker setup --preset sepolia (or set after local deploy)",
130
+ );
131
+ }
132
+
133
+ const pub = opts.publicClient ?? (await publicClientFromRpc(rpc));
134
+
135
+ const assess = () =>
136
+ assessMintBudget({
137
+ pub,
138
+ registry,
139
+ owner,
140
+ rpc,
141
+ operatorLabel: operator.label,
142
+ });
143
+
144
+ let budget = await assess();
145
+
146
+ if (flags.json && budget.funded) {
147
+ console.log(
148
+ JSON.stringify({ ok: true, ...budgetToJson(budget) }, null, 2),
149
+ );
150
+ return { exitCode: 0, funded: true, budget };
151
+ }
152
+
153
+ if (!flags.json) {
154
+ console.log(c.bold("clanker fund"));
155
+ console.log("");
156
+ console.log(`owner: ${owner}`);
157
+ console.log(`label: ${operator.label ?? "(none)"}`);
158
+ console.log(`registry: ${registry}`);
159
+ console.log(`budget: ${formatBudgetSummary(budget)}`);
160
+ console.log(`faucet: ${budget.faucetUrl}`);
161
+ console.log(`backup: ${budget.backupFaucetUrl}`);
162
+ console.log("");
163
+ }
164
+
165
+ if (budget.funded) {
166
+ if (!flags.json) {
167
+ console.log(c.green("Already funded for remaining mint fees + gas."));
168
+ nextHint([
169
+ "clanker doctor",
170
+ `clanker operator mint ${operator.label ?? "<label>"} --yes`,
171
+ "clanker bot mint <bot_label> --yes",
172
+ ]);
173
+ }
174
+ return { exitCode: 0, funded: true, budget };
175
+ }
176
+
177
+ if (!flags.noOpen) {
178
+ const opened = await openUrlImpl(budget.faucetUrl);
179
+ if (!flags.json) {
180
+ if (opened) {
181
+ console.log(c.dim(`Opened faucet in browser (paste ${owner}).`));
182
+ } else {
183
+ console.log(
184
+ c.dim(`Could not open browser — visit ${budget.faucetUrl}`),
185
+ );
186
+ }
187
+ console.log(
188
+ c.dim(
189
+ `Select Base Sepolia → ETH. CDP ~${budget.dripEth} ETH/claim; claim ~${budget.claimsNeeded} time(s).`,
190
+ ),
191
+ );
192
+ console.log(c.dim("Waiting for balance…"));
193
+ console.log("");
194
+ }
195
+ } else if (!flags.json) {
196
+ console.log(c.dim(`Open faucet: ${budget.faucetUrl}`));
197
+ console.log(c.dim(`Paste address: ${owner}`));
198
+ console.log(c.dim("Waiting for balance…"));
199
+ console.log("");
200
+ }
201
+
202
+ const deadline = now() + flags.timeoutMs;
203
+ while (now() < deadline) {
204
+ await sleep(flags.pollMs);
205
+ budget = await assess();
206
+ if (!flags.json) {
207
+ console.log(
208
+ c.dim(
209
+ `have ${formatEthTrim(budget.balanceWei)} / need ${formatEthTrim(budget.neededWei)} ETH`,
210
+ ),
211
+ );
212
+ }
213
+ if (budget.funded) {
214
+ if (flags.json) {
215
+ console.log(
216
+ JSON.stringify({ ok: true, ...budgetToJson(budget) }, null, 2),
217
+ );
218
+ } else {
219
+ console.log("");
220
+ console.log(c.green("Funded. Ready to mint."));
221
+ nextHint([
222
+ "clanker doctor",
223
+ `clanker operator mint ${operator.label ?? "<label>"} --yes`,
224
+ "clanker bot mint <bot_label> --yes",
225
+ ]);
226
+ }
227
+ return { exitCode: 0, funded: true, budget };
228
+ }
229
+ }
230
+
231
+ if (flags.json) {
232
+ console.log(
233
+ JSON.stringify(
234
+ { ok: false, funded: false, timedOut: true, ...budgetToJson(budget) },
235
+ null,
236
+ 2,
237
+ ),
238
+ );
239
+ } else {
240
+ console.log("");
241
+ console.log(c.red("Timed out waiting for funds."));
242
+ console.log(formatBudgetSummary(budget));
243
+ nextHint([
244
+ `Open ${budget.faucetUrl} and claim again`,
245
+ `Backup: ${budget.backupFaucetUrl}`,
246
+ "clanker fund",
247
+ ]);
248
+ }
249
+ return { exitCode: 1, funded: false, budget, timedOut: true };
250
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Mint ETH budget helpers: live registry fees + balance + CDP faucet claim estimate.
3
+ */
4
+
5
+ import { formatEther, parseEther } from "viem";
6
+ import { clankerIdentityAbi } from "./clanker-identity-abi.mjs";
7
+ import { readOperator } from "./identity-query.mjs";
8
+ import { isLocalRpc } from "./profile.mjs";
9
+ import {
10
+ ALCHEMY_BASE_SEPOLIA_FAUCET_URL,
11
+ BASE_SEPOLIA_FAUCET_URL,
12
+ CDP_FAUCET_DRIP_ETH,
13
+ } from "./operator-key.mjs";
14
+
15
+ /** Fixed gas cushion for one operator mint + one bot mint (no live estimateGas). */
16
+ export const MINT_GAS_RESERVE_WEI = parseEther("0.00005");
17
+
18
+ /** Documented CDP Base Sepolia ETH drip per claim (wei). */
19
+ export const CDP_FAUCET_DRIP_WEI = parseEther(CDP_FAUCET_DRIP_ETH);
20
+
21
+ /**
22
+ * @param {bigint} wei
23
+ * @returns {string}
24
+ */
25
+ export function formatEthTrim(wei) {
26
+ const s = formatEther(wei);
27
+ if (!s.includes(".")) return s;
28
+ const trimmed = s.replace(/\.?0+$/, "");
29
+ return trimmed === "" ? "0" : trimmed;
30
+ }
31
+
32
+ /**
33
+ * Ceiling of shortfall / drip (integer claims).
34
+ * @param {bigint} shortfallWei
35
+ * @param {bigint} [dripWei]
36
+ * @returns {number}
37
+ */
38
+ export function claimsNeeded(shortfallWei, dripWei = CDP_FAUCET_DRIP_WEI) {
39
+ if (shortfallWei <= 0n) return 0;
40
+ if (dripWei <= 0n) return 0;
41
+ return Number((shortfallWei + dripWei - 1n) / dripWei);
42
+ }
43
+
44
+ /**
45
+ * Pure budget math given fees, balance, and which fees remain.
46
+ *
47
+ * @param {{
48
+ * operatorFee: bigint,
49
+ * botFee: bigint,
50
+ * balance: bigint,
51
+ * needOperatorFee: boolean,
52
+ * needBotFee: boolean,
53
+ * gasReserve?: bigint,
54
+ * local?: boolean,
55
+ * }} opts
56
+ */
57
+ export function computeMintBudget(opts) {
58
+ const gasReserve = opts.gasReserve ?? MINT_GAS_RESERVE_WEI;
59
+ if (opts.local) {
60
+ return {
61
+ local: true,
62
+ funded: true,
63
+ balanceWei: opts.balance,
64
+ operatorFeeWei: opts.operatorFee,
65
+ botFeeWei: opts.botFee,
66
+ gasReserveWei: gasReserve,
67
+ feesWei: 0n,
68
+ neededWei: 0n,
69
+ shortfallWei: 0n,
70
+ claimsNeeded: 0,
71
+ needOperatorFee: false,
72
+ needBotFee: false,
73
+ };
74
+ }
75
+
76
+ let feesWei = 0n;
77
+ if (opts.needOperatorFee) feesWei += opts.operatorFee;
78
+ if (opts.needBotFee) feesWei += opts.botFee;
79
+ // If nothing left to mint, needed is 0 (already funded for first operator+bot path).
80
+ const effectiveNeeded =
81
+ !opts.needOperatorFee && !opts.needBotFee ? 0n : feesWei + gasReserve;
82
+ const shortfallWei =
83
+ opts.balance >= effectiveNeeded ? 0n : effectiveNeeded - opts.balance;
84
+
85
+ return {
86
+ local: false,
87
+ funded: shortfallWei === 0n,
88
+ balanceWei: opts.balance,
89
+ operatorFeeWei: opts.operatorFee,
90
+ botFeeWei: opts.botFee,
91
+ gasReserveWei: gasReserve,
92
+ feesWei,
93
+ neededWei: effectiveNeeded,
94
+ shortfallWei,
95
+ claimsNeeded: claimsNeeded(shortfallWei),
96
+ needOperatorFee: opts.needOperatorFee,
97
+ needBotFee: opts.needBotFee,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Read live fees + balance and decide remaining fees for the onboarding path.
103
+ *
104
+ * @param {{
105
+ * pub: { readContract: Function, getBalance: Function },
106
+ * registry: string,
107
+ * owner: string,
108
+ * rpc: string,
109
+ * operatorLabel?: string|null,
110
+ * mode?: 'onboarding'|'operator'|'bot',
111
+ * gasReserve?: bigint,
112
+ * }} opts
113
+ */
114
+ export async function assessMintBudget(opts) {
115
+ const local = isLocalRpc(opts.rpc);
116
+ if (local) {
117
+ return computeMintBudget({
118
+ operatorFee: 0n,
119
+ botFee: 0n,
120
+ balance: 0n,
121
+ needOperatorFee: false,
122
+ needBotFee: false,
123
+ local: true,
124
+ gasReserve: opts.gasReserve,
125
+ });
126
+ }
127
+
128
+ if (!opts.registry || !/^0x[0-9a-fA-F]{40}$/.test(opts.registry)) {
129
+ throw new Error("registry address required to assess mint budget");
130
+ }
131
+
132
+ const [operatorFee, botFee, balance] = await Promise.all([
133
+ opts.pub.readContract({
134
+ address: opts.registry,
135
+ abi: clankerIdentityAbi,
136
+ functionName: "operatorFee",
137
+ }),
138
+ opts.pub.readContract({
139
+ address: opts.registry,
140
+ abi: clankerIdentityAbi,
141
+ functionName: "botFee",
142
+ }),
143
+ opts.pub.getBalance({ address: opts.owner }),
144
+ ]);
145
+
146
+ const mode = opts.mode ?? "onboarding";
147
+ let needOperatorFee = false;
148
+ let needBotFee = false;
149
+
150
+ if (mode === "operator") {
151
+ needOperatorFee = true;
152
+ needBotFee = false;
153
+ } else if (mode === "bot") {
154
+ needOperatorFee = false;
155
+ needBotFee = true;
156
+ } else {
157
+ // onboarding: operator + first bot unless operator already active
158
+ needBotFee = true;
159
+ needOperatorFee = true;
160
+ if (opts.operatorLabel) {
161
+ try {
162
+ const op = await readOperator(opts.pub, opts.registry, opts.operatorLabel);
163
+ if (op.active) {
164
+ needOperatorFee = false;
165
+ }
166
+ } catch {
167
+ // keep both fees if read fails
168
+ }
169
+ }
170
+ }
171
+
172
+ const budget = computeMintBudget({
173
+ operatorFee: BigInt(operatorFee),
174
+ botFee: BigInt(botFee),
175
+ balance: BigInt(balance),
176
+ needOperatorFee,
177
+ needBotFee,
178
+ gasReserve: opts.gasReserve,
179
+ local: false,
180
+ });
181
+
182
+ return {
183
+ ...budget,
184
+ owner: opts.owner,
185
+ registry: opts.registry,
186
+ operatorLabel: opts.operatorLabel ?? null,
187
+ faucetUrl: BASE_SEPOLIA_FAUCET_URL,
188
+ backupFaucetUrl: ALCHEMY_BASE_SEPOLIA_FAUCET_URL,
189
+ dripEth: CDP_FAUCET_DRIP_ETH,
190
+ };
191
+ }
192
+
193
+ /**
194
+ * Human-readable one-liner for doctor / fund.
195
+ * @param {ReturnType<typeof computeMintBudget> & { dripEth?: string }} budget
196
+ */
197
+ export function formatBudgetSummary(budget) {
198
+ if (budget.local) {
199
+ return "local RPC — Anvil accounts are prefunded";
200
+ }
201
+ if (budget.funded) {
202
+ return `have ${formatEthTrim(budget.balanceWei)} ETH (need ${formatEthTrim(budget.neededWei)} ETH)`;
203
+ }
204
+ const parts = [];
205
+ if (budget.needOperatorFee) parts.push(`operator fee ${formatEthTrim(budget.operatorFeeWei)}`);
206
+ if (budget.needBotFee) parts.push(`bot fee ${formatEthTrim(budget.botFeeWei)}`);
207
+ parts.push(`gas ~${formatEthTrim(budget.gasReserveWei)}`);
208
+ const drip = budget.dripEth ?? CDP_FAUCET_DRIP_ETH;
209
+ return (
210
+ `have ${formatEthTrim(budget.balanceWei)} ETH, need ${formatEthTrim(budget.neededWei)} ETH` +
211
+ ` (${parts.join(" + ")}). CDP ~${drip} ETH/claim → claim ~${budget.claimsNeeded} time(s)`
212
+ );
213
+ }
214
+
215
+ /**
216
+ * JSON-serializable budget fields (wei as strings).
217
+ * @param {object} budget
218
+ */
219
+ export function budgetToJson(budget) {
220
+ return {
221
+ local: Boolean(budget.local),
222
+ funded: Boolean(budget.funded),
223
+ balanceWei: String(budget.balanceWei ?? 0n),
224
+ neededWei: String(budget.neededWei ?? 0n),
225
+ shortfallWei: String(budget.shortfallWei ?? 0n),
226
+ claimsNeeded: budget.claimsNeeded ?? 0,
227
+ operatorFeeWei: String(budget.operatorFeeWei ?? 0n),
228
+ botFeeWei: String(budget.botFeeWei ?? 0n),
229
+ gasReserveWei: String(budget.gasReserveWei ?? 0n),
230
+ needOperatorFee: Boolean(budget.needOperatorFee),
231
+ needBotFee: Boolean(budget.needBotFee),
232
+ owner: budget.owner ?? null,
233
+ faucetUrl: budget.faucetUrl ?? BASE_SEPOLIA_FAUCET_URL,
234
+ backupFaucetUrl: budget.backupFaucetUrl ?? ALCHEMY_BASE_SEPOLIA_FAUCET_URL,
235
+ dripEth: budget.dripEth ?? CDP_FAUCET_DRIP_ETH,
236
+ };
237
+ }
@@ -14,6 +14,13 @@ export { defaultOperatorKeyPath };
14
14
  export const BASE_SEPOLIA_FAUCET_URL =
15
15
  "https://portal.cdp.coinbase.com/products/faucet";
16
16
 
17
+ /** Backup Base Sepolia faucet (amounts not guaranteed). */
18
+ export const ALCHEMY_BASE_SEPOLIA_FAUCET_URL =
19
+ "https://www.alchemy.com/faucets/base-sepolia";
20
+
21
+ /** Documented CDP ETH drip per claim on Base Sepolia. */
22
+ export const CDP_FAUCET_DRIP_ETH = "0.0001";
23
+
17
24
  /**
18
25
  * Generate a new secp256k1 key and write it to destPath (mode 0o600).
19
26
  * Does not print the key.
@@ -42,7 +49,8 @@ export function generateOperatorKeyFile(destPath, opts = {}) {
42
49
  export function consumerFundHints(opts) {
43
50
  const mintLabel = opts.label ? ` ${opts.label}` : "";
44
51
  return [
45
- `Your operator address is ${opts.address} — fund it with Base Sepolia ETH: ${BASE_SEPOLIA_FAUCET_URL}`,
52
+ `Your operator address is ${opts.address} — fund it with Base Sepolia ETH`,
53
+ "clanker fund",
46
54
  "clanker doctor",
47
55
  `clanker operator mint${mintLabel} --yes`,
48
56
  "clanker bot mint <bot_label> --yes",
package/lib/setup.mjs CHANGED
@@ -34,7 +34,6 @@ import {
34
34
  resolveFoundryAddress,
35
35
  } from "./foundry.mjs";
36
36
  import {
37
- BASE_SEPOLIA_FAUCET_URL,
38
37
  consumerFundHints,
39
38
  generateOperatorKeyFile,
40
39
  } from "./operator-key.mjs";
@@ -698,7 +697,7 @@ export async function runSetupInteractive(argv, opts = {}) {
698
697
  nextHint(consumerFundHints({ address, label }));
699
698
  } else if (preset === "sepolia") {
700
699
  nextHint([
701
- `If this address needs test ETH: ${BASE_SEPOLIA_FAUCET_URL}`,
700
+ "clanker fund",
702
701
  "clanker doctor",
703
702
  "clanker whoami",
704
703
  `clanker bot mint <label>`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clanker-chain/clanker-cli",
3
- "version": "2026.9.10",
3
+ "version": "2026.9.12",
4
4
  "description": "CLI for wiring clanker-chain identity and MQTT into OpenClaw and other agentic stacks.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,6 +25,9 @@
25
25
  "dependencies": {
26
26
  "@clack/prompts": "^1.8.0",
27
27
  "picocolors": "^1.1.1",
28
- "viem": "^2.21.0"
28
+ "viem": "^2.56.3"
29
+ },
30
+ "overrides": {
31
+ "ws": "^8.21.0"
29
32
  }
30
33
  }