@clanker-chain/clanker-cli 2026.9.12 → 2026.9.13

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/login.mjs ADDED
@@ -0,0 +1,200 @@
1
+ /**
2
+ * `clanker login` / `clanker logout` — Privy device grant for the operator vault.
3
+ */
4
+
5
+ import { getAddress } from "viem";
6
+ import { openUrl } from "./fund.mjs";
7
+ import {
8
+ loadOperator,
9
+ writeOperator,
10
+ clankerHome,
11
+ loadConfig,
12
+ } from "./profile.mjs";
13
+ import {
14
+ authenticateWallets,
15
+ pollDeviceToken,
16
+ requestDeviceAuthorization,
17
+ } from "./privy-client.mjs";
18
+ import {
19
+ clearPrivySession,
20
+ loadPrivySession,
21
+ savePrivySession,
22
+ } from "./privy-session.mjs";
23
+ import { resolvePrivyAppId } from "./privy-constants.mjs";
24
+ import { c, nextHint } from "./ui.mjs";
25
+
26
+ /**
27
+ * @param {string[]} argv
28
+ */
29
+ export function parseLoginFlags(argv) {
30
+ let noOpen = false;
31
+ let json = false;
32
+ let operator = null;
33
+ for (let i = 0; i < argv.length; i += 1) {
34
+ const a = argv[i];
35
+ if (a === "--no-open") noOpen = true;
36
+ else if (a === "--json") json = true;
37
+ else if (a === "--operator" && argv[i + 1]) operator = argv[++i];
38
+ }
39
+ return { noOpen, json, operator };
40
+ }
41
+
42
+ /**
43
+ * @param {string[]} argv
44
+ * @param {{
45
+ * home?: string,
46
+ * env?: NodeJS.ProcessEnv,
47
+ * fetchImpl?: typeof fetch,
48
+ * openUrlImpl?: typeof openUrl,
49
+ * sleep?: (ms: number) => Promise<void>,
50
+ * now?: () => number,
51
+ * }} [opts]
52
+ */
53
+ export async function runLogin(argv = [], opts = {}) {
54
+ const flags = parseLoginFlags(argv);
55
+ const env = opts.env ?? process.env;
56
+ const home = opts.home ?? clankerHome(env);
57
+ const openUrlImpl = opts.openUrlImpl ?? openUrl;
58
+
59
+ const device = await requestDeviceAuthorization({
60
+ appId: resolvePrivyAppId(env),
61
+ fetchImpl: opts.fetchImpl,
62
+ });
63
+
64
+ const url =
65
+ device.verificationUriComplete ||
66
+ `${device.verificationUri}?user_code=${encodeURIComponent(device.userCode)}`;
67
+
68
+ if (!flags.json) {
69
+ console.log(c.bold("clanker login"));
70
+ console.log("");
71
+ console.log("Approve CLI access to your operator wallet (email vault).");
72
+ console.log("");
73
+ console.log(`Visit: ${url}`);
74
+ console.log(`Code: ${device.userCode}`);
75
+ console.log("");
76
+ console.log(c.dim("Waiting for browser approval…"));
77
+ }
78
+
79
+ if (!flags.noOpen) {
80
+ await openUrlImpl(url);
81
+ }
82
+
83
+ const tokens = await pollDeviceToken({
84
+ appId: device.appId,
85
+ deviceCode: device.deviceCode,
86
+ interval: device.interval,
87
+ expiresIn: device.expiresIn,
88
+ fetchImpl: opts.fetchImpl,
89
+ sleep: opts.sleep,
90
+ now: opts.now,
91
+ });
92
+
93
+ const auth = await authenticateWallets({
94
+ appId: device.appId,
95
+ accessToken: tokens.accessToken,
96
+ fetchImpl: opts.fetchImpl,
97
+ });
98
+
99
+ const session = {
100
+ appId: device.appId,
101
+ accessToken: tokens.accessToken,
102
+ refreshToken: tokens.refreshToken,
103
+ expiresAt: Date.now() + tokens.expiresIn * 1000,
104
+ walletId: auth.walletId,
105
+ address: auth.address,
106
+ createdAt: Date.now(),
107
+ };
108
+ const stored = savePrivySession(session, home);
109
+
110
+ const existing = loadOperator(home);
111
+ const label = flags.operator || existing?.label || null;
112
+ if (existing?.owner && getAddress(existing.owner) !== auth.address) {
113
+ throw new Error(
114
+ `Logged-in address ${auth.address} does not match operator.json owner ${existing.owner}. ` +
115
+ `Use a matching login, or clanker setup --force with the new owner.`,
116
+ );
117
+ }
118
+
119
+ if (label || existing) {
120
+ writeOperator(
121
+ {
122
+ label: label || existing.label,
123
+ owner: auth.address,
124
+ key: { type: "privy", value: auth.walletId },
125
+ },
126
+ home,
127
+ );
128
+ } else if (!loadConfig(home)) {
129
+ // Login alone is enough for session; setup still needed for network + label.
130
+ } else {
131
+ // Config exists but no operator yet — leave operator.json to setup / mint.
132
+ }
133
+
134
+ const payload = {
135
+ ok: true,
136
+ address: auth.address,
137
+ walletId: auth.walletId,
138
+ storage: stored.storage,
139
+ label: label || existing?.label || null,
140
+ };
141
+
142
+ if (flags.json) {
143
+ console.log(JSON.stringify(payload, null, 2));
144
+ } else {
145
+ console.log("");
146
+ console.log(c.green(`Logged in as ${auth.address}`));
147
+ console.log(c.dim(`wallet: ${auth.walletId}`));
148
+ console.log(c.dim(`session: ${stored.storage}`));
149
+ if (!label && !existing?.label) {
150
+ nextHint([
151
+ "clanker setup --preset sepolia --operator org.you --yes # if no profile yet",
152
+ "clanker fund",
153
+ "clanker whoami",
154
+ "clanker pair add <peer> --yes",
155
+ ]);
156
+ } else {
157
+ nextHint([
158
+ "clanker fund",
159
+ "clanker whoami",
160
+ "clanker pair add <peer> --yes",
161
+ "clanker operator mint <label> --yes # if not minted yet",
162
+ ]);
163
+ }
164
+ }
165
+ return { exitCode: 0, ...payload, session };
166
+ }
167
+
168
+ /**
169
+ * @param {string[]} argv
170
+ * @param {{ home?: string, env?: NodeJS.ProcessEnv }} [opts]
171
+ */
172
+ export async function runLogout(argv = [], opts = {}) {
173
+ const json = argv.includes("--json");
174
+ const home = opts.home ?? clankerHome(opts.env ?? process.env);
175
+ const had = Boolean(loadPrivySession(home));
176
+ clearPrivySession(home);
177
+ const operator = loadOperator(home);
178
+ if (operator?.key?.type === "privy") {
179
+ writeOperator(
180
+ {
181
+ label: operator.label,
182
+ owner: operator.owner,
183
+ key: null,
184
+ },
185
+ home,
186
+ );
187
+ }
188
+ if (json) {
189
+ console.log(JSON.stringify({ ok: true, cleared: had }, null, 2));
190
+ } else {
191
+ console.log(c.bold("clanker logout"));
192
+ console.log(had ? c.green("Cleared Privy CLI session.") : c.dim("No session stored."));
193
+ console.log(
194
+ c.dim(
195
+ "Revoke grants anytime in the Privy dashboard / account settings if needed.",
196
+ ),
197
+ );
198
+ }
199
+ return { exitCode: 0, cleared: had };
200
+ }
@@ -10,9 +10,8 @@ import { defaultOperatorKeyPath } from "./foundry.mjs";
10
10
 
11
11
  export { defaultOperatorKeyPath };
12
12
 
13
- /** Coinbase Developer Platform faucet UI (Base Sepolia). */
14
- export const BASE_SEPOLIA_FAUCET_URL =
15
- "https://portal.cdp.coinbase.com/products/faucet";
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/";
16
15
 
17
16
  /** Backup Base Sepolia faucet (amounts not guaranteed). */
18
17
  export const ALCHEMY_BASE_SEPOLIA_FAUCET_URL =
@@ -0,0 +1,284 @@
1
+ /**
2
+ * Pluggable operator signer: local hex key or Privy device-grant vault.
3
+ */
4
+
5
+ import {
6
+ createPublicClient,
7
+ createWalletClient,
8
+ defineChain,
9
+ encodeFunctionData,
10
+ getAddress,
11
+ http,
12
+ } from "viem";
13
+ import { privateKeyToAccount } from "viem/accounts";
14
+ import {
15
+ isAnvilDefaultKey,
16
+ OWNER_SIGNER_REQUIRED,
17
+ parseKeyFlags,
18
+ resolveOperatorKey,
19
+ } from "./resolve.mjs";
20
+ import {
21
+ ANVIL_DEFAULT_PRIVATE_KEY,
22
+ isLocalRpc,
23
+ loadOperator,
24
+ resolveNetwork,
25
+ } from "./profile.mjs";
26
+ import { loadPrivySession } from "./privy-session.mjs";
27
+ import { privySendTransaction, privySignMessage } from "./privy-client.mjs";
28
+ import { existsSync, readFileSync } from "node:fs";
29
+ import { normalizePrivateKey } from "./resolve.mjs";
30
+
31
+ export { OWNER_SIGNER_REQUIRED };
32
+
33
+ /**
34
+ * @typedef {{
35
+ * address: `0x${string}`,
36
+ * source: string,
37
+ * keyPointer: { type: string, value: string }|null,
38
+ * network: object,
39
+ * kind: 'local'|'privy',
40
+ * key?: `0x${string}`,
41
+ * signMessage: (args: { message: string }) => Promise<`0x${string}`>,
42
+ * writeContract: (args: object) => Promise<`0x${string}`>,
43
+ * publicClient: import('viem').PublicClient,
44
+ * }} OperatorSigner
45
+ */
46
+
47
+ async function chainFromRpc(rpc) {
48
+ const publicClient = createPublicClient({ transport: http(rpc) });
49
+ const id = await publicClient.getChainId();
50
+ return defineChain({
51
+ id,
52
+ name: `clanker-chain-${id}`,
53
+ nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
54
+ rpcUrls: { default: { http: [rpc] } },
55
+ });
56
+ }
57
+
58
+ /**
59
+ * @param {string} rpc
60
+ * @param {`0x${string}`} key
61
+ */
62
+ async function localSignerFromKey(rpc, key, meta) {
63
+ const account = privateKeyToAccount(key);
64
+ const transport = http(rpc);
65
+ const chain = await chainFromRpc(rpc);
66
+ const publicClient = createPublicClient({ chain, transport });
67
+ const wallet = createWalletClient({ account, chain, transport });
68
+ return {
69
+ ...meta,
70
+ kind: "local",
71
+ key,
72
+ address: account.address,
73
+ publicClient,
74
+ signMessage: async ({ message }) => account.signMessage({ message }),
75
+ writeContract: async (args) => wallet.writeContract(args),
76
+ };
77
+ }
78
+
79
+ /**
80
+ * @param {string} rpc
81
+ * @param {{ address: string, walletId: string, home?: string, fetchImpl?: typeof fetch }} privy
82
+ * @param {object} meta
83
+ */
84
+ async function privySignerFromSession(rpc, privy, meta) {
85
+ const transport = http(rpc);
86
+ const chain = await chainFromRpc(rpc);
87
+ const publicClient = createPublicClient({ chain, transport });
88
+ const address = getAddress(privy.address);
89
+
90
+ return {
91
+ ...meta,
92
+ kind: "privy",
93
+ address,
94
+ publicClient,
95
+ signMessage: async ({ message }) =>
96
+ privySignMessage(message, {
97
+ home: privy.home,
98
+ fetchImpl: privy.fetchImpl,
99
+ }),
100
+ writeContract: async (args) => {
101
+ const data = encodeFunctionData({
102
+ abi: args.abi,
103
+ functionName: args.functionName,
104
+ args: args.args,
105
+ });
106
+ const value =
107
+ args.value == null
108
+ ? undefined
109
+ : typeof args.value === "bigint"
110
+ ? `0x${args.value.toString(16)}`
111
+ : args.value;
112
+ const chainId = chain.id;
113
+ return privySendTransaction(
114
+ {
115
+ to: args.address,
116
+ data,
117
+ value,
118
+ chain_id: chainId,
119
+ },
120
+ { home: privy.home, fetchImpl: privy.fetchImpl },
121
+ );
122
+ },
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Resolve an operator signer (local key or Privy session).
128
+ *
129
+ * Precedence for local: --key > --key-file > OPERATOR_PRIVATE_KEY > profile keyFile/env.
130
+ * Else if profile key.type === privy or a Privy session exists → Privy vault.
131
+ * Else Anvil on local RPC only.
132
+ *
133
+ * @param {string[]} argv
134
+ * @param {{
135
+ * home?: string,
136
+ * env?: NodeJS.ProcessEnv,
137
+ * rpc?: string,
138
+ * requireRegistry?: boolean,
139
+ * fetchImpl?: typeof fetch,
140
+ * }} [opts]
141
+ * @returns {Promise<OperatorSigner>}
142
+ */
143
+ export async function resolveOperatorSigner(argv = [], opts = {}) {
144
+ const env = opts.env ?? process.env;
145
+ const network = resolveNetwork(argv, { home: opts.home, env });
146
+ const rpc = opts.rpc ?? network.rpc;
147
+ const home = opts.home ?? network.home;
148
+ const operator = loadOperator(home);
149
+ const requireRegistry = opts.requireRegistry !== false;
150
+ const flags = parseKeyFlags(argv);
151
+ const local = isLocalRpc(rpc);
152
+
153
+ if (requireRegistry && (!network.registry || !/^0x[0-9a-fA-F]{40}$/.test(network.registry))) {
154
+ throw new Error(
155
+ "REGISTRY_ADDRESS or --registry is required (0x + 40 hex). " +
156
+ "Run `clanker init --preset sepolia` or set registry after `clanker chain deploy`.",
157
+ );
158
+ }
159
+
160
+ // Explicit local key paths first.
161
+ let key = null;
162
+ let source = null;
163
+ let keyFilePath = null;
164
+ if (flags.key) {
165
+ key = normalizePrivateKey(flags.key);
166
+ source = "--key";
167
+ } else if (flags.keyFile || network.keyFile) {
168
+ const path = flags.keyFile || network.keyFile;
169
+ if (!existsSync(path)) throw new Error(`Key file not found: ${path}`);
170
+ key = normalizePrivateKey(readFileSync(path, "utf8").split(/\r?\n/)[0]);
171
+ keyFilePath = path;
172
+ source = `--key-file ${path}`;
173
+ } else if (env.OPERATOR_PRIVATE_KEY) {
174
+ key = normalizePrivateKey(env.OPERATOR_PRIVATE_KEY);
175
+ source = "OPERATOR_PRIVATE_KEY";
176
+ } else if (operator?.key?.type === "keyFile" && operator.key.value) {
177
+ const path = operator.key.value;
178
+ if (!existsSync(path)) throw new Error(`Profile keyFile not found: ${path}`);
179
+ key = normalizePrivateKey(readFileSync(path, "utf8").split(/\r?\n/)[0]);
180
+ keyFilePath = path;
181
+ source = `profile keyFile ${path}`;
182
+ } else if (operator?.key?.type === "env" && operator.key.value && env[operator.key.value]) {
183
+ key = normalizePrivateKey(env[operator.key.value]);
184
+ source = `profile env ${operator.key.value}`;
185
+ }
186
+
187
+ if (key) {
188
+ if (!local && isAnvilDefaultKey(key)) {
189
+ throw new Error(
190
+ "Refusing Anvil account #0 key on a non-local RPC. " +
191
+ "Use clanker login, or a real OPERATOR_PRIVATE_KEY / --key-file.",
192
+ );
193
+ }
194
+ const keyPointer =
195
+ keyFilePath
196
+ ? { type: "keyFile", value: keyFilePath }
197
+ : source === "OPERATOR_PRIVATE_KEY" || source?.startsWith("profile env")
198
+ ? { type: "env", value: "OPERATOR_PRIVATE_KEY" }
199
+ : { type: "env", value: "OPERATOR_PRIVATE_KEY" };
200
+ const signer = await localSignerFromKey(rpc, key, {
201
+ source,
202
+ keyPointer,
203
+ network: { ...network, rpc },
204
+ });
205
+ if (
206
+ operator?.owner &&
207
+ getAddress(operator.owner) !== getAddress(signer.address)
208
+ ) {
209
+ throw new Error(
210
+ `Signing address ${signer.address} does not match operator.json owner ${operator.owner}`,
211
+ );
212
+ }
213
+ return signer;
214
+ }
215
+
216
+ // Privy vault.
217
+ const session = loadPrivySession(home);
218
+ const wantsPrivy =
219
+ operator?.key?.type === "privy" || Boolean(session?.accessToken);
220
+
221
+ if (wantsPrivy) {
222
+ if (!session?.accessToken || !session?.address || !session?.walletId) {
223
+ throw new Error(
224
+ "Privy session missing — run clanker login (or use --key-file / OPERATOR_PRIVATE_KEY).",
225
+ );
226
+ }
227
+ if (
228
+ operator?.key?.type === "privy" &&
229
+ operator.key.value &&
230
+ operator.key.value !== session.walletId
231
+ ) {
232
+ throw new Error(
233
+ `Privy wallet ${session.walletId} does not match operator.json ${operator.key.value} — run clanker login again`,
234
+ );
235
+ }
236
+ if (
237
+ operator?.owner &&
238
+ getAddress(operator.owner) !== getAddress(session.address)
239
+ ) {
240
+ throw new Error(
241
+ `Privy address ${session.address} does not match operator.json owner ${operator.owner}`,
242
+ );
243
+ }
244
+ return privySignerFromSession(
245
+ rpc,
246
+ {
247
+ address: session.address,
248
+ walletId: session.walletId,
249
+ home,
250
+ fetchImpl: opts.fetchImpl,
251
+ },
252
+ {
253
+ source: "privy session",
254
+ keyPointer: { type: "privy", value: session.walletId },
255
+ network: { ...network, rpc },
256
+ },
257
+ );
258
+ }
259
+
260
+ if (local) {
261
+ return localSignerFromKey(
262
+ rpc,
263
+ normalizePrivateKey(ANVIL_DEFAULT_PRIVATE_KEY),
264
+ {
265
+ source: "anvil-default (local RPC)",
266
+ keyPointer: { type: "env", value: "OPERATOR_PRIVATE_KEY" },
267
+ network: { ...network, rpc },
268
+ },
269
+ );
270
+ }
271
+
272
+ throw new Error(
273
+ "Owner actions need clanker login (email vault), or a signing key (--key-file / OPERATOR_PRIVATE_KEY). " +
274
+ "A read-only profile can still run whoami, bots, fund, and doctor.",
275
+ );
276
+ }
277
+
278
+ /**
279
+ * Back-compat: local hex only (used by forge deploy etc.).
280
+ * Prefer resolveOperatorSigner for pair/mint.
281
+ */
282
+ export function resolveOperatorKeyLocal(argv, opts) {
283
+ return resolveOperatorKey(argv, opts);
284
+ }
package/lib/pair.mjs CHANGED
@@ -6,10 +6,9 @@
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
8
  import { join } from "node:path";
9
- import { privateKeyToAccount } from "viem/accounts";
10
9
  import { loadOperator } from "./profile.mjs";
11
10
  import { openclawConfigPath } from "./openclaw-wire.mjs";
12
- import { resolveOperatorKey } from "./resolve.mjs";
11
+ import { resolveOperatorSigner } from "./operator-signer.mjs";
13
12
 
14
13
  /**
15
14
  * @param {string} baseUrl
@@ -159,11 +158,11 @@ export function resolveOpenclawHome(argv) {
159
158
  * @param {string|null} peerLabel
160
159
  */
161
160
  export async function runPairAction(argv, action, peerLabel) {
162
- const resolved = resolveOperatorKey(argv, { requireRegistry: true });
161
+ const signer = await resolveOperatorSigner(argv, { requireRegistry: true });
163
162
  const operator =
164
- loadOperator(resolved.network.home) ??
165
- (resolved.network.operatorLabel
166
- ? { label: resolved.network.operatorLabel }
163
+ loadOperator(signer.network.home) ??
164
+ (signer.network.operatorLabel
165
+ ? { label: signer.network.operatorLabel }
167
166
  : null);
168
167
  const operatorId =
169
168
  argv.includes("--operator") && argv[argv.indexOf("--operator") + 1]
@@ -175,15 +174,14 @@ export async function runPairAction(argv, action, peerLabel) {
175
174
  );
176
175
  }
177
176
 
178
- const authUrl = resolvePairAuthUrl(argv, resolved.network);
179
- const account = privateKeyToAccount(resolved.key);
177
+ const authUrl = resolvePairAuthUrl(argv, signer.network);
180
178
 
181
179
  if (action === "list" || action === "status") {
182
180
  const { nonce, message } = await fetchPairNonce(authUrl, {
183
181
  operatorId,
184
182
  action: "list",
185
183
  });
186
- const signature = await account.signMessage({ message });
184
+ const signature = await signer.signMessage({ message });
187
185
  const listed = await getPairList(authUrl, {
188
186
  operatorId,
189
187
  nonce,
@@ -191,16 +189,6 @@ export async function runPairAction(argv, action, peerLabel) {
191
189
  });
192
190
  if (action === "status") {
193
191
  if (!peerLabel) throw new Error("Usage: clanker pair status <operator-label>");
194
- // Resolve peer by scanning allows — mutual flag is per peer_operator_id;
195
- // we match by requesting add-style peer label via a second pair-nonce is heavy.
196
- // Instead: check if any allow entry is mutual when peer label hashes match… we
197
- // only have peer_operator_id hex. Re-fetch is unnecessary: call add-path status
198
- // by computing whether peer is in allow list via a dedicated pair status using
199
- // the peer label through POST-less approach — GET list doesn't include labels.
200
- // Best effort: show mutual if we can add then remove? No.
201
- // Call /pair-nonce + post with action that doesn't mutate? Use list + ask user.
202
- // Simpler: after list, for status we POST nothing — check if peer's keccak is in list.
203
- // We need peer id: keccak of peerLabel.
204
192
  const { keccak256, toBytes } = await import("viem");
205
193
  const peerId = keccak256(toBytes(peerLabel)).toLowerCase();
206
194
  const hit = (listed.allows ?? []).find(
@@ -228,7 +216,7 @@ export async function runPairAction(argv, action, peerLabel) {
228
216
  action,
229
217
  peerLabel,
230
218
  });
231
- const signature = await account.signMessage({ message });
219
+ const signature = await signer.signMessage({ message });
232
220
  const result = await postPair(authUrl, {
233
221
  operatorId,
234
222
  peerLabel,