@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/api.test.ts
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { Keypair } from "@solana/web3.js";
|
|
2
|
+
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
ProxyApiError,
|
|
5
|
+
buildAmazonProductLocator,
|
|
6
|
+
createOrder,
|
|
7
|
+
createTransfer,
|
|
8
|
+
getOrder,
|
|
9
|
+
getWalletBalance,
|
|
10
|
+
isAccessTokenExpiredError,
|
|
11
|
+
purchaseProduct,
|
|
12
|
+
startSetup,
|
|
13
|
+
submitPaymentConfirmation,
|
|
14
|
+
verifySetup,
|
|
15
|
+
refreshInit,
|
|
16
|
+
refreshToken,
|
|
17
|
+
waitForTransactionBroadcast,
|
|
18
|
+
type CrossmintApiConfig,
|
|
19
|
+
} from "./api.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("plugin api (server-backed)", () => {
|
|
31
|
+
const config: CrossmintApiConfig = {
|
|
32
|
+
serverBaseUrl: "https://example.com",
|
|
33
|
+
requestTimeoutMs: 5000,
|
|
34
|
+
accessToken: "test-access-token",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const fetchMock = vi.fn();
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
fetchMock.mockReset();
|
|
41
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
vi.unstubAllGlobals();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("buildAmazonProductLocator", () => {
|
|
49
|
+
it("returns existing amazon: locator unchanged", () => {
|
|
50
|
+
expect(buildAmazonProductLocator("amazon:B00O79SKV6")).toBe("amazon:B00O79SKV6");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("wraps Amazon URL with amazon: prefix", () => {
|
|
54
|
+
expect(buildAmazonProductLocator("https://www.amazon.com/dp/B00O79SKV6")).toBe(
|
|
55
|
+
"amazon:https://www.amazon.com/dp/B00O79SKV6"
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("wraps ASIN with amazon: prefix", () => {
|
|
60
|
+
expect(buildAmazonProductLocator("B00O79SKV6")).toBe("amazon:B00O79SKV6");
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("setup endpoints", () => {
|
|
65
|
+
it("calls /setup/start", async () => {
|
|
66
|
+
fetchMock.mockResolvedValueOnce(
|
|
67
|
+
jsonResponse({ pairingId: "pair-1", pairingNonce: "nonce-1", expiresAt: 12345 })
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const response = await startSetup(
|
|
71
|
+
{ serverBaseUrl: "https://example.com", requestTimeoutMs: 5000 },
|
|
72
|
+
{
|
|
73
|
+
agentId: "agent-1",
|
|
74
|
+
agentPubKey: "pubkey",
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(response.pairingId).toBe("pair-1");
|
|
79
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
80
|
+
|
|
81
|
+
const [url, request] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
82
|
+
expect(url).toBe("https://example.com/api/claw/setup/start");
|
|
83
|
+
expect(request.method).toBe("POST");
|
|
84
|
+
expect(request.body).toBe(JSON.stringify({ agentId: "agent-1", agentPubKey: "pubkey" }));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("calls /setup/verify and refresh endpoints", async () => {
|
|
88
|
+
fetchMock
|
|
89
|
+
.mockResolvedValueOnce(jsonResponse({ verified: true, consentUrl: "https://x/consent" }))
|
|
90
|
+
.mockResolvedValueOnce(jsonResponse({ nonceId: "nonce-id", refreshNonce: "nonce" }))
|
|
91
|
+
.mockResolvedValueOnce(
|
|
92
|
+
jsonResponse({ accessToken: "a2", refreshToken: "r2", expiresAt: 999999 })
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const verify = await verifySetup(
|
|
96
|
+
{ serverBaseUrl: "https://example.com", requestTimeoutMs: 5000 },
|
|
97
|
+
{ pairingId: "pair-1", signature: "sig" }
|
|
98
|
+
);
|
|
99
|
+
expect(verify.verified).toBe(true);
|
|
100
|
+
|
|
101
|
+
const init = await refreshInit(
|
|
102
|
+
{ serverBaseUrl: "https://example.com", requestTimeoutMs: 5000 },
|
|
103
|
+
{ agentId: "agent-1", agentPubKey: "pubkey" }
|
|
104
|
+
);
|
|
105
|
+
expect(init.nonceId).toBe("nonce-id");
|
|
106
|
+
|
|
107
|
+
const refreshed = await refreshToken(
|
|
108
|
+
{ serverBaseUrl: "https://example.com", requestTimeoutMs: 5000 },
|
|
109
|
+
{
|
|
110
|
+
agentId: "agent-1",
|
|
111
|
+
agentPubKey: "pubkey",
|
|
112
|
+
nonceId: "nonce-id",
|
|
113
|
+
refreshToken: "r1",
|
|
114
|
+
refreshSignature: "sig2",
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
expect(refreshed.accessToken).toBe("a2");
|
|
118
|
+
|
|
119
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("wallet + tx endpoints", () => {
|
|
124
|
+
it("requires access token for authenticated endpoints", async () => {
|
|
125
|
+
await expect(
|
|
126
|
+
getWalletBalance(
|
|
127
|
+
{
|
|
128
|
+
serverBaseUrl: "https://example.com",
|
|
129
|
+
requestTimeoutMs: 5000,
|
|
130
|
+
},
|
|
131
|
+
"wallet-1"
|
|
132
|
+
)
|
|
133
|
+
).rejects.toThrow("Missing access token");
|
|
134
|
+
|
|
135
|
+
expect(fetchMock).toHaveBeenCalledTimes(0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("creates and approves a transfer using proxy endpoints", async () => {
|
|
139
|
+
const keypair = Keypair.generate();
|
|
140
|
+
|
|
141
|
+
fetchMock
|
|
142
|
+
.mockResolvedValueOnce(
|
|
143
|
+
jsonResponse({
|
|
144
|
+
transactionId: "tx-1",
|
|
145
|
+
status: "awaiting-approval",
|
|
146
|
+
messageToSign: "hello",
|
|
147
|
+
messageToSignEncoding: "utf8",
|
|
148
|
+
})
|
|
149
|
+
)
|
|
150
|
+
.mockResolvedValueOnce(
|
|
151
|
+
jsonResponse({
|
|
152
|
+
transactionId: "tx-1",
|
|
153
|
+
status: "pending",
|
|
154
|
+
txHash: "hash-1",
|
|
155
|
+
explorerUrl: "https://explorer/tx/hash-1",
|
|
156
|
+
})
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const tx = await createTransfer(config, "wallet-1", "recipient-1", "usdc", "1.5", keypair);
|
|
160
|
+
|
|
161
|
+
expect(tx.id).toBe("tx-1");
|
|
162
|
+
expect(tx.hash).toBe("hash-1");
|
|
163
|
+
expect(tx.explorerLink).toBe("https://explorer/tx/hash-1");
|
|
164
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
165
|
+
|
|
166
|
+
const [, createReq] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
167
|
+
const createHeaders = createReq.headers as Record<string, string>;
|
|
168
|
+
expect(createHeaders.Authorization).toBe("Bearer test-access-token");
|
|
169
|
+
expect(createReq.body).toBe(
|
|
170
|
+
JSON.stringify({ type: "transfer", to: "recipient-1", token: "usdc", amount: "1.5" })
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const [, approveReq] = fetchMock.mock.calls[1] as [string, RequestInit];
|
|
174
|
+
const approveBody = JSON.parse(approveReq.body as string) as { signature: string };
|
|
175
|
+
expect(approveBody.signature).toMatch(/^[0-9a-f]+$/i);
|
|
176
|
+
expect(approveBody.signature.length).toBeGreaterThan(0);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("maps ACCESS_TOKEN_EXPIRED errors", async () => {
|
|
180
|
+
fetchMock.mockResolvedValueOnce(
|
|
181
|
+
jsonResponse(
|
|
182
|
+
{
|
|
183
|
+
error: {
|
|
184
|
+
code: "ACCESS_TOKEN_EXPIRED",
|
|
185
|
+
message: "token expired",
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
401
|
|
189
|
+
)
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
await getWalletBalance(config, "wallet-1");
|
|
194
|
+
expect.fail("Expected getWalletBalance to throw");
|
|
195
|
+
} catch (error) {
|
|
196
|
+
expect(error).toBeInstanceOf(ProxyApiError);
|
|
197
|
+
expect(isAccessTokenExpiredError(error)).toBe(true);
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("preserves proxy error metadata for non-expired failures", async () => {
|
|
202
|
+
fetchMock.mockResolvedValueOnce(
|
|
203
|
+
jsonResponse(
|
|
204
|
+
{
|
|
205
|
+
error: {
|
|
206
|
+
code: "RATE_LIMITED",
|
|
207
|
+
message: "Too many requests",
|
|
208
|
+
details: { retryAfterSeconds: 10 },
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
429
|
|
212
|
+
)
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
try {
|
|
216
|
+
await getWalletBalance(config, "wallet-1");
|
|
217
|
+
expect.fail("Expected getWalletBalance to throw");
|
|
218
|
+
} catch (error) {
|
|
219
|
+
expect(error).toBeInstanceOf(ProxyApiError);
|
|
220
|
+
expect(isAccessTokenExpiredError(error)).toBe(false);
|
|
221
|
+
expect((error as ProxyApiError).status).toBe(429);
|
|
222
|
+
expect((error as ProxyApiError).code).toBe("RATE_LIMITED");
|
|
223
|
+
expect((error as ProxyApiError).details).toEqual({ retryAfterSeconds: 10 });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("throws when broadcast polling observes failed transaction", async () => {
|
|
228
|
+
fetchMock.mockResolvedValueOnce(
|
|
229
|
+
jsonResponse({
|
|
230
|
+
transactionId: "tx-failed",
|
|
231
|
+
status: "failed",
|
|
232
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
233
|
+
updatedAt: "2026-01-01T00:00:01.000Z",
|
|
234
|
+
})
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
await expect(
|
|
238
|
+
waitForTransactionBroadcast(config, "wallet-1", "tx-failed", 1000, 1)
|
|
239
|
+
).rejects.toThrow("Transaction failed: tx-failed");
|
|
240
|
+
|
|
241
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("orders endpoints", () => {
|
|
246
|
+
it("includes x-order-client-secret for order follow-up calls", async () => {
|
|
247
|
+
fetchMock
|
|
248
|
+
.mockResolvedValueOnce(
|
|
249
|
+
jsonResponse({
|
|
250
|
+
orderId: "order-1",
|
|
251
|
+
clientSecret: "secret-1",
|
|
252
|
+
phase: "payment",
|
|
253
|
+
serializedTransaction: "serialized",
|
|
254
|
+
})
|
|
255
|
+
)
|
|
256
|
+
.mockResolvedValueOnce(jsonResponse({ orderId: "order-1", phase: "payment" }))
|
|
257
|
+
.mockResolvedValueOnce(jsonResponse({ orderId: "order-1", phase: "completed" }));
|
|
258
|
+
|
|
259
|
+
const create = await createOrder(config, {
|
|
260
|
+
recipient: {
|
|
261
|
+
email: "test@example.com",
|
|
262
|
+
physicalAddress: {
|
|
263
|
+
name: "Test",
|
|
264
|
+
line1: "123 Main",
|
|
265
|
+
city: "Miami",
|
|
266
|
+
postalCode: "33101",
|
|
267
|
+
country: "US",
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
payment: {
|
|
271
|
+
receiptEmail: "test@example.com",
|
|
272
|
+
method: "solana",
|
|
273
|
+
currency: "usdc",
|
|
274
|
+
payerAddress: "wallet-1",
|
|
275
|
+
},
|
|
276
|
+
lineItems: [{ productLocator: "amazon:B00O79SKV6" }],
|
|
277
|
+
});
|
|
278
|
+
expect(create.clientSecret).toBe("secret-1");
|
|
279
|
+
|
|
280
|
+
await getOrder(config, "order-1", "secret-1");
|
|
281
|
+
await submitPaymentConfirmation(config, "order-1", "tx-1", "secret-1");
|
|
282
|
+
|
|
283
|
+
const [, getReq] = fetchMock.mock.calls[1] as [string, RequestInit];
|
|
284
|
+
const getHeaders = getReq.headers as Record<string, string>;
|
|
285
|
+
expect(getHeaders["x-order-client-secret"]).toBe("secret-1");
|
|
286
|
+
|
|
287
|
+
const [, payReq] = fetchMock.mock.calls[2] as [string, RequestInit];
|
|
288
|
+
const payHeaders = payReq.headers as Record<string, string>;
|
|
289
|
+
expect(payHeaders["x-order-client-secret"]).toBe("secret-1");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("completes purchaseProduct flow", async () => {
|
|
293
|
+
const keypair = Keypair.generate();
|
|
294
|
+
|
|
295
|
+
fetchMock
|
|
296
|
+
.mockResolvedValueOnce(
|
|
297
|
+
jsonResponse({
|
|
298
|
+
orderId: "order-2",
|
|
299
|
+
clientSecret: "secret-2",
|
|
300
|
+
phase: "payment",
|
|
301
|
+
serializedTransaction: "serialized-tx",
|
|
302
|
+
})
|
|
303
|
+
)
|
|
304
|
+
.mockResolvedValueOnce(
|
|
305
|
+
jsonResponse({
|
|
306
|
+
transactionId: "tx-2",
|
|
307
|
+
status: "awaiting-approval",
|
|
308
|
+
messageToSign: "hello",
|
|
309
|
+
messageToSignEncoding: "utf8",
|
|
310
|
+
})
|
|
311
|
+
)
|
|
312
|
+
.mockResolvedValueOnce(
|
|
313
|
+
jsonResponse({
|
|
314
|
+
transactionId: "tx-2",
|
|
315
|
+
status: "success",
|
|
316
|
+
txHash: "on-chain-2",
|
|
317
|
+
explorerUrl: "https://explorer/tx/on-chain-2",
|
|
318
|
+
})
|
|
319
|
+
)
|
|
320
|
+
.mockResolvedValueOnce(
|
|
321
|
+
jsonResponse({
|
|
322
|
+
orderId: "order-2",
|
|
323
|
+
phase: "completed",
|
|
324
|
+
payment: { status: "completed" },
|
|
325
|
+
})
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
const result = await purchaseProduct(
|
|
329
|
+
config,
|
|
330
|
+
{
|
|
331
|
+
recipient: {
|
|
332
|
+
email: "test@example.com",
|
|
333
|
+
physicalAddress: {
|
|
334
|
+
name: "Test",
|
|
335
|
+
line1: "123 Main",
|
|
336
|
+
city: "Miami",
|
|
337
|
+
postalCode: "33101",
|
|
338
|
+
country: "US",
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
payment: {
|
|
342
|
+
receiptEmail: "test@example.com",
|
|
343
|
+
method: "solana",
|
|
344
|
+
currency: "usdc",
|
|
345
|
+
payerAddress: "wallet-1",
|
|
346
|
+
},
|
|
347
|
+
lineItems: [{ productLocator: "amazon:B00O79SKV6" }],
|
|
348
|
+
},
|
|
349
|
+
keypair
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
expect(result.orderId).toBe("order-2");
|
|
353
|
+
expect(result.clientSecret).toBe("secret-2");
|
|
354
|
+
expect(result.transactionId).toBe("tx-2");
|
|
355
|
+
expect(result.onChainTxId).toBe("on-chain-2");
|
|
356
|
+
expect(result.explorerLink).toBe("https://explorer/tx/on-chain-2");
|
|
357
|
+
expect(fetchMock).toHaveBeenCalledTimes(4);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("falls back to broadcast polling when approve returns no tx hash", async () => {
|
|
361
|
+
const keypair = Keypair.generate();
|
|
362
|
+
const prodConfig: CrossmintApiConfig = {
|
|
363
|
+
serverBaseUrl: "https://api.crossmint.com",
|
|
364
|
+
requestTimeoutMs: 5000,
|
|
365
|
+
accessToken: "test-access-token",
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
fetchMock
|
|
369
|
+
.mockResolvedValueOnce(
|
|
370
|
+
jsonResponse({
|
|
371
|
+
orderId: "order-3",
|
|
372
|
+
clientSecret: "secret-3",
|
|
373
|
+
phase: "payment",
|
|
374
|
+
serializedTransaction: "serialized-tx-3",
|
|
375
|
+
})
|
|
376
|
+
)
|
|
377
|
+
.mockResolvedValueOnce(
|
|
378
|
+
jsonResponse({
|
|
379
|
+
transactionId: "tx-3",
|
|
380
|
+
status: "awaiting-approval",
|
|
381
|
+
messageToSign: "hello",
|
|
382
|
+
messageToSignEncoding: "utf8",
|
|
383
|
+
})
|
|
384
|
+
)
|
|
385
|
+
.mockResolvedValueOnce(
|
|
386
|
+
jsonResponse({
|
|
387
|
+
transactionId: "tx-3",
|
|
388
|
+
status: "pending",
|
|
389
|
+
})
|
|
390
|
+
)
|
|
391
|
+
.mockResolvedValueOnce(
|
|
392
|
+
jsonResponse({
|
|
393
|
+
transactionId: "tx-3",
|
|
394
|
+
status: "pending",
|
|
395
|
+
txHash: "on-chain-3",
|
|
396
|
+
createdAt: "2026-01-01T00:00:00.000Z",
|
|
397
|
+
updatedAt: "2026-01-01T00:00:01.000Z",
|
|
398
|
+
})
|
|
399
|
+
)
|
|
400
|
+
.mockResolvedValueOnce(
|
|
401
|
+
jsonResponse({
|
|
402
|
+
orderId: "order-3",
|
|
403
|
+
phase: "completed",
|
|
404
|
+
payment: { status: "completed" },
|
|
405
|
+
})
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
const result = await purchaseProduct(
|
|
409
|
+
prodConfig,
|
|
410
|
+
{
|
|
411
|
+
recipient: {
|
|
412
|
+
email: "test@example.com",
|
|
413
|
+
physicalAddress: {
|
|
414
|
+
name: "Test",
|
|
415
|
+
line1: "123 Main",
|
|
416
|
+
city: "Miami",
|
|
417
|
+
postalCode: "33101",
|
|
418
|
+
country: "US",
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
payment: {
|
|
422
|
+
receiptEmail: "test@example.com",
|
|
423
|
+
method: "solana",
|
|
424
|
+
currency: "usdc",
|
|
425
|
+
payerAddress: "wallet-1",
|
|
426
|
+
},
|
|
427
|
+
lineItems: [{ productLocator: "amazon:B00O79SKV6" }],
|
|
428
|
+
},
|
|
429
|
+
keypair
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
expect(result.orderId).toBe("order-3");
|
|
433
|
+
expect(result.transactionId).toBe("tx-3");
|
|
434
|
+
expect(result.onChainTxId).toBe("on-chain-3");
|
|
435
|
+
expect(result.explorerLink).toBe("https://explorer.solana.com/tx/on-chain-3");
|
|
436
|
+
expect(fetchMock).toHaveBeenCalledTimes(5);
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
});
|