Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion sdks/ts/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@solana/kora",
"version": "0.2.0-beta.6",
"version": "0.3.0-beta.0",
"description": "TypeScript SDK for Kora RPC",
"main": "dist/src/index.js",
"type": "module",
Expand Down
11 changes: 7 additions & 4 deletions sdks/ts/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { assertIsAddress, createNoopSigner, Instruction } from '@solana/kit';
import { Address, assertIsAddress, Instruction, isTransactionSigner } from '@solana/kit';
import { findAssociatedTokenPda, getTransferInstruction, TOKEN_PROGRAM_ADDRESS } from '@solana-program/token';
import crypto from 'crypto';

Expand Down Expand Up @@ -361,7 +361,10 @@ export class KoraClient {
signer_key,
sig_verify,
}: GetPaymentInstructionRequest): Promise<GetPaymentInstructionResponse> {
assertIsAddress(source_wallet);
const isSigner = typeof source_wallet !== 'string' && isTransactionSigner(source_wallet);
const walletAddress: Address = isSigner ? source_wallet.address : (source_wallet as Address);

assertIsAddress(walletAddress);
assertIsAddress(fee_token);
assertIsAddress(token_program_id);

Expand All @@ -375,7 +378,7 @@ export class KoraClient {

const [sourceTokenAccount] = await findAssociatedTokenPda({
mint: fee_token,
owner: source_wallet,
owner: walletAddress,
tokenProgram: token_program_id,
});

Expand All @@ -391,7 +394,7 @@ export class KoraClient {

const paymentInstruction: Instruction = getTransferInstruction({
amount: fee_in_token,
authority: createNoopSigner(source_wallet),
authority: isSigner ? source_wallet : walletAddress,
destination: destinationTokenAccount,
source: sourceTokenAccount,
});
Expand Down
7 changes: 5 additions & 2 deletions sdks/ts/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,11 @@ export interface GetPaymentInstructionRequest {
sig_verify?: boolean;
/** Optional signer address for the transaction */
signer_key?: string;
/** The wallet owner (not token account) that will be making the token payment */
source_wallet: string;
/** The wallet owner that will be making the token payment.
* Accepts a plain address string or a TransactionSigner. When a TransactionSigner is provided,
* it is used as the transfer authority on the payment instruction, preserving signer identity
* and avoiding conflicts with other instructions that reference the same address. */
source_wallet: TransactionSigner | string;
/** The token program id to use for the payment (defaults to TOKEN_PROGRAM_ID) */
token_program_id?: string;
/** Base64-encoded transaction to estimate fees for */
Expand Down
111 changes: 105 additions & 6 deletions sdks/ts/test/unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token';
import { getTransferInstruction, TOKEN_PROGRAM_ADDRESS } from '@solana-program/token';
import {
appendTransactionMessageInstructions,
Blockhash,
createNoopSigner,
createTransactionMessage,
generateKeyPairSigner,
Instruction,
partiallySignTransactionMessageWithSigners,
setTransactionMessageFeePayerSigner,
setTransactionMessageLifetimeUsingBlockhash,
} from '@solana/kit';

import { KoraClient } from '../src/client.js';
import {
Expand Down Expand Up @@ -491,12 +502,9 @@ describe('KoraClient Unit Tests', () => {
role: 1, // writable
}), // Destination token account
expect.objectContaining({
// readonly-signer
// readonly (plain address, no signer attached)
address: validRequest.source_wallet,
role: 2,
signer: expect.objectContaining({
address: validRequest.source_wallet,
}),
role: 0,
}), // Authority
],
data: expect.any(Uint8Array),
Expand Down Expand Up @@ -575,6 +583,97 @@ describe('KoraClient Unit Tests', () => {
await expect(client.getPaymentInstruction(validRequest)).rejects.toThrow('Network error');
});

it('should produce a payment instruction compatible with a real signer for the same address', async () => {
// Generate a real KeyPairSigner (simulates a user's wallet)
const userSigner = await generateKeyPairSigner();

// Mock estimateTransactionFee to return the user's address as source_wallet context
const feeEstimate: EstimateTransactionFeeResponse = {
fee_in_lamports: 5000,
fee_in_token: 50000,
payment_address: 'PayKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
signer_pubkey: 'DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
};
mockSuccessfulResponse(feeEstimate);

// Get payment instruction — authority is a plain address (no signer attached)
const result = await client.getPaymentInstruction({
...validRequest,
source_wallet: userSigner.address,
});

// Build another instruction that references the same address with the REAL signer
// (simulates a program instruction like makePurchase where the user is a signer)
const userOwnedIx: Instruction = getTransferInstruction({
amount: 1000n,
authority: userSigner, // <-- real KeyPairSigner
destination: 'PayKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7' as any,
source: '11111111111111111111111111111111' as any,
});

// Combine both instructions in a transaction — previously this would throw
// "Multiple distinct signers" because the payment instruction had a NoopSigner.
// Now the payment instruction uses a plain address, so no conflict.
const feePayer = createNoopSigner('DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7' as any);
const txMessage = appendTransactionMessageInstructions(
[userOwnedIx, result.payment_instruction],
setTransactionMessageLifetimeUsingBlockhash(
{ blockhash: '11111111111111111111111111111111' as Blockhash, lastValidBlockHeight: 0n },
setTransactionMessageFeePayerSigner(feePayer, createTransactionMessage({ version: 0 })),
),
);

// This should NOT throw "Multiple distinct signers"
await expect(partiallySignTransactionMessageWithSigners(txMessage)).resolves.toBeDefined();
});

it('should accept a TransactionSigner as source_wallet and preserve signer identity', async () => {
const userSigner = await generateKeyPairSigner();

const feeEstimate: EstimateTransactionFeeResponse = {
fee_in_lamports: 5000,
fee_in_token: 50000,
payment_address: 'PayKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
signer_pubkey: 'DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7',
};
mockSuccessfulResponse(feeEstimate);

// Pass the signer directly as source_wallet
const result = await client.getPaymentInstruction({
...validRequest,
source_wallet: userSigner,
});

// The authority account meta should carry the signer
const authorityMeta = result.payment_instruction.accounts?.[2];
expect(authorityMeta).toEqual(
expect.objectContaining({
address: userSigner.address,
role: 2, // readonly-signer
signer: userSigner,
}),
);

// Combining with another instruction using the same signer should work
const userOwnedIx: Instruction = getTransferInstruction({
amount: 1000n,
authority: userSigner,
destination: 'PayKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7' as any,
source: '11111111111111111111111111111111' as any,
});

const feePayer = createNoopSigner('DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7' as any);
const txMessage = appendTransactionMessageInstructions(
[userOwnedIx, result.payment_instruction],
setTransactionMessageLifetimeUsingBlockhash(
{ blockhash: '11111111111111111111111111111111' as Blockhash, lastValidBlockHeight: 0n },
setTransactionMessageFeePayerSigner(feePayer, createTransactionMessage({ version: 0 })),
),
);

await expect(partiallySignTransactionMessageWithSigners(txMessage)).resolves.toBeDefined();
});

it('should return correct payment details in response', async () => {
mockFetch.mockResolvedValueOnce({
json: jest.fn().mockResolvedValueOnce({
Expand Down
Loading