# Solana Payments

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

- **Type:** Skill
- **Install:** `agentstack add skill-sepivip-solana-payments-skill-solana-payments-skill`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [sepivip](https://agentstack.voostack.com/s/sepivip)
- **Installs:** 0
- **Category:** [Finance & Payments](https://agentstack.voostack.com/c/finance-and-payments)
- **Latest version:** 0.1.0
- **License:** MIT
- **Upstream author:** [sepivip](https://github.com/sepivip)
- **Source:** https://github.com/sepivip/solana-payments-skill

## Install

```sh
agentstack add skill-sepivip-solana-payments-skill-solana-payments-skill
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## 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)

```typescript
// 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

```bash
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`:

```typescript
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

```typescript
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

```typescript
import { PublicKey } from '@solana/web3.js';

isValidAddress(address: string): boolean {
  try {
    new PublicKey(address);
    return true;
  } catch {
    return false;
  }
}
```

### Balance Checking

```typescript
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:

```typescript
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

```typescript
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`:

```typescript
import { SolanaPaymentProvider } from './solana';

const providers: Record PaymentProvider> = {
  bsc: () => new BnbPaymentProvider(),
  polygon: () => new PolygonPaymentProvider(),
  solana: () => new SolanaPaymentProvider(),
};
```

## Environment Variables

```env
# 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

```typescript
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).

2. **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).

3. **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.

4. **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.

5. **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' });
   ```

6. **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.

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

8. **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.

9. **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.

10. **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`)

```typescript
// 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:

```typescript
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:

```typescript
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.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** yes
- **Filesystem access:** no
- **Shell / process execution:** no
- **Environment & secrets:** yes
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-sepivip-solana-payments-skill-solana-payments-skill
- Seller: https://agentstack.voostack.com/s/sepivip
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
