Solana program and clients for managed token delegations on SPL Token and Token-2022.
For each (user, mint) pair, the program creates a Subscription Authority (SA) PDA and sets it as the single delegate on the user's token account with u64::MAX approval. The SA can only transfer tokens when a Delegation PDA authorizes it, making the system as secure as traditional approval-based delegations while enabling multiple simultaneous delegations from a single token account.
This works for both token programs: SPL Token and Token-2022.
Supported delegation models:
- Fixed delegation: authorize a delegatee to spend up to a total amount with an optional expiry timestamp.
- Recurring delegation: authorize a delegatee to spend up to a per-period amount that resets each period, with configurable period length and overall expiry.
- Subscription plan: a merchant publishes a plan with pricing terms; subscribers accept those terms and the merchant (or whitelisted pullers) can pull funds each billing period.
Rent stays recoverable once a delegation or subscription becomes terminal — the user closes or rotates (init_id) their Subscription Authority, or the plan ends: the recorded payer can then reclaim rent from the stranded PDAs via the generated RevokeAbandonedDelegation / RevokeAbandonedSubscription instructions. Revoking only the ATA delegate does not make a subscription terminal — see Security Considerations.
The program emits on-chain events via self-CPI for indexer integration (subscription created/cancelled/resumed, plan updated, and fixed/recurring/subscription transfers). The events are registered in the Codama IDL, so indexers can decode them.
Token-2022 mints are supported, including mints with a configured TransferHook. On delegated transfers the program forwards the caller-supplied hook accounts into the Token-2022 TransferChecked CPI, which resolves and runs the hook exactly as it would for a direct transfer; the program does not add or require extra hook-account guards of its own.
Destination accounts with the MemoTransfer extension (require-incoming-memo) are not supported: the program does not emit a Memo CPI before the transfer, so Token-2022 rejects the transfer atomically (no funds move). Use a destination without the incoming-memo requirement.
Delegation accounts include a version field and a versioning scaffold (lazy in-place update plus revoke/recreate) with a planned explicit-migrate path for future upgrades. No live migration step is wired yet (CURRENT_VERSION == 1). See ADR-003 for details.
This repository contains:
- A Rust Solana program built with Pinocchio
- IDL generation via Codama
- Generated clients via Codama:
- TypeScript client (
@solana/subscriptions) inclients/typescript - Rust client (
subscriptions) inclients/rust
- TypeScript client (
- A local demo webapp in
webapp/ - CI pipeline with build, test, lint, and CU benchmarking
Rent is recoverable: closing a delegation, plan, or subscription authority returns its rent to the original payer.
| Flow | Account(s) created | Rent for new account(s) (SOL) |
|---|---|---|
| Enable authority for a mint | SubscriptionAuthority | 0.00162864 |
| Merchant creates a plan | Plan | 0.00430824 |
| Subscribe to a plan | SubscriptionDelegation | 0.00196968 |
| Grant fixed delegation | FixedDelegation | 0.00219240 |
| Grant recurring delegation | RecurringDelegation | 0.00235944 |
Subscribe and delegation flows require an existing
SubscriptionAuthority. If starting from scratch, add 0.00162864 SOL for the "Enable authority" step.
De1egAFMkMWZSN5rYXRj9CAdheBamobVNubTsi9avR44
subscriptions/
├── program/ # Rust Solana program
│ ├── src/
│ │ ├── instructions/ # Instruction handlers
│ │ │ └── helpers/ # Transfer validation, token helpers, traits
│ │ ├── state/ # Account types (SA, fixed, recurring, plan, subscription)
│ │ │ └── versioning/ # Version checks and migration logic
│ │ ├── events/ # On-chain event definitions
│ │ ├── event_engine.rs # Self-CPI event emission
│ │ ├── errors.rs # Error codes
│ │ ├── constants.rs # Program constants
│ │ └── tests/ # Rust unit tests
├── idl/ # Generated IDL (subscriptions.json)
├── clients/
│ ├── typescript/ # TypeScript SDK + integration tests
│ └── rust/ # Rust generated client
├── tests/ # LiteSVM integration tests + transfer-hook example program
├── webapp/ # Demo UI (React) + local API server
│ ├── src/ # React app (routes, components, hooks)
│ ├── api/ # Node.js API server (faucet, deploy, config)
│ └── scripts/ # Environment init, mock test-token minting
├── scripts/ # Shell scripts (validator, webapp launcher)
├── docs/ # Architecture Decision Records
├── runbooks/ # Surfpool deployment runbooks
├── .github/ # CI workflows and shared setup action
├── .githooks/ # Git hooks (pre-push: fmt + lint checks)
├── keys/ # Program keypair (gitignored)
├── justfile # Build/test/dev task runner
├── scripts/generate-clients.ts # Codama client generation script
└── txtx.yml # Surfpool runbook config
git clone git@github.com:solana-foundation/subscriptions.git
cd subscriptions
just setup
just build
just test-programFor the full suite (program + client tests):
just testjust setup checks for these tools: pnpm, cargo, solana-keygen, and surfpool.
Install the toolchain:
- Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh- Solana CLI (includes
solana-keygen)
sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)"- pnpm
curl -fsSL https://get.pnpm.io/install.sh | sh -- Just
curl --proto '=https' --tlsv1.2 -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin- Surfpool CLI
curl -sL https://run.surfpool.run/ | bash- Node.js (required by
webapp/scripts)
The program ID is declared in program/src/lib.rs. Local Surfpool workflows install the program at that canonical address via runbooks/surfnet-setup, so a checked-in program keypair is not required for local tests.
Print the program ID at any time:
just program-idThe justfile is the main entrypoint for day-to-day development.
| Recipe | Description |
|---|---|
just build |
Build program + generate IDL + generate clients + build TypeScript client |
just build-program |
Compile the SBF program (.so) |
just generate-idl |
Regenerate idl/subscriptions.json |
just generate-clients |
Regenerate TypeScript and Rust clients from IDL via Codama |
just build-client |
Build clients/typescript into clients/typescript/dist |
| Recipe | Description |
|---|---|
just test |
Run program tests + client integration tests |
just test-program |
Backwards-compatible alias for just unit-test |
just unit-test |
Run Rust unit tests |
just integration-test |
Run Rust LiteSVM integration tests |
just test-client |
Run TypeScript integration tests (vitest with Surfpool) |
just test-and-benchmark |
Run tests and generate cu_report.md with compute unit usage |
| Recipe | Description |
|---|---|
just check |
Run fmt-check + lint-check |
just fmt-check |
Check Rust and TypeScript formatting |
just fmt |
Auto-format Rust and TypeScript |
just lint-check |
Check Rust (clippy) and TypeScript (ESLint) |
just lint |
Lint with auto-fix |
| Recipe | Description |
|---|---|
just clean |
Remove all build artifacts, node_modules, validator state |
just webapp-clean |
Stop webapp processes, remove webapp-specific generated state |
just kill-validator |
Stop all running validators (surfpool + solana-test-validator) |
Two local validator flows are available:
just test-clientstarts fresh Surfpool validators (a mainnet-fork pass, then an offline pass). The program is deployed fromtarget/deploy/using Surfpool's built-in deployment.just webapp-runstartssolana-test-validatorviascripts/start-webapp.sh, then deploys the program and initializes the test environment.
Both default to http://localhost:8899.
The @solana/subscriptions package in clients/typescript is a @solana/kit plugin (subscriptionsProgram()) plus hand-written overlay instruction builders that wrap the Codama-generated client (PDA/ATA derivation, sponsor payer trailing accounts, event-account resolution).
Overlay instruction builders (get*OverlayInstruction[Async]):
| Builder | Purpose |
|---|---|
getInitSubscriptionAuthorityOverlayInstructionAsync / getCloseSubscriptionAuthorityOverlayInstructionAsync |
Create or close the SA for a (user, mint) pair |
getCreateFixedDelegationOverlayInstructionAsync / getTransferFixedOverlayInstructionAsync |
Create a fixed delegation and pull against it |
getCreateRecurringDelegationOverlayInstructionAsync / getTransferRecurringOverlayInstructionAsync |
Create a recurring delegation and pull against it |
getCreatePlanOverlayInstructionAsync / getUpdatePlanOverlayInstruction / getDeletePlanOverlayInstruction |
Manage merchant subscription plans |
getSubscribeOverlayInstructionAsync / getCancelSubscriptionOverlayInstructionAsync / getCancelSubscriptionNowOverlayInstructionAsync / getResumeSubscriptionOverlayInstructionAsync / getTransferSubscriptionOverlayInstructionAsync |
Subscribe, cancel immediately or at period end, resume, and pull payments |
getRevokeDelegationOverlayInstruction / getRevokeSubscriptionOverlayInstruction |
Close a fixed/recurring delegation, or a subscription PDA, and reclaim rent |
getRevokeSubscriptionAuthorityOverlayInstructionAsync |
Revoke the program's SPL delegate and close the SA PDA |
Account fetchers: fetchDelegationsByDelegatee, fetchDelegationsByDelegator, fetchPlansForOwner, fetchSubscriptionsForUser. The plugin's queries namespace includes isSubscriptionAuthorityInitialized.
PDA derivation helpers are Codama-generated async functions re-exported from the package root: findSubscriptionAuthorityPda, findFixedDelegationPda, findRecurringDelegationPda, findPlanPda, findSubscriptionDelegationPda, findEventAuthorityPda.
Install and use:
pnpm add @solana/subscriptionsimport { subscriptionsProgram } from '@solana/subscriptions';Rust client:
cargo add subscriptionsuse subscriptions::instructions::*;The demo app in webapp/ provides a local UI and API for development flows.
Tech stack: React 19, Vite, Tailwind CSS, Radix UI, TanStack Query, Jotai, Solana Kit, ConnectorKit.
just build # build program + clients
just webapp-run # start validator + init + API + web UIExpected local endpoints:
- Validator RPC:
http://localhost:8899 - API server:
http://localhost:3001 - Web UI:
http://localhost:5173
| Route | Feature |
|---|---|
/setup |
Setup wizard (validator, program deploy, mock test token) |
/ |
Dashboard overview |
/delegations |
Create and manage fixed/recurring delegations |
/plans |
Create and manage merchant subscription plans |
/plans/collect |
Collect subscription payments |
/subscriptions |
View and manage active subscriptions |
/marketplace |
Browse available plans |
/faucet |
SOL and test-token airdrops (localnet/devnet) |
/program |
Program deploy/upgrade status |
Stop local processes:
just kill-validator
just webapp-clean # also removes generated statesubscriptions has been audited multiple times by Cantina. The latest audit is the Subscriptions security review, with external audit baseline commit d6b3a5dc7ab18c4168441af733c81ab0a599d414 and audit fixes implemented and verified through commit debb4f75ff7571218b39de3b633074dd843e70db.
The full audit history, audited-through commits, and the current unaudited delta are tracked in audits/AUDIT_STATUS.md.
init_idis slot-granular. EachSubscriptionAuthoritystores aninit_id(its creation slot) that every delegation copies; a pull works only while they match. Close+reinit invalidates old delegations only if the reinit lands in a later slot — same-slot rotation reusesinit_idand keeps them valid, and idempotent reinit never refreshes it. So rotation is not reliable revocation: userevokeDelegation/cancelSubscriptionfor a specific permission, orrevokeSubscriptionAuthorityto revoke the approval and close the authority. Neither is durable against an unexecuted signed transaction (see below).
- A signature binds terms, not a submission deadline. Recent-blockhash txs expire in ~150 slots, but a durable-nonce tx or a pending multisig/smart-wallet proposal stays valid until the nonce advances or the proposal is cancelled — so a signed action can execute long after the user's intent moved on. Each control action is bound to the state the signer observed, which rejects cross-generation replays; the residual cases below share this one root.
- The sentinel
init_idis slot-scoped.UNKNOWN_INIT_IDletssubscribe/create_*_delegationaccept an authority whoseinit_idequals the current slot, soinit_subscription_authoritycan be bundled in the same tx without knowingClock::slotat signing. It binds to the landing slot, not the signing time — a held sentinel create binds to whatever slot it lands in. Pass the realinit_idwhen known to pin the create to one authority generation. - A held init+subscribe bundle survives
revokeSubscriptionAuthority. The bundle carries its own init, so on landing — any slot, after any revoke — it re-creates the authority (u64::MAXre-approved) and the sentinel binds, restoring a spendable permission with no new signature.revokeDelegation/cancelSubscriptiononly help after the permission exists; the only reliable defense is to keep the held tx from landing (submit it, or advance the nonce / cancel the proposal). - Authority-control actions aren't bound to a generation. A held
init_subscription_authorityre-approvesu64::MAXafter the user manually revoked the SPL delegate, restoring collectibility of the existing delegations. A heldclose/revoke_subscription_authorityfor one authority generation, landing after the user re-creates the authority at the same PDA, destroys the new generation (revoke also drops the delegate). Generations are told apart only byinit_id, i.e. only across slots. Same defense: don't leave authority-control txs unsubmitted. cancel_subscription_nowapprovals are second-granular. Bound tocurrent_period_start_ts, so a withheld dual-signed cancel replays only if close+resubscribe lands in the same unix second as the observed period start — and then it cancels a subscription that consumed no service.resume_subscriptioncan't distinguish same-period cancellations. Resume binds to the observedexpires_at_ts, but two cancellations within one billing period compute the identical value — so a resume held across a cancel→resume→cancel cycle inside a single period can clear the second cancellation (cross-period cancellations differ and are rejected). Impact is bounded to one billing period of continued collection; the defense is the general one above — don't leave a signed resume unsubmitted.update_planbinds to observed values, not a revision (ABA). The approval carries the Plan state the owner saw and is rejected on any mismatch, so removing a puller invalidates a stale approval that still lists it. But it compares values, not an update count: if later updates change a field and then restore the observed value, the stale approval matches again — e.g. one expecting an empty puller set can re-add puller A once the live set returns to empty. (expected_created_atstill blocks cross-lifecycle replay.)- A near-expiry subscribe is charged a full period for a partial first window. On a finite plan the full
amountis due even when less than one period remains beforeend_ts(zero duration if subscribing exactly atend_ts). Intentional — the subscriber seesend_ts,amount, andperiodwhen signing;end_tsisn't bound in the approval, but it can only be shortened (which only reduces charges), so the sole non-consensual case is a held subscribe landing in that window.
- On-chain "Active" is not proof of collectability. A subscriber can revoke the SPL approval or freeze/empty/close their token account — pulls then fail while the subscription still reads active (
expires_at_ts == 0); there is no delinquency state, so confirm collectability off-chain before granting service. Revoking the ATA delegate also leaves the subscription non-terminal, so a sponsor cannot reclaim its rent while the authority stays open; on a perpetual (end_ts == 0) plan the rent is locked until the subscriber cancels or closes the authority. Prefer a finiteend_tswhen sponsoring.
Thanks to Moonsong Labs for the initial design and implementation of this program.
GitHub Actions runs split workflows on PRs and pushes to main:
| Workflow | Description |
|---|---|
| Build | Build program and clients |
| Test | Run Rust unit, Rust integration, and TS client tests |
| Format | Check Rust and TypeScript formatting |
| Lint | Check Rust clippy and TypeScript ESLint |
| Benchmark | Generate CU report and post it as a PR comment |
| IDL Check | Verify committed IDL and generated clients are fresh |
| Document | Description |
|---|---|
| ADR-001 | Core program architecture: SA, fixed/recurring delegations, PDA design |
| ADR-002 | Subscription plans: merchant plans, subscriber flow, pull payments |
| ADR-003 | Versioning and migration: three-tier fallback chain for on-chain account upgrades |
| ADR-004 | Program upgrades: Squads-governed upgrade authority and deployment flow |
The TypeScript client integration tests cover smart wallet flows with Squads (multisig) and Swig wallets, verifying that delegations work when the delegator or delegatee is a program-controlled authority.
Native Multisig account owners (the SPL Token / Token-2022 built-in multisig account type) are not supported — init/revoke require the owner to sign and don't forward multisig member signers to the Approve/Revoke CPI. Use a smart-wallet program (Squads, Swig) for multi-signer treasuries.