Solana smart contract for recurring USDC payments using token delegation
This Anchor program powers the subscription billing system in Recipe 03. It enables automatic recurring charges without requiring user signatures after the initial subscription - similar to how traditional SaaS billing works.
This program demonstrates how LazorKit can be integrated with complex on-chain programs to build real-world applications. By combining LazorKit's passkey authentication and gasless transactions with custom Anchor programs, developers can create sophisticated blockchain applications while maintaining a seamless user experience.
The subscription service showcases:
- Token delegation for automatic recurring payments (no user signature needed after initial setup)
- PDA-based state management for secure subscription storage
- Prepaid billing model similar to traditional SaaS platforms
- Complete lifecycle management (create, charge, cancel, update)
β οΈ Important: Devnet DeploymentThis program is currently deployed on Solana Devnet and should be considered a proof-of-concept. Before deploying to Mainnet:
- Security Audit Required: The program should undergo a professional security audit to identify and fix any vulnerabilities
- Upgrade Authority: After successful audit, the upgrade authority can be revoked to make the program fully trustless and immutable
- Production Hardening: Additional security measures, monitoring, and error handling should be implemented
| Property | Value |
|---|---|
| Program ID | 3kZ9Fdzadk8NXwjHaSabKrXBsU1y226BgXJdHZ78Qx4v |
| Network | Solana Devnet |
| Framework | Anchor 0.31.1 |
| Token | USDC (SPL Token) |
| Model | Prepaid (first payment on subscription) |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β SUBSCRIPTION ACCOUNT (PDA) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β Seeds: ["subscription", user_wallet, merchant_wallet] β
β β
β ββββββββββββββββββββ ββββββββββββββββββββ ββββββββββββββββββββ β
β β authority β β recipient β β token_accounts β β
β β (user wallet) β β (merchant) β β (user + merch) β β
β ββββββββββββββββββββ ββββββββββββββββββββ ββββββββββββββββββββ β
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β amount_per_period | interval_seconds | last_charge_timestamp β β
β β created_at | expires_at | is_active | total_charged | bump β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β Token Delegation: User's USDC account delegated to this PDA β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
The Subscription account stores all state for a user's subscription:
| Field | Type | Description |
|---|---|---|
authority |
Pubkey |
User/subscriber wallet |
recipient |
Pubkey |
Merchant wallet |
user_token_account |
Pubkey |
User's USDC token account |
recipient_token_account |
Pubkey |
Merchant's USDC token account |
token_mint |
Pubkey |
USDC mint address |
amount_per_period |
u64 |
Charge amount (in token base units) |
interval_seconds |
i64 |
Seconds between charges |
last_charge_timestamp |
i64 |
Unix timestamp of last charge |
created_at |
i64 |
Subscription creation time |
expires_at |
Option<i64> |
Optional expiry timestamp |
is_active |
bool |
Whether subscription is active |
total_charged |
u64 |
Cumulative amount charged |
bump |
u8 |
PDA bump seed |
Source: See the
Subscriptionstruct inlib.rs
Creates a new subscription and charges the first payment immediately (prepaid model).
Parameters:
| Parameter | Type | Description |
|---|---|---|
amount_per_period |
u64 |
Amount to charge each period (in token base units) |
interval_seconds |
i64 |
Seconds between charges (e.g., 2592000 for 30 days) |
expires_at |
Option<i64> |
Optional Unix timestamp when subscription ends |
What it does:
- Delegates token account - Approves subscription PDA as delegate for user's token account
- Charges first payment - Transfers
amount_per_periodfrom user to merchant immediately - Initializes state - Stores subscription details in the PDA
Core Logic (token delegation and first charge):
// Delegate user's token account to subscription PDA
let delegate_ix = token_instruction::approve(
&ctx.accounts.token_program.key(),
&ctx.accounts.user_token_account.key(),
&ctx.accounts.subscription.key(), // PDA becomes delegate
&ctx.accounts.authority.key(),
&[],
u64::MAX, // Unlimited delegation
)?;
// Charge first payment using PDA as delegate
let transfer_ix = token_instruction::transfer(
&ctx.accounts.token_program.key(),
&ctx.accounts.user_token_account.key(),
&ctx.accounts.recipient_token_account.key(),
&subscription_key,
&[],
amount_per_period,
)?;
invoke_signed(&transfer_ix, accounts, signer_seeds)?;Source: See
initialize_subscription()andInitializeSubscriptionaccounts inlib.rs
Charges a recurring payment. Called by the backend service - no user signature required (uses token delegation).
Validation checks:
- Subscription must be active (
is_active == true) - If
expires_atis set, current time must be before expiry - Enough time must have passed since last charge (
time_since_last >= interval_seconds) - Token accounts must be valid SPL token accounts
Core Logic:
// Validations
require!(subscription.is_active, ErrorCode::SubscriptionInactive);
if let Some(expires_at) = subscription.expires_at {
require!(clock.unix_timestamp < expires_at, ErrorCode::SubscriptionExpired);
}
let time_since_last_charge = clock.unix_timestamp - subscription.last_charge_timestamp;
require!(time_since_last_charge >= subscription.interval_seconds, ErrorCode::IntervalNotMet);
// Transfer tokens using PDA as delegate (no user signature needed!)
invoke_signed(&transfer_ix, accounts, signer_seeds)?;
// Update state
subscription.last_charge_timestamp = clock.unix_timestamp;
subscription.total_charged += subscription.amount_per_period;Source: See
charge_subscription()inlib.rs
Cancels a subscription, revokes token delegation, and refunds PDA rent to user.
What it does:
- Revokes delegation - Removes PDA's ability to transfer user's tokens
- Marks inactive - Sets
is_active = false - Closes account - Returns ~0.002 SOL rent to user (via Anchor's
closeconstraint)
Core Logic:
require!(subscription.is_active, ErrorCode::SubscriptionAlreadyCancelled);
// Revoke token delegation - user regains full control
let revoke_ix = token_instruction::revoke(
&ctx.accounts.token_program.key(),
&ctx.accounts.user_token_account.key(),
&ctx.accounts.authority.key(),
&[],
)?;
invoke(&revoke_ix, accounts)?;
// Mark inactive (account closes automatically via `close = authority`)
subscription.is_active = false;Source: See
cancel_subscription()inlib.rs
Updates subscription parameters. Only the authority (user) can call this.
Parameters:
| Parameter | Type | Description |
|---|---|---|
new_amount |
Option<u64> |
New charge amount |
new_interval |
Option<i64> |
New interval in seconds |
new_expires_at |
Option<i64> |
New expiry timestamp |
Migration helper for cleaning up cancelled subscriptions. Used for legacy data cleanup.
#[error_code]
pub enum ErrorCode {
#[msg("Subscription is not active")]
SubscriptionInactive,
#[msg("Subscription has expired")]
SubscriptionExpired,
#[msg("Not enough time has passed since last charge")]
IntervalNotMet,
#[msg("Subscription already cancelled")]
SubscriptionAlreadyCancelled,
#[msg("Invalid token account - must be owned by Token Program")]
InvalidTokenAccount,
#[msg("Cannot cleanup - subscription is still active")]
SubscriptionStillActive,
}The subscription account address is deterministically derived:
// TypeScript
const [subscriptionPDA, bump] = PublicKey.findProgramAddressSync(
[
Buffer.from('subscription'),
userWallet.toBuffer(),
merchantWallet.toBuffer(),
],
SUBSCRIPTION_PROGRAM_ID
);// Rust
seeds = [
b"subscription",
authority.key().as_ref(),
recipient.key().as_ref(),
]This means:
- Each user can have one subscription per merchant
- The address is predictable (no need to store it separately)
- Anyone can derive and verify the subscription address
βββββββββββββββ βββββββββββββββ
β User's β ββββ Delegate βββββΆβ Subscriptionβ
β USDC ATA β β PDA β
βββββββββββββββ βββββββββββββββ
β
β Can transfer
β on behalf of user
βΌ
βββββββββββββββ
β Merchant's β
β USDC ATA β
βββββββββββββββ
- On Subscribe: User's token account delegates to subscription PDA
- On Charge: PDA signs transfer instruction (no user signature needed)
- On Cancel: Delegation is revoked
# Install Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# Install Solana CLI
sh -c "$(curl -sSfL https://release.solana.com/stable/install)"
# Install Anchor
cargo install --git https://github.com/coral-xyz/anchor avm --locked --force
avm install latest
avm use latestcd program/subscription-program
anchor buildanchor test# Configure for devnet
solana config set --url devnet
# Deploy
anchor deployThe cookbook includes a complete TypeScript helper library for interacting with this program.
Key Functions:
| Function | Description |
|---|---|
getSubscriptionPDA() |
Derives the subscription account address |
buildInitializeSubscriptionIx() |
Builds the initialize instruction with all required accounts |
buildCancelSubscriptionIx() |
Builds the cancel instruction |
hasActiveSubscription() |
Checks if user has an active subscription |
getUSDCBalance() |
Fetches user's USDC balance |
Example Usage:
import { buildInitializeSubscriptionIx } from '@/lib/program/subscription-service';
// Build subscription instructions
const instructions = await buildInitializeSubscriptionIx({
userWallet,
amountPerPeriod: 0.10, // $0.10 USDC
intervalSeconds: 2592000, // 30 days
expiresAt: undefined, // No expiry
}, connection);
// Send via LazorKit (gasless!)
const signature = await signAndSendTransaction({ instructions });Source: See the full TypeScript helper library at
subscription-service.ts
| Concern | Mitigation |
|---|---|
| Unlimited delegation | Users must explicitly subscribe; can cancel anytime |
| Merchant key security | Store in secure vault (AWS KMS, etc.) in production |
| Double charging | Program checks interval_seconds has elapsed |
| Expired subscriptions | Program checks expires_at before charging |
| PDA security | Only derived addresses can sign; deterministic |
The program emits helpful logs:
Subscription initialized with PREPAID model!
First payment charged: 100000 tokens
Next charge in 2592000 seconds (30 days)
Token account delegated to subscription PDA
Subscription charged!
Amount: 100000 tokens
Total charged: 200000 tokens
Subscription cancelled by user
Token delegation revoked
Account closed - rent refunded to user