@crossmint/lobster.cash 0.1.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/README.md +121 -0
- package/index.ts +73 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +43 -0
- package/skills/crossmint/SKILL.md +274 -0
- package/src/amazon-order.test.ts +548 -0
- package/src/api.test.ts +439 -0
- package/src/api.ts +668 -0
- package/src/config.test.ts +53 -0
- package/src/config.ts +50 -0
- package/src/tools.test.ts +354 -0
- package/src/tools.ts +989 -0
- package/src/wallet.test.ts +367 -0
- package/src/wallet.ts +328 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const DEFAULT_SERVER_BASE_URL = "https://www.lobster.cash";
|
|
2
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 15000;
|
|
3
|
+
|
|
4
|
+
export type CrossmintPluginConfig = {
|
|
5
|
+
serverBaseUrl: string;
|
|
6
|
+
requestTimeoutMs: number;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function normalizeBaseUrl(baseUrl: string): string {
|
|
10
|
+
return baseUrl.replace(/\/+$/, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const crossmintConfigSchema = {
|
|
14
|
+
parse(value: unknown): CrossmintPluginConfig {
|
|
15
|
+
if (value !== undefined && value !== null && typeof value !== "object") {
|
|
16
|
+
throw new Error("Plugin config must be an object");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const cfg = (value ?? {}) as Record<string, unknown>;
|
|
20
|
+
const keys = Object.keys(cfg);
|
|
21
|
+
const allowedKeys = new Set(["serverBaseUrl", "requestTimeoutMs"]);
|
|
22
|
+
const unknownKeys = keys.filter((key) => !allowedKeys.has(key));
|
|
23
|
+
if (unknownKeys.length > 0) {
|
|
24
|
+
throw new Error(`Unknown plugin config keys: ${unknownKeys.join(", ")}`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const serverBaseUrlRaw = cfg.serverBaseUrl;
|
|
28
|
+
if (serverBaseUrlRaw !== undefined && typeof serverBaseUrlRaw !== "string") {
|
|
29
|
+
throw new Error("serverBaseUrl must be a string");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const requestTimeoutMsRaw = cfg.requestTimeoutMs;
|
|
33
|
+
if (
|
|
34
|
+
requestTimeoutMsRaw !== undefined &&
|
|
35
|
+
(typeof requestTimeoutMsRaw !== "number" ||
|
|
36
|
+
!Number.isFinite(requestTimeoutMsRaw) ||
|
|
37
|
+
requestTimeoutMsRaw <= 0)
|
|
38
|
+
) {
|
|
39
|
+
throw new Error("requestTimeoutMs must be a positive number");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const serverBaseUrl = normalizeBaseUrl(serverBaseUrlRaw || DEFAULT_SERVER_BASE_URL);
|
|
43
|
+
const requestTimeoutMs = requestTimeoutMsRaw ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
serverBaseUrl,
|
|
47
|
+
requestTimeoutMs,
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
};
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk";
|
|
6
|
+
import {
|
|
7
|
+
completeSetup,
|
|
8
|
+
configureWallet,
|
|
9
|
+
getOrCreateWallet,
|
|
10
|
+
getWallet,
|
|
11
|
+
updateSetupPending,
|
|
12
|
+
} from "./wallet.js";
|
|
13
|
+
import {
|
|
14
|
+
createCrossmintBalanceTool,
|
|
15
|
+
createCrossmintConfigureTool,
|
|
16
|
+
createCrossmintOrderStatusTool,
|
|
17
|
+
createCrossmintSetupTool,
|
|
18
|
+
createCrossmintWalletInfoTool,
|
|
19
|
+
} from "./tools.js";
|
|
20
|
+
|
|
21
|
+
function jsonResponse(data: unknown, status = 200): Response {
|
|
22
|
+
return new Response(JSON.stringify(data), {
|
|
23
|
+
status,
|
|
24
|
+
headers: {
|
|
25
|
+
"Content-Type": "application/json",
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("tools (server flow)", () => {
|
|
31
|
+
let tmpDir: string;
|
|
32
|
+
let previousWalletDir: string | undefined;
|
|
33
|
+
|
|
34
|
+
const fetchMock = vi.fn();
|
|
35
|
+
|
|
36
|
+
const config = {
|
|
37
|
+
serverBaseUrl: "https://example.com",
|
|
38
|
+
requestTimeoutMs: 5000,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const context = {
|
|
42
|
+
agentId: "agent-test",
|
|
43
|
+
} as OpenClawPluginToolContext;
|
|
44
|
+
|
|
45
|
+
beforeEach(async () => {
|
|
46
|
+
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "openclaw-plugin-tools-"));
|
|
47
|
+
previousWalletDir = process.env.CROSSMINT_WALLETS_DIR;
|
|
48
|
+
process.env.CROSSMINT_WALLETS_DIR = tmpDir;
|
|
49
|
+
|
|
50
|
+
fetchMock.mockReset();
|
|
51
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(async () => {
|
|
55
|
+
vi.unstubAllGlobals();
|
|
56
|
+
|
|
57
|
+
if (previousWalletDir === undefined) {
|
|
58
|
+
delete process.env.CROSSMINT_WALLETS_DIR;
|
|
59
|
+
} else {
|
|
60
|
+
process.env.CROSSMINT_WALLETS_DIR = previousWalletDir;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await fs.promises.rm(tmpDir, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("starts setup and returns consent URL", async () => {
|
|
67
|
+
fetchMock
|
|
68
|
+
.mockResolvedValueOnce(
|
|
69
|
+
jsonResponse({ pairingId: "pair-1", pairingNonce: "nonce-1", expiresAt: 12345 })
|
|
70
|
+
)
|
|
71
|
+
.mockResolvedValueOnce(
|
|
72
|
+
jsonResponse({
|
|
73
|
+
verified: true,
|
|
74
|
+
consentUrl: "https://example.com/setup/consent?pairingId=pair-1",
|
|
75
|
+
})
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const tool = createCrossmintSetupTool({} as never, config);
|
|
79
|
+
const result = await tool.execute("id", {}, context);
|
|
80
|
+
|
|
81
|
+
expect(result.content[0]?.type).toBe("text");
|
|
82
|
+
expect((result.content[0] as { text: string }).text).toContain(
|
|
83
|
+
"https://example.com/setup/consent?pairingId=pair-1"
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const wallet = getWallet("agent-test");
|
|
87
|
+
expect(wallet?.pairingId).toBe("pair-1");
|
|
88
|
+
expect(wallet?.consentUrl).toBe("https://example.com/setup/consent?pairingId=pair-1");
|
|
89
|
+
expect(wallet?.setupStatus).toBe("awaiting-consent");
|
|
90
|
+
|
|
91
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
92
|
+
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://example.com/api/claw/setup/start");
|
|
93
|
+
expect(fetchMock.mock.calls[1]?.[0]).toBe("https://example.com/api/claw/setup/verify");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returns already configured when setup is complete", async () => {
|
|
97
|
+
getOrCreateWallet("agent-test");
|
|
98
|
+
completeSetup("agent-test", "wallet-1", "access-token", "refresh-token", 9999999999);
|
|
99
|
+
|
|
100
|
+
const tool = createCrossmintSetupTool({} as never, config);
|
|
101
|
+
const result = await tool.execute("id", {}, context);
|
|
102
|
+
|
|
103
|
+
expect((result.content[0] as { text: string }).text).toContain("Wallet already configured");
|
|
104
|
+
expect((result.details as { status?: string }).status).toBe("already_configured");
|
|
105
|
+
expect(fetchMock).toHaveBeenCalledTimes(0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns migration guidance when setup sees legacy wallet data", async () => {
|
|
109
|
+
getOrCreateWallet("agent-test");
|
|
110
|
+
configureWallet("agent-test", "legacy-wallet", "legacy-api-key");
|
|
111
|
+
|
|
112
|
+
const tool = createCrossmintSetupTool({} as never, config);
|
|
113
|
+
const result = await tool.execute("id", {}, context);
|
|
114
|
+
|
|
115
|
+
expect((result.content[0] as { text: string }).text).toContain(
|
|
116
|
+
"Legacy wallet configuration detected"
|
|
117
|
+
);
|
|
118
|
+
expect((result.details as { status?: string }).status).toBe("legacy_detected");
|
|
119
|
+
expect(fetchMock).toHaveBeenCalledTimes(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("keeps setup pending when user has not approved consent yet", async () => {
|
|
123
|
+
getOrCreateWallet("agent-test");
|
|
124
|
+
updateSetupPending(
|
|
125
|
+
"agent-test",
|
|
126
|
+
"pair-approval",
|
|
127
|
+
"https://example.com/setup/consent?pairingId=pair-approval"
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
fetchMock.mockResolvedValueOnce(
|
|
131
|
+
jsonResponse({ status: "verified-awaiting-consent", expiresAt: 12345 })
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const tool = createCrossmintSetupTool({} as never, config);
|
|
135
|
+
const result = await tool.execute("id", {}, context);
|
|
136
|
+
const wallet = getWallet("agent-test");
|
|
137
|
+
|
|
138
|
+
expect((result.content[0] as { text: string }).text).toContain(
|
|
139
|
+
"waiting for user consent for agent"
|
|
140
|
+
);
|
|
141
|
+
expect((result.details as { status?: string }).status).toBe("awaiting-approval");
|
|
142
|
+
expect(wallet?.pairingId).toBe("pair-approval");
|
|
143
|
+
expect(wallet?.setupStatus).toBe("awaiting-approval");
|
|
144
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("clears pairing and marks setup denied when consent is denied", async () => {
|
|
148
|
+
getOrCreateWallet("agent-test");
|
|
149
|
+
updateSetupPending("agent-test", "pair-denied", "https://example.com/setup/consent");
|
|
150
|
+
|
|
151
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ status: "denied" }));
|
|
152
|
+
|
|
153
|
+
const tool = createCrossmintSetupTool({} as never, config);
|
|
154
|
+
const result = await tool.execute("id", {}, context);
|
|
155
|
+
const wallet = getWallet("agent-test");
|
|
156
|
+
|
|
157
|
+
expect((result.content[0] as { text: string }).text).toContain("Setup was denied");
|
|
158
|
+
expect(wallet?.setupStatus).toBe("denied");
|
|
159
|
+
expect(wallet?.pairingId).toBeUndefined();
|
|
160
|
+
expect(wallet?.consentUrl).toBeUndefined();
|
|
161
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("clears pairing and marks setup expired when setup window expires", async () => {
|
|
165
|
+
getOrCreateWallet("agent-test");
|
|
166
|
+
updateSetupPending("agent-test", "pair-expired", "https://example.com/setup/consent");
|
|
167
|
+
|
|
168
|
+
fetchMock.mockResolvedValueOnce(jsonResponse({ status: "expired" }));
|
|
169
|
+
|
|
170
|
+
const tool = createCrossmintSetupTool({} as never, config);
|
|
171
|
+
const result = await tool.execute("id", {}, context);
|
|
172
|
+
const wallet = getWallet("agent-test");
|
|
173
|
+
|
|
174
|
+
expect((result.content[0] as { text: string }).text).toContain("Setup request expired");
|
|
175
|
+
expect(wallet?.setupStatus).toBe("expired");
|
|
176
|
+
expect(wallet?.pairingId).toBeUndefined();
|
|
177
|
+
expect(wallet?.consentUrl).toBeUndefined();
|
|
178
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("finalizes setup when pairing is approved", async () => {
|
|
182
|
+
getOrCreateWallet("agent-test");
|
|
183
|
+
updateSetupPending(
|
|
184
|
+
"agent-test",
|
|
185
|
+
"pair-2",
|
|
186
|
+
"https://example.com/setup/consent?pairingId=pair-2"
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
fetchMock
|
|
190
|
+
.mockResolvedValueOnce(
|
|
191
|
+
jsonResponse({ status: "approved", retrievalNonce: "retrieval-nonce", expiresAt: 12345 })
|
|
192
|
+
)
|
|
193
|
+
.mockResolvedValueOnce(
|
|
194
|
+
jsonResponse({
|
|
195
|
+
walletAddress: "wallet-1",
|
|
196
|
+
accessToken: "access-token",
|
|
197
|
+
refreshToken: "refresh-token",
|
|
198
|
+
expiresAt: 9999999999,
|
|
199
|
+
})
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const tool = createCrossmintSetupTool({} as never, config);
|
|
203
|
+
const result = await tool.execute("id", {}, context);
|
|
204
|
+
|
|
205
|
+
expect((result.content[0] as { text: string }).text).toContain(
|
|
206
|
+
"Wallet configured successfully"
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
const wallet = getWallet("agent-test");
|
|
210
|
+
expect(wallet?.walletAddress).toBe("wallet-1");
|
|
211
|
+
expect(wallet?.accessToken).toBe("access-token");
|
|
212
|
+
expect(wallet?.refreshToken).toBe("refresh-token");
|
|
213
|
+
expect(wallet?.setupStatus).toBe("configured");
|
|
214
|
+
expect(wallet?.pairingId).toBeUndefined();
|
|
215
|
+
|
|
216
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
217
|
+
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
218
|
+
"https://example.com/api/claw/setup/status?pairingId=pair-2"
|
|
219
|
+
);
|
|
220
|
+
expect(fetchMock.mock.calls[1]?.[0]).toBe("https://example.com/api/claw/setup/retrieve");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("refreshes expired access token and retries balance", async () => {
|
|
224
|
+
getOrCreateWallet("agent-test");
|
|
225
|
+
completeSetup("agent-test", "wallet-1", "old-access-token", "old-refresh-token", 9999999999);
|
|
226
|
+
|
|
227
|
+
fetchMock
|
|
228
|
+
.mockResolvedValueOnce(
|
|
229
|
+
jsonResponse(
|
|
230
|
+
{
|
|
231
|
+
error: {
|
|
232
|
+
code: "ACCESS_TOKEN_EXPIRED",
|
|
233
|
+
message: "expired",
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
401
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
.mockResolvedValueOnce(jsonResponse({ nonceId: "nonce-id", refreshNonce: "refresh-nonce" }))
|
|
240
|
+
.mockResolvedValueOnce(
|
|
241
|
+
jsonResponse({
|
|
242
|
+
accessToken: "new-access-token",
|
|
243
|
+
refreshToken: "new-refresh-token",
|
|
244
|
+
expiresAt: 9999999999,
|
|
245
|
+
})
|
|
246
|
+
)
|
|
247
|
+
.mockResolvedValueOnce(
|
|
248
|
+
jsonResponse({
|
|
249
|
+
balances: [{ token: "usdc", amount: "5" }],
|
|
250
|
+
})
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
const tool = createCrossmintBalanceTool({} as never, config);
|
|
254
|
+
const result = await tool.execute("id", {}, context);
|
|
255
|
+
|
|
256
|
+
const text = (result.content[0] as { text: string }).text;
|
|
257
|
+
expect(text).toContain("usdc: 5");
|
|
258
|
+
|
|
259
|
+
const wallet = getWallet("agent-test");
|
|
260
|
+
expect(wallet?.accessToken).toBe("new-access-token");
|
|
261
|
+
expect(wallet?.refreshToken).toBe("new-refresh-token");
|
|
262
|
+
|
|
263
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
264
|
+
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
|
265
|
+
"https://example.com/api/proxy/wallets/wallet-1/balance"
|
|
266
|
+
);
|
|
267
|
+
expect(fetchMock.mock.calls[1]?.[0]).toBe("https://example.com/api/claw/token/refresh/init");
|
|
268
|
+
expect(fetchMock.mock.calls[2]?.[0]).toBe("https://example.com/api/claw/token/refresh");
|
|
269
|
+
expect(fetchMock.mock.calls[3]?.[0]).toBe(
|
|
270
|
+
"https://example.com/api/proxy/wallets/wallet-1/balance"
|
|
271
|
+
);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("refreshes expiring tokens before first authenticated request", async () => {
|
|
275
|
+
getOrCreateWallet("agent-test");
|
|
276
|
+
completeSetup(
|
|
277
|
+
"agent-test",
|
|
278
|
+
"wallet-1",
|
|
279
|
+
"almost-expired-token",
|
|
280
|
+
"refresh-token",
|
|
281
|
+
Math.floor(Date.now() / 1000) + 10
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
fetchMock
|
|
285
|
+
.mockResolvedValueOnce(
|
|
286
|
+
jsonResponse({ nonceId: "nonce-id", refreshNonce: "refresh-nonce-proactive" })
|
|
287
|
+
)
|
|
288
|
+
.mockResolvedValueOnce(
|
|
289
|
+
jsonResponse({
|
|
290
|
+
accessToken: "fresh-access-token",
|
|
291
|
+
refreshToken: "fresh-refresh-token",
|
|
292
|
+
expiresAt: 9999999999,
|
|
293
|
+
})
|
|
294
|
+
)
|
|
295
|
+
.mockResolvedValueOnce(
|
|
296
|
+
jsonResponse({
|
|
297
|
+
balances: [{ token: "usdc", amount: "9" }],
|
|
298
|
+
})
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const tool = createCrossmintBalanceTool({} as never, config);
|
|
302
|
+
const result = await tool.execute("id", {}, context);
|
|
303
|
+
|
|
304
|
+
expect((result.content[0] as { text: string }).text).toContain("usdc: 9");
|
|
305
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
306
|
+
expect(fetchMock.mock.calls[0]?.[0]).toBe("https://example.com/api/claw/token/refresh/init");
|
|
307
|
+
expect(fetchMock.mock.calls[1]?.[0]).toBe("https://example.com/api/claw/token/refresh");
|
|
308
|
+
expect(fetchMock.mock.calls[2]?.[0]).toBe(
|
|
309
|
+
"https://example.com/api/proxy/wallets/wallet-1/balance"
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it("returns deprecation guidance from crossmint_configure when wallet is not configured", async () => {
|
|
314
|
+
const tool = createCrossmintConfigureTool({} as never, config);
|
|
315
|
+
const result = await tool.execute("id", {}, context);
|
|
316
|
+
|
|
317
|
+
expect((result.content[0] as { text: string }).text).toContain(
|
|
318
|
+
"crossmint_configure is deprecated"
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("returns already configured from crossmint_configure after setup", async () => {
|
|
323
|
+
getOrCreateWallet("agent-test");
|
|
324
|
+
completeSetup("agent-test", "wallet-1", "access-token", "refresh-token", 9999999999);
|
|
325
|
+
|
|
326
|
+
const tool = createCrossmintConfigureTool({} as never, config);
|
|
327
|
+
const result = await tool.execute("id", {}, context);
|
|
328
|
+
|
|
329
|
+
expect((result.content[0] as { text: string }).text).toContain("already configured");
|
|
330
|
+
expect((result.details as { status?: string }).status).toBe("already_configured");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("returns wallet info for legacy configuration", async () => {
|
|
334
|
+
getOrCreateWallet("agent-test");
|
|
335
|
+
configureWallet("agent-test", "legacy-wallet", "legacy-api-key");
|
|
336
|
+
|
|
337
|
+
const tool = createCrossmintWalletInfoTool({} as never, config);
|
|
338
|
+
const result = await tool.execute("id", {}, context);
|
|
339
|
+
|
|
340
|
+
expect((result.content[0] as { text: string }).text).toContain("legacy config");
|
|
341
|
+
expect((result.details as { legacy?: boolean }).legacy).toBe(true);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("order status tool returns hint when client secret is not available", async () => {
|
|
345
|
+
getOrCreateWallet("agent-test");
|
|
346
|
+
completeSetup("agent-test", "wallet-1", "access-token", "refresh-token", 9999999999);
|
|
347
|
+
|
|
348
|
+
const tool = createCrossmintOrderStatusTool({} as never, config);
|
|
349
|
+
const result = await tool.execute("id", { orderId: "order-missing" }, context);
|
|
350
|
+
|
|
351
|
+
expect((result.content[0] as { text: string }).text).toContain("No stored clientSecret");
|
|
352
|
+
expect(fetchMock).toHaveBeenCalledTimes(0);
|
|
353
|
+
});
|
|
354
|
+
});
|