Gasless transactions with Kora
Anyone who’s sent crypto from a non-custodial wallet knows that transaction fees are a pain point for users coming from traditional payments. Having to fund a wallet with SOL, POL etc. just to send USDC for example, is not a good UX.
Ethereum (& EVM networks) addressed this with the introduction of two protocol improvements (EIP7702 & EIP2612) which together, allow a third party to pay the transaction fee on behalf of a user.
The equivalent functionality in Solana is achieved by setting the feePayer property on a transaction. Any account can cover fees.
Kora lets users pay Solana transaction fees with tokens like USDC instead of SOL by paying the transaction fee on behalf of the user and then being reimbursed by the user in USDC- much like Circle’s paymaster does on EVM networks.
Kora setup is fairly straightfoward.
The operators documentation has all relevant info for running a kora node, but below is a step-by-step MVP setup with a few other pieces of info you might find useful.
Before installing and running kora you need Solana CLI v2.2.x or greater, Rust/Cargo and Node.js v22+ after which you can run:
cargo install kora-cliA Kora server needs the following:
- Server config — kora.toml
- Signing config — signers.toml
- A docker file (for deployment)
- Environment variables to populate fields in the config files.
You can find examples of the kora & signer toml files, and dockerfile here.
To generate a new fee payer wallet if you don’t have one already, you can use the Solana CLI:
solana-keygen new --outfile signer.jsonThis outputs a 64-byte array: [32 bytes private key seed || 32 bytes public key]. Convert this to a base-58 encoded private key. Here’s a sample node script to do that.
//keypair-to-b58.tsimport { readFileSync } from 'fs';
import { resolve } from 'path';
import bs58 from 'bs58';const filePath = process.argv[2];
if (!filePath) {
console.error('Usage: npx ts-node keypair-to-b58.ts <keypair.json>');
process.exit(1);
}const raw = JSON.parse(readFileSync(resolve(filePath), 'utf-8')) as number[];
const bytes = new Uint8Array(raw);console.log(bs58.encode(bytes));
Thats the private key for your fee payer, keep it safe.
Running your Kora node
a. Running node locally
- Create an .env file & add the private key as KORA_PRIVATE_KEY along with other required variables:
# .env
KORA_PRIVATE_KEY=***********************************
RPC_URL=https://api.devnet.solana.com
TOKEN_MINT=4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU # devnet USDCSigners.toml references KORA_PRIVATE_KEY from .env.
2. Ensure that kora.toml is configured correctly.
3. Start Kora with:
kora rpc start --signers-config signers.tomlThe fee paid to the kora node will be wildly inflated for USDC sends on devnet if you have price source set to mock, but rest assured it will be the transaction cost + margin when using Jupiter as price source on prod.
b. Running node in production with Railway
- Create a git repo with the signers.toml, kora.toml and dockerfile. Don’t commit your .env file
Signers.toml needs to have pricing source set to Jupiter to get accurate transaction pricing and will require JUPITER_API_KEY environment variable to be set later.
2. Create a Railway account & configure which github repos to connect.
3. Hit deploy & choose the github repo you want to deploy.
Click on the deployment and head across to the variables tab.
Get Brad Roodt’s stories in your inbox
Join Medium for free to get updates from this writer.
3. Add environment variables for KORA_PRIVATE_KEY, RPC_URL and JUPITER_API_KEY.
Connecting to your Kora node
Install the Kora typescript sdk to connect to your Kora node
npm install @solana/koraThere are 2 ways to connect to your node via the sdk- Standalone and Kit clients.
Standalone
With this more granular approach where you’re responsible for building the transaction yourself, estimating & injecting fees manually and submitting & signing the transaction.
Kit
This is the recommended higher-level client that handles transaction planning, fee estimation, payment injection, and submission automatically.
Here’s an example using Kora Kit Client to send USDC.
/**
* Example: Send 0.5 USDC on devnet using createKitKoraClient.
*
* Kora's fee payer covers the SOL gas fee. The sender and recipient
* keypairs are loaded from local JSON files (byte-array format, e.g.
* output of `solana-keygen new --outfile sender.json`).
*
* Usage:
* npx ts-node examples/kit-client-usdc-transfer.ts
*
* Required files (relative to package root):
* sender.json - keypair that holds USDC and signs the transfer
* recipient.json - keypair whose address receives the USDC
*
* Environment variables (optional overrides):
* KORA_ENDPOINT - Kora RPC URL (default: http://localhost:8080)
* SOLANA_RPC_URL - Solana RPC URL (default: https://api.devnet.solana.com)
* USDC_MINT - USDC mint address (default: devnet USDC)
*/import { readFileSync } from 'fs';
import { resolve } from 'path';
import {
address,
createKeyPairSignerFromBytes,
type Address,
type KeyPairSigner,
} from '@solana/kit';
import {
findAssociatedTokenPda,
getCreateAssociatedTokenIdempotentInstructionAsync,
getTransferInstruction,
TOKEN_PROGRAM_ADDRESS,
} from '@solana-program/token';
import { createKitKoraClient } from '@solana/kora';// --- Configuration ---const KORA_ENDPOINT = 'https://kora-production.up.railway.app'; //'http://localhost:8080';
const SOLANA_RPC_URL = 'https://api.devnet.solana.com';// Devnet USDC mint (Circle)
const USDC_MINT = address(
'4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU') as Address,
);
const USDC_DECIMALS = 6;
const TRANSFER_AMOUNT = 500_000n; // 0.5 USDC (6 decimals)// --- Helpers ---function loadKeypairFromFile(filePath: string): KeyPairSigner | Promise<KeyPairSigner> {
const absPath = resolve(filePath);
const raw = JSON.parse(readFileSync(absPath, 'utf-8')) as number[];
return createKeyPairSignerFromBytes(new Uint8Array(raw));
}// --- Main ---async function main() {
console.log('Loading keypairs...');
const sender = await loadKeypairFromFile('./sender.json');
const recipient = await loadKeypairFromFile('./recipient.json'); console.log('Sender: ', sender.address);
console.log('Recipient:', recipient.address);
console.log('USDC mint:', USDC_MINT);
console.log('Kora: ', KORA_ENDPOINT);
console.log('Solana: ', SOLANA_RPC_URL);
console.log(); // createKitKoraClient calls getPayerSigner on init to learn Kora's fee payer address.
// feeToken / feePayerWallet describe how the sender pays Kora's service fee (if any).
// Kora's fee payer address (not the sender) covers the SOL transaction fee.
const client = await createKitKoraClient({
endpoint: KORA_ENDPOINT,
rpcUrl: SOLANA_RPC_URL,
feeToken: USDC_MINT,
feePayerWallet: sender,
}); console.log('Kora fee payer:', client.payer.address);
if (client.paymentAddress) {
console.log('Kora payment address:', client.paymentAddress);
}
console.log(); // Derive Associated Token Accounts
const [senderAta] = await findAssociatedTokenPda({
mint: USDC_MINT,
owner: sender.address,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
});
const [recipientAta] = await findAssociatedTokenPda({
mint: USDC_MINT,
owner: recipient.address,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
}); // Create the recipient ATA if it doesn't exist yet.
// Kora's fee payer (client.payer) funds the rent for account creation.
const createAtaIx = await getCreateAssociatedTokenIdempotentInstructionAsync({
payer: client.payer,
mint: USDC_MINT,
owner: recipient.address,
}); // Build the USDC transfer instruction (sender → recipient, 1 USDC)
const transferIx = getTransferInstruction({
source: senderAta,
destination: recipientAta,
authority: sender,
amount: TRANSFER_AMOUNT,
}); console.log(`Sending ${Number(TRANSFER_AMOUNT) / 10 ** USDC_DECIMALS} USDC...`);
console.log(' Kora fee payer covers SOL gas\n'); // sendTransaction handles everything:
// 1. Gets a fresh blockhash from Kora
// 2. Estimates compute units via Solana RPC simulation
// 3. Builds a placeholder payment instruction (if Kora charges an SPL fee)
// 4. Estimates the actual fee amount and replaces the placeholder
// 5. Calls signAndSendTransaction on Kora (Kora co-signs as fee payer and broadcasts)
const result = await client.sendTransaction([createAtaIx, transferIx]); const sig = result.context.signature;
console.log('Transaction confirmed!');
console.log('Signature:', sig);
console.log(`Explorer: https://explorer.solana.com/tx/${sig}?cluster=devnet`);
}main().catch(err => {
console.error('Error:', err);
process.exit(1);
});
Its worth talking about the createKoraKitClient function.
const client = await createKitKoraClient({
endpoint: 'http://localhost:8080', // Kora RPC
rpcUrl: 'https://api.mainnet-beta.solana.com',
feeToken: address('EPjFWdd5Auf...TDt1v'), // e.g. USDC mint
feePayerWallet: userSigner, // the sender's Kit signer
});The feePayerWallet here is a little different from the feePayer on a solana transaction. In this context, fee payer refers to the user sending the transaction, not the kora node(Kora acts as the on-chain fee payer so its confusing terminology) .
The userSigner is a TransactionSigner object that looks something like this:
{
address: 'BYJVBqQ2xV9GECc84FeoPQy2DpgoonZQFQu97MMWTbBc',
keyPair: CryptoKeyPair, // non-extractable WebCrypto key
signMessages: async (messages) => [...],
signTransactions: async (txs) => [...],
}KoraKit will call signTransactions internally. The useful thing about this is that you can create other signers that use different transports but conform to the same basic interface such as hardware signer.
Output
Here’s an example a transaction with a kora feepayer.
Wallet1 is the feepayer who has paid the transaction cost of (0.014ZAR or 1.4c) andis being re-imbursed in USDC with a 5% margin applied for total cost to send of (0.0147ZAR or 1.47c).
Wallet’s 2 and 3 are recipient and sender respectively.







