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