@staratlas/profile-subscription
v0.46.4
Published
TypeScript client for the **Profile Subscription** Solana program — fully on-chain recurring payments with Star Atlas Player Profile validation and optional Profile Vault-backed settlement.
Downloads
708
Maintainers
Keywords
Readme
@staratlas/profile-subscription
TypeScript client for the Profile Subscription Solana program — fully on-chain recurring payments with Star Atlas Player Profile validation and optional Profile Vault-backed settlement.
| Network | Program ID |
|---------|------------|
| Mainnet | PSubzJGRCQKoBadKLcg3cqryrb7ZLPujdqSokUoQptQ |
| Devnet / Testnet | PSubAEkp16MhpP7AfB5kBbpWQ4cppUj9nxS5vUkHT13 |
Note: The SDK's exported
PROFILE_SUBSCRIPTION_PROGRAM_ADDRESSconstant is the devnet address. For mainnet deployments, pass the mainnet program ID explicitly via theprogramAddressconfig option on each instruction builder.
Install
npm install @staratlas/profile-subscription @solana/kitQuick Start
import {
getInitializeSubscriptionInstruction,
getInitializeFromVaultAndChargeInstruction,
getSettleInstruction,
getSettleFromVaultInstruction,
getCancelSubscriptionInstruction,
getUpdatePayoutKeyInstruction,
getCloseSubscriptionInstruction,
fetchSubscriptionAccount,
findSubscriptionAccountPda,
PROFILE_SUBSCRIPTION_PROGRAM_ADDRESS,
} from '@staratlas/profile-subscription';Subscription Lifecycle
┌──────────┐ subscribe ┌────────┐ settle (success) ┌────────┐
│ │ ──────────────▶│ Active │ ────────────────────▶ │ Active │
│ (none) │ └────────┘ └────────┘
└──────────┘ │ │ │
│ │ settle (transfer fails) │
│ ▼ │
│ ┌──────────┐ │
│ │ PastDue │───settle (ok)────▶│
│ └──────────┘ │
│ │ │
│ │ cancel │ cancel
│ ▼ ▼
│ ┌───────────┐ ┌───────────┐
└▶│ Cancelled │ │ Cancelled │
└───────────┘ └───────────┘
│ settle (up to cancelled_at)
▼
┌──────────┐
│ Expired │──── close ────▶ (account deleted)
└──────────┘Status Definitions
| Status | Meaning | Can Settle? | Can Cancel? | Can Close? |
|--------|---------|-------------|-------------|------------|
| Active | Subscription is live, payments current | ✅ | ✅ | ❌ |
| PastDue | Settlement attempted but transfer failed (insufficient funds, revoked delegate, etc.) | ✅ | ✅ | ❌ |
| Cancelled | Subscriber cancelled. Outstanding periods before cancelled_at can still be settled | ✅ (up to boundary) | ❌ | ❌ |
| Expired | Cancelled + fully settled. Terminal state | ❌ | ❌ | ✅ |
Instructions
1. Initialize Subscription (Profile ATA delegate path)
Creates a new subscription that settles from the subscriber profile's token ATA. The subscriber signs with their Player Profile key (requires SUBSCRIBE permission scoped to the subscription program), and a wallet signer funds the subscription account rent.
import { findSubscriptionAccountPda, getInitializeSubscriptionInstruction } from '@staratlas/profile-subscription';
// Derive the subscription PDA
const [subscriptionPda] = await findSubscriptionAccountPda({
profile: profileAddress,
serviceProvider: serviceProviderAddress,
subscriptionId: 1n, // unique per profile+provider
});
const ix = getInitializeSubscriptionInstruction({
// Profile validation accounts
profileValidationSigner: subscriberKeypair,
profileValidationProfile: profileAddress,
profileValidationCertificate: undefined, // optional
profileValidationProgram: PLAYER_PROFILE_PROGRAM_ADDRESS,
// Rent funder and subscription accounts
funder: walletSigner,
subscriptionAccount: subscriptionPda,
serviceProvider: serviceProviderAddress,
tokenMint: usdcMintAddress,
systemProgram: SYSTEM_PROGRAM_ADDRESS,
// Instruction args
keyIndex: 0, // profile key index with SUBSCRIBE permission
subscriptionId: 1n, // unique subscription identifier
amountPerPeriod: 29_990_000n, // 29.99 USDC (6 decimals)
periodSeconds: 2_592_000n, // 30 days
metadata: new Uint8Array(64), // custom tier/plan metadata
});After initialization, the subscriber must also approve the subscription PDA as a token delegate on their token account:
// Client-side: approve delegate for the subscription PDA
const approveIx = createApproveInstruction(
subscriberProfileUsdcAta,
subscriptionPda, // delegate
subscriberWallet, // owner
BigInt(2**64 - 1), // max approval
);2. Settle (Permissionless Crank)
Collects payment for elapsed billing periods. Anyone can call this — no signer required beyond the transaction fee payer.
import { getSettleInstruction } from '@staratlas/profile-subscription';
const ix = getSettleInstruction({
subscriptionAccount: subscriptionPda,
sourceTokenAccount: subscriberProfileUsdcAta, // subscriber profile's ATA
destinationTokenAccount: providerUsdcAta, // payout_key_owner's ATA
tokenProgram: TOKEN_PROGRAM_ADDRESS,
});Settlement rules:
- Calculates
periods_owed = (now - last_settled_at) / period_seconds - Capped at 3 periods per transaction (prevents accumulation shock)
- Transfers
periods_owed × amount_per_periodtokens - Advances
last_settled_atby exact period increments (no drift) - Requires
sourceTokenAccountto be the subscriber profile's ATA for the subscription mint - Requires
destinationTokenAccountto be the payout owner's ATA for the subscription mint - If transfer fails on an active subscription (insufficient funds, revoked delegate), sets status to
PastDue
For cancelled subscriptions: only settles up to cancelled_at timestamp, remains Cancelled on transfer failure, and transitions to Expired once all full periods through cancellation are settled. If no full periods are owed, it expires without charging.
3. Initialize From Vault And Charge
Creates a Profile Vault-backed subscription and charges the first billing period in the same transaction. The subscriber profile key needs SUBSCRIBE | SETTLE_FROM_VAULT scoped to the Profile Subscription program. Separately, the derived subscription PDA must already be present as a Player Profile key with Profile Vault DRAIN_VAULT permission scoped to the selected vault authority or vault token account. Program-wide Profile Vault drain scope is rejected for recurring billing.
import {
findSubscriptionAccountPda,
getInitializeFromVaultAndChargeInstruction,
} from '@staratlas/profile-subscription';
const [subscriptionPda] = await findSubscriptionAccountPda(
{
profile: profileAddress,
serviceProvider: serviceProviderAddress,
subscriptionId: 1n,
},
{ programAddress } // pass mainnet program ID when needed
);
const ix = getInitializeFromVaultAndChargeInstruction(
{
profileValidationSigner: subscriberKeypair,
profileValidationProfile: profileAddress,
profileValidationCertificate: undefined,
profileValidationProgram: PLAYER_PROFILE_PROGRAM_ADDRESS,
profile,
funder: walletSigner,
subscriptionAccount: subscriptionPda,
serviceProvider: serviceProviderAddress,
tokenMint: usdcMintAddress,
vaultAuthority,
vaultTokenAccount,
destinationTokenAccount: providerUsdcAta,
profileVaultProgram: PROFILE_VAULT_PROGRAM_ADDRESS,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
systemProgram: SYSTEM_PROGRAM_ADDRESS,
keyIndex: 0,
subscriptionId: 1n,
vaultKeyIndex, // profile key index for the subscription PDA + DRAIN_VAULT
amountPerPeriod: 29_990_000n,
periodSeconds: 2_592_000n,
metadata: new Uint8Array(64),
},
{ programAddress }
);4. Settle From Vault (Permissionless Crank)
Collects elapsed billing periods from the configured profile vault through profile_vault::drain_vault, with profile_subscription signing as the subscription PDA after validating amount, timing, mint, vault source, destination, and the scoped DRAIN_VAULT permission. Anyone can crank it.
import { getSettleFromVaultInstruction } from '@staratlas/profile-subscription';
const ix = getSettleFromVaultInstruction(
{
profile: profileAddress,
subscriptionAccount: subscriptionPda,
vaultAuthority,
vaultTokenAccount,
destinationTokenAccount: providerUsdcAta,
profileVaultProgram: PROFILE_VAULT_PROGRAM_ADDRESS,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
vaultKeyIndex,
},
{ programAddress }
);Vault-backed settlement follows the same period math and 3-period cap as Settle. If vault balance is insufficient, active subscriptions move to PastDue; cancelled subscriptions remain Cancelled until they can be fully settled or expire.
5. Cancel Subscription
Subscriber cancels. Requires CANCEL_SUBSCRIPTION permission on their profile key.
import { getCancelSubscriptionInstruction } from '@staratlas/profile-subscription';
const ix = getCancelSubscriptionInstruction({
profileValidationSigner: subscriberKeypair,
profileValidationProfile: profileAddress,
profileValidationCertificate: undefined,
profileValidationProgram: PLAYER_PROFILE_PROGRAM_ADDRESS,
subscriptionAccount: subscriptionPda,
profileProgram: PLAYER_PROFILE_PROGRAM_ADDRESS,
keyIndex: 0,
});Important: Cancellation does NOT settle outstanding payments. The service provider should call Settle before or after cancellation to collect any owed periods up to cancelled_at.
6. Update Payout Key
Service provider changes the payout destination. Only the current payout_key_owner can call this.
import { getUpdatePayoutKeyInstruction } from '@staratlas/profile-subscription';
const ix = getUpdatePayoutKeyInstruction({
payoutKeyOwner: currentPayoutOwnerKeypair,
subscriptionAccount: subscriptionPda,
newServiceProvider: newPayoutAddress,
});Note: This updates payout_key_owner, which is both the payout destination owner checked during settlement and the authority for future payout rotations. The immutable service_provider field remains the original PDA seed identity so payout rotation cannot break settlement signing.
7. Close Subscription
Reclaims rent from a terminal (Expired) subscription account.
import { getCloseSubscriptionInstruction } from '@staratlas/profile-subscription';
const ix = getCloseSubscriptionInstruction({
// Profile validation accounts
profileValidationSigner: subscriberKeypair,
profileValidationProfile: profileAddress,
profileValidationCertificate: undefined,
profileValidationProgram: PLAYER_PROFILE_PROGRAM_ADDRESS,
subscriptionAccount: subscriptionPda,
profileProgram: PLAYER_PROFILE_PROGRAM_ADDRESS,
keyIndex: 0,
});Only works on Expired subscriptions and requires the profile key to have CANCEL_SUBSCRIPTION permission. Rent is refunded to the validated profile account.
Reading Subscription State
import { fetchSubscriptionAccount } from '@staratlas/profile-subscription';
const sub = await fetchSubscriptionAccount(rpc, subscriptionPda);
console.log(sub.data.status); // 'Active' | 'PastDue' | 'Cancelled' | 'Expired'
console.log(sub.data.amountPerPeriod); // bigint (token base units)
console.log(sub.data.periodSeconds); // bigint
console.log(sub.data.periodsSettled); // bigint
console.log(sub.data.lastSettledAt); // bigint (unix timestamp)
console.log(sub.data.cancelledAt); // bigint (0 if not cancelled)
console.log(sub.data.serviceProvider); // Address
console.log(sub.data.tokenMint); // Address
console.log(sub.data.metadata); // Uint8Array(64)Service Provider Integration Guide
What Your System Needs to Do
As a service provider integrating profile subscriptions (e.g., Zink), your backend is responsible for:
1. Transaction Construction (Frontend/API)
When a user clicks "Pay with Crypto":
- Derive the subscription PDA from their profile + your service provider address + a unique subscription ID
- Build either the
InitializeSubscriptionprofile-ATA transaction or theInitializeFromVaultAndChargeProfile Vault-backed transaction with your configured parameters (amount, period, metadata) - For the profile-ATA path, include the SPL token
approveinstruction for the subscription PDA delegate - For the vault-backed path, ensure the subscription PDA has a Profile Vault
DRAIN_VAULTkey scoped to the selected vault authority/token account and pass itsvaultKeyIndex - Present for the user to sign
Your liability: The service_provider address, amount_per_period, period_seconds, and metadata are set by the transaction you construct. Triple-check these values — they're immutable after creation.
2. Settlement Crank (Backend)
Run a bot/cron that periodically calls Settle for profile-ATA subscriptions or SettleFromVault for vault-backed subscriptions:
// Poll all subscription PDAs for your service provider
// For each active subscription where time > last_settled_at + period_seconds:
const ix = getSettleInstruction({
subscriptionAccount: pda,
sourceTokenAccount: subscriberProfileAta,
destinationTokenAccount: yourAta,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
});
// Send transaction (any wallet can pay the fee)Settlement is capped at 3 periods per transaction. If a subscriber was PastDue for months, you need to call settle multiple times to collect all owed periods.
Your liability: If you don't run the crank, you don't get paid. The program doesn't push payments — it requires an explicit settle call.
3. Status Sync (Backend)
Monitor subscription account changes and sync to your off-chain database:
| On-chain Status | Your System Should |
|---|---|
| Active | Grant access. Verify periodsSettled is current. |
| PastDue | Grace period → attempt settle again. After N failures, restrict access. |
| Cancelled | Settle any remaining periods. Access continues until end of current paid period. |
| Expired | Revoke access. Optionally close the account to reclaim rent. |
4. Metadata Usage
The 64-byte metadata field is yours to define. Example encoding:
bytes 0-3: plan_id (u32) — which subscription tier
bytes 4-7: features (u32) — feature flags bitmask
bytes 8-15: custom_id (u64) — your internal reference ID
bytes 16-63: reserved — future useThis is set at initialization and is immutable. If a user changes plans, create a new subscription (cancel old → init new).
Security Considerations for Service Providers
Validate the subscription PDA derivation — When your backend reads subscription data, verify the PDA is correctly derived from the expected seeds. Don't trust arbitrary account addresses.
Verify your payout address —
Settlerequiresdestination_token_accountto be the ATA forpayout_key_ownerand the subscription mint.service_provideris immutable PDA seed identity;payout_key_owneris the current payout destination/rotation authority. Your off-chain system should verify both match your expected provider identity and payout wallet.Use the correct settlement source —
Settlerejects arbitrary same-mint source accounts and requires the subscriber profile ATA.SettleFromVaultrequires the vault token account to be owned by the supplied vault authority and rejects program-scoped Profile Vault drain keys; the subscription PDA'sDRAIN_VAULTpermission must be scoped to the vault authority or vault token account.Handle PastDue gracefully — A PastDue status means the token transfer failed. This could be:
- Subscriber ran out of funds
- Subscriber revoked the token delegate
- Subscriber closed their token account
Your system should attempt to re-settle periodically but eventually give up and restrict access.
Don't rely solely on on-chain state for access control — Use on-chain state as the source of truth, but cache locally with reasonable TTLs. Polling every subscription every second is wasteful; every few minutes is fine for most use cases.
Payout key security — If your
payout_key_ownerprivate key is compromised, an attacker can redirect all future settlements to their wallet viaUpdatePayoutKey. Store this key with the same security as your hot wallet. Rotation updatespayout_key_owneronly; the originalservice_providerseed stays immutable.Settlement timing — The program uses
Clock::get()for timestamps. Validators have ~1-2 second clock skew tolerance. Don't rely on sub-second timing precision for billing period boundaries.
Permissions
The program uses Player Profile permissions scoped to the active Profile Subscription program ID (the SDK default is devnet PSubAEkp16MhpP7AfB5kBbpWQ4cppUj9nxS5vUkHT13; pass the mainnet program ID in instruction config when building mainnet transactions):
| Permission | Bit | Required For |
|---|---|---|
| SUBSCRIBE | 1 << 0 | InitializeSubscription, InitializeFromVaultAndCharge |
| CANCEL_SUBSCRIPTION | 1 << 1 | CancelSubscription, CloseSubscription |
| SETTLE_FROM_VAULT | 1 << 2 | InitializeFromVaultAndCharge authorization |
Add these to a profile key:
const permissions = (1n << 0n) | (1n << 1n) | (1n << 2n); // SUBSCRIBE + CANCEL + SETTLE_FROM_VAULT
// Use AddKeys instruction on the Player Profile program
// with scope = PROFILE_SUBSCRIPTION_PROGRAM_ADDRESSProgram Addresses
| Network | Program ID |
|---------|------------|
| Mainnet | PSubzJGRCQKoBadKLcg3cqryrb7ZLPujdqSokUoQptQ |
| Devnet / Testnet | PSubAEkp16MhpP7AfB5kBbpWQ4cppUj9nxS5vUkHT13 |
Related Programs
| Program | Mainnet Address |
|---------|----------------|
| Player Profile | pprofELXjL5Kck7Jn5hCpwAL82DpTkSYBENzahVtbc9 |
| Profile Vault | pv1ttom8tbyh83C1AVh6QH2naGRdVQUVt3HY1Yst5sv |
| SPL Token | TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA |
Constants
| Constant | Value | Description |
|---|---|---|
| MAX_SETTLEMENT_PERIODS | 3 | Max periods settled per transaction |
| PROFILE_SUBSCRIPTION_PROGRAM_ADDRESS | PSubAEkp16MhpP7AfB5kBbpWQ4cppUj9nxS5vUkHT13 | SDK default (devnet) |
Using the Mainnet Program ID
The SDK defaults to the devnet program address. For mainnet, override via the config parameter:
const MAINNET_PROGRAM_ID = 'PSubzJGRCQKoBadKLcg3cqryrb7ZLPujdqSokUoQptQ';
const ix = getInitializeSubscriptionInstruction(
{ /* ...accounts and args... */ },
{ programAddress: address(MAINNET_PROGRAM_ID) }
);License
Apache-2.0
