@clanker-chain/clanker-cli 2026.9.12-1 → 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/bin/chain-identity.mjs +40 -62
- package/bin/clanker.mjs +48 -10
- package/lib/doctor.mjs +16 -8
- package/lib/login.mjs +200 -0
- package/lib/operator-signer.mjs +284 -0
- package/lib/pair.mjs +8 -20
- package/lib/privy-client.mjs +419 -0
- package/lib/privy-constants.mjs +20 -0
- package/lib/privy-hpke.mjs +101 -0
- package/lib/privy-session.mjs +151 -0
- package/lib/profile.mjs +1 -1
- package/lib/resolve.mjs +3 -2
- package/lib/setup.mjs +34 -8
- package/package.json +6 -1
|
@@ -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 {
|
|
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
|
|
161
|
+
const signer = await resolveOperatorSigner(argv, { requireRegistry: true });
|
|
163
162
|
const operator =
|
|
164
|
-
loadOperator(
|
|
165
|
-
(
|
|
166
|
-
? { label:
|
|
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,
|
|
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
|
|
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
|
|
219
|
+
const signature = await signer.signMessage({ message });
|
|
232
220
|
const result = await postPair(authUrl, {
|
|
233
221
|
operatorId,
|
|
234
222
|
peerLabel,
|