@crossmint/openclaw-wallet 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +157 -0
- package/index.ts +72 -0
- package/openclaw.plugin.json +12 -0
- package/package.json +42 -0
- package/skills/crossmint/SKILL.md +274 -0
- package/src/api.test.ts +211 -0
- package/src/api.ts +495 -0
- package/src/config.ts +11 -0
- package/src/tools.ts +787 -0
- package/src/wallet.test.ts +291 -0
- package/src/wallet.ts +154 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { Keypair } from "@solana/web3.js";
|
|
2
|
+
import bs58 from "bs58";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
configureWallet,
|
|
9
|
+
deleteWallet,
|
|
10
|
+
getKeypair,
|
|
11
|
+
getOrCreateWallet,
|
|
12
|
+
getWallet,
|
|
13
|
+
isWalletConfigured,
|
|
14
|
+
listWallets,
|
|
15
|
+
signMessage,
|
|
16
|
+
} from "./wallet.js";
|
|
17
|
+
|
|
18
|
+
const isWindows = process.platform === "win32";
|
|
19
|
+
|
|
20
|
+
function expectPerms(actual: number, expected: number) {
|
|
21
|
+
if (isWindows) {
|
|
22
|
+
// Windows doesn't support Unix permissions
|
|
23
|
+
expect([expected, 0o666, 0o777]).toContain(actual);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
expect(actual).toBe(expected);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("crossmint wallet", () => {
|
|
30
|
+
let tmpDir: string;
|
|
31
|
+
let originalEnv: string | undefined;
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "crossmint-wallet-test-"));
|
|
35
|
+
originalEnv = process.env.CROSSMINT_WALLETS_DIR;
|
|
36
|
+
process.env.CROSSMINT_WALLETS_DIR = tmpDir;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(async () => {
|
|
40
|
+
if (originalEnv === undefined) {
|
|
41
|
+
delete process.env.CROSSMINT_WALLETS_DIR;
|
|
42
|
+
} else {
|
|
43
|
+
process.env.CROSSMINT_WALLETS_DIR = originalEnv;
|
|
44
|
+
}
|
|
45
|
+
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("keypair creation", () => {
|
|
49
|
+
it("creates a new Solana ed25519 keypair", () => {
|
|
50
|
+
const wallet = getOrCreateWallet("test-agent");
|
|
51
|
+
|
|
52
|
+
expect(wallet).toBeDefined();
|
|
53
|
+
expect(wallet.address).toBeDefined();
|
|
54
|
+
expect(wallet.secretKey).toBeDefined();
|
|
55
|
+
expect(wallet.createdAt).toBeDefined();
|
|
56
|
+
|
|
57
|
+
// Verify it's a valid Solana address (base58, 32-44 chars)
|
|
58
|
+
expect(wallet.address).toMatch(/^[1-9A-HJ-NP-Za-km-z]{32,44}$/);
|
|
59
|
+
|
|
60
|
+
// Verify the secret key can reconstruct the keypair
|
|
61
|
+
const secretKeyBytes = bs58.decode(wallet.secretKey);
|
|
62
|
+
expect(secretKeyBytes.length).toBe(64); // ed25519 secret key is 64 bytes
|
|
63
|
+
|
|
64
|
+
const keypair = Keypair.fromSecretKey(secretKeyBytes);
|
|
65
|
+
expect(keypair.publicKey.toBase58()).toBe(wallet.address);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("returns existing wallet on subsequent calls", () => {
|
|
69
|
+
const wallet1 = getOrCreateWallet("test-agent");
|
|
70
|
+
const wallet2 = getOrCreateWallet("test-agent");
|
|
71
|
+
|
|
72
|
+
expect(wallet1.address).toBe(wallet2.address);
|
|
73
|
+
expect(wallet1.secretKey).toBe(wallet2.secretKey);
|
|
74
|
+
expect(wallet1.createdAt).toBe(wallet2.createdAt);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("creates different wallets for different agents", () => {
|
|
78
|
+
const wallet1 = getOrCreateWallet("agent-1");
|
|
79
|
+
const wallet2 = getOrCreateWallet("agent-2");
|
|
80
|
+
|
|
81
|
+
expect(wallet1.address).not.toBe(wallet2.address);
|
|
82
|
+
expect(wallet1.secretKey).not.toBe(wallet2.secretKey);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("getKeypair returns a valid Solana Keypair", () => {
|
|
86
|
+
getOrCreateWallet("test-agent");
|
|
87
|
+
const keypair = getKeypair("test-agent");
|
|
88
|
+
|
|
89
|
+
expect(keypair).toBeInstanceOf(Keypair);
|
|
90
|
+
expect(keypair).not.toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("getKeypair returns null for non-existent agent", () => {
|
|
94
|
+
const keypair = getKeypair("non-existent");
|
|
95
|
+
expect(keypair).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("secure storage", () => {
|
|
100
|
+
it("stores wallet file with secure permissions (0o600)", async () => {
|
|
101
|
+
getOrCreateWallet("test-agent");
|
|
102
|
+
|
|
103
|
+
const storePath = path.join(tmpDir, "wallets.json");
|
|
104
|
+
const stats = await fs.promises.stat(storePath);
|
|
105
|
+
const mode = stats.mode & 0o777;
|
|
106
|
+
|
|
107
|
+
expectPerms(mode, 0o600);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("creates wallets directory with secure permissions (0o700)", async () => {
|
|
111
|
+
// Remove the directory first to test creation
|
|
112
|
+
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
|
113
|
+
await fs.promises.mkdir(tmpDir, { recursive: true });
|
|
114
|
+
|
|
115
|
+
const walletsSubDir = path.join(tmpDir, "subdir");
|
|
116
|
+
process.env.CROSSMINT_WALLETS_DIR = walletsSubDir;
|
|
117
|
+
|
|
118
|
+
getOrCreateWallet("test-agent");
|
|
119
|
+
|
|
120
|
+
const stats = await fs.promises.stat(walletsSubDir);
|
|
121
|
+
const mode = stats.mode & 0o777;
|
|
122
|
+
|
|
123
|
+
expectPerms(mode, 0o700);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("stores secret key as base58 encoded string", () => {
|
|
127
|
+
const wallet = getOrCreateWallet("test-agent");
|
|
128
|
+
|
|
129
|
+
// Should be valid base58
|
|
130
|
+
expect(() => bs58.decode(wallet.secretKey)).not.toThrow();
|
|
131
|
+
|
|
132
|
+
const decoded = bs58.decode(wallet.secretKey);
|
|
133
|
+
expect(decoded.length).toBe(64); // ed25519 secret key
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("persists wallet data to disk", async () => {
|
|
137
|
+
const wallet = getOrCreateWallet("test-agent");
|
|
138
|
+
|
|
139
|
+
const storePath = path.join(tmpDir, "wallets.json");
|
|
140
|
+
const raw = await fs.promises.readFile(storePath, "utf-8");
|
|
141
|
+
const data = JSON.parse(raw) as { wallets: Record<string, unknown> };
|
|
142
|
+
|
|
143
|
+
expect(data.wallets["test-agent"]).toBeDefined();
|
|
144
|
+
expect((data.wallets["test-agent"] as { address: string }).address).toBe(wallet.address);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("credential storage", () => {
|
|
149
|
+
it("stores smart wallet address from web app", () => {
|
|
150
|
+
getOrCreateWallet("test-agent");
|
|
151
|
+
|
|
152
|
+
const smartWalletAddress = "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm";
|
|
153
|
+
const apiKey = "sk_test_123456";
|
|
154
|
+
|
|
155
|
+
const configured = configureWallet("test-agent", smartWalletAddress, apiKey);
|
|
156
|
+
|
|
157
|
+
expect(configured.smartWalletAddress).toBe(smartWalletAddress);
|
|
158
|
+
expect(configured.configuredAt).toBeDefined();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("stores client-side API key from web app", () => {
|
|
162
|
+
getOrCreateWallet("test-agent");
|
|
163
|
+
|
|
164
|
+
const smartWalletAddress = "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm";
|
|
165
|
+
const apiKey = "sk_test_abcdef123456";
|
|
166
|
+
|
|
167
|
+
const configured = configureWallet("test-agent", smartWalletAddress, apiKey);
|
|
168
|
+
|
|
169
|
+
expect(configured.apiKey).toBe(apiKey);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("persists credentials to disk with secure permissions", async () => {
|
|
173
|
+
getOrCreateWallet("test-agent");
|
|
174
|
+
|
|
175
|
+
const smartWalletAddress = "EKpQGSJtjMFqKZ9KQanSqYXRcF8fBopzLHYxdM65zcjm";
|
|
176
|
+
const apiKey = "sk_test_xyz789";
|
|
177
|
+
|
|
178
|
+
configureWallet("test-agent", smartWalletAddress, apiKey);
|
|
179
|
+
|
|
180
|
+
const storePath = path.join(tmpDir, "wallets.json");
|
|
181
|
+
const raw = await fs.promises.readFile(storePath, "utf-8");
|
|
182
|
+
const data = JSON.parse(raw) as {
|
|
183
|
+
wallets: Record<string, { smartWalletAddress?: string; apiKey?: string }>;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
expect(data.wallets["test-agent"].smartWalletAddress).toBe(smartWalletAddress);
|
|
187
|
+
expect(data.wallets["test-agent"].apiKey).toBe(apiKey);
|
|
188
|
+
|
|
189
|
+
// Verify file permissions remain secure after update
|
|
190
|
+
const stats = await fs.promises.stat(storePath);
|
|
191
|
+
const mode = stats.mode & 0o777;
|
|
192
|
+
expectPerms(mode, 0o600);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("throws error when configuring non-existent wallet", () => {
|
|
196
|
+
expect(() => {
|
|
197
|
+
configureWallet("non-existent", "address", "key");
|
|
198
|
+
}).toThrow('No wallet found for agent "non-existent"');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("isWalletConfigured returns false before configuration", () => {
|
|
202
|
+
getOrCreateWallet("test-agent");
|
|
203
|
+
expect(isWalletConfigured("test-agent")).toBe(false);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("isWalletConfigured returns true after configuration", () => {
|
|
207
|
+
getOrCreateWallet("test-agent");
|
|
208
|
+
configureWallet("test-agent", "wallet-address", "api-key");
|
|
209
|
+
expect(isWalletConfigured("test-agent")).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("isWalletConfigured returns false for non-existent agent", () => {
|
|
213
|
+
expect(isWalletConfigured("non-existent")).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("wallet management", () => {
|
|
218
|
+
it("getWallet returns null for non-existent agent", () => {
|
|
219
|
+
const wallet = getWallet("non-existent");
|
|
220
|
+
expect(wallet).toBeNull();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("getWallet returns wallet data for existing agent", () => {
|
|
224
|
+
const created = getOrCreateWallet("test-agent");
|
|
225
|
+
const retrieved = getWallet("test-agent");
|
|
226
|
+
|
|
227
|
+
expect(retrieved).not.toBeNull();
|
|
228
|
+
expect(retrieved?.address).toBe(created.address);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("listWallets returns all wallets", () => {
|
|
232
|
+
getOrCreateWallet("agent-1");
|
|
233
|
+
getOrCreateWallet("agent-2");
|
|
234
|
+
getOrCreateWallet("agent-3");
|
|
235
|
+
|
|
236
|
+
const wallets = listWallets();
|
|
237
|
+
|
|
238
|
+
expect(Object.keys(wallets)).toHaveLength(3);
|
|
239
|
+
expect(wallets["agent-1"]).toBeDefined();
|
|
240
|
+
expect(wallets["agent-2"]).toBeDefined();
|
|
241
|
+
expect(wallets["agent-3"]).toBeDefined();
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("deleteWallet removes wallet", () => {
|
|
245
|
+
getOrCreateWallet("test-agent");
|
|
246
|
+
expect(getWallet("test-agent")).not.toBeNull();
|
|
247
|
+
|
|
248
|
+
const deleted = deleteWallet("test-agent");
|
|
249
|
+
|
|
250
|
+
expect(deleted).toBe(true);
|
|
251
|
+
expect(getWallet("test-agent")).toBeNull();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("deleteWallet returns false for non-existent agent", () => {
|
|
255
|
+
const deleted = deleteWallet("non-existent");
|
|
256
|
+
expect(deleted).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("message signing", () => {
|
|
261
|
+
it("signs messages with ed25519", async () => {
|
|
262
|
+
getOrCreateWallet("test-agent");
|
|
263
|
+
|
|
264
|
+
const message = new TextEncoder().encode("test message");
|
|
265
|
+
const signature = await signMessage("test-agent", message);
|
|
266
|
+
|
|
267
|
+
expect(signature).toBeInstanceOf(Uint8Array);
|
|
268
|
+
expect(signature.length).toBe(64); // ed25519 signature is 64 bytes
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("produces valid ed25519 signature", async () => {
|
|
272
|
+
const wallet = getOrCreateWallet("test-agent");
|
|
273
|
+
|
|
274
|
+
const message = new TextEncoder().encode("test message for verification");
|
|
275
|
+
const signature = await signMessage("test-agent", message);
|
|
276
|
+
|
|
277
|
+
// Verify signature using tweetnacl
|
|
278
|
+
const { sign } = await import("tweetnacl");
|
|
279
|
+
const publicKey = bs58.decode(wallet.address);
|
|
280
|
+
|
|
281
|
+
const isValid = sign.detached.verify(message, signature, publicKey);
|
|
282
|
+
expect(isValid).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("throws error for non-existent agent", async () => {
|
|
286
|
+
await expect(signMessage("non-existent", new Uint8Array([1, 2, 3]))).rejects.toThrow(
|
|
287
|
+
"No wallet found for agent: non-existent",
|
|
288
|
+
);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
});
|
package/src/wallet.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { Keypair } from "@solana/web3.js";
|
|
2
|
+
import bs58 from "bs58";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
|
|
7
|
+
export type WalletData = {
|
|
8
|
+
// Local keypair (generated by OpenClaw)
|
|
9
|
+
secretKey: string; // base58 encoded secret key
|
|
10
|
+
address: string; // base58 public key (signer address)
|
|
11
|
+
createdAt: string;
|
|
12
|
+
|
|
13
|
+
// From web app (after user completes delegation)
|
|
14
|
+
smartWalletAddress?: string; // The Crossmint smart wallet address
|
|
15
|
+
apiKey?: string; // Client-side API key from Crossmint
|
|
16
|
+
configuredAt?: string; // When the user completed setup
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type WalletStore = {
|
|
20
|
+
wallets: Record<string, WalletData>;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function getWalletsDir(): string {
|
|
24
|
+
// Allow override for testing
|
|
25
|
+
const override = process.env.CROSSMINT_WALLETS_DIR;
|
|
26
|
+
if (override) {
|
|
27
|
+
return override;
|
|
28
|
+
}
|
|
29
|
+
return path.join(os.homedir(), ".openclaw", "crossmint-wallets");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function ensureWalletsDir(): void {
|
|
33
|
+
const dir = getWalletsDir();
|
|
34
|
+
if (!fs.existsSync(dir)) {
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getStorePath(): string {
|
|
40
|
+
return path.join(getWalletsDir(), "wallets.json");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function loadStore(): WalletStore {
|
|
44
|
+
ensureWalletsDir();
|
|
45
|
+
const storePath = getStorePath();
|
|
46
|
+
if (!fs.existsSync(storePath)) {
|
|
47
|
+
return { wallets: {} };
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const data = fs.readFileSync(storePath, "utf-8");
|
|
51
|
+
return JSON.parse(data) as WalletStore;
|
|
52
|
+
} catch {
|
|
53
|
+
return { wallets: {} };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function saveStore(store: WalletStore): void {
|
|
58
|
+
ensureWalletsDir();
|
|
59
|
+
const storePath = getStorePath();
|
|
60
|
+
fs.writeFileSync(storePath, JSON.stringify(store, null, 2), {
|
|
61
|
+
encoding: "utf-8",
|
|
62
|
+
mode: 0o600, // Secure file permissions
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getOrCreateWallet(agentId: string): WalletData {
|
|
67
|
+
const store = loadStore();
|
|
68
|
+
|
|
69
|
+
if (store.wallets[agentId]) {
|
|
70
|
+
return store.wallets[agentId];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Generate new Solana keypair
|
|
74
|
+
const keypair = Keypair.generate();
|
|
75
|
+
const walletData: WalletData = {
|
|
76
|
+
secretKey: bs58.encode(keypair.secretKey),
|
|
77
|
+
address: keypair.publicKey.toBase58(),
|
|
78
|
+
createdAt: new Date().toISOString(),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
store.wallets[agentId] = walletData;
|
|
82
|
+
saveStore(store);
|
|
83
|
+
|
|
84
|
+
return walletData;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function getWallet(agentId: string): WalletData | null {
|
|
88
|
+
const store = loadStore();
|
|
89
|
+
return store.wallets[agentId] ?? null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isWalletConfigured(agentId: string): boolean {
|
|
93
|
+
const wallet = getWallet(agentId);
|
|
94
|
+
return !!(wallet?.smartWalletAddress && wallet?.apiKey);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function configureWallet(
|
|
98
|
+
agentId: string,
|
|
99
|
+
smartWalletAddress: string,
|
|
100
|
+
apiKey: string,
|
|
101
|
+
): WalletData {
|
|
102
|
+
const store = loadStore();
|
|
103
|
+
const existing = store.wallets[agentId];
|
|
104
|
+
|
|
105
|
+
if (!existing) {
|
|
106
|
+
throw new Error(`No wallet found for agent "${agentId}". Generate keypair first.`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
store.wallets[agentId] = {
|
|
110
|
+
...existing,
|
|
111
|
+
smartWalletAddress,
|
|
112
|
+
apiKey,
|
|
113
|
+
configuredAt: new Date().toISOString(),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
saveStore(store);
|
|
117
|
+
return store.wallets[agentId];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function listWallets(): Record<string, WalletData> {
|
|
121
|
+
const store = loadStore();
|
|
122
|
+
return store.wallets;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function deleteWallet(agentId: string): boolean {
|
|
126
|
+
const store = loadStore();
|
|
127
|
+
if (!store.wallets[agentId]) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
delete store.wallets[agentId];
|
|
131
|
+
saveStore(store);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function getKeypair(agentId: string): Keypair | null {
|
|
136
|
+
const walletData = getWallet(agentId);
|
|
137
|
+
if (!walletData) {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const secretKey = bs58.decode(walletData.secretKey);
|
|
142
|
+
return Keypair.fromSecretKey(secretKey);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function signMessage(agentId: string, message: Uint8Array): Promise<Uint8Array> {
|
|
146
|
+
const keypair = getKeypair(agentId);
|
|
147
|
+
if (!keypair) {
|
|
148
|
+
throw new Error(`No wallet found for agent: ${agentId}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Solana uses ed25519 signing via nacl
|
|
152
|
+
const nacl = (await import("tweetnacl")).default;
|
|
153
|
+
return nacl.sign.detached(message, keypair.secretKey);
|
|
154
|
+
}
|