@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.
@@ -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
+ }