Frontend architecture design and practical implementation for high-performance data access and transaction optimisation when migrating from Ethereum to Solana.


Teams are moving EVM projects to Solana for higher throughput, lower fees, and better UX. We've done this in production — migrated multiple Ethereum protocols to Solana across different industries. The hard parts are contract architecture, data models, transaction logic, and frontend-backend coordination. We've learned what breaks and what works.
This article is part of a broader series on migrating Ethereum protocols to Solana. We split the work across contracts, backend, and frontend — each post builds on the same staking example in evm-to-solana.
You are here: Frontend (Part 1) — the infrastructure layer: wallets, ALTs, priority fees, transaction retry, and Jito Bundles.
If you're new to the series, start with the Preamble for account models, execution, and fees, then the Contracts guides. After this post, continue with Frontend (Part 2) for a complete staking demo, or the Backend guide for event sync and automation.
| Layer | Article | What it covers |
|---|---|---|
| Foundation | Preamble | Account model, execution, fees |
| Contracts | Part 1 / Part 2 | Account model, CPI, PDAs, limits, staking port |
| Frontend | Part 1 (this article) | Wallets, ALTs, priority fees, retry, Jito |
| Frontend | Part 2 | Staking demo: read/write state, events |
| Backend | Backend | Event sync, log parsing, cron automation |
This article covers the frontend pieces you actually touch when moving a DApp from EVM to Solana: wallet connection (including custom mobile adapters for wallets without official support), hardware wallet login via signTransaction + Memo, Address Lookup Tables, priority fees, transaction retry, and Jito Bundles. Not a staking demo — that's Part 2. Everything below comes with working code you can use.
Connecting a wallet to a Solana frontend is easy. Keeping the experience consistent across platforms is not:
A wallet's capabilities and behavior vary across these environments. Adapting to each wallet and platform individually doesn't scale. @solana/wallet-adapter (from Anza) solves this with an Adapter pattern — you write against a single interface, and it handles the rest.
Below is the standard setup from the official docs (APP.md), then we'll get into something the docs don't cover well: what happens on mobile when a wallet doesn't have an adapter yet.
When integrating a wallet with a Solana DApp, you'll face:
ConnectionProvider + WalletProviderIn a typical React application, the minimum recommended access method for @solana/wallet-adapter is:
ConnectionProvider to provide an RPC connection.WalletProvider to register a set of supported wallets (official adapters are already implemented for common wallets).WalletModalProvider + WalletMultiButton) for interaction.In a desktop environment, as long as the wallet injects an object conforming to expectations via window.xxx, the connection or signing can often be completed through "non-standard methods" even without explicit adapter registration.
However, this method does not naturally migrate to mobile scenarios.
In practice, you hit these problems:
This leads to a crucial difference:
The official documentation mainly describes the problem from the perspective of "how a wallet should implement an adapter", but in reality, the progress of wallet-side adaptation is not entirely controllable. As a DApp developer who has adopted the @solana/wallet-adapter system, you naturally face this problem: if a certain wallet does not have an official adapter yet, but you still want to provide a clear, usable connection entry point for mobile users, what should you do?
@solana/wallet-adapter's core abstraction is the WalletAdapter interface (defined in @solana/wallet-adapter-base). The application layer doesn't care whether an adapter is official or custom — it just needs something that implements the interface.
So we build a custom adapter backed by a deep link, with:
name / url / icon / deepLink.publicKey / connecting / readyState.connect() / disconnect() / isConnected()sendTransaction(tx, connection, options) and others.From the application layer perspective:
WalletProvider.WalletMultiButton) does not need to perform any special checks.This makes a custom adapter very suitable as a fallback solution for scenarios where "the official adapter has not yet covered this wallet, but mobile users must have an entry point."
As long as the same interface is implemented, you can register your CustomWalletAdapter just like registering Phantom, and it will be included in the WalletProvider's wallet list.
export class CustomWalletAdapter extends BaseWalletAdapter {
readonly name: WalletName;
readonly icon: string;
readonly url: string;
readonly deepLink: string;
readonly supportedTransactionVersions = new Set<"legacy" | 0>(["legacy", 0]);
private _connecting: boolean = false;
private _publicKey: PublicKey | null = null;
private _readyState: WalletReadyState;
constructor(config: CustomWalletConfig) {
super();
this.name = config.name;
this.icon = config.icon;
this.url = config.url;
this.deepLink = config.deepLink;
// Loadable: The wallet can be loaded (via deep link) but isn't installed as browser extension
this._readyState = WalletReadyState.Loadable;
}
get publicKey(): PublicKey | null {
return this._publicKey;
}
get connecting(): boolean {
return this._connecting;
}
get connected(): boolean {
return !!this._publicKey;
}
get readyState(): WalletReadyState {
return this._readyState;
}
async autoConnect(): Promise<void> {
// Deep link adapters should never auto-connect
// Auto-redirect without user interaction is bad UX
// Do nothing - require explicit user action to connect
}
async connect(): Promise<void> {
try {
// For deep link adapters, we only redirect to the wallet app
// The actual connection happens in the wallet's in-app browser
// We don't emit "connect" here because the connection isn't established yet
window.location.href = this.deepLink;
} catch (error) {
this.emit("error", new WalletConnectionError((error as Error).message));
throw error;
}
}
async disconnect(): Promise<void> {
this._publicKey = null;
this.emit("disconnect");
}
async sendTransaction<T extends Transaction | VersionedTransaction>(
_transaction: T,
_connection: Connection,
_options?: SendOptions,
): Promise<TransactionSignature> {
// Deep link adapters handle transactions within the wallet app
// The actual signing happens after the deep link redirect
throw new Error(
"sendTransaction is handled by the wallet app after deep link redirect",
);
}
async signTransaction<T extends Transaction | VersionedTransaction>(
_transaction: T,
): Promise<T> {
throw new Error(
"signTransaction is handled by the wallet app after deep link redirect",
);
}
async signAllTransactions<T extends Transaction | VersionedTransaction>(
_transactions: T[],
): Promise<T[]> {
throw new Error(
"signAllTransactions is handled by the wallet app after deep link redirect",
);
}
async signMessage(_message: Uint8Array): Promise<Uint8Array> {
throw new Error(
"signMessage is handled by the wallet app after deep link redirect",
);
}
}
Based on this basic adapter, we can further wrap a utility method to create different wallet adapters based on configuration, such as Backpack.
/**
* Build the Backpack deep link URL for mobile.
* Uses Universal Link format for iOS/Android.
*/
const buildBackpackDeepLink = (): string => {
const currentUrl = getCurrentUrl();
const origin = getCurrentOrigin();
// Backpack Universal Link format
return `https://backpack.app/ul/v1/browse/${encodeURIComponent(
currentUrl,
)}?ref=${encodeURIComponent(origin)}`;
};
/**
* Create a Backpack wallet adapter.
*
* On mobile: Uses deep link to open Backpack app
* On desktop: Redirects to Chrome extension installation page
*/
export function createBackpackWalletAdapter(): CustomWalletAdapter {
return createCustomWalletAdapter({
name: "Backpack" as WalletName<"Backpack">,
icon: BACKPACK_ICON,
url: BACKPACK_URL,
deepLinkBuilder: () =>
isMobile() ? buildBackpackDeepLink() : BACKPACK_CHROME_EXTENSION_URL,
});
}
/**
* Create Backpack adapter only for mobile devices.
* Returns null on desktop (where the official adapter should be used).
*/
export function createBackpackMobileAdapter(): CustomWalletAdapter | null {
if (!isMobile()) {
return null;
}
return createBackpackWalletAdapter();
}
In this way, we can register BackpackWalletAdapter() as a "custom wallet" in WalletProvider to provide a fallback for scenarios not yet covered by the official adapter.
In a mobile environment, most Solana wallets exist as standalone Apps, and communication between the DApp and the wallet is typically through Deeplink / Universal Link. The typical flow is:
mywallet://connect?dapp_url=...&session=...In the custom adapter design above:
connect(), sendTransaction(), etc.WalletAdapter.@solana/wallet-adapter integration pattern.Thus, when an official adapter is temporarily unavailable for a wallet:
You can still use the "Custom Adapter + Deeplink" approach to provide a clear, discoverable, and maintainable connection entry point for mobile users, without disrupting the existing @solana/wallet-adapter architecture.
In a Solana DApp, simply getting the user's publicKey through "connecting the wallet" does not prove that the address is indeed controlled by the current user. The safer and more standard approach is to introduce a Challenge-Response protocol, requiring the user to sign a message generated by the backend to complete the login verification.
The typical wallet login flow is as follows:
User Connects Wallet
The user clicks "Connect Wallet" on the frontend, and the DApp establishes a connection with the wallet via the wallet adapter and obtains the user's publicKey.
This step only indicates that the user agrees to expose the address and does not constitute identity verification.
Frontend Requests Challenge from Backend
The frontend sends the obtained publicKey to the backend, requesting the generation of a login Challenge.
Backend Generates Challenge (Challenge Information)
The Challenge typically includes:
publicKey)nonce)timestamp / TTL)Frontend Requests Wallet Signature
The frontend passes the Challenge to the wallet, requesting the user to sign it.
Frontend Submits Signature Result
The frontend sends the original Challenge and the signature generated by the user to the backend.
Backend Verifies Signature and State
The backend performs the following checks:
publicKey.publicKey in the request.nonce has not been used (to prevent replay attacks).Login Success
After successful verification, the backend issues a session credential (e.g., JWT or Session) for that wallet address, used for subsequent identity identification.
%2Fdiagram.png&w=3840&q=75)
signMessage: Most software wallets support signing arbitrary messages, making it the simplest and most direct way to implement login.signTransaction: Some hardware wallets (e.g., Ledger, Trezor, etc.) typically do not support signing arbitrary messages; they only allow signing standard Solana Transactions.To accommodate these wallets, we construct a "special transaction" that contains only a Memo or other side-effect-free instruction, and ask the user to complete the login signature via signTransaction. Note: this transaction is only for local signing and backend verification. It is not sent on-chain — it's an off-chain signature that doesn't record anything on-chain or consume gas.
Below we introduce both paths, aiming to reuse a single set of verification logic on the frontend and backend as much as possible.
Constructing and Signing a Message
A readable message format, similar to "Sign-In with Solana," can be used, for example:
const generateSignInMessage = useCallback((walletAddress: PublicKey) => {
const timestamp = new Date().toISOString();
const nonce = Math.random().toString(36).substring(7);
return `${appName} wants you to sign in with your Solana account:
${walletAddress.toBase58()}
Please sign in to verify your ownership of this wallet
URI: https://${domain}
Version: 1
Network: Solana
Nonce: ${nonce}
Issued A
// ... (rest of the message) ...
}, [appName, domain]);
Then, use the wallet's signMessage for signing. When using @solana/wallet-adapter, signMessage is an optional method, so its existence must be checked first:
const signMessageForAuth = useCallback(
async (message: string): Promise<SignMessageResult> => {
if (!publicKey || !signMessage) {
throw new Error(
"Wallet not connected or does not support message signing",
);
}
const encodedMessage = new TextEncoder().encode(message);
const signature = await signMessage(encodedMessage);
const signatureBase58 = bs58.encode(signature);
return {
signature: signatureBase58,
publicKey: publicKey.toBase58(),
success: true,
};
},
[publicKey, signMessage],
);
Here, we choose to base58 encode the messageBytes (serializedMessage). This allows the backend to uniformly handle the "signature + original signed bytes" (two pieces of data) to be compatible with both the signMessage and the signTransaction solution discussed later.
signMessage SignatureThe backend logic is essentially standard Ed25519 signature verification. In Node.js, for example:
const publicKeyObj = new PublicKey(publicKeyStr);
// Decode the base58 encoded inputs
const signatureBytes = bs58.decode(signature);
const messageBytes = bs58.decode(serializedMessageBase58);
// Verify the signature using the ed25519 algorithm
const isValid = ed25519.verify(
signatureBytes,
messageBytes,
publicKeyObj.toBytes(),
);
// Note: The commented-out code below shows an alternative
// verification method using nacl.
// const isValid = nacl.sign.detached.verify(
// messageBytes,
// signatureBytes,
// publicKeyObj.toBytes()
// );
return isValid;
Before calling the signature verification function, the backend must also:
nonce has not been used.publicKeyStr.That's the signMessage-based signature login flow, which works for most software wallets.
signTransaction + Memo Disguised as Sign MessageIn the security model of many hardware wallets, only signing a Transaction is allowed, but not signing an arbitrary sequence of bytes. Therefore, at the adaptation layer, you might encounter:
wallet.signMessage does not exist or throws a "not supported" error directly.signMessage login flow.To ensure compatibility with these wallets, the Challenge can be embedded into a transaction that is "only for signing, not necessarily for going on-chain," allowing the wallet to complete the signature with the same meaning via signTransaction.
signTransaction + MemoExample implementation (Frontend):
const signTransactionForAuth = useCallback(
async (message: string): Promise<SignMessageResult> => {
if (!publicKey || !signTransaction) {
throw new Error(
"Wallet not connected or does not support transaction signing",
);
}
const blockhashResponse = await connection.getLatestBlockhash();
const lastValidBlockHeight = blockhashResponse.lastValidBlockHeight - 150;
const memoInstruction = new TransactionInstruction({
keys: [],
programId: MEMO_PROGRAM_ID,
data: Buffer.from(message, "utf-8"),
});
const tx = new Transaction({
feePayer: publicKey,
blockhash: blockhashResponse.blockhash,
lastValidBlockHeight: lastValidBlockHeight,
}).add(memoInstruction);
// Sign the transaction
const signedTx = await signTransaction(tx);
// Get the signature from the signed transaction
const txSignature = signedTx.signatures[0];
const signatureBase58 = bs58.encode(txSignature.signature!);
// Get the serialized message that was actually signed
// This is what the wallet signed - the serialized transaction data
const serializedMessage = signedTx.serializeMessage();
const serializedMessageBase58 = bs58.encode(serializedMessage);
return {
signature: signatureBase58,
publicKey: publicKey.toBase58(),
success: true,
serializedMessage: serializedMessageBase58,
};
},
[publicKey, signTransaction, connection],
);
In the hardware wallet path, you also obtain two core pieces of data:
signatureBase58: The Ed25519 signature of the transaction message.serializedMessageBase58: The raw message bytes obtained by calling tx.serializeMessage().The format is consistent with the signMessage path, allowing the backend to use the same set of verification logic.
signTransaction SignatureWhen the backend receives this type of request (which can be treated as type: 'transaction'), it first needs to verify that the signature was generated by the private key corresponding to the address:
const publicKeyObj = new PublicKey(publicKeyStr);
const signatureBytes = bs58.decode(signature);
const messageBytes = bs58.decode(serializedMessageBase58);
// Verify the signature using ed25519
const isValid = ed25519.verify(
signatureBytes,
messageBytes,
publicKeyObj.toBytes(),
);
return isValid;
// The commented-out nacl verification is also provided for reference:
/*
// Verify the signature using nacl
const isValidNacl = nacl.sign.detached.verify(
messageBytes,
signatureBytes,
publicKeyObj.toBytes()
);
*/
On this basis, it can also cooperate with the Challenge for additional verification:
nonce/expiration time/address, etc.) is still generated and saved by the backend.data field when constructing the Memo.messageBytes, find the Memo instruction, and compare its data to ensure it matches the Challenge (this step is an enhanced verification, and the specific parsing code is not expanded here).Overall, the security semantics of this path are the same as the signMessage path: the user performed a non-repudiable signature on a Challenge initiated by the backend, and the backend establishes a login session for that address after verifying the signature and the Challenge's validity.
Fetching and querying on-chain data efficiently matters for any DApp's frontend. The Solana ecosystem has its own indexing story — different from EVM's but covering the same needs.
In the EVM ecosystem, Subgraph is a tool used to build custom GraphQL APIs specifically for indexing blockchain data. Developers can use Subgraphs to:
The specific steps to build a Subgraph can be found in the official tutorial: The Graph Quick Start.
Once a Subgraph is created, the frontend can query on-chain data via GraphQL, eliminating the need to call RPC nodes directly, which greatly improves query efficiency.
Querying the latest reward claim records:
import { gql, request } from "graphql-request";
import { useQuery } from "@tanstack/react-query";
const REWARD_HISTORY_QUERY = gql`
{
rewardClaimeds(first: 10, orderBy: blockNumber, orderDirection: desc) {
id
user
reward
blockNumber
}
}
`;
const useRewardHistory = () => {
const { data, refetch, isLoading, error, isRefetching } = useQuery<{
rewardClaimeds: RewardRecord[];
}>({
queryKey: ["reward-history"],
queryFn: async () => {
const graphqlUrl = process.env.NEXT_PUBLIC_GRAPH_URL || "";
return await request(graphqlUrl, REWARD_HISTORY_QUERY, {}, headers);
},
refetchInterval: 30000, //Refetch every 30 seconds
refetchOnWindowFocus: true, // Refetch when window gains focus
staleTime: 10000, // Data is considered stale after 10 seconds
});
return { data, refetch, isLoading, error, isRefetching };
};
Solana RPC nodes are fast at reading state — good for single-account queries and simple filtering. @solana/rpc-graphql gives you a GraphQL interface on top of RPC, which handles multi-account aggregation and complex data access without extra traversal code.
However, native RPC and rpc-graphql still have limitations:
Therefore, third-party indexing services remain valuable, such as Helius high-performance API and event subscription, which supports NFT, transaction events, and staking data queries.
While Solana native RPC + rpc-graphql can address most query needs, third-party indexing services remain indispensable for complex queries, event subscriptions, and historical data access.
Event backtracking can also be implemented based on the Solana RPC without relying on third-party indexing services. The core idea is:
This method can cover the needs of "historical playback + resuming from a breakpoint," but specialized indexing services are still more efficient in scenarios like complex cross-program analysis, real-time subscriptions, and retrieving massive amounts of historical data.
We built a working indexer with NestJS: it periodically pulls new transactions from the chain, uses Anchor's EventParser to extract events, stores them in a database, and exposes aggregated data to the frontend through a REST API.
With the backend handling indexing and aggregation, the frontend's job is straightforward: call a REST API.
const API_BASE = import.meta.env.VITE_API_BASE_URL || "http://localhost:3000";
async function fetchRewards(userAddress: string): Promise<UserRewardResponse> {
const res = await fetch(`${API_BASE}/api/rewards/${userAddress}`);
if (!res.ok) {
throw new Error(`Failed to fetch rewards: ${res.statusText}`);
}
return res.json();
}
const rewards = useQuery<UserRewardResponse>({
queryKey: ["reward-history", address],
queryFn: () => fetchRewards(address),
enabled: !!address,
refetchInterval: 30000,
staleTime: 10000,
});
When developing DApps on Solana, a common limitation is that a single transaction can reference a maximum of 32 accounts, and each transaction has a size limit of 1232 bytes. The 1232-byte transaction size limit is not arbitrary but directly derived from the MTU constraints in real network environments, ensuring that transactions can be reliably broadcast without packet fragmentation (see Solana official transaction documentation).
This can become a bottleneck in complex scenarios, such as:
To solve this problem, Solana provides the Address Lookup Table (ALT) — a specialized on-chain structure used to store account addresses.
extend operation can write up to 20 addresses.Versioned Transaction), which can break the 32-account limit and reduce transaction size./**
* Create an Address Lookup Table (ALT) for stake accounts
*/
export const createLookupTable = async (
connection: Connection,
payer: PublicKey,
accounts: AltAccountInfo,
signTransaction: <T extends Transaction | VersionedTransaction>(
tx: T,
) => Promise<T>,
): Promise<AddressLookupTableAccount | null> => {
const recentSlot = await connection.getSlot();
// Create lookup table instruction
const [lookupTableInst, lookupTableAddress] =
AddressLookupTableProgram.createLookupTable({
authority: payer,
payer,
recentSlot,
});
// Add accounts to lookup table instruction
const addAccountsInst = AddressLookupTableProgram.extendLookupTable({
payer: payer,
authority: payer,
lookupTable: lookupTableAddress,
addresses: [
accounts.state,
accounts.userStakeInfo,
accounts.userTokenAccount,
accounts.stakingVault,
accounts.rewardVault,
accounts.userRewardAccount,
accounts.tokenProgram,
accounts.blacklistEntry,
accounts.systemProgram,
accounts.clock,
],
});
// Combine both instructions in a single transaction
const combinedTx = new Transaction()
.add(lookupTableInst)
.add(addAccountsInst);
combinedTx.recentBlockhash = (
await connection.getLatestBlockhash()
).blockhash;
combinedTx.feePayer = payer;
const signedTx = await signTransaction(combinedTx);
await sendAndConfirmTransaction(connection, signedTx.serialize());
// Fetch the created lookup table
const lookupTableResponse =
await connection.getAddressLookupTable(lookupTableAddress);
return lookupTableResponse.value;
};
try {
const accountInfo = await createStakeAccountInfo(publicKey!, program!);
const accounts: AltAccountInfo = {
state: accountInfo.statePda,
userStakeInfo: accountInfo.userStakeInfoPda,
userTokenAccount: accountInfo.userTokenAccount,
stakingVault: accountInfo.stakingVault,
rewardVault: accountInfo.rewardVault,
userRewardAccount: accountInfo.userRewardAccount,
tokenProgram: TOKEN_PROGRAM_ID,
blacklistEntry: accountInfo.blacklistPda,
systemProgram: anchor.web3.SystemProgram.programId,
clock: anchor.web3.SYSVAR_CLOCK_PUBKEY,
};
const lookupTable = await getOrCreateLookupTable(accounts);
if (!lookupTable) {
onError({ message: ERROR_MESSAGES.FAILED_TO_LOAD_LOOKUP_TABLE });
return;
}
const versionedTx = await createVersionedStakeTransaction(
connection,
publicKey!,
program!,
stakeAmount,
lookupTable,
);
const signedVersionedTx = await signTransaction!(versionedTx);
const signature = await sendAndConfirmTransaction(
connection,
signedVersionedTx.serialize(),
);
setTransactionSignature(signature);
onSuccess();
setTransactionSignature(undefined);
} catch (err) {
const errorInfo = formatErrorForDisplay(err);
onError(errorInfo);
} finally {
setIsStaking(false);
}
If your transaction only involves a few dozen accounts, a regular transaction is sufficient. However, for batch operations or complex cross-protocol interactions, ALT can significantly reduce development complexity, improve performance, and lower Gas costs.
This transaction is a transaction without lookup, and this transaction is a transaction with lookup, showing a significant fee reduction from 0.000025 SOL to 0.000005 SOL, a saving of 80%.
In Solana, the transaction fee consists of two parts: base fee and priority fee. The base fee is fixed and very low, while the priority fee allows users to pay an additional amount to boost the transaction's packing priority during periods of high account contention.
If the account you are interacting with has no contention (e.g., personal wallet transfers, or a staking account only used by yourself), no extra priority fee is needed, and the transaction will still be landed normally.
The computing resources consumed by each transaction in the network are measured in Compute Units (CU). By default, setting the CU too high leads to unnecessary waste, while setting it too low might cause transaction execution failure. Solana provides two mechanisms to help developers find the "appropriate CU request":
Use simulateTransaction to estimate the CU required for transaction execution. You can first simulate the execution using simulateTransaction + the transaction containing all instructions, then read the consumed units. Add 10% on top of this value.
export const estimateComputeUnits = async (
connection: Connection,
transaction: VersionedTransaction,
): Promise<number> => {
try {
const simulation = await connection.simulateTransaction(transaction, {
sigVerify: false,
replaceRecentBlockhash: true,
});
if (simulation.value.err) {
console.warn("Simulation failed:", simulation.value.err);
return DEFAULT_COMPUTE_UNITS;
}
const estimatedCU = simulation.value.unitsConsumed || 0;
if (estimatedCU === 0) {
return DEFAULT_COMPUTE_UNITS;
}
// Add 10% safety margin
return addSafetyMargin(estimatedCU, 0.1);
} catch (error) {
console.warn("Failed to estimate compute units:", error);
return DEFAULT_COMPUTE_UNITS;
}
};
Note that the Compute Unit Limit is an upper bound, and the priority fee is calculated based on this upper bound, not the actual consumed CU. If you set a high CU Limit but use very little in practice, you might still pay an "overestimated" priority fee.
Solana provides the getRecentPrioritizationFees RPC method to query the priority fee situation for recently landed transactions on specified accounts.
/**
* Fetch recent priority fees for given accounts
*/
export const getRecentPriorityFees = async (
connection: Connection,
publicKey: PublicKey,
accountInfo: StakeAccountInfo,
): Promise<number> => {
try {
const response = await connection.getRecentPrioritizationFees({
lockedWritableAccounts: [
publicKey,
accountInfo.statePda,
accountInfo.userStakeInfoPda,
accountInfo.userTokenAccount,
accountInfo.stakingVault,
accountInfo.rewardVault,
accountInfo.userRewardAccount,
accountInfo.blacklistPda,
],
});
if (!response || response.length === 0) {
return DEFAULT_PRIORITY_FEE;
}
const allFees = response.map((item) => item.prioritizationFee);
const validFees = filterValidFees(allFees);
if (areAllFeesZero(validFees)) {
return DEFAULT_PRIORITY_FEE;
}
return calculateRecommendedFee(validFees);
} catch (error) {
console.warn("Failed to fetch priority fees:", error);
return DEFAULT_PRIORITY_FEE;
}
};
The priority fee is calculated using the formula:
Prioritization Fee = Compute Unit Limit x Compute Unit Price
When constructing a transaction, priority fee parameters can be explicitly specified via instructions:
const instructions = [
createComputeUnitPriceInstruction(priorityFee),
createComputeUnitLimitInstruction(estimatedCU),
instruction,
];
const versionedTx = await createVersionedTransaction(
connection,
publicKey!,
instructions,
);
const signedTx = await signTransaction!(versionedTx);
const signature = await sendAndConfirmTransaction(
connection,
signedTx.serialize(),
);
The mechanism for transaction broadcast and confirmation in Solana is significantly different from EVM.
This means that in high-load scenarios, transactions may fail to land due to packet loss, queue overflow, or fork rollback, thus requiring developers to implement retry logic on the client to ensure reliable transaction delivery.
Every Solana transaction must carry a recentBlockhash, which serves two purposes:
Therefore, the core of the retry logic is: continuously re-broadcast the transaction before the blockhash expires.
The common retry logic is as follows:
recentBlockhash and its corresponding lastValidBlockHeight.lastValidBlockHeight - 150, to avoid retrying too close to expiration.blockHeight < lastValidBlockHeight, until the transaction is confirmed or expires./**
* Executes the stake transaction with blockhash retry logic.
* Returns the transaction signature if successful, or undefined on error.
*/
const executeStakeWithRetry = async (): Promise<string | undefined> => {
// Get latest blockhash and set lastValidBlockHeight
const blockhashResponse = await connection.getLatestBlockhash();
// Subtract a small safety buffer to ensure we stop retrying
// before the transaction actually expires, avoiding failures near expiration
const SAFETY_BUFFER = 100; // blocks
const lastValidBlockHeight =
blockhashResponse.lastValidBlockHeight - SAFETY_BUFFER;
// Create stake instruction using the common utility
const { instruction } = await createStakeInstruction({
publicKey: publicKey!,
program: program!,
stakeAmount,
});
// Create transaction with blockhash and lastValidBlockHeight
const transaction = new Transaction({
feePayer: publicKey,
blockhash: blockhashResponse.blockhash,
lastValidBlockHeight: lastValidBlockHeight,
}).add(instruction);
const signedTransaction = await signTransaction!(transaction);
const rawTransaction = signedTransaction.serialize();
// Get current block height
let blockHeight = await connection.getBlockHeight();
let confirmedSignature: string | undefined;
// Keep sending transaction until block height exceeds lastValidBlockHeight
while (blockHeight < lastValidBlockHeight) {
try {
const signature = await connection.sendRawTransaction(rawTransaction, {
// skipPreflight: true is REQUIRED for retry logic
// Reasons:
// 1. We intentionally send the same transaction multiple times
// 2. Preflight checks would fail on repeat sends with "Transaction already exists"
// 3. During network congestion, transactions may be dropped and need resending
// 4. We want to maximize chances of the transaction being accepted
// 5. Final confirmation is handled separately with proper validation
skipPreflight: true,
});
const confirmation = await connection.confirmTransaction(
{
signature,
blockhash: blockhashResponse.blockhash,
lastValidBlockHeight: lastValidBlockHeight,
},
"confirmed",
);
if (!confirmation.value.err) {
confirmedSignature = signature;
onSuccess();
break;
}
await sleep(500);
blockHeight = await connection.getBlockHeight();
} catch (error) {
// Ignore send errors, continue retrying
await sleep(500);
blockHeight = await connection.getBlockHeight();
}
}
// Check if we had a successful confirmation
if (!confirmedSignature) {
onError({
message:
"Transaction failed to send during the valid block height window. Please try again with a new transaction.",
title: "Transaction Failed",
});
return;
}
return confirmedSignature;
};
Solana official documentation also provides relevant implementations for transaction retry; details can be found here.
Solana-related errors can generally be divided into three categories:
signMessage).A robust DApp needs to achieve "categorization, noise reduction, and user-friendly prompts" at all three levels.
All errors captured on the frontend or during the transaction process are handed over to a "classifier" for categorization, thereby simplifying the error handling logic in the UI layer.
The UI layer only needs to be concerned with one of the following four error types:
ErrorLevel.WalletAdapter)ErrorLevel.RpcNetwork)ErrorLevel.OnChainProgram)ErrorLevel.Unknown)The utility function classifyError(error) serves as this unified entry point, responsible for categorizing any error into one of the four ErrorLevel types.
/**
* Classifies any error into one of four error levels.
* This is the unified entry point for all error handling.
*/
export function classifyError(error: unknown): ClassifiedError {
if (!error) {
return {
level: ErrorLevel.Unknown,
error: null,
originalError: error,
};
}
const errorObj = error as { message?: string; msg?: string };
const errorMessage = errorObj?.message || errorObj?.msg || String(error);
// Level 1: Check for wallet adapter errors first
const walletError = matchWalletAdapterError(errorMessage);
if (walletError) {
return {
level: ErrorLevel.WalletAdapter,
error: walletError,
originalError: error,
};
}
// Level 2: Check for RPC/network errors
const rpcError = matchRpcNetworkError(errorMessage);
if (rpcError) {
return {
level: ErrorLevel.RpcNetwork,
error: rpcError,
originalError: error,
};
}
// Level 3: Check for on-chain program errors
const programError = parseProgramErrorInternal(error);
if (programError) {
return {
level: ErrorLevel.OnChainProgram,
error: programError,
originalError: error,
};
}
// Unknown error
return {
level: ErrorLevel.Unknown,
error: null,
originalError: error,
};
}
formatErrorForDisplay(error): Builds upon error classification to output a structure readily usable by the UI, containing the following fields:
/**
* Formats an error for display in the UI.
* This function classifies the error and returns a structure
* suitable for ErrorModal.
*/
export function formatErrorForDisplay(error: unknown): FormattedError {
const classified = classifyError(error);
const userMessage = getUserFriendlyError(error);
const title = getTitleForLevel(classified.level);
const result: FormattedError = {
title,
message: userMessage,
level: classified.level,
};
// Add error code if available
if (classified.error?.code) {
result.code = classified.error.code;
}
return result;
}
In Solana, most transactions are submitted via RPC nodes. The RPC forwards the transaction to the current and next Leader, where it enters the Leader's execution queue and is packaged into a block. Before the Leader actually executes it and the network votes, the transaction only exists in the memory queues of the client and the forwarding node. The core constraint: the scheduling unit is a single transaction, and competing transactions are ordered in the public forwarding path.
While this model offers extremely high throughput under high parallel execution, it is not ideal for complex chained operations. If a business process involves multiple dependent transactions (e.g., borrow -> swap -> repay), these transactions are propagated, ordered, and executed independently. The system cannot guarantee their atomic submission as a whole or ensure strict execution in the intended order. Consequently, the native model lacks the ability for "multi-transaction atomic submission" and "deterministic sequential scheduling."
The Jito Bundle wraps a group of transactions into an ordered sequence: Bundle = [Tx1, Tx2, ..., TxN]. The execution semantic: all transactions run in order, and state commits only if every transaction succeeds. If any fail, the whole bundle rolls back. By making the scheduling unit a "transaction sequence" instead of a single transaction, Bundles provide atomicity and sequential guarantees for complex multi-step operations — without changing the underlying execution engine.
Under the hood, Bundles travel through Jito Labs' Block Engine — a private scheduling channel separate from public RPC. The Block Engine receives the transaction sequence and routes it directly to validators running the Jito-Solana client. In the standard path, transactions enter the candidate queue individually and compete for ordering. A Bundle skips this — it's treated as one indivisible unit with its internal order locked.
A Jito Bundle isn't a new transaction type — it's a scheduling layer on top of the existing execution model. It upgrades the scheduling unit from "single transaction" to "transaction sequence," giving you atomic execution and deterministic ordering for multi-transaction workflows.
Bundles are useful in two ways: the atomicity semantics, and the engineering support for complex chained strategies. Typical cases:
The common feature of these scenarios is their dependence on "sequential determinism and atomic submission semantics among multiple transactions." Under the standard model of public propagation and independent ordering, these strategies are susceptible to scheduling competition and state changes. By encapsulating the transaction sequence into a Bundle and submitting it through Jito Labs' private scheduling channel, the execution order can be locked, and overall consistency can be guaranteed before entering the block construction phase.
Based on the above capabilities, the following section will focus on the typical scenario of MEV Protection, demonstrating how to submit a Staking transaction through the Jito Bundle's private channel to enhance result determinism and strategy stability in a price-sensitive execution environment.
The Jito Block Engine provides two transaction submission interfaces:
sendBundle is used to submit a Bundle containing multiple related transactions, suitable for complex scenarios requiring atomic execution of multiple operations (e.g., flash loan arbitrage, multi-step DeFi operations).sendTransaction is a simplified interface for single transactions, when paired with the bundleOnly=true parameter, also gains MEV protection and rollback protection capabilities.For the Stake operation in this project, since it only involves a single transaction, we choose sendTransaction over sendBundle to simplify the implementation while maintaining the same level of protection:
POST https://<region>.block-engine.jito.wtf/api/v1/transactions?bundleOnly=true
The bundleOnly=true parameter treats the transaction as a single-transaction Bundle — if execution fails, all state changes roll back. No partial execution.
export const sendJitoTransaction = async (
signedTransaction: Transaction,
): Promise<string> => {
try {
// Serialize and encode the transaction for the API body
const serialized = signedTransaction.serialize();
const base58Tx = bs58.encode(serialized);
const endpointUrl = `${JITO_BLOCK_ENGINE_URL}/api/v1/transactions?bundleOnly=true`;
const requestBody = {
jsonrpc: "2.0",
id: 1,
method: "sendTransaction",
params: [base58Tx],
};
// Send the transaction via a POST request to Jito's API
const response = await fetch(endpointUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(requestBody),
});
// Check for HTTP errors
if (!response.ok) {
throw new Error(
`Jito API request failed: ${response.status} ${response.statusText}`,
);
}
const result = await response.json();
// Check for JSON-RPC errors returned in the response body
if (result.error) {
throw new Error(
`Jito sendTransaction error: ${result.error.message || JSON.stringify(result.error)}`,
);
}
// Success: Return the transaction signature
const signature = result.result;
return signature;
} catch (error) {
// Log and re-throw a standardized error
console.error("Failed to send transaction via Jito:", error);
throw new Error(
`Jito transaction failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
};
sendBundle and sendTransaction have fundamental differences in their fee strategies:
sendBundle: Only requires a Jito Tip. Bundles are sent directly to the Jito Block Engine and prioritized by Jito validators. Priority Fee has no effect on Bundle processing priority — validators decide whether to accept and process a Bundle based on the Tip amount.sendTransaction: A 70/30 allocation principle is recommended, allocating 70% of the total fee as the Priority Fee and 30% as the Jito Tip. The Priority Fee is set via ComputeBudgetProgram.setComputeUnitPrice(), increasing the transaction's priority in the validator's queue. The Jito Tip incentivizes Jito validators to prioritize the transaction.| Submission Method | Priority Fee | Jito Tip | Core Explanation |
|---|---|---|---|
sendBundle |
Not Required | Required | Tip determines the Bundle's processing priority |
sendTransaction |
70% | 30% | Both work together to increase success rate |
Important Notes on the Tip:
The fee amount determination relies on the tip_floor API:
GET https://bundles.jito.wtf/api/v1/bundles/tip_floor
This interface returns the statistical distribution of Tips for recently successfully landed transactions. For sendTransaction, we use the landed_tips_50th_percentile (median) as a benchmark and calculate the 70/30 ratio:
export const calculateJitoFees = async () => {
const tipFloor = await fetchTipFloor();
const baseFee = Math.ceil(
tipFloor.landed_tips_50th_percentile * LAMPORTS_PER_SOL,
);
const totalFee = Math.max(JITO_MIN_TIP_LAMPORTS, baseFee);
return {
priorityFee: Math.ceil(totalFee * 0.7),
jitoTip: Math.max(JITO_MIN_TIP_LAMPORTS, Math.ceil(totalFee * 0.3)),
};
};
The Jito Tip must be transferred to a specific Tip account. The API interface for fetching these accounts is:
POST https://<region>.block-engine.jito.wtf/api/v1/getTipAccounts
Jito maintains 8 Tip accounts for receiving tips. To achieve load balancing, one account should be randomly selected when constructing the transaction:
export const getRandomTipAccount = async (): Promise<PublicKey> => {
const tipAccounts = await fetchTipAccounts();
const randomIndex = Math.floor(Math.random() * tipAccounts.length);
return new PublicKey(tipAccounts[randomIndex]);
};
Alternative Solution: If the above API is unavailable, the static accounts listed here can be used as a substitute.
The Jito Block Engine's test environment only supports Solana Testnet, not Devnet. Ensure that your contract is deployed to Testnet and that your wallet and RPC are connected to Testnet during development and debugging.
Additionally, the tip_floor API has CORS restrictions, meaning browsers cannot access it directly. The development environment can use a Vite proxy to resolve this, while the production environment requires configuring an Nginx reverse proxy or forwarding the request through a backend service.
// vite.config.ts
import { defineConfig } from "vite";
export default defineConfig({
server: {
proxy: {
// Proxy requests starting with "/api/v1/bundles/tip_floor"
// to the Jito Bundles API
"/api/v1/bundles/tip_floor": {
target: "https://bundles.jito.wtf",
changeOrigin: true, // Needed for cross-origin requests
},
},
},
});
If you're coming from EVM, Solana mechanisms like ALT, priority fees, blockhash expiration, and account contention will feel unfamiliar. ALT isn't a gas optimization — it's a constraint you work within when your transaction touches many accounts. Priority fees aren't validator tips; they're bids on contentious accounts. Blockhash expiration gives your transaction a shelf life of about 60 seconds — your retry logic needs to account for that.
Once these patterns click, you can build interactions that play to Solana's strengths: complex operations in a single transaction, predictable costs, stability under load.
Part 2 walks through a complete Staking demo, connecting all the frontend patterns covered here into a working end-to-end example.
We recommend tackling one piece at a time — ALT, priority fee estimation, or the error handler — and getting it solid before adding the rest. Full sample code: evm-to-solana.