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
4 changes: 4 additions & 0 deletions crates/lib/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
12 changes: 11 additions & 1 deletion crates/lib/src/rpc_server/method/estimate_transaction_fee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ pub struct EstimateTransactionFeeResponse {
pub fee_in_token: Option<f64>,
/// 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(
Expand All @@ -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 =
Expand All @@ -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,
Expand All @@ -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(),
})
}
1 change: 1 addition & 0 deletions sdks/ts/scripts/test-with-validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ async function waitForValidator() {

if (exitCode === 0) {
console.log('Validator is ready!');
await setTimeout(2_000);
return;
}
} catch (error) {
Expand Down
81 changes: 81 additions & 0 deletions sdks/ts/src/client.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -281,4 +292,74 @@ export class KoraClient {
async transferTransaction(request: TransferTransactionRequest): Promise<TransferTransactionResponse> {
return this.rpcRequest<TransferTransactionResponse, TransferTransactionRequest>('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<GetPaymentInstructionResponse> {
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,
};
}
}
36 changes: 36 additions & 0 deletions sdks/ts/src/types/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { Instruction, Address } from '@solana/kit';

/**
* Request Types
*/
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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
*/
Expand Down
47 changes: 45 additions & 2 deletions sdks/ts/test/integration.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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', () => {
Expand Down
26 changes: 9 additions & 17 deletions sdks/ts/test/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
};

Expand All @@ -197,19 +195,13 @@ async function sendAndConfirmInstructions(
payer: TransactionSigner,
instructions: Instruction[],
description: string,
commitment: Commitment = loadEnvironmentVariables().commitment,
): Promise<Signature> {
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) {
Expand Down Expand Up @@ -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<TestSuite> {
Expand Down Expand Up @@ -329,12 +321,12 @@ async function setupTestSuite(): Promise<TestSuite> {
// 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,
}),
Expand Down
Loading