| name | solana-payments |
| description | Integrate Solana SPL token payments (USDC/USDT) into the Toppio payment provider system. Use this skill when implementing the Solana payment provider, handling SPL token transfers, building sweep logic for Solana, or debugging Solana-related payment issues. |
This skill guides implementation of a Solana SPL token payment provider for Toppio's existing chain-agnostic PaymentProvider interface. It covers wallet generation, balance checking, transfer detection, and sweeping — all adapted to Solana's unique account model.
Solana vs EVM — Key Differences
Solana is NOT EVM-compatible. Do not use ethers.js or EVM patterns. Key differences:
| Concept | EVM (BSC/Polygon) | Solana |
|---|
| Library | ethers.js v6 | @solana/web3.js + @solana/spl-token |
| Keypair | Random wallet (secp256k1) | Ed25519 keypair |
| Token balances | ERC-20 contract.balanceOf() | Associated Token Account (ATA) |
| Token transfer | contract.transfer() | createTransferInstruction() |
| Gas token | BNB / POL | SOL |
| Gas cost | ~$0.01-0.05 | ~$0.001 (base) + ~$0.40 (ATA creation) |
| Finality | ~3-15s | ~400ms (optimistic), ~30s (confirmed) |
| Block scanning | Transfer events via getLogs | getSignaturesForAddress + getParsedTransaction |
Token Addresses (Mainnet)
const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
const USDT_MINT = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB';
const TOKEN_PROGRAM_ID = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA';
CRITICAL: USDC and USDT on Solana both use the original Token Program, NOT Token-2022. Using the wrong program produces invalid ATAs and failed transfers.
Dependencies
npm install @solana/web3.js@1 @solana/spl-token bs58
Use @solana/web3.js v1 (stable). v2 is a full rewrite with different APIs. bs58 is needed for encoding/decoding Solana keypairs (not built into web3.js).
Provider Implementation
File: providers/payment/solana.ts
Implement the PaymentProvider interface from providers/payment/types.ts:
interface PaymentProvider {
readonly chain: string;
readonly chainId: string;
readonly token: string;
readonly decimals: number;
readonly gasToken: string;
generateDepositAddress(): Promise<DepositAddress>;
checkBalance(address: string): Promise<BalanceResult>;
getIncomingTransfers(address: string, fromBlock?: number): Promise<TransferInfo[]>;
sweep(fromPrivateKey: string, toAddress: string): Promise<SweepResult>;
getExplorerUrl(txHash: string): string;
getAddressExplorerUrl(address: string): string;
isValidAddress(address: string): boolean;
getMasterGasBalance(): Promise<string>;
}
Wallet Generation
import { Keypair } from '@solana/web3.js';
import bs58 from 'bs58';
async generateDepositAddress(): Promise<DepositAddress> {
const keypair = Keypair.generate();
return {
address: keypair.publicKey.toBase58(),
privateKey: bs58.encode(keypair.secretKey),
};
}
Note: Solana secret keys are 64 bytes (includes the public key). Store the full secretKey, not just the seed.
Address Validation
import { PublicKey } from '@solana/web3.js';
isValidAddress(address: string): boolean {
try {
new PublicKey(address);
return true;
} catch {
return false;
}
}
Balance Checking
import { Connection, PublicKey } from '@solana/web3.js';
import { getAssociatedTokenAddress, getAccount, TokenAccountNotFoundError } from '@solana/spl-token';
async checkBalance(address: string): Promise<BalanceResult> {
const connection = new Connection(this.rpcUrl);
const owner = new PublicKey(address);
let total = 0n;
for (const mintStr of [USDC_MINT, USDT_MINT]) {
const mint = new PublicKey(mintStr);
const ata = await getAssociatedTokenAddress(mint, owner);
try {
const account = await getAccount(connection, ata);
total += account.amount;
} catch (e) {
if (e instanceof TokenAccountNotFoundError) continue;
throw e;
}
}
const balance = Number(total) / 10 ** this.decimals;
return { balance, raw: total.toString() };
}
Important: ATAs may not exist for new deposit wallets. This is normal — the ATA gets created when the sender transfers tokens. Most wallets and dApps handle ATA creation automatically.
Incoming Transfer Detection
Solana doesn't have event logs like EVM. Use signature history + parsed transactions:
import { Connection, PublicKey, ParsedTransactionWithMeta } from '@solana/web3.js';
async getIncomingTransfers(address: string): Promise<TransferInfo[]> {
const connection = new Connection(this.rpcUrl);
const owner = new PublicKey(address);
const transfers: TransferInfo[] = [];
for (const mintStr of [USDC_MINT, USDT_MINT]) {
const mint = new PublicKey(mintStr);
const ata = await getAssociatedTokenAddress(mint, owner);
const signatures = await connection.getSignaturesForAddress(ata, {
limit: 20,
});
for (const sig of signatures) {
if (sig.err) continue;
const tx = await connection.getParsedTransaction(sig.signature, {
maxSupportedTransactionVersion: 0,
});
if (!tx?.meta) continue;
for (const ix of tx.transaction.message.instructions) {
if (!('parsed' in ix)) continue;
if (ix.parsed?.type === 'transferChecked' || ix.parsed?.type === 'transfer') {
const info = ix.parsed.info;
if (info.destination === ata.toBase58()) {
const amount = ix.parsed.type === 'transferChecked'
? Number(info.tokenAmount.amount) / 10 ** this.decimals
: Number(info.amount) / 10 ** this.decimals;
transfers.push({
hash: sig.signature,
from: info.authority || info.source,
amount,
});
}
}
}
}
}
return transfers;
}
Note: The fromBlock parameter from the interface doesn't map directly to Solana. Use the before/until signature options or timestamp filtering if needed. For the watcher's 15s poll cycle, fetching recent signatures is sufficient.
Sweep Logic
Sweeping on Solana differs fundamentally from EVM. No permit/approve pattern. Instead:
- The deposit wallet signs a transfer instruction directly
- Master wallet can pay for the transaction fee (as fee payer)
- ATA creation on the master side may be needed
import {
Connection, Keypair, PublicKey, Transaction, sendAndConfirmTransaction,
SystemProgram, LAMPORTS_PER_SOL,
} from '@solana/web3.js';
import {
getAssociatedTokenAddress, createAssociatedTokenAccountInstruction,
createTransferInstruction, getAccount, TokenAccountNotFoundError,
} from '@solana/spl-token';
async sweep(fromPrivateKey: string, toAddress: string): Promise<SweepResult> {
const connection = new Connection(this.rpcUrl);
const fromKeypair = Keypair.fromSecretKey(bs58.decode(fromPrivateKey));
const toPublicKey = new PublicKey(toAddress);
let totalSwept = 0;
let lastTxHash = '';
for (const mintStr of [USDC_MINT, USDT_MINT]) {
const mint = new PublicKey(mintStr);
const sourceAta = await getAssociatedTokenAddress(mint, fromKeypair.publicKey);
let balance: bigint;
try {
const account = await getAccount(connection, sourceAta);
balance = account.amount;
} catch (e) {
if (e instanceof TokenAccountNotFoundError) continue;
throw e;
}
if (balance === 0n) continue;
const destAta = await getAssociatedTokenAddress(mint, toPublicKey);
const tx = new Transaction();
try {
await getAccount(connection, destAta);
} catch (e) {
if (e instanceof TokenAccountNotFoundError) {
tx.add(
createAssociatedTokenAccountInstruction(
fromKeypair.publicKey,
destAta,
toPublicKey,
mint,
)
);
} else {
throw e;
}
}
tx.add(
createTransferInstruction(
sourceAta,
destAta,
fromKeypair.publicKey,
balance,
)
);
const solBalance = await connection.getBalance(fromKeypair.publicKey);
const estimatedFee = 10_000;
if (solBalance < estimatedFee) {
const masterKeypair = Keypair.fromSecretKey(
bs58.decode(process.env.SOLANA_MASTER_PRIVATE_KEY!)
);
const fundTx = new Transaction().add(
SystemProgram.transfer({
fromPubkey: masterKeypair.publicKey,
toPubkey: fromKeypair.publicKey,
lamports: 50_000,
})
);
await sendAndConfirmTransaction(connection, fundTx, [masterKeypair]);
}
const txHash = await sendAndConfirmTransaction(connection, tx, [fromKeypair]);
lastTxHash = txHash;
totalSwept += Number(balance) / 10 ** this.decimals;
}
if (!lastTxHash) throw new Error('No tokens to sweep');
return { txHash: lastTxHash, amount: totalSwept };
}
Alternative sweep approach — Master as fee payer (saves a funding step):
const masterKeypair = Keypair.fromSecretKey(
bs58.decode(process.env.SOLANA_MASTER_PRIVATE_KEY!)
);
const tx = new Transaction();
tx.feePayer = masterKeypair.publicKey;
tx.add(
createTransferInstruction(
sourceAta,
destAta,
fromKeypair.publicKey,
balance,
)
);
const txHash = await sendAndConfirmTransaction(connection, tx, [masterKeypair, fromKeypair]);
This is the preferred approach — it eliminates the SOL funding step entirely. The master wallet pays ~$0.001 in SOL per sweep, and the deposit wallet never needs SOL.
Explorer URLs
getExplorerUrl(txHash: string): string {
return `https://solscan.io/tx/${txHash}`;
}
getAddressExplorerUrl(address: string): string {
return `https://solscan.io/account/${address}`;
}
Master Gas Balance
async getMasterGasBalance(): Promise<string> {
const connection = new Connection(this.rpcUrl);
const masterAddress = new PublicKey(process.env.SOLANA_MASTER_WALLET_ADDRESS!);
const balance = await connection.getBalance(masterAddress);
return (balance / LAMPORTS_PER_SOL).toFixed(4);
}
Registration
Add to providers/payment/index.ts:
import { SolanaPaymentProvider } from './solana';
const providers: Record<string, () => PaymentProvider> = {
bsc: () => new BnbPaymentProvider(),
polygon: () => new PolygonPaymentProvider(),
solana: () => new SolanaPaymentProvider(),
};
Environment Variables
# Solana
SOLANA_RPC_URL=https://api.mainnet-beta.solana.com
SOLANA_MASTER_WALLET_ADDRESS=<base58 public key>
SOLANA_MASTER_PRIVATE_KEY=<base58 encoded secret key>
RPC Note: The default public RPC (api.mainnet-beta.solana.com) has strict rate limits. For production, use a dedicated RPC from Helius, QuickNode, or Alchemy.
Configuration Constants
const LOW_SOL_THRESHOLD = 0.05;
const DECIMALS = 6;
const SIGNATURE_LIMIT = 20;
Gotchas and Edge Cases
-
ATA may not exist: New deposit wallets have no ATAs. Most sending wallets/dApps create the recipient's ATA during transfer. If the sender doesn't, the transfer fails on their end (not ours).
-
Decimals are 6: Unlike BSC where USDC has 18 decimals, Solana USDC/USDT both use 6 decimals (matching their real-world value: 1_000_000 = $1.00).
-
No permit/approve pattern: Solana uses direct authority-based transfers. The owner of tokens signs the transfer instruction directly. No need for approve + transferFrom flows.
-
Fee payer separation: Unlike EVM where msg.sender pays gas, Solana transactions have an explicit feePayer field. Use the master wallet as fee payer to avoid funding deposit wallets with SOL.
-
Transaction confirmation: Use confirmed commitment for balance checks and finalized for sweep confirmations:
const connection = new Connection(rpcUrl, 'confirmed');
await sendAndConfirmTransaction(connection, tx, signers, { commitment: 'finalized' });
-
RPC rate limits: Public Solana RPC has aggressive rate limits. Batch requests carefully. For the watcher's per-order checks, consider using getMultipleAccountsInfo to batch ATA lookups.
-
Rent exemption: Token accounts require ~0.00203928 SOL for rent exemption. This is paid during ATA creation. When the master wallet creates ATAs, budget for this cost.
-
Private key format: Solana uses 64-byte Ed25519 keys (not 32-byte seeds). Store as base58. The existing AES-256-GCM encryption in lib/crypto.ts works fine — it encrypts arbitrary strings.
-
No block numbers: Solana uses slot numbers, not block numbers. The fromBlock parameter in getIncomingTransfers should be ignored or adapted to use before/until signature cursors.
-
Transaction versioning: Always pass maxSupportedTransactionVersion: 0 when fetching parsed transactions, otherwise versioned transactions return null.
Watcher Integration Notes
The existing watcher in scripts/watcher.ts loops per chain. The Solana provider will be picked up automatically once registered. However:
- Poll timing: Solana's ~400ms block time means 15s polling is fine; payments will be detected quickly.
- Transfer detection: Unlike EVM event scanning, Solana uses
getSignaturesForAddress which returns the most recent signatures. No need to track block numbers.
- Sweep timing: Sweeps complete in ~1-2s on Solana (vs 3-15s on EVM). The existing retry logic with 3 max attempts works well.
Testing Checklist
Before going live:
Payment Verification
Three approaches to verify incoming payments, from simplest to most robust:
1. Balance Query (simplest — used by checkBalance)
2. WebSocket Subscription (real-time monitoring)
For lower-latency detection than 15s polling, subscribe to ATA account changes:
import { createSolanaRpcSubscriptions } from '@solana/kit';
async function watchPayments(tokenAccountAddress: string, onPayment: (amount: bigint) => void) {
const rpcSubscriptions = createSolanaRpcSubscriptions('wss://api.mainnet-beta.solana.com');
const abortController = new AbortController();
const subscription = await rpcSubscriptions
.accountNotifications(tokenAccountAddress, {
commitment: 'confirmed',
encoding: 'base64',
})
.subscribe({ abortSignal: abortController.signal });
let previousBalance = 0n;
for await (const notification of subscription) {
const currentBalance = ;
if (currentBalance > previousBalance) {
onPayment(currentBalance - previousBalance);
}
previousBalance = currentBalance;
}
}
Note: WebSocket subscriptions are optional. The existing 15s watcher poll cycle works fine for Toppio's use case. Consider WebSocket only if faster detection is needed.
3. Transaction History with Pre/Post Balances (most accurate)
Parse preTokenBalances and postTokenBalances from transaction metadata for precise per-transaction amounts:
async function getRecentPayments(ataAddress: string, mintAddress: string, limit = 100) {
const signatures = await connection.getSignaturesForAddress(new PublicKey(ataAddress), { limit });
const payments = [];
for (const sig of signatures) {
const tx = await connection.getTransaction(sig.signature, { maxSupportedTransactionVersion: 0 });
if (!tx?.meta?.preTokenBalances || !tx?.meta?.postTokenBalances) continue;
const accountKeys = tx.transaction.message.accountKeys;
const ataIndex = accountKeys.findIndex(key => key.toBase58() === ataAddress);
if (ataIndex === -1) continue;
const pre = tx.meta.preTokenBalances.find(b => b.accountIndex === ataIndex && b.mint === mintAddress);
const post = tx.meta.postTokenBalances.find(b => b.accountIndex === ataIndex && b.mint === mintAddress);
const preAmount = BigInt(pre?.uiTokenAmount.amount ?? '0');
const postAmount = BigInt(post?.uiTokenAmount.amount ?? '0');
const diff = postAmount - preAmount;
if (diff > 0n) {
payments.push({
signature: sig.signature,
timestamp: tx.blockTime,
amount: diff,
type: 'incoming',
});
}
}
return payments;
}
This is more reliable than parsing instructions because it accounts for complex multi-instruction transactions.
Commitment Levels
| Level | Latency | Safety | Use For |
|---|
processed | ~400ms | Can be dropped during forks | UI feedback only — never for business logic |
confirmed | ~1-2s | Supermajority voted | Balance checks, payment detection |
finalized | ~13s | Irreversible | Sweep confirmations, high-value operations |
Toppio mapping:
checkBalance() → confirmed
getIncomingTransfers() → confirmed
sweep() → finalized (wait for irreversibility before marking complete)
Transaction Confirmation & Retry
Blockhash Management
Solana transactions reference a recent blockhash and expire after ~60-90 seconds (~150 blocks):
const { blockhash, lastValidBlockHeight } = await connection.getLatestBlockhash('confirmed');
tx.recentBlockhash = blockhash;
const signature = await connection.sendTransaction(tx, signers, {
maxRetries: 0,
skipPreflight: false,
});
const confirmation = await connection.confirmTransaction({
signature,
blockhash,
lastValidBlockHeight,
}, 'finalized');
if (confirmation.value.err) {
throw new Error(`Transaction failed: ${JSON.stringify(confirmation.value.err)}`);
}
Priority Fees
During network congestion, add priority fees to ensure inclusion:
import { ComputeBudgetProgram } from '@solana/web3.js';
tx.add(
ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }),
ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 50_000 }),
);
Normal cost: ~$0.001. During congestion: $0.01-0.05. Use RPC provider priority fee APIs (Helius, QuickNode) for dynamic pricing.
Error Handling
const RETRYABLE_ERRORS = [
'BlockhashNotFound',
'BlockheightExceeded',
];
const FATAL_ERRORS = [
'InsufficientFundsForFee',
'InsufficientFunds',
'AccountNotFound',
];
Security Checklist
- Hardcode mint addresses: Never accept mint addresses dynamically. Spoofed tokens with identical names are common on Solana.
- Server-side verification only: Never trust client-side "payment confirmed" signals.
- Idempotency: Store processed transaction signatures to prevent double-fulfillment from watcher re-runs.
- Validate token program: Verify the ATA's owner program matches
TOKEN_PROGRAM_ID. A token account owned by Token-2022 would indicate a spoofed token.
- Separate hot/cold wallets: The master wallet (hot) should hold minimal SOL for gas. Sweep to a cold wallet periodically.
- RPC URL as secret: Treat RPC endpoints as credentials. Never expose in frontend code.
- Distinct devnet/mainnet keys: Never reuse keypairs across environments.
Production RPC
The public api.mainnet-beta.solana.com is rate-limited with no SLA. For production:
| Provider | Feature |
|---|
| Helius | Priority fee API, webhooks, enhanced RPCs |
| QuickNode | Multi-chain, add-ons for Solana-specific features |
| Triton | Yellowstone gRPC streaming, low-latency |
| Alchemy | Enterprise SLA, multi-region |
Implement multi-provider failover for reliability:
const RPC_URLS = [
process.env.SOLANA_RPC_URL!,
process.env.SOLANA_RPC_URL_FALLBACK!,
];
Indexing (High Volume)
For basic Toppio order volume, RPC polling via getSignaturesForAddress is sufficient. If volume grows significantly, consider:
- Yellowstone gRPC: Real-time streaming directly from validators, sub-100ms latency. Available through Helius, Triton, QuickNode.
- Webhooks: Most RPC providers offer webhook notifications for account changes — eliminates polling entirely.
- Carbon/Vixen: Rust frameworks for building custom indexing pipelines (overkill for current Toppio scale).
Devnet Testing
For testing, use Solana devnet with test tokens:
const DEVNET_USDC = '4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU';
const DEVNET_RPC = 'https://api.devnet.solana.com';
Devnet faucets for test tokens: Circle (USDC), QuickNode, Solana CLI (solana airdrop).