@clanker-chain/clanker-cli 2026.9.10 → 2026.9.12-1
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 +115 -11
- package/lib/clanker-identity-abi.mjs +18 -0
- package/lib/doctor.mjs +100 -8
- package/lib/fund.mjs +287 -0
- package/lib/mint-budget.mjs +237 -0
- package/lib/operator-key.mjs +11 -4
- package/lib/resolve.mjs +10 -6
- package/lib/setup.mjs +94 -15
- package/package.json +5 -2
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,
|
|
@@ -165,7 +170,8 @@ function usage() {
|
|
|
165
170
|
console.log(`clanker - clanker-chain helper CLI
|
|
166
171
|
|
|
167
172
|
Usage:
|
|
168
|
-
clanker setup [--preset sepolia|local] [--operator <label>] [--address 0x…] [--key-file path] [--force]
|
|
173
|
+
clanker setup [--preset sepolia|local] [--operator <label>] [--address 0x…] [--skip-key] [--bot-key path] [--key-file path] [--force]
|
|
174
|
+
clanker fund [--address 0x…] [--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
|
-
|
|
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
|
-
|
|
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 {{
|
|
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;
|
|
@@ -90,8 +105,8 @@ export async function runDoctorChecks(opts = {}) {
|
|
|
90
105
|
ok: true,
|
|
91
106
|
level: hasKey ? "pass" : "warn",
|
|
92
107
|
message: hasKey
|
|
93
|
-
? "signing key pointer available (mint/
|
|
94
|
-
: "read-only profile — whoami/bots OK; mint
|
|
108
|
+
? "signing key pointer available (mint/pair/rotate OK)"
|
|
109
|
+
: "read-only profile — whoami/bots/fund OK; mint/pair/rotate need a key pointer or the /join owner",
|
|
95
110
|
});
|
|
96
111
|
|
|
97
112
|
checks.push({
|
|
@@ -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
|
|
150
|
-
|
|
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,16 +290,25 @@ 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
|
-
console.log(
|
|
296
|
+
console.log(
|
|
297
|
+
c.dim(
|
|
298
|
+
"Mint/pair/rotate: need a signing key or stay on /join (non-Anvil owner on public RPC)",
|
|
299
|
+
),
|
|
300
|
+
);
|
|
212
301
|
}
|
|
213
302
|
|
|
214
303
|
if (!report.readyWhoami) {
|
|
215
304
|
nextHint(["clanker setup"]);
|
|
305
|
+
} else if (report.budget && !report.budget.funded && !report.budget.local) {
|
|
306
|
+
nextHint(["clanker fund", "clanker doctor"]);
|
|
216
307
|
} else if (!report.readyMint) {
|
|
217
308
|
nextHint([
|
|
218
309
|
"clanker whoami",
|
|
219
|
-
"clanker
|
|
310
|
+
"clanker bots",
|
|
311
|
+
"mint/pair/rotate need --key-file / OPERATOR_PRIVATE_KEY or stay on /join",
|
|
220
312
|
]);
|
|
221
313
|
} else {
|
|
222
314
|
nextHint(["clanker whoami", "clanker bots"]);
|
package/lib/fund.mjs
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
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 {
|
|
8
|
+
clankerHome,
|
|
9
|
+
loadConfig,
|
|
10
|
+
loadOperator,
|
|
11
|
+
isLocalRpc,
|
|
12
|
+
PRESETS,
|
|
13
|
+
} from "./profile.mjs";
|
|
14
|
+
import { publicClientFromRpc } from "./identity-query.mjs";
|
|
15
|
+
import {
|
|
16
|
+
assessMintBudget,
|
|
17
|
+
budgetToJson,
|
|
18
|
+
formatBudgetSummary,
|
|
19
|
+
formatEthTrim,
|
|
20
|
+
} from "./mint-budget.mjs";
|
|
21
|
+
import { c, nextHint } from "./ui.mjs";
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_FUND_TIMEOUT_MS = 5 * 60 * 1000;
|
|
24
|
+
export const DEFAULT_FUND_POLL_MS = 5000;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Best-effort open URL in the default browser. Never throws.
|
|
28
|
+
* @param {string} url
|
|
29
|
+
* @param {{ platform?: string, spawnImpl?: typeof spawn }} [opts]
|
|
30
|
+
* @returns {Promise<boolean>}
|
|
31
|
+
*/
|
|
32
|
+
export async function openUrl(url, opts = {}) {
|
|
33
|
+
const platform = opts.platform ?? process.platform;
|
|
34
|
+
const spawnImpl = opts.spawnImpl ?? spawn;
|
|
35
|
+
try {
|
|
36
|
+
if (platform === "darwin") {
|
|
37
|
+
spawnImpl("open", [url], { stdio: "ignore", detached: true }).unref();
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
if (platform === "win32") {
|
|
41
|
+
spawnImpl("cmd", ["/c", "start", "", url], {
|
|
42
|
+
stdio: "ignore",
|
|
43
|
+
detached: true,
|
|
44
|
+
}).unref();
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
spawnImpl("xdg-open", [url], { stdio: "ignore", detached: true }).unref();
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string[]} argv
|
|
56
|
+
*/
|
|
57
|
+
export function parseFundFlags(argv) {
|
|
58
|
+
let timeoutMs = DEFAULT_FUND_TIMEOUT_MS;
|
|
59
|
+
let pollMs = DEFAULT_FUND_POLL_MS;
|
|
60
|
+
let noOpen = false;
|
|
61
|
+
let json = false;
|
|
62
|
+
let address = null;
|
|
63
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
64
|
+
const a = argv[i];
|
|
65
|
+
if (a === "--no-open") noOpen = true;
|
|
66
|
+
else if (a === "--json") json = true;
|
|
67
|
+
else if (a === "--address" && argv[i + 1]) address = argv[++i];
|
|
68
|
+
else if (a === "--timeout" && argv[i + 1]) {
|
|
69
|
+
timeoutMs = Number(argv[++i]);
|
|
70
|
+
} else if (a === "--poll" && argv[i + 1]) {
|
|
71
|
+
pollMs = Number(argv[++i]);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 0) {
|
|
75
|
+
throw new Error("--timeout must be a non-negative number (ms)");
|
|
76
|
+
}
|
|
77
|
+
if (!Number.isFinite(pollMs) || pollMs < 100) {
|
|
78
|
+
throw new Error("--poll must be >= 100 (ms)");
|
|
79
|
+
}
|
|
80
|
+
if (address && !/^0x[0-9a-fA-F]{40}$/.test(address)) {
|
|
81
|
+
throw new Error("--address must be 0x + 40 hex");
|
|
82
|
+
}
|
|
83
|
+
return { timeoutMs, pollMs, noOpen, json, address };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* @param {{ operator?: { key?: object|null, label?: string|null }|null, canSign?: boolean }} opts
|
|
88
|
+
*/
|
|
89
|
+
function fundNextHints(opts = {}) {
|
|
90
|
+
const canSign = opts.canSign ?? Boolean(opts.operator?.key);
|
|
91
|
+
if (canSign) {
|
|
92
|
+
return [
|
|
93
|
+
"clanker doctor",
|
|
94
|
+
`clanker operator mint ${opts.operator?.label ?? "<label>"} --yes`,
|
|
95
|
+
"clanker bot mint <bot_label> --yes",
|
|
96
|
+
];
|
|
97
|
+
}
|
|
98
|
+
return [
|
|
99
|
+
"clanker doctor",
|
|
100
|
+
"clanker whoami",
|
|
101
|
+
"mint/pair/rotate need a signing key or stay on /join",
|
|
102
|
+
];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* @param {string[]} argv
|
|
107
|
+
* @param {{
|
|
108
|
+
* home?: string,
|
|
109
|
+
* env?: NodeJS.ProcessEnv,
|
|
110
|
+
* publicClient?: { readContract: Function, getBalance: Function },
|
|
111
|
+
* openUrlImpl?: typeof openUrl,
|
|
112
|
+
* sleep?: (ms: number) => Promise<void>,
|
|
113
|
+
* now?: () => number,
|
|
114
|
+
* }} [opts]
|
|
115
|
+
*/
|
|
116
|
+
export async function runFund(argv = [], opts = {}) {
|
|
117
|
+
const flags = parseFundFlags(argv);
|
|
118
|
+
const env = opts.env ?? process.env;
|
|
119
|
+
const home = opts.home ?? clankerHome(env);
|
|
120
|
+
const config = loadConfig(home);
|
|
121
|
+
const operator = loadOperator(home);
|
|
122
|
+
const sleep =
|
|
123
|
+
opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
124
|
+
const now = opts.now ?? (() => Date.now());
|
|
125
|
+
const openUrlImpl = opts.openUrlImpl ?? openUrl;
|
|
126
|
+
|
|
127
|
+
const sepolia = PRESETS.sepolia;
|
|
128
|
+
let owner;
|
|
129
|
+
let rpc;
|
|
130
|
+
let registry;
|
|
131
|
+
let label = operator?.label ?? null;
|
|
132
|
+
|
|
133
|
+
if (flags.address) {
|
|
134
|
+
owner = getAddress(flags.address);
|
|
135
|
+
rpc = config?.chainRpcUrl ?? sepolia.chainRpcUrl;
|
|
136
|
+
registry = config?.registryAddress ?? sepolia.registryAddress;
|
|
137
|
+
} else {
|
|
138
|
+
if (!config) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
"config.json missing — run clanker setup, or pass --address 0x… to fund before setup",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
if (!operator?.owner || !/^0x[0-9a-fA-F]{40}$/.test(operator.owner)) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
"operator.json incomplete — run clanker setup, or pass --address 0x…",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
owner = getAddress(operator.owner);
|
|
149
|
+
rpc = config.chainRpcUrl ?? "";
|
|
150
|
+
registry = config.registryAddress;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (isLocalRpc(rpc)) {
|
|
154
|
+
const payload = {
|
|
155
|
+
ok: true,
|
|
156
|
+
funded: true,
|
|
157
|
+
local: true,
|
|
158
|
+
owner,
|
|
159
|
+
message: "Anvil is prefunded — no faucet needed",
|
|
160
|
+
};
|
|
161
|
+
if (flags.json) {
|
|
162
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
163
|
+
} else {
|
|
164
|
+
console.log(c.bold("clanker fund"));
|
|
165
|
+
console.log("");
|
|
166
|
+
console.log(c.green("Anvil is prefunded — no faucet needed."));
|
|
167
|
+
nextHint(["clanker doctor", "clanker whoami"]);
|
|
168
|
+
}
|
|
169
|
+
return { exitCode: 0, ...payload };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!registry || !/^0x[0-9a-fA-F]{40}$/.test(registry)) {
|
|
173
|
+
throw new Error(
|
|
174
|
+
"registry missing — run clanker setup --preset sepolia (or set after local deploy)",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const pub = opts.publicClient ?? (await publicClientFromRpc(rpc));
|
|
179
|
+
|
|
180
|
+
const assess = () =>
|
|
181
|
+
assessMintBudget({
|
|
182
|
+
pub,
|
|
183
|
+
registry,
|
|
184
|
+
owner,
|
|
185
|
+
rpc,
|
|
186
|
+
operatorLabel: label,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
let budget = await assess();
|
|
190
|
+
|
|
191
|
+
if (flags.json && budget.funded) {
|
|
192
|
+
console.log(
|
|
193
|
+
JSON.stringify({ ok: true, ...budgetToJson(budget) }, null, 2),
|
|
194
|
+
);
|
|
195
|
+
return { exitCode: 0, funded: true, budget };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!flags.json) {
|
|
199
|
+
console.log(c.bold("clanker fund"));
|
|
200
|
+
console.log("");
|
|
201
|
+
console.log(`owner: ${owner}`);
|
|
202
|
+
console.log(`label: ${label ?? "(none)"}`);
|
|
203
|
+
console.log(`registry: ${registry}`);
|
|
204
|
+
console.log(`budget: ${formatBudgetSummary(budget)}`);
|
|
205
|
+
console.log(`faucet: ${budget.faucetUrl}`);
|
|
206
|
+
console.log(`backup: ${budget.backupFaucetUrl}`);
|
|
207
|
+
console.log("");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (budget.funded) {
|
|
211
|
+
if (!flags.json) {
|
|
212
|
+
console.log(c.green("Already funded for remaining mint fees + gas."));
|
|
213
|
+
nextHint(fundNextHints({ operator, canSign: Boolean(operator?.key) }));
|
|
214
|
+
}
|
|
215
|
+
return { exitCode: 0, funded: true, budget };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (!flags.noOpen) {
|
|
219
|
+
const opened = await openUrlImpl(budget.faucetUrl);
|
|
220
|
+
if (!flags.json) {
|
|
221
|
+
if (opened) {
|
|
222
|
+
console.log(c.dim(`Opened faucet in browser (paste ${owner}).`));
|
|
223
|
+
} else {
|
|
224
|
+
console.log(
|
|
225
|
+
c.dim(`Could not open browser — visit ${budget.faucetUrl}`),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
console.log(
|
|
229
|
+
c.dim(
|
|
230
|
+
`Select Base Sepolia → ETH. CDP ~${budget.dripEth} ETH/claim; claim ~${budget.claimsNeeded} time(s).`,
|
|
231
|
+
),
|
|
232
|
+
);
|
|
233
|
+
console.log(c.dim("Waiting for balance…"));
|
|
234
|
+
console.log("");
|
|
235
|
+
}
|
|
236
|
+
} else if (!flags.json) {
|
|
237
|
+
console.log(c.dim(`Open faucet: ${budget.faucetUrl}`));
|
|
238
|
+
console.log(c.dim(`Paste address: ${owner}`));
|
|
239
|
+
console.log(c.dim("Waiting for balance…"));
|
|
240
|
+
console.log("");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const deadline = now() + flags.timeoutMs;
|
|
244
|
+
while (now() < deadline) {
|
|
245
|
+
await sleep(flags.pollMs);
|
|
246
|
+
budget = await assess();
|
|
247
|
+
if (!flags.json) {
|
|
248
|
+
console.log(
|
|
249
|
+
c.dim(
|
|
250
|
+
`have ${formatEthTrim(budget.balanceWei)} / need ${formatEthTrim(budget.neededWei)} ETH`,
|
|
251
|
+
),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (budget.funded) {
|
|
255
|
+
if (flags.json) {
|
|
256
|
+
console.log(
|
|
257
|
+
JSON.stringify({ ok: true, ...budgetToJson(budget) }, null, 2),
|
|
258
|
+
);
|
|
259
|
+
} else {
|
|
260
|
+
console.log("");
|
|
261
|
+
console.log(c.green("Funded. Ready to mint."));
|
|
262
|
+
nextHint(fundNextHints({ operator, canSign: Boolean(operator?.key) }));
|
|
263
|
+
}
|
|
264
|
+
return { exitCode: 0, funded: true, budget };
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (flags.json) {
|
|
269
|
+
console.log(
|
|
270
|
+
JSON.stringify(
|
|
271
|
+
{ ok: false, funded: false, timedOut: true, ...budgetToJson(budget) },
|
|
272
|
+
null,
|
|
273
|
+
2,
|
|
274
|
+
),
|
|
275
|
+
);
|
|
276
|
+
} else {
|
|
277
|
+
console.log("");
|
|
278
|
+
console.log(c.red("Timed out waiting for funds."));
|
|
279
|
+
console.log(formatBudgetSummary(budget));
|
|
280
|
+
nextHint([
|
|
281
|
+
`Open ${budget.faucetUrl} and claim again`,
|
|
282
|
+
`Backup: ${budget.backupFaucetUrl}`,
|
|
283
|
+
flags.address ? `clanker fund --address ${owner}` : "clanker fund",
|
|
284
|
+
]);
|
|
285
|
+
}
|
|
286
|
+
return { exitCode: 1, funded: false, budget, timedOut: true };
|
|
287
|
+
}
|
|
@@ -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
|
+
}
|
package/lib/operator-key.mjs
CHANGED
|
@@ -10,9 +10,15 @@ import { defaultOperatorKeyPath } from "./foundry.mjs";
|
|
|
10
10
|
|
|
11
11
|
export { defaultOperatorKeyPath };
|
|
12
12
|
|
|
13
|
-
/** Coinbase Developer Platform
|
|
14
|
-
export const BASE_SEPOLIA_FAUCET_URL =
|
|
15
|
-
|
|
13
|
+
/** Coinbase Developer Platform portal (Faucets live in-nav; deep link /products/faucet 404s). */
|
|
14
|
+
export const BASE_SEPOLIA_FAUCET_URL = "https://portal.cdp.coinbase.com/";
|
|
15
|
+
|
|
16
|
+
/** Backup Base Sepolia faucet (amounts not guaranteed). */
|
|
17
|
+
export const ALCHEMY_BASE_SEPOLIA_FAUCET_URL =
|
|
18
|
+
"https://www.alchemy.com/faucets/base-sepolia";
|
|
19
|
+
|
|
20
|
+
/** Documented CDP ETH drip per claim on Base Sepolia. */
|
|
21
|
+
export const CDP_FAUCET_DRIP_ETH = "0.0001";
|
|
16
22
|
|
|
17
23
|
/**
|
|
18
24
|
* Generate a new secp256k1 key and write it to destPath (mode 0o600).
|
|
@@ -42,7 +48,8 @@ export function generateOperatorKeyFile(destPath, opts = {}) {
|
|
|
42
48
|
export function consumerFundHints(opts) {
|
|
43
49
|
const mintLabel = opts.label ? ` ${opts.label}` : "";
|
|
44
50
|
return [
|
|
45
|
-
`Your operator address is ${opts.address} — fund it with Base Sepolia ETH
|
|
51
|
+
`Your operator address is ${opts.address} — fund it with Base Sepolia ETH`,
|
|
52
|
+
"clanker fund",
|
|
46
53
|
"clanker doctor",
|
|
47
54
|
`clanker operator mint${mintLabel} --yes`,
|
|
48
55
|
"clanker bot mint <bot_label> --yes",
|
package/lib/resolve.mjs
CHANGED
|
@@ -78,6 +78,10 @@ export function keyPointerFromSource(opts) {
|
|
|
78
78
|
return { type: "env", value: "OPERATOR_PRIVATE_KEY" };
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** Honest error when mint/pair/rotate/transfer have no owner signer. */
|
|
82
|
+
export const OWNER_SIGNER_REQUIRED =
|
|
83
|
+
"Owner actions need a signing key (--key-file / OPERATOR_PRIVATE_KEY) or the browser door that created this address. A read-only profile can still run whoami, bots, fund, and doctor.";
|
|
84
|
+
|
|
81
85
|
/**
|
|
82
86
|
* Resolve the operator signing key with Anvil guard.
|
|
83
87
|
*
|
|
@@ -133,11 +137,7 @@ export function resolveOperatorKey(argv = [], opts = {}) {
|
|
|
133
137
|
key = normalizePrivateKey(ANVIL_DEFAULT_PRIVATE_KEY);
|
|
134
138
|
source = "anvil-default (local RPC)";
|
|
135
139
|
} else {
|
|
136
|
-
throw new Error(
|
|
137
|
-
"Operator private key required for non-local RPC. Set OPERATOR_PRIVATE_KEY, " +
|
|
138
|
-
"pass --key / --key-file, or configure ~/.clanker/operator.json key pointer. " +
|
|
139
|
-
"Anvil account #0 is not used on public networks.",
|
|
140
|
-
);
|
|
140
|
+
throw new Error(OWNER_SIGNER_REQUIRED);
|
|
141
141
|
}
|
|
142
142
|
}
|
|
143
143
|
|
|
@@ -216,7 +216,11 @@ export function resolveReadIdentity(argv = [], opts = {}) {
|
|
|
216
216
|
};
|
|
217
217
|
} catch (err) {
|
|
218
218
|
const msg = err?.message ?? String(err);
|
|
219
|
-
if (
|
|
219
|
+
if (
|
|
220
|
+
!/private key required|Owner actions need a signing key|Anvil account #0|Key file not found|Profile keyFile/i.test(
|
|
221
|
+
msg,
|
|
222
|
+
)
|
|
223
|
+
) {
|
|
220
224
|
throw err;
|
|
221
225
|
}
|
|
222
226
|
}
|
package/lib/setup.mjs
CHANGED
|
@@ -3,7 +3,14 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { stdin as input } from "node:process";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
chmodSync,
|
|
8
|
+
copyFileSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
} from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { basename, join } from "node:path";
|
|
7
14
|
import { getAddress } from "viem";
|
|
8
15
|
import * as clack from "@clack/prompts";
|
|
9
16
|
import {
|
|
@@ -34,12 +41,11 @@ import {
|
|
|
34
41
|
resolveFoundryAddress,
|
|
35
42
|
} from "./foundry.mjs";
|
|
36
43
|
import {
|
|
37
|
-
BASE_SEPOLIA_FAUCET_URL,
|
|
38
44
|
consumerFundHints,
|
|
39
45
|
generateOperatorKeyFile,
|
|
40
46
|
} from "./operator-key.mjs";
|
|
47
|
+
import { wireOpenClawMqtt } from "./openclaw-wire.mjs";
|
|
41
48
|
import { c, nextHint } from "./ui.mjs";
|
|
42
|
-
import { join } from "node:path";
|
|
43
49
|
|
|
44
50
|
/**
|
|
45
51
|
* @param {string[]} argv
|
|
@@ -58,6 +64,7 @@ export function parseSetupFlags(argv) {
|
|
|
58
64
|
let foundryAccount = null;
|
|
59
65
|
let exportKey = false;
|
|
60
66
|
let generateKey = false;
|
|
67
|
+
let botKey = null;
|
|
61
68
|
|
|
62
69
|
for (let i = 0; i < argv.length; i += 1) {
|
|
63
70
|
const a = argv[i];
|
|
@@ -68,6 +75,7 @@ export function parseSetupFlags(argv) {
|
|
|
68
75
|
else if (a === "--key-env" && argv[i + 1]) keyEnv = argv[++i];
|
|
69
76
|
else if (a === "--from-block" && argv[i + 1]) fromBlock = BigInt(argv[++i]);
|
|
70
77
|
else if (a === "--foundry-account" && argv[i + 1]) foundryAccount = argv[++i];
|
|
78
|
+
else if (a === "--bot-key" && argv[i + 1]) botKey = argv[++i];
|
|
71
79
|
else if (a === "--export-key") exportKey = true;
|
|
72
80
|
else if (a === "--generate-key") generateKey = true;
|
|
73
81
|
else if (a === "--force") force = true;
|
|
@@ -90,6 +98,7 @@ export function parseSetupFlags(argv) {
|
|
|
90
98
|
foundryAccount,
|
|
91
99
|
exportKey,
|
|
92
100
|
generateKey,
|
|
101
|
+
botKey,
|
|
93
102
|
};
|
|
94
103
|
}
|
|
95
104
|
|
|
@@ -163,6 +172,39 @@ export function buildKeyPointer({ keyFile, keyEnv, skipKey, env = process.env })
|
|
|
163
172
|
return null;
|
|
164
173
|
}
|
|
165
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Copy a downloaded bot key into ~/.openclaw/keys and wire channels.mqtt.
|
|
177
|
+
* @param {string} botKeyPath
|
|
178
|
+
* @param {{
|
|
179
|
+
* operatorLabel: string,
|
|
180
|
+
* network: { rpc: string, registry: string|null, brokerUrl?: string, mqttAuthServiceUrl?: string },
|
|
181
|
+
* openclawHome?: string,
|
|
182
|
+
* }} opts
|
|
183
|
+
*/
|
|
184
|
+
export function attachBotKeyFile(botKeyPath, opts) {
|
|
185
|
+
if (!existsSync(botKeyPath)) {
|
|
186
|
+
throw new Error(`Bot key file not found: ${botKeyPath}`);
|
|
187
|
+
}
|
|
188
|
+
const botId = basename(botKeyPath).replace(/\.key$/i, "");
|
|
189
|
+
if (!botId || botId.includes("/") || botId.includes("..")) {
|
|
190
|
+
throw new Error(`Invalid bot key basename: ${botKeyPath}`);
|
|
191
|
+
}
|
|
192
|
+
const openclawHome = opts.openclawHome ?? join(homedir(), ".openclaw");
|
|
193
|
+
const destDir = join(openclawHome, "keys");
|
|
194
|
+
mkdirSync(destDir, { recursive: true });
|
|
195
|
+
const dest = join(destDir, `${botId}.key`);
|
|
196
|
+
copyFileSync(botKeyPath, dest);
|
|
197
|
+
chmodSync(dest, 0o600);
|
|
198
|
+
const wired = wireOpenClawMqtt({
|
|
199
|
+
botId,
|
|
200
|
+
operatorId: opts.operatorLabel,
|
|
201
|
+
network: opts.network,
|
|
202
|
+
keyPath: dest,
|
|
203
|
+
openclawHome,
|
|
204
|
+
});
|
|
205
|
+
return { botId, keyPath: dest, openclawPath: wired.path };
|
|
206
|
+
}
|
|
207
|
+
|
|
166
208
|
/**
|
|
167
209
|
* Apply setup choices to disk.
|
|
168
210
|
*/
|
|
@@ -300,7 +342,7 @@ export async function runSetupNonInteractive(argv, opts = {}) {
|
|
|
300
342
|
}
|
|
301
343
|
}
|
|
302
344
|
|
|
303
|
-
|
|
345
|
+
const result = applySetup(
|
|
304
346
|
{
|
|
305
347
|
preset: flags.preset,
|
|
306
348
|
force: flags.force || flags.yes,
|
|
@@ -312,6 +354,34 @@ export async function runSetupNonInteractive(argv, opts = {}) {
|
|
|
312
354
|
},
|
|
313
355
|
{ home, env },
|
|
314
356
|
);
|
|
357
|
+
return finishSetupResult(result, flags, { ...opts, quiet: true });
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* @param {object} result
|
|
362
|
+
* @param {{ botKey?: string|null, operator?: string|null }} flags
|
|
363
|
+
* @param {{ openclawHome?: string, quiet?: boolean }} [opts]
|
|
364
|
+
*/
|
|
365
|
+
async function finishSetupResult(result, flags, opts = {}) {
|
|
366
|
+
if (flags.botKey) {
|
|
367
|
+
const attached = attachBotKeyFile(flags.botKey, {
|
|
368
|
+
operatorLabel: result.operator?.label ?? flags.operator,
|
|
369
|
+
network: {
|
|
370
|
+
rpc: result.config.chainRpcUrl,
|
|
371
|
+
registry: result.config.registryAddress,
|
|
372
|
+
brokerUrl: result.config.brokerUrl,
|
|
373
|
+
mqttAuthServiceUrl: result.config.mqttAuthServiceUrl,
|
|
374
|
+
},
|
|
375
|
+
openclawHome: opts.openclawHome,
|
|
376
|
+
});
|
|
377
|
+
result.botKey = attached;
|
|
378
|
+
if (!opts.quiet) {
|
|
379
|
+
clack.log.success(
|
|
380
|
+
`Bot key → ${attached.keyPath}; wired ${attached.openclawPath}`,
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return result;
|
|
315
385
|
}
|
|
316
386
|
|
|
317
387
|
/**
|
|
@@ -335,7 +405,7 @@ export async function runSetupInteractive(argv, opts = {}) {
|
|
|
335
405
|
);
|
|
336
406
|
clack.log.message(
|
|
337
407
|
c.dim(
|
|
338
|
-
"
|
|
408
|
+
"Attach an existing owner address (/join or any 0x), or create ~/.clanker/op.key. Mint needs test ETH later.",
|
|
339
409
|
),
|
|
340
410
|
);
|
|
341
411
|
clack.log.message(c.dim(`Profile: ${home}`));
|
|
@@ -441,10 +511,15 @@ export async function runSetupInteractive(argv, opts = {}) {
|
|
|
441
511
|
}
|
|
442
512
|
if (!address) {
|
|
443
513
|
const options = [
|
|
514
|
+
{
|
|
515
|
+
value: "__paste__",
|
|
516
|
+
label: "I already have an owner address",
|
|
517
|
+
hint: "/join or any 0x — usually read-only",
|
|
518
|
+
},
|
|
444
519
|
{
|
|
445
520
|
value: "__generate__",
|
|
446
521
|
label: "Create a new operator key for me",
|
|
447
|
-
hint: "writes ~/.clanker/op.key
|
|
522
|
+
hint: "writes ~/.clanker/op.key",
|
|
448
523
|
},
|
|
449
524
|
{
|
|
450
525
|
value: "__keyfile__",
|
|
@@ -467,20 +542,16 @@ export async function runSetupInteractive(argv, opts = {}) {
|
|
|
467
542
|
hint: "advanced — install Foundry first",
|
|
468
543
|
});
|
|
469
544
|
}
|
|
470
|
-
options.push({
|
|
471
|
-
value: "__paste__",
|
|
472
|
-
label: "Paste an address…",
|
|
473
|
-
hint: "read-only unless you add a key later",
|
|
474
|
-
});
|
|
475
545
|
|
|
476
546
|
const pick = cancelIf(
|
|
477
547
|
await clack.select({
|
|
478
548
|
message: "How do you want to set your operator identity?",
|
|
479
549
|
options,
|
|
480
|
-
initialValue: "
|
|
550
|
+
initialValue: "__paste__",
|
|
481
551
|
}),
|
|
482
552
|
);
|
|
483
553
|
|
|
554
|
+
let attachReadOnly = false;
|
|
484
555
|
if (pick === "__generate__") {
|
|
485
556
|
const dest = defaultOperatorKeyPath(home);
|
|
486
557
|
let forceGen = flags.force;
|
|
@@ -527,15 +598,17 @@ export async function runSetupInteractive(argv, opts = {}) {
|
|
|
527
598
|
/^0x[0-9a-fA-F]{40}$/.test(v || "") ? undefined : "Need 0x + 40 hex",
|
|
528
599
|
}),
|
|
529
600
|
);
|
|
601
|
+
attachReadOnly = true;
|
|
530
602
|
} else if (pick === "__paste__") {
|
|
531
603
|
address = cancelIf(
|
|
532
604
|
await clack.text({
|
|
533
|
-
message: "
|
|
605
|
+
message: "Owner address (from /join or any EOA)",
|
|
534
606
|
placeholder: "0x…",
|
|
535
607
|
validate: (v) =>
|
|
536
608
|
/^0x[0-9a-fA-F]{40}$/.test(v || "") ? undefined : "Need 0x + 40 hex",
|
|
537
609
|
}),
|
|
538
610
|
);
|
|
611
|
+
attachReadOnly = true;
|
|
539
612
|
} else {
|
|
540
613
|
foundryAccountUsed = pick;
|
|
541
614
|
try {
|
|
@@ -558,6 +631,9 @@ export async function runSetupInteractive(argv, opts = {}) {
|
|
|
558
631
|
);
|
|
559
632
|
}
|
|
560
633
|
}
|
|
634
|
+
if (attachReadOnly) {
|
|
635
|
+
flags.skipKey = true;
|
|
636
|
+
}
|
|
561
637
|
}
|
|
562
638
|
address = getAddress(address);
|
|
563
639
|
|
|
@@ -688,17 +764,20 @@ export async function runSetupInteractive(argv, opts = {}) {
|
|
|
688
764
|
{ home, env },
|
|
689
765
|
);
|
|
690
766
|
|
|
767
|
+
await finishSetupResult(result, { ...flags, operator: label }, opts);
|
|
768
|
+
|
|
691
769
|
clack.outro(c.green(`Wrote ${result.configPath}\nWrote ${result.operatorPath}`));
|
|
692
770
|
if (!key) {
|
|
693
771
|
nextHint([
|
|
772
|
+
"clanker fund",
|
|
694
773
|
"clanker whoami",
|
|
695
|
-
"
|
|
774
|
+
"mint/pair/rotate need a signing key or stay on /join",
|
|
696
775
|
]);
|
|
697
776
|
} else if (preset === "sepolia" && generatedKeyFile) {
|
|
698
777
|
nextHint(consumerFundHints({ address, label }));
|
|
699
778
|
} else if (preset === "sepolia") {
|
|
700
779
|
nextHint([
|
|
701
|
-
|
|
780
|
+
"clanker fund",
|
|
702
781
|
"clanker doctor",
|
|
703
782
|
"clanker whoami",
|
|
704
783
|
`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.
|
|
3
|
+
"version": "2026.9.12-1",
|
|
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.
|
|
28
|
+
"viem": "^2.56.3"
|
|
29
|
+
},
|
|
30
|
+
"overrides": {
|
|
31
|
+
"ws": "^8.21.0"
|
|
29
32
|
}
|
|
30
33
|
}
|