|
1 | | -import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; |
| 1 | +import { getTransferInstruction, TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; |
| 2 | +import { |
| 3 | + appendTransactionMessageInstructions, |
| 4 | + Blockhash, |
| 5 | + createNoopSigner, |
| 6 | + createTransactionMessage, |
| 7 | + generateKeyPairSigner, |
| 8 | + Instruction, |
| 9 | + partiallySignTransactionMessageWithSigners, |
| 10 | + setTransactionMessageFeePayerSigner, |
| 11 | + setTransactionMessageLifetimeUsingBlockhash, |
| 12 | +} from '@solana/kit'; |
2 | 13 |
|
3 | 14 | import { KoraClient } from '../src/client.js'; |
4 | 15 | import { |
@@ -491,12 +502,9 @@ describe('KoraClient Unit Tests', () => { |
491 | 502 | role: 1, // writable |
492 | 503 | }), // Destination token account |
493 | 504 | expect.objectContaining({ |
494 | | - // readonly-signer |
| 505 | + // readonly (plain address, no signer attached) |
495 | 506 | address: validRequest.source_wallet, |
496 | | - role: 2, |
497 | | - signer: expect.objectContaining({ |
498 | | - address: validRequest.source_wallet, |
499 | | - }), |
| 507 | + role: 0, |
500 | 508 | }), // Authority |
501 | 509 | ], |
502 | 510 | data: expect.any(Uint8Array), |
@@ -575,6 +583,97 @@ describe('KoraClient Unit Tests', () => { |
575 | 583 | await expect(client.getPaymentInstruction(validRequest)).rejects.toThrow('Network error'); |
576 | 584 | }); |
577 | 585 |
|
| 586 | + it('should produce a payment instruction compatible with a real signer for the same address', async () => { |
| 587 | + // Generate a real KeyPairSigner (simulates a user's wallet) |
| 588 | + const userSigner = await generateKeyPairSigner(); |
| 589 | + |
| 590 | + // Mock estimateTransactionFee to return the user's address as source_wallet context |
| 591 | + const feeEstimate: EstimateTransactionFeeResponse = { |
| 592 | + fee_in_lamports: 5000, |
| 593 | + fee_in_token: 50000, |
| 594 | + payment_address: 'PayKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7', |
| 595 | + signer_pubkey: 'DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7', |
| 596 | + }; |
| 597 | + mockSuccessfulResponse(feeEstimate); |
| 598 | + |
| 599 | + // Get payment instruction — authority is a plain address (no signer attached) |
| 600 | + const result = await client.getPaymentInstruction({ |
| 601 | + ...validRequest, |
| 602 | + source_wallet: userSigner.address, |
| 603 | + }); |
| 604 | + |
| 605 | + // Build another instruction that references the same address with the REAL signer |
| 606 | + // (simulates a program instruction like makePurchase where the user is a signer) |
| 607 | + const userOwnedIx: Instruction = getTransferInstruction({ |
| 608 | + amount: 1000n, |
| 609 | + authority: userSigner, // <-- real KeyPairSigner |
| 610 | + destination: 'PayKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7' as any, |
| 611 | + source: '11111111111111111111111111111111' as any, |
| 612 | + }); |
| 613 | + |
| 614 | + // Combine both instructions in a transaction — previously this would throw |
| 615 | + // "Multiple distinct signers" because the payment instruction had a NoopSigner. |
| 616 | + // Now the payment instruction uses a plain address, so no conflict. |
| 617 | + const feePayer = createNoopSigner('DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7' as any); |
| 618 | + const txMessage = appendTransactionMessageInstructions( |
| 619 | + [userOwnedIx, result.payment_instruction], |
| 620 | + setTransactionMessageLifetimeUsingBlockhash( |
| 621 | + { blockhash: '11111111111111111111111111111111' as Blockhash, lastValidBlockHeight: 0n }, |
| 622 | + setTransactionMessageFeePayerSigner(feePayer, createTransactionMessage({ version: 0 })), |
| 623 | + ), |
| 624 | + ); |
| 625 | + |
| 626 | + // This should NOT throw "Multiple distinct signers" |
| 627 | + await expect(partiallySignTransactionMessageWithSigners(txMessage)).resolves.toBeDefined(); |
| 628 | + }); |
| 629 | + |
| 630 | + it('should accept a TransactionSigner as source_wallet and preserve signer identity', async () => { |
| 631 | + const userSigner = await generateKeyPairSigner(); |
| 632 | + |
| 633 | + const feeEstimate: EstimateTransactionFeeResponse = { |
| 634 | + fee_in_lamports: 5000, |
| 635 | + fee_in_token: 50000, |
| 636 | + payment_address: 'PayKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7', |
| 637 | + signer_pubkey: 'DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7', |
| 638 | + }; |
| 639 | + mockSuccessfulResponse(feeEstimate); |
| 640 | + |
| 641 | + // Pass the signer directly as source_wallet |
| 642 | + const result = await client.getPaymentInstruction({ |
| 643 | + ...validRequest, |
| 644 | + source_wallet: userSigner, |
| 645 | + }); |
| 646 | + |
| 647 | + // The authority account meta should carry the signer |
| 648 | + const authorityMeta = result.payment_instruction.accounts?.[2]; |
| 649 | + expect(authorityMeta).toEqual( |
| 650 | + expect.objectContaining({ |
| 651 | + address: userSigner.address, |
| 652 | + role: 2, // readonly-signer |
| 653 | + signer: userSigner, |
| 654 | + }), |
| 655 | + ); |
| 656 | + |
| 657 | + // Combining with another instruction using the same signer should work |
| 658 | + const userOwnedIx: Instruction = getTransferInstruction({ |
| 659 | + amount: 1000n, |
| 660 | + authority: userSigner, |
| 661 | + destination: 'PayKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7' as any, |
| 662 | + source: '11111111111111111111111111111111' as any, |
| 663 | + }); |
| 664 | + |
| 665 | + const feePayer = createNoopSigner('DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7' as any); |
| 666 | + const txMessage = appendTransactionMessageInstructions( |
| 667 | + [userOwnedIx, result.payment_instruction], |
| 668 | + setTransactionMessageLifetimeUsingBlockhash( |
| 669 | + { blockhash: '11111111111111111111111111111111' as Blockhash, lastValidBlockHeight: 0n }, |
| 670 | + setTransactionMessageFeePayerSigner(feePayer, createTransactionMessage({ version: 0 })), |
| 671 | + ), |
| 672 | + ); |
| 673 | + |
| 674 | + await expect(partiallySignTransactionMessageWithSigners(txMessage)).resolves.toBeDefined(); |
| 675 | + }); |
| 676 | + |
578 | 677 | it('should return correct payment details in response', async () => { |
579 | 678 | mockFetch.mockResolvedValueOnce({ |
580 | 679 | json: jest.fn().mockResolvedValueOnce({ |
|
0 commit comments