@solana/kora 0.2.1 → 0.3.0-beta.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.
@@ -1,5 +1,7 @@
1
+ import { getTransferInstruction, TOKEN_PROGRAM_ADDRESS } from '@solana-program/token';
2
+ import { appendTransactionMessageInstructions, createNoopSigner, createTransactionMessage, generateKeyPairSigner, partiallySignTransactionMessageWithSigners, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, } from '@solana/kit';
1
3
  import { KoraClient } from '../src/client.js';
2
- import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token';
4
+ import { KoraError } from '../src/error.js';
3
5
  import { getInstructionsFromBase64Message } from '../src/utils/transaction.js';
4
6
  // Mock fetch globally
5
7
  const mockFetch = jest.fn();
@@ -11,8 +13,8 @@ describe('KoraClient Unit Tests', () => {
11
13
  const mockSuccessfulResponse = (result) => {
12
14
  mockFetch.mockResolvedValueOnce({
13
15
  json: jest.fn().mockResolvedValueOnce({
14
- jsonrpc: '2.0',
15
16
  id: 1,
17
+ jsonrpc: '2.0',
16
18
  result,
17
19
  }),
18
20
  });
@@ -20,9 +22,9 @@ describe('KoraClient Unit Tests', () => {
20
22
  const mockErrorResponse = (error) => {
21
23
  mockFetch.mockResolvedValueOnce({
22
24
  json: jest.fn().mockResolvedValueOnce({
23
- jsonrpc: '2.0',
24
- id: 1,
25
25
  error,
26
+ id: 1,
27
+ jsonrpc: '2.0',
26
28
  }),
27
29
  });
28
30
  };
@@ -68,7 +70,7 @@ describe('KoraClient Unit Tests', () => {
68
70
  it('should handle RPC error responses', async () => {
69
71
  const mockError = { code: -32601, message: 'Method not found' };
70
72
  mockErrorResponse(mockError);
71
- await expect(client.getConfig()).rejects.toThrow('RPC Error -32601: Method not found');
73
+ await expect(client.getConfig()).rejects.toThrow('Kora Error -32601: Method not found');
72
74
  });
73
75
  it('should handle network errors', async () => {
74
76
  mockFetch.mockRejectedValueOnce(new Error('Network error'));
@@ -78,70 +80,81 @@ describe('KoraClient Unit Tests', () => {
78
80
  describe('getConfig', () => {
79
81
  it('should return configuration', async () => {
80
82
  const mockConfig = {
83
+ enabled_methods: {
84
+ estimate_bundle_fee: true,
85
+ estimate_transaction_fee: true,
86
+ get_blockhash: true,
87
+ get_config: true,
88
+ get_payer_signer: true,
89
+ get_supported_tokens: true,
90
+ get_version: true,
91
+ liveness: true,
92
+ sign_and_send_bundle: true,
93
+ sign_and_send_transaction: true,
94
+ sign_bundle: true,
95
+ sign_transaction: true,
96
+ transfer_transaction: true,
97
+ },
81
98
  fee_payers: ['test_fee_payer_address'],
82
99
  validation_config: {
83
- max_allowed_lamports: 1000000,
84
- max_signatures: 10,
85
- price_source: 'Jupiter',
86
100
  allowed_programs: ['program1', 'program2'],
87
- allowed_tokens: ['token1', 'token2'],
88
101
  allowed_spl_paid_tokens: ['spl_token1'],
102
+ allowed_tokens: ['token1', 'token2'],
89
103
  disallowed_accounts: ['account1'],
90
104
  fee_payer_policy: {
91
- system: {
105
+ spl_token: {
106
+ allow_approve: true,
107
+ allow_burn: true,
108
+ allow_close_account: true,
109
+ allow_freeze_account: true,
110
+ allow_initialize_account: true,
111
+ allow_initialize_mint: true,
112
+ allow_initialize_multisig: true,
113
+ allow_mint_to: true,
114
+ allow_revoke: true,
115
+ allow_set_authority: true,
116
+ allow_thaw_account: true,
92
117
  allow_transfer: true,
118
+ },
119
+ system: {
120
+ allow_allocate: true,
93
121
  allow_assign: true,
94
122
  allow_create_account: true,
95
- allow_allocate: true,
123
+ allow_transfer: true,
96
124
  nonce: {
97
- allow_initialize: true,
98
125
  allow_advance: true,
99
126
  allow_authorize: true,
127
+ allow_initialize: true,
100
128
  allow_withdraw: true,
101
129
  },
102
130
  },
103
- spl_token: {
104
- allow_transfer: true,
105
- allow_burn: true,
106
- allow_close_account: true,
107
- allow_approve: true,
108
- allow_revoke: true,
109
- allow_set_authority: true,
110
- allow_mint_to: true,
111
- allow_freeze_account: true,
112
- allow_thaw_account: true,
113
- },
114
131
  token_2022: {
115
- allow_transfer: false,
132
+ allow_approve: true,
116
133
  allow_burn: true,
117
134
  allow_close_account: true,
118
- allow_approve: true,
135
+ allow_freeze_account: true,
136
+ allow_initialize_account: true,
137
+ allow_initialize_mint: true,
138
+ allow_initialize_multisig: true,
139
+ allow_mint_to: true,
119
140
  allow_revoke: true,
120
141
  allow_set_authority: true,
121
- allow_mint_to: true,
122
- allow_freeze_account: true,
123
142
  allow_thaw_account: true,
143
+ allow_transfer: false,
124
144
  },
125
145
  },
146
+ max_allowed_lamports: 1000000,
147
+ max_signatures: 10,
126
148
  price: {
127
- type: 'margin',
128
149
  margin: 0.1,
150
+ type: 'margin',
129
151
  },
152
+ price_source: 'Jupiter',
130
153
  token2022: {
131
- blocked_mint_extensions: ['extension1', 'extension2'],
132
154
  blocked_account_extensions: ['account_extension1', 'account_extension2'],
155
+ blocked_mint_extensions: ['extension1', 'extension2'],
133
156
  },
134
157
  },
135
- enabled_methods: {
136
- liveness: true,
137
- estimate_transaction_fee: true,
138
- get_supported_tokens: true,
139
- sign_transaction: true,
140
- sign_and_send_transaction: true,
141
- transfer_transaction: true,
142
- get_blockhash: true,
143
- get_config: true,
144
- },
145
158
  };
146
159
  await testSuccessfulRpcMethod('getConfig', () => client.getConfig(), mockConfig);
147
160
  });
@@ -154,6 +167,14 @@ describe('KoraClient Unit Tests', () => {
154
167
  await testSuccessfulRpcMethod('getBlockhash', () => client.getBlockhash(), mockResponse);
155
168
  });
156
169
  });
170
+ describe('getVersion', () => {
171
+ it('should return server version', async () => {
172
+ const mockResponse = {
173
+ version: '2.1.0-beta.0',
174
+ };
175
+ await testSuccessfulRpcMethod('getVersion', () => client.getVersion(), mockResponse);
176
+ });
177
+ });
157
178
  describe('getSupportedTokens', () => {
158
179
  it('should return supported tokens list', async () => {
159
180
  const mockResponse = {
@@ -165,15 +186,15 @@ describe('KoraClient Unit Tests', () => {
165
186
  describe('getPayerSigner', () => {
166
187
  it('should return payer signer and payment destination', async () => {
167
188
  const mockResponse = {
168
- signer_address: 'DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
169
189
  payment_address: 'PayKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
190
+ signer_address: 'DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
170
191
  };
171
192
  await testSuccessfulRpcMethod('getPayerSigner', () => client.getPayerSigner(), mockResponse);
172
193
  });
173
194
  it('should return same address for signer and payment_destination when no separate paymaster', async () => {
174
195
  const mockResponse = {
175
- signer_address: 'DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
176
196
  payment_address: 'DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
197
+ signer_address: 'DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
177
198
  };
178
199
  await testSuccessfulRpcMethod('getPayerSigner', () => client.getPayerSigner(), mockResponse);
179
200
  expect(mockResponse.signer_address).toBe(mockResponse.payment_address);
@@ -182,14 +203,14 @@ describe('KoraClient Unit Tests', () => {
182
203
  describe('estimateTransactionFee', () => {
183
204
  it('should estimate transaction fee', async () => {
184
205
  const request = {
185
- transaction: 'base64_encoded_transaction',
186
206
  fee_token: 'SOL',
207
+ transaction: 'base64_encoded_transaction',
187
208
  };
188
209
  const mockResponse = {
189
210
  fee_in_lamports: 5000,
190
211
  fee_in_token: 25,
191
- signer_pubkey: 'DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
192
212
  payment_address: 'PayKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
213
+ signer_pubkey: 'DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
193
214
  };
194
215
  await testSuccessfulRpcMethod('estimateTransactionFee', () => client.estimateTransactionFee(request), mockResponse, request);
195
216
  });
@@ -218,149 +239,152 @@ describe('KoraClient Unit Tests', () => {
218
239
  };
219
240
  await testSuccessfulRpcMethod('signAndSendTransaction', () => client.signAndSendTransaction(request), mockResponse, request);
220
241
  });
221
- });
222
- describe('transferTransaction', () => {
223
- it('should create transfer transaction', async () => {
242
+ it('should pass the respond_after milestone through to the RPC request', async () => {
224
243
  const request = {
225
- amount: 1000000,
226
- token: 'SOL',
227
- source: 'source_address',
228
- destination: 'destination_address',
244
+ transaction: 'base64_encoded_transaction',
245
+ respond_after: 'signed',
229
246
  };
230
247
  const mockResponse = {
231
- transaction: 'base64_encoded_transaction',
232
- message: 'Transfer transaction created',
233
- blockhash: 'test_blockhash',
248
+ signature: 'transaction_signature',
249
+ signed_transaction: 'base64_signed_transaction',
234
250
  signer_pubkey: 'test_signer_pubkey',
235
- instructions: [],
236
251
  };
237
- await testSuccessfulRpcMethod('transferTransaction', () => client.transferTransaction(request), mockResponse, request);
252
+ await testSuccessfulRpcMethod('signAndSendTransaction', () => client.signAndSendTransaction(request), mockResponse, request);
238
253
  });
239
- it('should parse instructions from transfer transaction message', async () => {
254
+ });
255
+ describe('signBundle', () => {
256
+ it('should sign bundle of transactions', async () => {
240
257
  const request = {
241
- amount: 1000000,
242
- token: 'SOL',
243
- source: 'source_address',
244
- destination: 'destination_address',
258
+ transactions: ['base64_tx_1', 'base64_tx_2'],
245
259
  };
246
- // This is a real base64 encoded message for testing
247
- // In production, this would come from the RPC response
248
- const mockMessage = 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQABAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIDAAEMAgAAAAEAAAAAAAAA';
249
260
  const mockResponse = {
250
- transaction: 'base64_encoded_transaction',
251
- message: mockMessage,
252
- blockhash: 'test_blockhash',
261
+ signed_transactions: ['base64_signed_tx_1', 'base64_signed_tx_2'],
253
262
  signer_pubkey: 'test_signer_pubkey',
254
- instructions: [],
255
263
  };
256
- mockSuccessfulResponse(mockResponse);
257
- const result = await client.transferTransaction(request);
258
- expect(result.instructions).toBeDefined();
259
- expect(Array.isArray(result.instructions)).toBe(true);
260
- // The instructions array should be populated from the parsed message
261
- expect(result.instructions).not.toBeNull();
262
- });
263
- it('should handle transfer transaction with empty message gracefully', async () => {
264
+ await testSuccessfulRpcMethod('signBundle', () => client.signBundle(request), mockResponse, request);
265
+ });
266
+ it('should handle RPC error', async () => {
267
+ const request = {
268
+ transactions: ['base64_tx_1'],
269
+ };
270
+ const mockError = { code: -32000, message: 'Bundle validation failed' };
271
+ mockErrorResponse(mockError);
272
+ await expect(client.signBundle(request)).rejects.toThrow('Kora Error -32000: Bundle validation failed');
273
+ });
274
+ });
275
+ describe('signAndSendBundle', () => {
276
+ it('should sign and send bundle of transactions', async () => {
264
277
  const request = {
265
- amount: 1000000,
266
- token: 'SOL',
267
- source: 'source_address',
268
- destination: 'destination_address',
278
+ transactions: ['base64_tx_1', 'base64_tx_2'],
269
279
  };
270
280
  const mockResponse = {
271
- transaction: 'base64_encoded_transaction',
272
- message: '',
273
- blockhash: 'test_blockhash',
281
+ bundle_uuid: 'test-bundle-uuid-123',
282
+ signed_transactions: ['base64_signed_tx_1', 'base64_signed_tx_2'],
274
283
  signer_pubkey: 'test_signer_pubkey',
275
- instructions: [],
276
284
  };
277
- mockSuccessfulResponse(mockResponse);
278
- const result = await client.transferTransaction(request);
279
- // Should handle empty message gracefully
280
- expect(result.instructions).toEqual([]);
285
+ await testSuccessfulRpcMethod('signAndSendBundle', () => client.signAndSendBundle(request), mockResponse, request);
286
+ });
287
+ it('should handle RPC error', async () => {
288
+ const request = {
289
+ transactions: ['base64_tx_1'],
290
+ };
291
+ const mockError = { code: -32000, message: 'Jito submission failed' };
292
+ mockErrorResponse(mockError);
293
+ await expect(client.signAndSendBundle(request)).rejects.toThrow('Kora Error -32000: Jito submission failed');
281
294
  });
282
295
  });
283
296
  describe('getPaymentInstruction', () => {
284
- const mockConfig = {
297
+ const _mockConfig = {
298
+ enabled_methods: {
299
+ estimate_bundle_fee: true,
300
+ estimate_transaction_fee: true,
301
+ get_blockhash: true,
302
+ get_config: true,
303
+ get_payer_signer: true,
304
+ get_supported_tokens: true,
305
+ get_version: true,
306
+ liveness: true,
307
+ sign_and_send_bundle: true,
308
+ sign_and_send_transaction: true,
309
+ sign_bundle: true,
310
+ sign_transaction: true,
311
+ transfer_transaction: true,
312
+ },
285
313
  fee_payers: ['11111111111111111111111111111111'],
286
314
  validation_config: {
287
- max_allowed_lamports: 1000000,
288
- max_signatures: 10,
289
- price_source: 'Jupiter',
290
315
  allowed_programs: ['program1'],
291
- allowed_tokens: ['token1'],
292
316
  allowed_spl_paid_tokens: ['4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'],
317
+ allowed_tokens: ['token1'],
293
318
  disallowed_accounts: [],
294
319
  fee_payer_policy: {
295
- system: {
320
+ spl_token: {
321
+ allow_approve: true,
322
+ allow_burn: true,
323
+ allow_close_account: true,
324
+ allow_freeze_account: true,
325
+ allow_initialize_account: true,
326
+ allow_initialize_mint: true,
327
+ allow_initialize_multisig: true,
328
+ allow_mint_to: true,
329
+ allow_revoke: true,
330
+ allow_set_authority: true,
331
+ allow_thaw_account: true,
296
332
  allow_transfer: true,
333
+ },
334
+ system: {
335
+ allow_allocate: true,
297
336
  allow_assign: true,
298
337
  allow_create_account: true,
299
- allow_allocate: true,
338
+ allow_transfer: true,
300
339
  nonce: {
301
- allow_initialize: true,
302
340
  allow_advance: true,
303
341
  allow_authorize: true,
342
+ allow_initialize: true,
304
343
  allow_withdraw: true,
305
344
  },
306
345
  },
307
- spl_token: {
308
- allow_transfer: true,
309
- allow_burn: true,
310
- allow_close_account: true,
311
- allow_approve: true,
312
- allow_revoke: true,
313
- allow_set_authority: true,
314
- allow_mint_to: true,
315
- allow_freeze_account: true,
316
- allow_thaw_account: true,
317
- },
318
346
  token_2022: {
319
- allow_transfer: true,
347
+ allow_approve: true,
320
348
  allow_burn: true,
321
349
  allow_close_account: true,
322
- allow_approve: true,
350
+ allow_freeze_account: true,
351
+ allow_initialize_account: true,
352
+ allow_initialize_mint: true,
353
+ allow_initialize_multisig: true,
354
+ allow_mint_to: true,
323
355
  allow_revoke: true,
324
356
  allow_set_authority: true,
325
- allow_mint_to: true,
326
- allow_freeze_account: true,
327
357
  allow_thaw_account: true,
358
+ allow_transfer: true,
328
359
  },
329
360
  },
361
+ max_allowed_lamports: 1000000,
362
+ max_signatures: 10,
330
363
  price: {
331
- type: 'margin',
332
364
  margin: 0.1,
365
+ type: 'margin',
333
366
  },
367
+ price_source: 'Jupiter',
334
368
  token2022: {
335
- blocked_mint_extensions: [],
336
369
  blocked_account_extensions: [],
370
+ blocked_mint_extensions: [],
337
371
  },
338
372
  },
339
- enabled_methods: {
340
- liveness: true,
341
- estimate_transaction_fee: true,
342
- get_supported_tokens: true,
343
- sign_transaction: true,
344
- sign_and_send_transaction: true,
345
- transfer_transaction: true,
346
- get_blockhash: true,
347
- get_config: true,
348
- },
349
373
  };
350
374
  const mockFeeEstimate = {
351
375
  fee_in_lamports: 5000,
352
376
  fee_in_token: 50000,
353
- signer_pubkey: 'DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
354
377
  payment_address: 'PayKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
378
+ signer_pubkey: 'DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
355
379
  };
356
380
  // Create a mock base64-encoded transaction
357
381
  // This is a minimal valid transaction structure
358
382
  const mockTransactionBase64 = 'Aoq7ymA5OGP+gmDXiY5m3cYXlY2Rz/a/gFjOgt9ZuoCS7UzuiGGaEnW2OOtvHvMQHkkD7Z4LRF5B63ftu+1oZwIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgECB1urjQEjgFgzqYhJ8IXJeSg4cJP1j1g2CJstOQTDchOKUzqH3PxgGW3c4V3vZV05A5Y30/MggOBs0Kd00s1JEwg5TaEeaV4+KL2y7fXIAuf6cN0ZQitbhY+G9ExtBSChspOXPgNcy9pYpETe4bmB+fg4bfZx1tnicA/kIyyubczAmbcIKIuniNOOQYG2ggKCz8NjEsHVezrWMatndu1wk6J5miGP26J6Vwp31AljiAajAFuP0D9mWJwSeFuA7J5rPwbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpd/O36SW02zRtNtqk6GFeip2+yBQsVTeSbLL4rWJRkd4CBgQCBQQBCgxAQg8AAAAAAAYGBAIFAwEKDBAnAAAAAAAABg==';
359
383
  const validRequest = {
360
- transaction: mockTransactionBase64,
361
384
  fee_token: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU',
362
385
  source_wallet: '11111111111111111111111111111111',
363
386
  token_program_id: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA',
387
+ transaction: mockTransactionBase64,
364
388
  };
365
389
  beforeEach(() => {
366
390
  // Mock console.log to avoid noise in tests
@@ -373,16 +397,17 @@ describe('KoraClient Unit Tests', () => {
373
397
  // Mock estimateTransactionFee call
374
398
  mockFetch.mockResolvedValueOnce({
375
399
  json: jest.fn().mockResolvedValueOnce({
376
- jsonrpc: '2.0',
377
400
  id: 1,
401
+ jsonrpc: '2.0',
378
402
  result: mockFeeEstimate,
379
403
  }),
380
404
  });
381
405
  const result = await client.getPaymentInstruction(validRequest);
382
406
  expect(result).toEqual({
383
407
  original_transaction: validRequest.transaction,
408
+ payment_address: mockFeeEstimate.payment_address,
409
+ payment_amount: mockFeeEstimate.fee_in_token,
384
410
  payment_instruction: expect.objectContaining({
385
- programAddress: TOKEN_PROGRAM_ADDRESS,
386
411
  accounts: [
387
412
  expect.objectContaining({
388
413
  role: 1, // writable
@@ -391,15 +416,15 @@ describe('KoraClient Unit Tests', () => {
391
416
  role: 1, // writable
392
417
  }), // Destination token account
393
418
  expect.objectContaining({
394
- role: 0, // readonly (plain address, no signer attached)
419
+ // readonly (plain address, no signer attached)
395
420
  address: validRequest.source_wallet,
421
+ role: 0,
396
422
  }), // Authority
397
423
  ],
398
424
  data: expect.any(Uint8Array),
425
+ programAddress: TOKEN_PROGRAM_ADDRESS,
399
426
  }),
400
- payment_amount: mockFeeEstimate.fee_in_token,
401
427
  payment_token: validRequest.fee_token,
402
- payment_address: mockFeeEstimate.payment_address,
403
428
  signer_address: mockFeeEstimate.signer_pubkey,
404
429
  });
405
430
  // Verify only estimateTransactionFee was called
@@ -424,8 +449,8 @@ describe('KoraClient Unit Tests', () => {
424
449
  // Mock estimateTransactionFee call
425
450
  mockFetch.mockResolvedValueOnce({
426
451
  json: jest.fn().mockResolvedValueOnce({
427
- jsonrpc: '2.0',
428
452
  id: 1,
453
+ jsonrpc: '2.0',
429
454
  result: mockFeeEstimate,
430
455
  }),
431
456
  });
@@ -448,32 +473,96 @@ describe('KoraClient Unit Tests', () => {
448
473
  const mockError = { code: -32602, message: 'Invalid transaction' };
449
474
  mockFetch.mockResolvedValueOnce({
450
475
  json: jest.fn().mockResolvedValueOnce({
451
- jsonrpc: '2.0',
452
- id: 1,
453
476
  error: mockError,
477
+ id: 1,
478
+ jsonrpc: '2.0',
454
479
  }),
455
480
  });
456
- await expect(client.getPaymentInstruction(validRequest)).rejects.toThrow('RPC Error -32602: Invalid transaction');
481
+ await expect(client.getPaymentInstruction(validRequest)).rejects.toThrow('Kora Error -32602: Invalid transaction');
457
482
  });
458
483
  it('should handle network errors', async () => {
459
484
  mockFetch.mockRejectedValueOnce(new Error('Network error'));
460
485
  await expect(client.getPaymentInstruction(validRequest)).rejects.toThrow('Network error');
461
486
  });
487
+ it('should produce a payment instruction compatible with a real signer for the same address', async () => {
488
+ // Generate a real KeyPairSigner (simulates a user's wallet)
489
+ const userSigner = await generateKeyPairSigner();
490
+ // Mock estimateTransactionFee to return the user's address as source_wallet context
491
+ const feeEstimate = {
492
+ fee_in_lamports: 5000,
493
+ fee_in_token: 50000,
494
+ payment_address: 'PayKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
495
+ signer_pubkey: 'DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
496
+ };
497
+ mockSuccessfulResponse(feeEstimate);
498
+ // Get payment instruction — authority is a plain address (no signer attached)
499
+ const result = await client.getPaymentInstruction({
500
+ ...validRequest,
501
+ source_wallet: userSigner.address,
502
+ });
503
+ // Build another instruction that references the same address with the REAL signer
504
+ // (simulates a program instruction like makePurchase where the user is a signer)
505
+ const userOwnedIx = getTransferInstruction({
506
+ amount: 1000n,
507
+ authority: userSigner, // <-- real KeyPairSigner
508
+ destination: 'PayKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
509
+ source: '11111111111111111111111111111111',
510
+ });
511
+ // Combine both instructions in a transaction — previously this would throw
512
+ // "Multiple distinct signers" because the payment instruction had a NoopSigner.
513
+ // Now the payment instruction uses a plain address, so no conflict.
514
+ const feePayer = createNoopSigner('DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7');
515
+ const txMessage = appendTransactionMessageInstructions([userOwnedIx, result.payment_instruction], setTransactionMessageLifetimeUsingBlockhash({ blockhash: '11111111111111111111111111111111', lastValidBlockHeight: 0n }, setTransactionMessageFeePayerSigner(feePayer, createTransactionMessage({ version: 0 }))));
516
+ // This should NOT throw "Multiple distinct signers"
517
+ await expect(partiallySignTransactionMessageWithSigners(txMessage)).resolves.toBeDefined();
518
+ });
519
+ it('should accept a TransactionSigner as source_wallet and preserve signer identity', async () => {
520
+ const userSigner = await generateKeyPairSigner();
521
+ const feeEstimate = {
522
+ fee_in_lamports: 5000,
523
+ fee_in_token: 50000,
524
+ payment_address: 'PayKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
525
+ signer_pubkey: 'DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
526
+ };
527
+ mockSuccessfulResponse(feeEstimate);
528
+ // Pass the signer directly as source_wallet
529
+ const result = await client.getPaymentInstruction({
530
+ ...validRequest,
531
+ source_wallet: userSigner,
532
+ });
533
+ // The authority account meta should carry the signer
534
+ const authorityMeta = result.payment_instruction.accounts?.[2];
535
+ expect(authorityMeta).toEqual(expect.objectContaining({
536
+ address: userSigner.address,
537
+ role: 2, // readonly-signer
538
+ signer: userSigner,
539
+ }));
540
+ // Combining with another instruction using the same signer should work
541
+ const userOwnedIx = getTransferInstruction({
542
+ amount: 1000n,
543
+ authority: userSigner,
544
+ destination: 'PayKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
545
+ source: '11111111111111111111111111111111',
546
+ });
547
+ const feePayer = createNoopSigner('DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7');
548
+ const txMessage = appendTransactionMessageInstructions([userOwnedIx, result.payment_instruction], setTransactionMessageLifetimeUsingBlockhash({ blockhash: '11111111111111111111111111111111', lastValidBlockHeight: 0n }, setTransactionMessageFeePayerSigner(feePayer, createTransactionMessage({ version: 0 }))));
549
+ await expect(partiallySignTransactionMessageWithSigners(txMessage)).resolves.toBeDefined();
550
+ });
462
551
  it('should return correct payment details in response', async () => {
463
552
  mockFetch.mockResolvedValueOnce({
464
553
  json: jest.fn().mockResolvedValueOnce({
465
- jsonrpc: '2.0',
466
554
  id: 1,
555
+ jsonrpc: '2.0',
467
556
  result: mockFeeEstimate,
468
557
  }),
469
558
  });
470
559
  const result = await client.getPaymentInstruction(validRequest);
471
560
  expect(result).toMatchObject({
472
561
  original_transaction: validRequest.transaction,
473
- payment_instruction: expect.any(Object),
562
+ payment_address: mockFeeEstimate.payment_address,
474
563
  payment_amount: mockFeeEstimate.fee_in_token,
564
+ payment_instruction: expect.any(Object),
475
565
  payment_token: validRequest.fee_token,
476
- payment_address: mockFeeEstimate.payment_address,
477
566
  signer_address: mockFeeEstimate.signer_pubkey,
478
567
  });
479
568
  });
@@ -488,35 +577,150 @@ describe('KoraClient Unit Tests', () => {
488
577
  it('should handle responses with an error object', async () => {
489
578
  const mockError = { code: -32602, message: 'Invalid params' };
490
579
  mockErrorResponse(mockError);
491
- await expect(client.getConfig()).rejects.toThrow('RPC Error -32602: Invalid params');
580
+ await expect(client.getConfig()).rejects.toThrow('Kora Error -32602: Invalid params');
492
581
  });
493
- it('should handle empty error object', async () => {
582
+ it('should handle empty error object gracefully', async () => {
494
583
  mockErrorResponse({});
495
- await expect(client.getConfig()).rejects.toThrow('RPC Error undefined: undefined');
584
+ try {
585
+ await client.getConfig();
586
+ throw new Error('Should have thrown KoraError');
587
+ }
588
+ catch (e) {
589
+ expect(e).toBeInstanceOf(KoraError);
590
+ expect(e.code).toBe(-32603);
591
+ expect(e.message).toBe('Kora Error -32603: Unknown error');
592
+ expect(e.data).toBeUndefined();
593
+ }
594
+ });
595
+ it('should populate KoraError properties (code and data)', async () => {
596
+ const mockError = {
597
+ code: -32050,
598
+ data: {
599
+ error_type: 'AccountNotFound',
600
+ },
601
+ message: 'Account not found message',
602
+ };
603
+ mockErrorResponse(mockError);
604
+ try {
605
+ await client.getConfig();
606
+ throw new Error('Should have thrown KoraError');
607
+ }
608
+ catch (e) {
609
+ expect(e).toBeInstanceOf(KoraError);
610
+ expect(e.code).toBe(-32050);
611
+ expect(e.data).toEqual({
612
+ error_type: 'AccountNotFound',
613
+ });
614
+ expect(e.message).toBe('Kora Error -32050: Account not found message');
615
+ }
616
+ });
617
+ });
618
+ describe('reCAPTCHA Authentication', () => {
619
+ it('should include x-recaptcha-token header when getRecaptchaToken callback is provided (sync)', async () => {
620
+ const recaptchaClient = new KoraClient({
621
+ getRecaptchaToken: () => 'test-recaptcha-token',
622
+ rpcUrl: mockRpcUrl,
623
+ });
624
+ mockSuccessfulResponse({ version: '1.0.0' });
625
+ await recaptchaClient.getVersion();
626
+ expect(mockFetch).toHaveBeenCalledWith(mockRpcUrl, {
627
+ body: JSON.stringify({
628
+ id: 1,
629
+ jsonrpc: '2.0',
630
+ method: 'getVersion',
631
+ params: undefined,
632
+ }),
633
+ headers: {
634
+ 'Content-Type': 'application/json',
635
+ 'x-recaptcha-token': 'test-recaptcha-token',
636
+ },
637
+ method: 'POST',
638
+ });
639
+ });
640
+ it('should include x-recaptcha-token header when getRecaptchaToken callback returns Promise', async () => {
641
+ const recaptchaClient = new KoraClient({
642
+ getRecaptchaToken: () => Promise.resolve('async-recaptcha-token'),
643
+ rpcUrl: mockRpcUrl,
644
+ });
645
+ mockSuccessfulResponse({ version: '1.0.0' });
646
+ await recaptchaClient.getVersion();
647
+ expect(mockFetch).toHaveBeenCalledWith(mockRpcUrl, {
648
+ body: JSON.stringify({
649
+ id: 1,
650
+ jsonrpc: '2.0',
651
+ method: 'getVersion',
652
+ params: undefined,
653
+ }),
654
+ headers: {
655
+ 'Content-Type': 'application/json',
656
+ 'x-recaptcha-token': 'async-recaptcha-token',
657
+ },
658
+ method: 'POST',
659
+ });
660
+ });
661
+ it('should NOT include x-recaptcha-token header when getRecaptchaToken is not provided', async () => {
662
+ mockSuccessfulResponse({ version: '1.0.0' });
663
+ await client.getVersion();
664
+ expect(mockFetch).toHaveBeenCalledWith(mockRpcUrl, {
665
+ body: JSON.stringify({
666
+ id: 1,
667
+ jsonrpc: '2.0',
668
+ method: 'getVersion',
669
+ params: undefined,
670
+ }),
671
+ headers: {
672
+ 'Content-Type': 'application/json',
673
+ },
674
+ method: 'POST',
675
+ });
676
+ });
677
+ it('should include x-recaptcha-token along with other auth headers', async () => {
678
+ const combinedAuthClient = new KoraClient({
679
+ apiKey: 'test-api-key',
680
+ getRecaptchaToken: () => 'test-recaptcha-token',
681
+ rpcUrl: mockRpcUrl,
682
+ });
683
+ mockSuccessfulResponse({ version: '1.0.0' });
684
+ await combinedAuthClient.getVersion();
685
+ const callArgs = mockFetch.mock.calls[0][1];
686
+ expect(callArgs.headers).toMatchObject({
687
+ 'Content-Type': 'application/json',
688
+ 'x-api-key': 'test-api-key',
689
+ 'x-recaptcha-token': 'test-recaptcha-token',
690
+ });
691
+ });
692
+ it('should call getRecaptchaToken callback for each request', async () => {
693
+ let callCount = 0;
694
+ const recaptchaClient = new KoraClient({
695
+ getRecaptchaToken: () => `token-${++callCount}`,
696
+ rpcUrl: mockRpcUrl,
697
+ });
698
+ mockSuccessfulResponse({ version: '1.0.0' });
699
+ await recaptchaClient.getVersion();
700
+ mockSuccessfulResponse({ blockhash: 'test-blockhash' });
701
+ await recaptchaClient.getBlockhash();
702
+ expect(callCount).toBe(2);
703
+ const calls = mockFetch.mock.calls;
704
+ expect(calls[0][1].headers['x-recaptcha-token']).toBe('token-1');
705
+ expect(calls[1][1].headers['x-recaptcha-token']).toBe('token-2');
706
+ });
707
+ it('should propagate errors when getRecaptchaToken callback throws', async () => {
708
+ const recaptchaClient = new KoraClient({
709
+ getRecaptchaToken: () => {
710
+ throw new Error('reCAPTCHA failed to load');
711
+ },
712
+ rpcUrl: mockRpcUrl,
713
+ });
714
+ await expect(recaptchaClient.getVersion()).rejects.toThrow('reCAPTCHA failed to load');
715
+ });
716
+ it('should propagate errors when getRecaptchaToken returns rejected Promise', async () => {
717
+ const recaptchaClient = new KoraClient({
718
+ getRecaptchaToken: () => Promise.reject(new Error('Token generation failed')),
719
+ rpcUrl: mockRpcUrl,
720
+ });
721
+ await expect(recaptchaClient.getVersion()).rejects.toThrow('Token generation failed');
496
722
  });
497
723
  });
498
- // TODO: Add Authentication Tests (separate PR)
499
- //
500
- // describe('Authentication', () => {
501
- // describe('API Key Authentication', () => {
502
- // - Test that x-api-key header is included when apiKey is provided
503
- // - Test requests work without apiKey when not provided
504
- // - Test all RPC methods include the header
505
- // });
506
- //
507
- // describe('HMAC Authentication', () => {
508
- // - Test x-timestamp and x-hmac-signature headers are included when hmacSecret is provided
509
- // - Test HMAC signature calculation is correct (SHA256 of timestamp + body)
510
- // - Test timestamp is current (within reasonable bounds)
511
- // - Test requests work without HMAC when not provided
512
- // - Test all RPC methods include the headers
513
- // });
514
- //
515
- // describe('Combined Authentication', () => {
516
- // - Test both API key and HMAC headers are included when both are provided
517
- // - Test headers are correctly combined
518
- // });
519
- // });
520
724
  });
521
725
  describe('Transaction Utils', () => {
522
726
  describe('getInstructionsFromBase64Message', () => {