@staratlas/profile-subscription
v0.46.1
Published
TypeScript client for the **Profile Subscription** Solana program — fully on-chain recurring payments using Star Atlas Player Profile vaults.
Maintainers
Keywords
Readme
@staratlas/profile-subscription
TypeScript client for the Profile Subscription Solana program — fully on-chain recurring payments using Star Atlas Player Profile vaults.
| 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,
getSettleInstruction,
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
Creates a new subscription. The subscriber signs with their Player Profile key (requires SUBSCRIBE permission scoped to the subscription program).
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,
// Subscription accounts
subscriptionAccount: subscriptionPda,
serviceProvider: serviceProviderAddress,
tokenMint: usdcMintAddress,
systemProgram: SYSTEM_PROGRAM_ADDRESS,
profileProgram: PLAYER_PROFILE_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(
subscriberTokenAccount,
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: subscriberUsdcAta, // subscriber's token account
destinationTokenAccount: providerUsdcAta, // service provider's token account
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) - If the transfer fails (insufficient funds, revoked delegate), sets status to
PastDue
For cancelled subscriptions: only settles up to cancelled_at timestamp, then transitions to Expired.
3. 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.
4. 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: currentServiceProviderKeypair,
subscriptionAccount: subscriptionPda,
newServiceProvider: newPayoutAddress,
});Note: This updates both service_provider (payout destination) AND payout_key_owner (signing authority). The old owner loses control permanently.
5. Close Subscription
Reclaims rent from a terminal (Expired) subscription account.
import { getCloseSubscriptionInstruction } from '@staratlas/profile-subscription';
const ix = getCloseSubscriptionInstruction({
rentRecipient: subscriberKeypair, // receives the rent refund
subscriptionAccount: subscriptionPda,
});Only works on Expired subscriptions.
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 the
InitializeSubscriptiontransaction with your configured parameters (amount, period, metadata) - Include the SPL token
approveinstruction for the subscription PDA delegate - 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 on all active 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: subscriberAta,
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 service_provider address — The
destination_token_accountowner in Settle must matchservice_provideron the subscription. The program enforces this, but your off-chain system should also verify it matches your expected wallet.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.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 PSubAEkp16MhpP7AfB5kBbpWQ4cppUj9nxS5vUkHT13:
| Permission | Bit | Required For |
|---|---|---|
| SUBSCRIBE | 1 << 0 | InitializeSubscription |
| CANCEL_SUBSCRIPTION | 1 << 1 | CancelSubscription |
Add these to a profile key:
const permissions = (1n << 0n) | (1n << 1n); // SUBSCRIBE + CANCEL_SUBSCRIPTION
// 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
