Install
$ agentstack add skill-sepivip-solana-payments-skill-solana-payments-skill ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
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)
// USDC — Token Program (not Token-2022)
const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
// USDT — Token Program (not Token-2022)
const USDT_MINT = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB';
// Token Program address (both USDC and USDT use this, NOT Token-2022)
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; // 'Solana'
readonly chainId: string; // 'solana'
readonly token: string; // 'USDC/USDT'
readonly decimals: number; // 6 (both USDC and USDT on Solana are 6 decimals)
readonly gasToken: string; // 'SOL'
generateDepositAddress(): Promise;
checkBalance(address: string): Promise;
getIncomingTransfers(address: string, fromBlock?: number): Promise;
sweep(fromPrivateKey: string, toAddress: string): Promise;
getExplorerUrl(txHash: string): string;
getAddressExplorerUrl(address: string): string;
isValidAddress(address: string): boolean;
getMasterGasBalance(): Promise;
}
Wallet Generation
import { Keypair } from '@solana/web3.js';
import bs58 from 'bs58';
async generateDepositAddress(): Promise {
const keypair = Keypair.generate();
return {
address: keypair.publicKey.toBase58(),
// Store secret key as base58 string (compatible with existing encrypt/decrypt)
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 {
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; // bigint, already in smallest units
} catch (e) {
if (e instanceof TokenAccountNotFoundError) continue; // ATA doesn't exist yet
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 {
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);
// Get recent signatures for the ATA
const signatures = await connection.getSignaturesForAddress(ata, {
limit: 20,
});
for (const sig of signatures) {
if (sig.err) continue; // skip failed txs
const tx = await connection.getParsedTransaction(sig.signature, {
maxSupportedTransactionVersion: 0,
});
if (!tx?.meta) continue;
// Look for token transfers TO this ATA in the parsed instructions
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 {
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);
// Source ATA (deposit wallet)
const sourceAta = await getAssociatedTokenAddress(mint, fromKeypair.publicKey);
// Check balance
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;
// Destination ATA (master wallet)
const destAta = await getAssociatedTokenAddress(mint, toPublicKey);
const tx = new Transaction();
// Create destination ATA if it doesn't exist
try {
await getAccount(connection, destAta);
} catch (e) {
if (e instanceof TokenAccountNotFoundError) {
tx.add(
createAssociatedTokenAccountInstruction(
fromKeypair.publicKey, // payer (needs SOL)
destAta,
toPublicKey,
mint,
)
);
} else {
throw e;
}
}
// Transfer all tokens
tx.add(
createTransferInstruction(
sourceAta,
destAta,
fromKeypair.publicKey, // authority (signer)
balance,
)
);
// Deposit wallet needs SOL for tx fees
// Check if it has enough, if not fund from master
const solBalance = await connection.getBalance(fromKeypair.publicKey);
const estimatedFee = 10_000; // ~0.00001 SOL, generous estimate
if (solBalance {
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 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=
SOLANA_MASTER_PRIVATE_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; // SOL — warn in admin dashboard
const DECIMALS = 6; // Both USDC and USDT are 6 decimals on Solana
const SIGNATURE_LIMIT = 20; // Max recent signatures to check
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: 1000000 = $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
feePayerfield. Use the master wallet as fee payer to avoid funding deposit wallets with SOL.
- Transaction confirmation: Use
confirmedcommitment for balance checks andfinalizedfor sweep confirmations:
``typescript const connection = new Connection(rpcUrl, 'confirmed'); // For sweeps, wait for finalized: 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
getMultipleAccountsInfoto 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.tsworks fine — it encrypts arbitrary strings.
- No block numbers: Solana uses slot numbers, not block numbers. The
fromBlockparameter ingetIncomingTransfersshould be ignored or adapted to usebefore/untilsignature cursors.
- Transaction versioning: Always pass
maxSupportedTransactionVersion: 0when 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
getSignaturesForAddresswhich 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:
- [ ] Generate keypair and verify address format (base58, 32-44 chars)
- [ ] Encrypt/decrypt private key through
lib/crypto.tsroundtrip - [ ] Check balance returns 0 for empty wallet
- [ ] Send USDC on devnet, verify
checkBalancedetects it - [ ] Verify
getIncomingTransfersreturns correct hash, sender, amount - [ ] Sweep tokens to master wallet with master as fee payer
- [ ] Verify explorer URLs resolve correctly
- [ ] Test with both USDC and USDT mints
- [ ] Confirm watcher detects and processes Solana orders end-to-end
- [ ] Verify admin dashboard shows SOL gas balance
Payment Verification
Three approaches to verify incoming payments, from simplest to most robust:
1. Balance Query (simplest — used by checkBalance)
// Already covered above. Use getAccount() on the ATA.
// Limitation: only shows net balance, not individual transfers.
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) {
// Parse token account data to extract balance
// Compare with previous balance to detect incoming payment
const currentBalance = /* parse from notification.value.data */;
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({
…
## Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- **Author:** [sepivip](https://github.com/sepivip)
- **Source:** [sepivip/solana-payments-skill](https://github.com/sepivip/solana-payments-skill)
- **License:** MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.