AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified MIT Self-run

Solana Payments

skill-sepivip-solana-payments-skill-solana-payments-skill · by sepivip

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.

No reviews yet
0 installs
6 views
0.0% view→install

Install

$ agentstack add skill-sepivip-solana-payments-skill-solana-payments-skill

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No 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.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-sepivip-solana-payments-skill-solana-payments-skill)

Reliability & compatibility

Security review passed
0 installs to date
no reviews yet
5mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

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 →
Are you the author of Solana Payments? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

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:

  1. The deposit wallet signs a transfer instruction directly
  2. Master wallet can pay for the transaction fee (as fee payer)
  3. 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

  1. 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).
  1. 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).
  1. 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.
  1. 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.
  1. Transaction confirmation: Use confirmed commitment for balance checks and finalized for sweep confirmations:

``typescript const connection = new Connection(rpcUrl, 'confirmed'); // For sweeps, wait for finalized: await sendAndConfirmTransaction(connection, tx, signers, { commitment: 'finalized' }); ``

  1. 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.
  1. 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.
  1. 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.
  1. 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.
  1. 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:

  • [ ] Generate keypair and verify address format (base58, 32-44 chars)
  • [ ] Encrypt/decrypt private key through lib/crypto.ts roundtrip
  • [ ] Check balance returns 0 for empty wallet
  • [ ] Send USDC on devnet, verify checkBalance detects it
  • [ ] Verify getIncomingTransfers returns 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.

Versions

  • v0.1.0 Imported from the upstream source.