diff --git a/crates/lib/src/config.rs b/crates/lib/src/config.rs index e7457b18..0439833b 100644 --- a/crates/lib/src/config.rs +++ b/crates/lib/src/config.rs @@ -79,6 +79,10 @@ impl ValidationConfig { pub fn is_payment_required(&self) -> bool { !matches!(&self.price.model, PriceModel::Free) } + + pub fn supports_token(&self, token: &str) -> bool { + self.allowed_spl_paid_tokens.iter().any(|s| s == token) + } } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] diff --git a/crates/lib/src/rpc_server/method/estimate_transaction_fee.rs b/crates/lib/src/rpc_server/method/estimate_transaction_fee.rs index ede870fa..534f0068 100644 --- a/crates/lib/src/rpc_server/method/estimate_transaction_fee.rs +++ b/crates/lib/src/rpc_server/method/estimate_transaction_fee.rs @@ -30,6 +30,8 @@ pub struct EstimateTransactionFeeResponse { pub fee_in_token: Option, /// Public key of the signer used for fee estimation (for client consistency) pub signer_pubkey: String, + /// Public key of the payment destination + pub payment_address: String, } pub async fn estimate_transaction_fee( @@ -39,7 +41,10 @@ pub async fn estimate_transaction_fee( let transaction = TransactionUtil::decode_b64_transaction(&request.transaction)?; let signer = get_request_signer_with_signer_key(request.signer_key.as_deref())?; - let validation_config = &get_config()?.validation; + let config = get_config()?; + let payment_destination = config.kora.get_payment_address(&signer.solana_pubkey())?; + + let validation_config = &config.validation; let fee_payer = signer.solana_pubkey(); let mut resolved_transaction = @@ -61,6 +66,10 @@ pub async fn estimate_transaction_fee( KoraError::InvalidTransaction("Invalid fee token mint address".to_string()) })?; + if !validation_config.supports_token(fee_token) { + return Err(KoraError::InvalidRequest(format!("Token {fee_token} is not supported"))); + } + let fee_value_in_token = TokenUtil::calculate_lamports_value_in_token( fee_in_lamports, &token_mint, @@ -76,5 +85,6 @@ pub async fn estimate_transaction_fee( fee_in_lamports, fee_in_token, signer_pubkey: fee_payer.to_string(), + payment_address: payment_destination.to_string(), }) } diff --git a/sdks/ts/scripts/test-with-validator.js b/sdks/ts/scripts/test-with-validator.js index f7e42e34..cbcc4325 100644 --- a/sdks/ts/scripts/test-with-validator.js +++ b/sdks/ts/scripts/test-with-validator.js @@ -38,6 +38,7 @@ async function waitForValidator() { if (exitCode === 0) { console.log('Validator is ready!'); + await setTimeout(2_000); return; } } catch (error) { diff --git a/sdks/ts/src/client.ts b/sdks/ts/src/client.ts index 9110332f..a753031d 100644 --- a/sdks/ts/src/client.ts +++ b/sdks/ts/src/client.ts @@ -1,3 +1,5 @@ +// TODO Make sure to change necessary deps from devdeps to deps +import { assertIsAddress, createNoopSigner, Instruction } from '@solana/kit'; import { Config, EstimateTransactionFeeRequest, @@ -17,8 +19,17 @@ import { AuthenticationHeaders, KoraClientOptions, GetPayerSignerResponse, + GetPaymentInstructionRequest, + GetPaymentInstructionResponse, } from './types/index.js'; import crypto from 'crypto'; +import { + findAssociatedTokenPda, + getTransferCheckedInstruction, + TOKEN_PROGRAM_ADDRESS, + fetchMaybeMint, + getTransferInstruction, +} from '@solana-program/token'; /** * Kora RPC client for interacting with the Kora paymaster service. @@ -281,4 +292,74 @@ export class KoraClient { async transferTransaction(request: TransferTransactionRequest): Promise { return this.rpcRequest('transferTransaction', request); } + + /** + * Creates a payment instruction to append to a transaction for fee payment to the Kora paymaster. + * + * This method estimates the required fee and generates a token transfer instruction + * from the source wallet to the Kora payment address. The server handles decimal + * conversion internally, so the raw token amount is used directly. + * + * @param request - Payment instruction request parameters + * @param request.transaction - Base64-encoded transaction to estimate fees for + * @param request.fee_token - Mint address of the token to use for payment + * @param request.source_wallet - Public key of the wallet paying the fees + * @param request.token_program_id - Optional token program ID (defaults to TOKEN_PROGRAM_ADDRESS) + * @returns Payment instruction details including the instruction, amount, and addresses + * @throws {Error} When the token is not supported, payment is not required, or invalid addresses are provided + * + * @example + * ```typescript + * const paymentInfo = await client.getPaymentInstruction({ + * transaction: 'base64EncodedTransaction', + * fee_token: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + * source_wallet: 'sourceWalletPublicKey' + * }); + * // Append paymentInfo.payment_instruction to your transaction + * ``` + */ + async getPaymentInstruction({ + transaction, + fee_token, + source_wallet, + token_program_id = TOKEN_PROGRAM_ADDRESS, + }: GetPaymentInstructionRequest): Promise { + assertIsAddress(source_wallet); + assertIsAddress(fee_token); + assertIsAddress(token_program_id); + + const { fee_in_token, payment_address, signer_pubkey } = await this.estimateTransactionFee({ + transaction, + fee_token, + }); + assertIsAddress(payment_address); + + const [sourceTokenAccount] = await findAssociatedTokenPda({ + owner: source_wallet, + tokenProgram: token_program_id, + mint: fee_token, + }); + + const [destinationTokenAccount] = await findAssociatedTokenPda({ + owner: payment_address, + tokenProgram: token_program_id, + mint: fee_token, + }); + + const paymentInstruction: Instruction = getTransferInstruction({ + source: sourceTokenAccount, + destination: destinationTokenAccount, + authority: createNoopSigner(source_wallet), + amount: fee_in_token, + }); + + return { + original_transaction: transaction, + payment_instruction: paymentInstruction, + payment_amount: fee_in_token, + payment_token: fee_token, + payment_address, + signer_address: signer_pubkey, + }; + } } diff --git a/sdks/ts/src/types/index.ts b/sdks/ts/src/types/index.ts index a4cbdfb9..9f5919e8 100644 --- a/sdks/ts/src/types/index.ts +++ b/sdks/ts/src/types/index.ts @@ -1,3 +1,5 @@ +import { Instruction, Address } from '@solana/kit'; + /** * Request Types */ @@ -60,6 +62,20 @@ export interface EstimateTransactionFeeRequest { signer_key?: string; } +/** + * Parameters for getting a payment instruction. + */ +export interface GetPaymentInstructionRequest { + /** Base64-encoded transaction to estimate fees for */ + transaction: string; + /** Mint address of the token to calculate fees in */ + fee_token: string; + /** The wallet owner (not token account) that will be making the token payment */ + source_wallet: string; + /** The token program id to use for the payment (defaults to TOKEN_PROGRAM_ID) */ + token_program_id?: string; +} + /** * Response Types */ @@ -142,6 +158,8 @@ export interface EstimateTransactionFeeResponse { fee_in_token: number; /** Public key of the signer used to estimate the fee */ signer_pubkey: string; + /** Public key of the payment destination */ + payment_address: string; } /** @@ -154,6 +172,24 @@ export interface GetPayerSignerResponse { payment_address: string; } +/** + * Response containing a payment instruction. + */ +export interface GetPaymentInstructionResponse { + /** Base64-encoded original transaction */ + original_transaction: string; + /** Base64-encoded payment instruction */ + payment_instruction: Instruction; + /** Payment amount in the requested token */ + payment_amount: number; + /** Mint address of the token used for payment */ + payment_token: string; + /** Public key of the payment destination */ + payment_address: string; + /** Public key of the payer signer */ + signer_address: string; +} + /** * Configuration Types */ diff --git a/sdks/ts/test/integration.test.ts b/sdks/ts/test/integration.test.ts index d21c0142..8bc77bba 100644 --- a/sdks/ts/test/integration.test.ts +++ b/sdks/ts/test/integration.test.ts @@ -1,17 +1,16 @@ import { KoraClient } from '../src/index.js'; import setupTestSuite from './setup.js'; import { runAuthenticationTests } from './auth-setup.js'; - import { Address, getBase64EncodedWireTransaction, getBase64Encoder, getTransactionDecoder, - partiallySignTransaction, signTransaction, type KeyPairSigner, type Transaction, } from '@solana/kit'; +import { findAssociatedTokenPda, TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; function transactionFromBase64(base64: string): Transaction { const encoder = getBase64Encoder(); @@ -193,6 +192,50 @@ describe(`KoraClient Integration Tests (${AUTH_ENABLED ? 'with auth' : 'without expect(signResult.transaction).toBeDefined(); expect(signResult.signed_transaction).toBeDefined(); }); + + it('should get payment instruction', async () => { + const transferRequest = { + amount: 1000000, + token: usdcMint, + source: testWalletAddress, + destination: destinationAddress, + }; + const [expectedSenderAta] = await findAssociatedTokenPda({ + owner: testWalletAddress, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + mint: usdcMint, + }); + const [koraAta] = await findAssociatedTokenPda({ + owner: koraAddress, + tokenProgram: TOKEN_PROGRAM_ADDRESS, + mint: usdcMint, + }); + + const { transaction } = await client.transferTransaction(transferRequest); + const { + payment_instruction, + payment_amount, + payment_token, + payment_address, + signer_address, + original_transaction, + } = await client.getPaymentInstruction({ + transaction, + fee_token: usdcMint, + source_wallet: testWalletAddress, + }); + expect(payment_instruction).toBeDefined(); + expect(payment_instruction.programAddress).toBe(TOKEN_PROGRAM_ADDRESS); + expect(payment_instruction.accounts?.[0].address).toBe(expectedSenderAta); + expect(payment_instruction.accounts?.[1].address).toBe(koraAta); + expect(payment_instruction.accounts?.[2].address).toBe(testWalletAddress); + // todo math to verify payment amount + // expect(payment_amount).toBe(1000000); + expect(payment_token).toBe(usdcMint); + expect(payment_address).toBe(koraAddress); + expect(signer_address).toBe(koraAddress); + expect(original_transaction).toBe(transaction); + }); }); describe('Error Handling', () => { diff --git a/sdks/ts/test/setup.ts b/sdks/ts/test/setup.ts index be5e7eff..3fd17175 100644 --- a/sdks/ts/test/setup.ts +++ b/sdks/ts/test/setup.ts @@ -84,7 +84,7 @@ const createKeyPairSignerFromB58Secret = async (b58Secret: string) => { const b58SecretEncoded = base58Encoder.encode(b58Secret); return await createKeyPairSignerFromBytes(b58SecretEncoded); }; - +// TODO Add KORA_PRIVATE_KEY_2= support for multi-signer configs function loadEnvironmentVariables() { const koraSignerType = process.env.KORA_SIGNER_TYPE || DEFAULTS.KORA_SIGNER_TYPE; @@ -169,13 +169,11 @@ const createDefaultTransaction = async ( const signAndSendTransaction = async ( client: Client, transactionMessage: CompilableTransactionMessage & TransactionMessageWithBlockhashLifetime, - commitment: Commitment = loadEnvironmentVariables().commitment, + commitment: Commitment, ) => { const signedTransaction = await signTransactionMessageWithSigners(transactionMessage); const signature = getSignatureFromTransaction(signedTransaction); - await sendAndConfirmTransactionFactory(client)(signedTransaction, { - commitment, - }); + await sendAndConfirmTransactionFactory(client)(signedTransaction, { commitment, skipPreflight: true }); return signature; }; @@ -197,19 +195,13 @@ async function sendAndConfirmInstructions( payer: TransactionSigner, instructions: Instruction[], description: string, + commitment: Commitment = loadEnvironmentVariables().commitment, ): Promise { try { - const simulationTx = await pipe(await createDefaultTransaction(client, payer), tx => - appendTransactionMessageInstructions(instructions, tx), - ); - const estimateCompute = estimateComputeUnitLimitFactory({ - rpc: client.rpc, - }); - const computeUnitLimit = await estimateCompute(simulationTx); const signature = await pipe( - await createDefaultTransaction(client, payer, computeUnitLimit), + await createDefaultTransaction(client, payer, 200_000), tx => appendTransactionMessageInstructions(instructions, tx), - tx => signAndSendTransaction(client, tx), + tx => signAndSendTransaction(client, tx, commitment), ); return signature; } catch (error) { @@ -292,7 +284,7 @@ async function initializeToken({ ) : []; const instructions = [...baseInstructions, ...otherAtaInstructions]; - await sendAndConfirmInstructions(client, payer, instructions, 'Initialize token and ATAs'); + await sendAndConfirmInstructions(client, payer, instructions, 'Initialize token and ATAs', 'finalized'); } async function setupTestSuite(): Promise { @@ -329,12 +321,12 @@ async function setupTestSuite(): Promise { // Airdrop SOL to test sender and kora wallets await Promise.all([ airdrop({ - commitment, + commitment: 'finalized', lamports: lamports(solDropAmount), recipientAddress: koraAddress, }), airdrop({ - commitment, + commitment: 'finalized', lamports: lamports(solDropAmount), recipientAddress: testWallet.address, }), diff --git a/sdks/ts/test/unit.test.ts b/sdks/ts/test/unit.test.ts index 30618af1..f4128d5f 100644 --- a/sdks/ts/test/unit.test.ts +++ b/sdks/ts/test/unit.test.ts @@ -15,6 +15,7 @@ import { TransferTransactionResponse, EstimateTransactionFeeResponse, } from '../src/types/index.js'; +import { TOKEN_PROGRAM_ADDRESS } from '@solana-program/token'; // Mock fetch globally const mockFetch = jest.fn(); @@ -204,7 +205,8 @@ describe('KoraClient Unit Tests', () => { const mockResponse: EstimateTransactionFeeResponse = { fee_in_lamports: 5000, fee_in_token: 25, - signer_pubkey: 'test_signer_pubkey', + signer_pubkey: 'DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7', + payment_address: 'PayKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7', }; await testSuccessfulRpcMethod( @@ -303,6 +305,205 @@ describe('KoraClient Unit Tests', () => { ); }); }); + describe('getPaymentInstruction', () => { + const mockConfig: Config = { + fee_payers: ['11111111111111111111111111111111'], + validation_config: { + max_allowed_lamports: 1000000, + max_signatures: 10, + price_source: 'Jupiter', + allowed_programs: ['program1'], + allowed_tokens: ['token1'], + allowed_spl_paid_tokens: ['4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU'], + disallowed_accounts: [], + fee_payer_policy: { + allow_sol_transfers: true, + allow_spl_transfers: true, + allow_token2022_transfers: true, + allow_assign: true, + allow_burn: true, + allow_close_account: true, + allow_approve: true, + }, + price: { + type: 'margin', + margin: 0.1, + }, + token2022: { + blocked_mint_extensions: [], + blocked_account_extensions: [], + }, + }, + enabled_methods: { + liveness: true, + estimate_transaction_fee: true, + get_supported_tokens: true, + sign_transaction: true, + sign_and_send_transaction: true, + transfer_transaction: true, + get_blockhash: true, + get_config: true, + sign_transaction_if_paid: true, + }, + }; + + const mockFeeEstimate: EstimateTransactionFeeResponse = { + fee_in_lamports: 5000, + fee_in_token: 50000, + signer_pubkey: 'DemoKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7', + payment_address: 'PayKMZWkk483QoFPLRPQ2XVKB7bWnuXwSjvDE1JsWk7', + }; + + // Create a mock base64-encoded transaction + // This is a minimal valid transaction structure + const mockTransactionBase64 = + 'Aoq7ymA5OGP+gmDXiY5m3cYXlY2Rz/a/gFjOgt9ZuoCS7UzuiGGaEnW2OOtvHvMQHkkD7Z4LRF5B63ftu+1oZwIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgECB1urjQEjgFgzqYhJ8IXJeSg4cJP1j1g2CJstOQTDchOKUzqH3PxgGW3c4V3vZV05A5Y30/MggOBs0Kd00s1JEwg5TaEeaV4+KL2y7fXIAuf6cN0ZQitbhY+G9ExtBSChspOXPgNcy9pYpETe4bmB+fg4bfZx1tnicA/kIyyubczAmbcIKIuniNOOQYG2ggKCz8NjEsHVezrWMatndu1wk6J5miGP26J6Vwp31AljiAajAFuP0D9mWJwSeFuA7J5rPwbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpd/O36SW02zRtNtqk6GFeip2+yBQsVTeSbLL4rWJRkd4CBgQCBQQBCgxAQg8AAAAAAAYGBAIFAwEKDBAnAAAAAAAABg=='; + + const validRequest = { + transaction: mockTransactionBase64, + fee_token: '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU', + source_wallet: '11111111111111111111111111111111', + token_program_id: 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA', + }; + + beforeEach(() => { + // Mock console.log to avoid noise in tests + jest.spyOn(console, 'log').mockImplementation(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should successfully append payment instruction', async () => { + // Mock estimateTransactionFee call + mockFetch.mockResolvedValueOnce({ + json: jest.fn().mockResolvedValueOnce({ + jsonrpc: '2.0', + id: 1, + result: mockFeeEstimate, + }), + }); + + const result = await client.getPaymentInstruction(validRequest); + + expect(result).toEqual({ + original_transaction: validRequest.transaction, + payment_instruction: expect.objectContaining({ + programAddress: TOKEN_PROGRAM_ADDRESS, + accounts: [ + expect.objectContaining({ + role: 1, // writable + }), // Source token account + expect.objectContaining({ + role: 1, // writable + }), // Destination token account + expect.objectContaining({ + role: 2, // readonly-signer + address: validRequest.source_wallet, + signer: expect.objectContaining({ + address: validRequest.source_wallet, + }), + }), // Authority + ], + data: expect.any(Uint8Array), + }), + payment_amount: mockFeeEstimate.fee_in_token, + payment_token: validRequest.fee_token, + payment_address: mockFeeEstimate.payment_address, + signer_address: mockFeeEstimate.signer_pubkey, + }); + + // Verify only estimateTransactionFee was called + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetch).toHaveBeenCalledWith(mockRpcUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'estimateTransactionFee', + params: { + transaction: validRequest.transaction, + fee_token: validRequest.fee_token, + }, + }), + }); + }); + + it('should handle fixed pricing configuration', async () => { + // Mock estimateTransactionFee call + mockFetch.mockResolvedValueOnce({ + json: jest.fn().mockResolvedValueOnce({ + jsonrpc: '2.0', + id: 1, + result: mockFeeEstimate, + }), + }); + + const result = await client.getPaymentInstruction(validRequest); + + expect(result.payment_amount).toBe(mockFeeEstimate.fee_in_token); + expect(result.payment_token).toBe(validRequest.fee_token); + }); + + it('should throw error for invalid addresses', async () => { + const invalidRequests = [ + { ...validRequest, source_wallet: 'invalid_address' }, + { ...validRequest, fee_token: 'invalid_token' }, + { ...validRequest, token_program_id: 'invalid_program' }, + ]; + + for (const invalidRequest of invalidRequests) { + await expect(client.getPaymentInstruction(invalidRequest)).rejects.toThrow(); + } + }); + + it('should handle estimateTransactionFee RPC error', async () => { + // Mock failed estimateTransactionFee + const mockError = { code: -32602, message: 'Invalid transaction' }; + mockFetch.mockResolvedValueOnce({ + json: jest.fn().mockResolvedValueOnce({ + jsonrpc: '2.0', + id: 1, + error: mockError, + }), + }); + + await expect(client.getPaymentInstruction(validRequest)).rejects.toThrow( + 'RPC Error -32602: Invalid transaction', + ); + }); + + it('should handle network errors', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + await expect(client.getPaymentInstruction(validRequest)).rejects.toThrow('Network error'); + }); + + it('should return correct payment details in response', async () => { + mockFetch.mockResolvedValueOnce({ + json: jest.fn().mockResolvedValueOnce({ + jsonrpc: '2.0', + id: 1, + result: mockFeeEstimate, + }), + }); + + const result = await client.getPaymentInstruction(validRequest); + + expect(result).toMatchObject({ + original_transaction: validRequest.transaction, + payment_instruction: expect.any(Object), + payment_amount: mockFeeEstimate.fee_in_token, + payment_token: validRequest.fee_token, + payment_address: mockFeeEstimate.payment_address, + signer_address: mockFeeEstimate.signer_pubkey, + }); + }); + }); describe('Error Handling Edge Cases', () => { it('should handle malformed JSON responses', async () => { @@ -323,4 +524,27 @@ describe('KoraClient Unit Tests', () => { await expect(client.getConfig()).rejects.toThrow('RPC Error undefined: undefined'); }); }); + + // TODO: Add Authentication Tests (separate PR) + // + // describe('Authentication', () => { + // describe('API Key Authentication', () => { + // - Test that x-api-key header is included when apiKey is provided + // - Test requests work without apiKey when not provided + // - Test all RPC methods include the header + // }); + // + // describe('HMAC Authentication', () => { + // - Test x-timestamp and x-hmac-signature headers are included when hmacSecret is provided + // - Test HMAC signature calculation is correct (SHA256 of timestamp + body) + // - Test timestamp is current (within reasonable bounds) + // - Test requests work without HMAC when not provided + // - Test all RPC methods include the headers + // }); + // + // describe('Combined Authentication', () => { + // - Test both API key and HMAC headers are included when both are provided + // - Test headers are correctly combined + // }); + // }); }); diff --git a/tests/integration/rpc_integration_tests.rs b/tests/integration/rpc_integration_tests.rs index 3c494942..2e74d3fe 100644 --- a/tests/integration/rpc_integration_tests.rs +++ b/tests/integration/rpc_integration_tests.rs @@ -507,6 +507,7 @@ async fn test_estimate_transaction_fee_without_fee_token() { response["fee_in_token"].is_null(), "Expected fee_in_token to be null when not requested" ); + assert!(response["payment_address"].as_str().is_some(), "Expected payment_address in response"); } #[tokio::test] @@ -539,6 +540,7 @@ async fn test_estimate_transaction_fee_with_fee_token() { // 0.01 usdc * 10^6 = 10000 usdc in base units assert_eq!(fee_in_lamports, 10050, "Fee in lamports should be 10050"); assert_eq!(fee_in_token, 10050.0, "Fee in token should be 10050"); + assert!(response["payment_address"].as_str().is_some(), "Expected payment_address in response"); } #[tokio::test] @@ -571,7 +573,7 @@ async fn test_estimate_transaction_fee_without_payment_instruction() { .expect("Failed to estimate transaction fee with token"); assert!(response["fee_in_lamports"].as_u64().is_some(), "Expected fee_in_lamports in response"); - + assert!(response["payment_address"].as_str().is_some(), "Expected payment_address in response"); let fee_in_lamports = response["fee_in_lamports"].as_u64().unwrap(); println!("fee_in_lamports: {:?}", fee_in_lamports);