@solana/keychain-para 0.0.0 → 0.6.0

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,492 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ import { assertIsSolanaSigner } from '@solana/keychain-core';
4
+
5
+ vi.mock('@solana/keychain-core', async importOriginal => {
6
+ const mod = await importOriginal<typeof import('@solana/keychain-core')>();
7
+ return { ...mod, assertSignatureValid: vi.fn() };
8
+ });
9
+
10
+ import { ParaSigner } from '../para-signer.js';
11
+
12
+ // Mock fetch globally
13
+ global.fetch = vi.fn();
14
+
15
+ // Valid 64-byte Ed25519 signature as 128 hex chars
16
+ const MOCK_SIGNATURE = 'ab'.repeat(64);
17
+
18
+ const MOCK_ADDRESS = '11111111111111111111111111111111';
19
+
20
+ const mockConfig = {
21
+ apiKey: 'sk_test_api_key',
22
+ apiBaseUrl: 'https://api.test.getpara.com',
23
+ walletId: '00000000-0000-0000-0000-000000000000',
24
+ };
25
+
26
+ function mockWalletResponse(overrides?: Record<string, unknown>) {
27
+ return new Response(
28
+ JSON.stringify({
29
+ address: MOCK_ADDRESS,
30
+ id: '00000000-0000-0000-0000-000000000000',
31
+ publicKey: MOCK_ADDRESS,
32
+ status: 'ready',
33
+ type: 'SOLANA',
34
+ ...overrides,
35
+ }),
36
+ { status: 200 },
37
+ );
38
+ }
39
+
40
+ function mockSignResponse(signature = MOCK_SIGNATURE) {
41
+ return new Response(JSON.stringify({ signature }), { status: 200 });
42
+ }
43
+
44
+ describe('ParaSigner', () => {
45
+ beforeEach(() => {
46
+ vi.resetAllMocks();
47
+ });
48
+
49
+ describe('create', () => {
50
+ it('should create a signer by fetching wallet address', async () => {
51
+ vi.mocked(fetch).mockResolvedValueOnce(mockWalletResponse());
52
+
53
+ const signer = await ParaSigner.create(mockConfig);
54
+
55
+ expect(signer.address).toBe(MOCK_ADDRESS);
56
+ expect(fetch).toHaveBeenCalledWith(
57
+ 'https://api.test.getpara.com/v1/wallets/00000000-0000-0000-0000-000000000000',
58
+ expect.objectContaining({
59
+ headers: { 'X-API-Key': 'sk_test_api_key' },
60
+ method: 'GET',
61
+ }),
62
+ );
63
+ });
64
+
65
+ it('should satisfy the SolanaSigner interface', async () => {
66
+ vi.mocked(fetch).mockResolvedValueOnce(mockWalletResponse());
67
+
68
+ const signer = await ParaSigner.create(mockConfig);
69
+
70
+ assertIsSolanaSigner(signer);
71
+ });
72
+
73
+ it('should throw CONFIG_ERROR for missing apiKey', async () => {
74
+ await expect(ParaSigner.create({ ...mockConfig, apiKey: '' })).rejects.toMatchObject({
75
+ code: 'SIGNER_CONFIG_ERROR',
76
+ message: expect.stringContaining('Missing required configuration fields'),
77
+ });
78
+ });
79
+
80
+ it('should throw CONFIG_ERROR for missing walletId', async () => {
81
+ await expect(ParaSigner.create({ ...mockConfig, walletId: '' })).rejects.toMatchObject({
82
+ code: 'SIGNER_CONFIG_ERROR',
83
+ message: expect.stringContaining('Missing required configuration fields'),
84
+ });
85
+ });
86
+
87
+ it('should throw CONFIG_ERROR for non-sk_ apiKey', async () => {
88
+ await expect(ParaSigner.create({ ...mockConfig, apiKey: 'pk_test_key' })).rejects.toMatchObject({
89
+ code: 'SIGNER_CONFIG_ERROR',
90
+ message: expect.stringContaining('apiKey must be a Para secret key'),
91
+ });
92
+ });
93
+
94
+ it('should throw CONFIG_ERROR for non-UUID walletId', async () => {
95
+ await expect(ParaSigner.create({ ...mockConfig, walletId: 'not-a-uuid' })).rejects.toMatchObject({
96
+ code: 'SIGNER_CONFIG_ERROR',
97
+ message: expect.stringContaining('walletId must be a valid UUID'),
98
+ });
99
+ });
100
+
101
+ it('should throw CONFIG_ERROR for non-HTTPS apiBaseUrl', async () => {
102
+ await expect(
103
+ ParaSigner.create({ ...mockConfig, apiBaseUrl: 'http://api.getpara.com' }),
104
+ ).rejects.toMatchObject({
105
+ code: 'SIGNER_CONFIG_ERROR',
106
+ message: expect.stringContaining('apiBaseUrl must use HTTPS'),
107
+ });
108
+ });
109
+
110
+ it('should throw CONFIG_ERROR for invalid apiBaseUrl', async () => {
111
+ await expect(ParaSigner.create({ ...mockConfig, apiBaseUrl: 'not-a-url' })).rejects.toMatchObject({
112
+ code: 'SIGNER_CONFIG_ERROR',
113
+ message: expect.stringContaining('apiBaseUrl is not a valid URL'),
114
+ });
115
+ });
116
+
117
+ it('should throw CONFIG_ERROR for non-SOLANA wallet type', async () => {
118
+ vi.mocked(fetch).mockResolvedValueOnce(mockWalletResponse({ type: 'EVM' }));
119
+
120
+ await expect(ParaSigner.create(mockConfig)).rejects.toMatchObject({
121
+ code: 'SIGNER_CONFIG_ERROR',
122
+ message: expect.stringContaining('Expected SOLANA wallet but got EVM'),
123
+ });
124
+ });
125
+
126
+ it('should throw REMOTE_API_ERROR when wallet has no address', async () => {
127
+ vi.mocked(fetch).mockResolvedValueOnce(mockWalletResponse({ address: null }));
128
+
129
+ await expect(ParaSigner.create(mockConfig)).rejects.toMatchObject({
130
+ code: 'SIGNER_REMOTE_API_ERROR',
131
+ message: expect.stringContaining('does not have an address'),
132
+ });
133
+ });
134
+
135
+ it('should throw REMOTE_API_ERROR when API returns error', async () => {
136
+ vi.mocked(fetch).mockResolvedValueOnce(
137
+ new Response(JSON.stringify({ message: 'Unauthorized' }), { status: 401 }),
138
+ );
139
+
140
+ await expect(ParaSigner.create(mockConfig)).rejects.toMatchObject({
141
+ code: 'SIGNER_REMOTE_API_ERROR',
142
+ message: expect.stringContaining('Failed to fetch wallet'),
143
+ });
144
+ });
145
+
146
+ it('should throw HTTP_ERROR when network fails', async () => {
147
+ vi.mocked(fetch).mockRejectedValueOnce(new Error('Network error'));
148
+
149
+ await expect(ParaSigner.create(mockConfig)).rejects.toMatchObject({
150
+ code: 'SIGNER_HTTP_ERROR',
151
+ message: expect.stringContaining('Para network request failed'),
152
+ });
153
+ });
154
+
155
+ it('should remove trailing slash from apiBaseUrl', async () => {
156
+ vi.mocked(fetch).mockResolvedValueOnce(mockWalletResponse());
157
+
158
+ await ParaSigner.create({ ...mockConfig, apiBaseUrl: 'https://api.test.getpara.com/' });
159
+
160
+ expect(fetch).toHaveBeenCalledWith(
161
+ 'https://api.test.getpara.com/v1/wallets/00000000-0000-0000-0000-000000000000',
162
+ expect.anything(),
163
+ );
164
+ });
165
+
166
+ it('should throw CONFIG_ERROR for negative requestDelayMs', async () => {
167
+ vi.mocked(fetch).mockResolvedValueOnce(mockWalletResponse());
168
+
169
+ await expect(ParaSigner.create({ ...mockConfig, requestDelayMs: -1 })).rejects.toMatchObject({
170
+ code: 'SIGNER_CONFIG_ERROR',
171
+ message: expect.stringContaining('requestDelayMs must not be negative'),
172
+ });
173
+ });
174
+
175
+ it('should warn for high requestDelayMs', async () => {
176
+ vi.mocked(fetch).mockResolvedValueOnce(mockWalletResponse());
177
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
178
+
179
+ await ParaSigner.create({ ...mockConfig, requestDelayMs: 3001 });
180
+
181
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('requestDelayMs is greater than 3000ms'));
182
+ warnSpy.mockRestore();
183
+ });
184
+ });
185
+
186
+ describe('isAvailable', () => {
187
+ it('should return true when wallet is ready', async () => {
188
+ vi.mocked(fetch)
189
+ .mockResolvedValueOnce(mockWalletResponse()) // create
190
+ .mockResolvedValueOnce(mockWalletResponse({ status: 'ready' })); // isAvailable
191
+
192
+ const signer = await ParaSigner.create(mockConfig);
193
+ const result = await signer.isAvailable();
194
+
195
+ expect(result).toBe(true);
196
+ });
197
+
198
+ it('should return true when wallet status is ACTIVE', async () => {
199
+ vi.mocked(fetch)
200
+ .mockResolvedValueOnce(mockWalletResponse()) // create
201
+ .mockResolvedValueOnce(mockWalletResponse({ status: 'ACTIVE' })); // isAvailable
202
+
203
+ const signer = await ParaSigner.create(mockConfig);
204
+ const result = await signer.isAvailable();
205
+
206
+ expect(result).toBe(true);
207
+ });
208
+
209
+ it('should return true when wallet status is mixed case', async () => {
210
+ vi.mocked(fetch)
211
+ .mockResolvedValueOnce(mockWalletResponse()) // create
212
+ .mockResolvedValueOnce(mockWalletResponse({ status: 'Ready' })); // isAvailable
213
+
214
+ const signer = await ParaSigner.create(mockConfig);
215
+ const result = await signer.isAvailable();
216
+
217
+ expect(result).toBe(true);
218
+ });
219
+
220
+ it('should return false when wallet is not ready', async () => {
221
+ vi.mocked(fetch)
222
+ .mockResolvedValueOnce(mockWalletResponse()) // create
223
+ .mockResolvedValueOnce(mockWalletResponse({ status: 'creating' })); // isAvailable
224
+
225
+ const signer = await ParaSigner.create(mockConfig);
226
+ const result = await signer.isAvailable();
227
+
228
+ expect(result).toBe(false);
229
+ });
230
+
231
+ it('should return false when wallet type is not SOLANA', async () => {
232
+ vi.mocked(fetch)
233
+ .mockResolvedValueOnce(mockWalletResponse()) // create
234
+ .mockResolvedValueOnce(mockWalletResponse({ type: 'EVM', status: 'ready' })); // isAvailable
235
+
236
+ const signer = await ParaSigner.create(mockConfig);
237
+ const result = await signer.isAvailable();
238
+
239
+ expect(result).toBe(false);
240
+ });
241
+
242
+ it('should return false when API returns error', async () => {
243
+ vi.mocked(fetch)
244
+ .mockResolvedValueOnce(mockWalletResponse()) // create
245
+ .mockResolvedValueOnce(new Response('', { status: 500 })); // isAvailable
246
+
247
+ const signer = await ParaSigner.create(mockConfig);
248
+ const result = await signer.isAvailable();
249
+
250
+ expect(result).toBe(false);
251
+ });
252
+
253
+ it('should return false when network fails', async () => {
254
+ vi.mocked(fetch)
255
+ .mockResolvedValueOnce(mockWalletResponse()) // create
256
+ .mockRejectedValueOnce(new Error('Network error')); // isAvailable
257
+
258
+ const signer = await ParaSigner.create(mockConfig);
259
+ const result = await signer.isAvailable();
260
+
261
+ expect(result).toBe(false);
262
+ });
263
+ });
264
+
265
+ describe('signMessages', () => {
266
+ it('should sign a message successfully', async () => {
267
+ vi.mocked(fetch)
268
+ .mockResolvedValueOnce(mockWalletResponse()) // create
269
+ .mockResolvedValueOnce(mockSignResponse()); // sign
270
+
271
+ const signer = await ParaSigner.create(mockConfig);
272
+ const message = {
273
+ content: new Uint8Array([1, 2, 3, 4]),
274
+ signatures: {},
275
+ };
276
+
277
+ const [result] = await signer.signMessages([message]);
278
+
279
+ expect(result).toHaveProperty(MOCK_ADDRESS);
280
+ expect(fetch).toHaveBeenLastCalledWith(
281
+ 'https://api.test.getpara.com/v1/wallets/00000000-0000-0000-0000-000000000000/sign-raw',
282
+ expect.objectContaining({
283
+ body: JSON.stringify({ data: '01020304', encoding: 'hex' }),
284
+ headers: {
285
+ 'Content-Type': 'application/json',
286
+ 'X-API-Key': 'sk_test_api_key',
287
+ },
288
+ method: 'POST',
289
+ }),
290
+ );
291
+ });
292
+
293
+ it('should return empty array for empty input', async () => {
294
+ vi.mocked(fetch).mockResolvedValueOnce(mockWalletResponse());
295
+
296
+ const signer = await ParaSigner.create(mockConfig);
297
+ const result = await signer.signMessages([]);
298
+
299
+ expect(result).toEqual([]);
300
+ });
301
+
302
+ it('should handle 0x-prefixed signatures', async () => {
303
+ vi.mocked(fetch)
304
+ .mockResolvedValueOnce(mockWalletResponse()) // create
305
+ .mockResolvedValueOnce(mockSignResponse('0x' + MOCK_SIGNATURE)); // sign
306
+
307
+ const signer = await ParaSigner.create(mockConfig);
308
+ const message = { content: new Uint8Array([1, 2, 3, 4]), signatures: {} };
309
+
310
+ const [result] = await signer.signMessages([message]);
311
+
312
+ expect(result).toHaveProperty(MOCK_ADDRESS);
313
+ });
314
+
315
+ it('should throw REMOTE_API_ERROR on API error', async () => {
316
+ vi.mocked(fetch)
317
+ .mockResolvedValueOnce(mockWalletResponse()) // create
318
+ .mockResolvedValueOnce(new Response(JSON.stringify({ message: 'rate limited' }), { status: 429 }));
319
+
320
+ const signer = await ParaSigner.create(mockConfig);
321
+ const message = { content: new Uint8Array([1, 2, 3, 4]), signatures: {} };
322
+
323
+ await expect(signer.signMessages([message])).rejects.toMatchObject({
324
+ code: 'SIGNER_REMOTE_API_ERROR',
325
+ message: expect.stringContaining('Para signing failed'),
326
+ });
327
+ });
328
+
329
+ it('should throw HTTP_ERROR on network failure', async () => {
330
+ vi.mocked(fetch)
331
+ .mockResolvedValueOnce(mockWalletResponse()) // create
332
+ .mockRejectedValueOnce(new Error('Network failure'));
333
+
334
+ const signer = await ParaSigner.create(mockConfig);
335
+ const message = { content: new Uint8Array([1, 2, 3, 4]), signatures: {} };
336
+
337
+ await expect(signer.signMessages([message])).rejects.toMatchObject({
338
+ code: 'SIGNER_HTTP_ERROR',
339
+ message: expect.stringContaining('Para network request failed'),
340
+ });
341
+ });
342
+
343
+ it('should throw REMOTE_API_ERROR for missing signature', async () => {
344
+ vi.mocked(fetch)
345
+ .mockResolvedValueOnce(mockWalletResponse()) // create
346
+ .mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 }));
347
+
348
+ const signer = await ParaSigner.create(mockConfig);
349
+ const message = { content: new Uint8Array([1, 2, 3, 4]), signatures: {} };
350
+
351
+ await expect(signer.signMessages([message])).rejects.toMatchObject({
352
+ code: 'SIGNER_REMOTE_API_ERROR',
353
+ message: expect.stringContaining('Missing signature in Para response'),
354
+ });
355
+ });
356
+
357
+ it('should throw PARSING_ERROR for invalid signature length', async () => {
358
+ vi.mocked(fetch)
359
+ .mockResolvedValueOnce(mockWalletResponse()) // create
360
+ .mockResolvedValueOnce(mockSignResponse('aabb')); // too short
361
+
362
+ const signer = await ParaSigner.create(mockConfig);
363
+ const message = { content: new Uint8Array([1, 2, 3, 4]), signatures: {} };
364
+
365
+ await expect(signer.signMessages([message])).rejects.toMatchObject({
366
+ code: 'SIGNER_PARSING_ERROR',
367
+ message: expect.stringContaining('Invalid Ed25519 signature length'),
368
+ });
369
+ });
370
+
371
+ it('should throw PARSING_ERROR for non-hex characters in signature', async () => {
372
+ vi.mocked(fetch)
373
+ .mockResolvedValueOnce(mockWalletResponse()) // create
374
+ .mockResolvedValueOnce(mockSignResponse('zz'.repeat(64))); // invalid hex
375
+
376
+ const signer = await ParaSigner.create(mockConfig);
377
+ const message = { content: new Uint8Array([1, 2, 3, 4]), signatures: {} };
378
+
379
+ await expect(signer.signMessages([message])).rejects.toMatchObject({
380
+ code: 'SIGNER_PARSING_ERROR',
381
+ message: expect.stringContaining('Invalid hex characters'),
382
+ });
383
+ });
384
+
385
+ it('should apply request delay for multiple messages', async () => {
386
+ vi.mocked(fetch)
387
+ .mockResolvedValueOnce(mockWalletResponse()) // create
388
+ .mockResolvedValueOnce(mockSignResponse()) // sign 1
389
+ .mockResolvedValueOnce(mockSignResponse()) // sign 2
390
+ .mockResolvedValueOnce(mockSignResponse()); // sign 3
391
+
392
+ const delaySpy = vi.spyOn(global, 'setTimeout');
393
+ const signer = await ParaSigner.create({ ...mockConfig, requestDelayMs: 100 });
394
+
395
+ const messages = [
396
+ { content: new Uint8Array([1, 2, 3, 4]), signatures: {} },
397
+ { content: new Uint8Array([5, 6, 7, 8]), signatures: {} },
398
+ { content: new Uint8Array([9, 10, 11, 12]), signatures: {} },
399
+ ];
400
+
401
+ await signer.signMessages(messages);
402
+
403
+ // First message has no delay, second has 100ms, third has 200ms
404
+ expect(delaySpy).toHaveBeenCalledTimes(2);
405
+ expect(delaySpy).toHaveBeenNthCalledWith(1, expect.any(Function), 100);
406
+ expect(delaySpy).toHaveBeenNthCalledWith(2, expect.any(Function), 200);
407
+
408
+ delaySpy.mockRestore();
409
+ });
410
+ });
411
+
412
+ describe('signTransactions', () => {
413
+ it('should sign a transaction successfully', async () => {
414
+ vi.mocked(fetch)
415
+ .mockResolvedValueOnce(mockWalletResponse()) // create
416
+ .mockResolvedValueOnce(mockSignResponse()); // sign
417
+
418
+ const signer = await ParaSigner.create(mockConfig);
419
+ const mockTransaction = {
420
+ messageBytes: new Uint8Array([1, 2, 3, 4]),
421
+ signatures: {},
422
+ } as any;
423
+
424
+ const [result] = await signer.signTransactions([mockTransaction]);
425
+
426
+ expect(result).toHaveProperty(MOCK_ADDRESS);
427
+ expect(fetch).toHaveBeenLastCalledWith(
428
+ 'https://api.test.getpara.com/v1/wallets/00000000-0000-0000-0000-000000000000/sign-raw',
429
+ expect.objectContaining({
430
+ body: JSON.stringify({ data: '01020304', encoding: 'hex' }),
431
+ method: 'POST',
432
+ }),
433
+ );
434
+ });
435
+
436
+ it('should return empty array for empty input', async () => {
437
+ vi.mocked(fetch).mockResolvedValueOnce(mockWalletResponse());
438
+
439
+ const signer = await ParaSigner.create(mockConfig);
440
+ const result = await signer.signTransactions([]);
441
+
442
+ expect(result).toEqual([]);
443
+ });
444
+
445
+ it('should sign multiple transactions', async () => {
446
+ vi.mocked(fetch)
447
+ .mockResolvedValueOnce(mockWalletResponse()) // create
448
+ .mockResolvedValueOnce(mockSignResponse()) // sign 1
449
+ .mockResolvedValueOnce(mockSignResponse()); // sign 2
450
+
451
+ const signer = await ParaSigner.create(mockConfig);
452
+ const transactions = [
453
+ { messageBytes: new Uint8Array([1, 2]), signatures: {} } as any,
454
+ { messageBytes: new Uint8Array([3, 4]), signatures: {} } as any,
455
+ ];
456
+
457
+ const results = await signer.signTransactions(transactions);
458
+
459
+ expect(results).toHaveLength(2);
460
+ expect(results[0]).toHaveProperty(MOCK_ADDRESS);
461
+ expect(results[1]).toHaveProperty(MOCK_ADDRESS);
462
+ });
463
+
464
+ it('should throw REMOTE_API_ERROR on API error', async () => {
465
+ vi.mocked(fetch)
466
+ .mockResolvedValueOnce(mockWalletResponse()) // create
467
+ .mockResolvedValueOnce(new Response(JSON.stringify({ message: 'rate limited' }), { status: 429 }));
468
+
469
+ const signer = await ParaSigner.create(mockConfig);
470
+ const mockTransaction = { messageBytes: new Uint8Array([1, 2, 3, 4]), signatures: {} } as any;
471
+
472
+ await expect(signer.signTransactions([mockTransaction])).rejects.toMatchObject({
473
+ code: 'SIGNER_REMOTE_API_ERROR',
474
+ message: expect.stringContaining('Para signing failed'),
475
+ });
476
+ });
477
+
478
+ it('should throw HTTP_ERROR on network failure', async () => {
479
+ vi.mocked(fetch)
480
+ .mockResolvedValueOnce(mockWalletResponse()) // create
481
+ .mockRejectedValueOnce(new Error('Network failure'));
482
+
483
+ const signer = await ParaSigner.create(mockConfig);
484
+ const mockTransaction = { messageBytes: new Uint8Array([1, 2, 3, 4]), signatures: {} } as any;
485
+
486
+ await expect(signer.signTransactions([mockTransaction])).rejects.toMatchObject({
487
+ code: 'SIGNER_HTTP_ERROR',
488
+ message: expect.stringContaining('Para network request failed'),
489
+ });
490
+ });
491
+ });
492
+ });
@@ -0,0 +1,24 @@
1
+ import type { SolanaSigner } from '@solana/keychain-core';
2
+ import { SignerTestConfig, TestScenario } from '@solana/keychain-test-utils';
3
+ import { createParaSigner } from '../para-signer';
4
+
5
+ const SIGNER_TYPE = 'para';
6
+ const REQUIRED_ENV_VARS = ['PARA_API_KEY', 'PARA_WALLET_ID'];
7
+
8
+ const CONFIG: SignerTestConfig<SolanaSigner> = {
9
+ signerType: SIGNER_TYPE,
10
+ requiredEnvVars: REQUIRED_ENV_VARS,
11
+ createSigner: () =>
12
+ createParaSigner({
13
+ apiKey: process.env.PARA_API_KEY!,
14
+ apiBaseUrl: process.env.PARA_API_BASE_URL,
15
+ walletId: process.env.PARA_WALLET_ID!,
16
+ }),
17
+ };
18
+
19
+ export async function getConfig(scenarios: TestScenario[]): Promise<SignerTestConfig<SolanaSigner>> {
20
+ return {
21
+ ...CONFIG,
22
+ testScenarios: scenarios,
23
+ };
24
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { ParaSigner, createParaSigner } from './para-signer.js';
2
+ export type { ParaSignerConfig } from './para-signer.js';
3
+ export type { ParaErrorResponse, ParaSignRawRequest, ParaSignRawResponse, ParaWalletResponse } from './types.js';
4
+ export { assertIsSolanaSigner, isSolanaSigner } from '@solana/keychain-core';