Skip to content

Commit 4aba3f9

Browse files
authored
fix(ts-sdk): accept TransactionSigner in getPaymentInstruction source_wallet (#402)
* fix(ts-sdk): accept TransactionSigner in getPaymentInstruction source_wallet Resolves "Multiple distinct signers" error when combining payment instruction with other instructions that use a real signer for the same address. source_wallet now accepts TransactionSigner | string. * chore(ts-sdk): bump version to 0.3.0-beta.0
1 parent 3b9408b commit 4aba3f9

File tree

4 files changed

+118
-13
lines changed

4 files changed

+118
-13
lines changed

sdks/ts/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@solana/kora",
3-
"version": "0.2.0-beta.6",
3+
"version": "0.3.0-beta.0",
44
"description": "TypeScript SDK for Kora RPC",
55
"main": "dist/src/index.js",
66
"type": "module",

sdks/ts/src/client.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { assertIsAddress, createNoopSigner, Instruction } from '@solana/kit';
1+
import { Address, assertIsAddress, Instruction, isTransactionSigner } from '@solana/kit';
22
import { findAssociatedTokenPda, getTransferInstruction, TOKEN_PROGRAM_ADDRESS } from '@solana-program/token';
33
import crypto from 'crypto';
44

@@ -361,7 +361,10 @@ export class KoraClient {
361361
signer_key,
362362
sig_verify,
363363
}: GetPaymentInstructionRequest): Promise<GetPaymentInstructionResponse> {
364-
assertIsAddress(source_wallet);
364+
const isSigner = typeof source_wallet !== 'string' && isTransactionSigner(source_wallet);
365+
const walletAddress: Address = isSigner ? source_wallet.address : (source_wallet as Address);
366+
367+
assertIsAddress(walletAddress);
365368
assertIsAddress(fee_token);
366369
assertIsAddress(token_program_id);
367370

@@ -375,7 +378,7 @@ export class KoraClient {
375378

376379
const [sourceTokenAccount] = await findAssociatedTokenPda({
377380
mint: fee_token,
378-
owner: source_wallet,
381+
owner: walletAddress,
379382
tokenProgram: token_program_id,
380383
});
381384

@@ -391,7 +394,7 @@ export class KoraClient {
391394

392395
const paymentInstruction: Instruction = getTransferInstruction({
393396
amount: fee_in_token,
394-
authority: createNoopSigner(source_wallet),
397+
authority: isSigner ? source_wallet : walletAddress,
395398
destination: destinationTokenAccount,
396399
source: sourceTokenAccount,
397400
});

sdks/ts/src/types/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,11 @@ export interface GetPaymentInstructionRequest {
104104
sig_verify?: boolean;
105105
/** Optional signer address for the transaction */
106106
signer_key?: string;
107-
/** The wallet owner (not token account) that will be making the token payment */
108-
source_wallet: string;
107+
/** The wallet owner that will be making the token payment.
108+
* Accepts a plain address string or a TransactionSigner. When a TransactionSigner is provided,
109+
* it is used as the transfer authority on the payment instruction, preserving signer identity
110+
* and avoiding conflicts with other instructions that reference the same address. */
111+
source_wallet: TransactionSigner | string;
109112
/** The token program id to use for the payment (defaults to TOKEN_PROGRAM_ID) */
110113
token_program_id?: string;
111114
/** Base64-encoded transaction to estimate fees for */

sdks/ts/test/unit.test.ts

Lines changed: 105 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
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';
213

314
import { KoraClient } from '../src/client.js';
415
import {
@@ -491,12 +502,9 @@ describe('KoraClient Unit Tests', () => {
491502
role: 1, // writable
492503
}), // Destination token account
493504
expect.objectContaining({
494-
// readonly-signer
505+
// readonly (plain address, no signer attached)
495506
address: validRequest.source_wallet,
496-
role: 2,
497-
signer: expect.objectContaining({
498-
address: validRequest.source_wallet,
499-
}),
507+
role: 0,
500508
}), // Authority
501509
],
502510
data: expect.any(Uint8Array),
@@ -575,6 +583,97 @@ describe('KoraClient Unit Tests', () => {
575583
await expect(client.getPaymentInstruction(validRequest)).rejects.toThrow('Network error');
576584
});
577585

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+
578677
it('should return correct payment details in response', async () => {
579678
mockFetch.mockResolvedValueOnce({
580679
json: jest.fn().mockResolvedValueOnce({

0 commit comments

Comments
 (0)