Beyond Traditional Wallets
Traditional Solana wallets (Phantom, Solflare) store a private key and sign transactions directly. A PDA-based smart wallet flips this model: the wallet itself is an on-chain program that holds assets in Program Derived Addresses. This enables programmable logic — spending limits, multi-sig approval, session keys, and social recovery — without trusting a third party.
At Cyber Vision we built this architecture for the Cilantro Smart Wallet. This guide shares the design patterns.
Core Concepts
What Is a PDA Wallet?
A PDA (Program Derived Address) has no private key. It can only be "signed for" by the program that derived it. By storing user assets in PDAs controlled by a wallet program, you get:
- Programmable transaction logic — enforce rules before any transfer
- Session keys — temporary keys with limited permissions
- Guardian recovery — regain access without a seed phrase
- Gasless transactions — relayer pays fees on behalf of the user
flowchart LR
User -->|signs| WalletProgram
WalletProgram -->|PDA signer| Vault["PDA Vault (holds SOL/tokens)"]
WalletProgram -->|CPI| TokenProgram
WalletProgram -->|CPI| AnchorProgram["DeFi Programs"]Architecture Overview
┌─────────────────────────────────────────┐
│ Frontend (Next.js) │
│ - Wallet dashboard │
│ - Transaction builder │
│ - Session key management │
└──────────────┬──────────────────────────┘
│ API calls
┌──────────────▼──────────────────────────┐
│ Backend (NestJS) │
│ - Transaction simulation │
│ - Relayer service (fee sponsorship) │
│ - Wallet indexer (PostgreSQL) │
└──────────────┬──────────────────────────┘
│ RPC / Transactions
┌──────────────▼──────────────────────────┐
│ Solana Program (Anchor) │
│ - Wallet account (PDA) │
│ - Vault accounts (PDA per asset) │
│ - Session key registry │
│ - Guardian registry │
└─────────────────────────────────────────┘
1. The Wallet Account
Each user gets a wallet PDA derived from their public key:
#[account]
pub struct WalletAccount {
pub owner: Pubkey,
pub guardians: Vec<Pubkey>,
pub session_keys: Vec<SessionKey>,
pub spending_limit: u64,
pub spent_today: u64,
pub last_reset: i64,
pub bump: u8,
}
#[derive(AnchorSerialize, AnchorDeserialize, Clone)]
pub struct SessionKey {
pub key: Pubkey,
pub expires_at
The PDA seed:
seeds = [b"wallet", owner.key().as_ref()]2. Programmable Transfers
Every transfer goes through the wallet program, which enforces spending limits before executing:
pub fn transfer(ctx: Context<WalletTransfer>, amount: u64) -> Result<()> {
let wallet = &mut ctx.accounts.wallet;
let clock = Clock::get()?;
// Reset daily spending limit
if clock.unix_timestamp - wallet.last_reset > 86400 {
wallet.spent_today = 0;
wallet.last_reset = clock.unix_timestamp;
}
require!(
wallet.spent_today + amount <= wallet.spending_limit,
WalletError::SpendingLimitExceeded
Spending limits prevent catastrophic loss if a session key is compromised. Set a sensible default (e.g. 10 SOL/day) and let users adjust it through the owner key.
3. Session Keys
Session keys let dApps sign transactions on behalf of the user without accessing the master key. They expire and have limited permissions.
pub fn create_session_key(
ctx: Context<ManageSession>,
key: Pubkey,
expires_at: i64,
permissions: u8,
) -> Result<()> {
let wallet = &mut ctx.accounts.wallet;
require!(
wallet.session_keys.len() < 10,
WalletError::TooManySessionKeys
);
wallet.session_keys.push(SessionKey {
key,
expires_at,
permissions,
});
4. Guardian Recovery
If the owner loses their private key, guardians can vote to transfer ownership to a new key:
#[account]
pub struct RecoveryRequest {
pub wallet: Pubkey,
pub new_owner: Pubkey,
pub approvals: Vec<Pubkey>,
pub threshold: u8,
pub created_at: i64,
}
pub fn approve_recovery(ctx: Context<ApproveRecovery>) -> Result<()> {
let request = &mut ctx.accounts.recovery_request;
let guardian = ctx.accounts.guardian.key();
let wallet = &
Guardian recovery is powerful but dangerous. Use a time-lock (e.g. 48h delay after threshold is met) to give the original owner time to cancel a malicious recovery attempt.
5. Backend Orchestration (NestJS)
The NestJS backend handles transaction building, simulation, and optional fee sponsorship:
@Injectable()
export class WalletService {
constructor(
private readonly solana: SolanaService,
private readonly prisma: PrismaService,
) {}
async buildTransfer(walletAddress: string, recipient: string, amount: number) {
const connection = this.solana.getConnection();
// Fetch wallet PDA data
const walletPda = new PublicKey(walletAddress);
const walletAccount = await this
6. Wallet Indexing
Store wallet state in PostgreSQL for fast queries without hitting the RPC:
CREATE TABLE wallets (
address TEXT PRIMARY KEY,
owner TEXT NOT NULL,
spending_limit BIGINT,
spent_today BIGINT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE session_keys (
id SERIAL PRIMARY KEY,
wallet_address TEXT REFERENCES wallets(address),
public_key TEXT NOT NULL,
expires_at TIMESTAMP,
permissions INTEGER
);Sync on-chain state to the database using Solana onAccountChange subscriptions or Helius webhooks.
Security Checklist
| Concern | Mitigation |
|---|---|
| Session key theft | Short expiry (1-24h), limited permissions, spending limits |
| Guardian collusion | Use 3-of-5 threshold, time-lock on recovery |
| PDA seed collision | Include unique identifiers (owner pubkey) in seeds |
| Replay attacks | Anchor's built-in recent blockhash validation |
| Relayer abuse | Rate limiting, session key validation server-side |
Wrapping Up
PDA-based wallets unlock a new class of user experiences on Solana: gasless onboarding, programmable spending rules, and key recovery without seed phrases. The architecture — Anchor program for on-chain logic, NestJS for orchestration, PostgreSQL for indexing — scales from a personal wallet to a full wallet-as-a-service platform.
For the backend patterns, see our NestJS + Solana architecture guide. For frontend wallet integration, check Solana React Basics.