Plan d'abonnement

Un plan d'abonnement permet à un commerçant de publier des conditions de facturation que les utilisateurs peuvent accepter. Après qu'un utilisateur s'abonne, le commerçant ou un préleveur autorisé peut collecter jusqu'au montant du plan à chaque période de facturation.

Ce guide présente le flux complet sous forme de blocs de construction. Le commerçant crée un plan, l'abonné l'accepte, et le commerçant ou le préleveur collecte les paiements depuis le PDA d'abonnement résultant.

Installation

pnpm add @solana/subscriptions @solana/kit @solana/kit-plugin-rpc @solana/kit-plugin-signer @solana-program/token

Créer un plan

Le commerçant est propriétaire du plan. Le PDA du plan est dérivé de l'adresse du commerçant et de planId.

Un sponsor peut financer le rent du plan en passant le paramètre optionnel payer, tandis que le marchand reste propriétaire du plan. La suppression du plan rembourse le propriétaire, et non le payeur ; gérez donc la logique de parrainage hors chaîne.

import { address, createClient } from '@solana/kit';
import { solanaLocalRpc } from '@solana/kit-plugin-rpc';
import { signer } from '@solana/kit-plugin-signer';
import { findPlanPda, subscriptionsProgram } from '@solana/subscriptions';
const merchantClient = createClient()
.use(signer(merchantSigner))
.use(solanaLocalRpc({ rpcUrl: 'http://127.0.0.1:8899' }))
.use(subscriptionsProgram());
const planId = 1n;
const tokenMint = address('TOKEN_MINT_ADDRESS_HERE');
const amount = 5_000_000n;
const periodHours = 720n;
const metadataUri = 'https://example.com/plan.json';
const destinations = [merchantSigner.address];
const pullers = [address('PULLER_WALLET_ADDRESS_HERE')];
await merchantClient.subscriptions.instructions
.createPlan({
planId,
mint: tokenMint,
amount,
periodHours,
endTs: 0n,
destinations,
pullers,
metadataUri,
})
.sendTransaction();
const [planPda] = await findPlanPda({
owner: merchantSigner.address,
planId,
});

Mettre à jour un plan

Le commerçant peut mettre à jour les champs modifiables du plan après sa création. Les abonnés existants conservent les conditions qu'ils ont acceptées, tandis que les nouveaux abonnés acceptent les conditions actuelles du plan.

Quelques règles s'appliquent :

  • Un endTs fini ne peut être que raccourci, jamais prolongé ou effacé (PlanEndTsCannotExtend).
  • Dans un plan Sunset, vous pouvez supprimer des pullers (le nouvel ensemble doit être un sous-ensemble de l'ensemble actuel) pour révoquer un puller compromis ; le statut, le endTs et les métadonnées restent figés.
  • Les modifications sont possibles pendant la dernière période de facturation d'un plan, à condition que le endTs reste inchangé.
  • L'instruction contient l'état du plan observé au moment de la signature (expectedCreatedAt, expectedEndTs, expectedPullers, expectedMetadataUri). Si le plan actif ne correspond plus à cet état, la mise à jour est rejetée (StalePlanApproval). Ainsi, une mise à jour signée obsolète ne peut pas restaurer des pullers supprimés ni annuler des modifications ultérieures. Le client plugin updatePlan récupère l'état actif pour vous ; si vous construisez manuellement, récupérez le plan et transmettez les champs vous-même.
import { PlanStatus } from '@solana/subscriptions';
const updatedMetadataUri = 'https://example.com/updated-plan.json';
const updatedPullers = [address('NEW_PULLER_WALLET_ADDRESS_HERE')];
await merchantClient.subscriptions.instructions
.updatePlan({
owner: merchantSigner,
planPda,
status: PlanStatus.Active,
endTs: 0n,
pullers: updatedPullers,
metadataUri: updatedMetadataUri,
})
.sendTransaction();

S'abonner

L'abonné accepte les conditions du plan en vigueur. Le PDA d'abonnement est dérivé du PDA du plan et de l'adresse de l'abonné.

Pour regrouper l'initialisation de l'autorité et l'abonnement en une seule transaction, passez la valeur sentinelle UNKNOWN_INIT_ID (exportée par le SDK TypeScript) en tant que expectedSubscriptionAuthorityInitId. Le programme n'accepte l'autorité que si elle a été créée dans le slot actuel ; la sentinelle fonctionne donc pour les nouvelles inscriptions. Un utilisateur existant dont l'autorité a été créée dans un slot antérieur doit transmettre le vrai initId (que le client plugin récupère pour vous), faute de quoi l'appel échoue avec StaleSubscriptionAuthority.

import { createClient } from '@solana/kit';
import { solanaLocalRpc } from '@solana/kit-plugin-rpc';
import { signer } from '@solana/kit-plugin-signer';
import { findAssociatedTokenPda, TOKEN_PROGRAM_ADDRESS } from '@solana-program/token';
import {
fetchMaybeSubscriptionAuthority,
findSubscriptionAuthorityPda,
findSubscriptionDelegationPda,
subscriptionsProgram,
} from '@solana/subscriptions';
const subscriberClient = createClient()
.use(signer(subscriberSigner))
.use(solanaLocalRpc({ rpcUrl: 'http://127.0.0.1:8899' }))
.use(subscriptionsProgram());
const [subscriberAta] = await findAssociatedTokenPda({
mint: tokenMint,
owner: subscriberSigner.address,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
});
const [subscriptionAuthorityPda] = await findSubscriptionAuthorityPda({
user: subscriberSigner.address,
tokenMint,
});
const subscriptionAuthority = await fetchMaybeSubscriptionAuthority(
subscriberClient.rpc,
subscriptionAuthorityPda,
);
if (!subscriptionAuthority.exists) {
await subscriberClient.subscriptions.instructions
.initSubscriptionAuthority({
tokenMint,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
userAta: subscriberAta,
})
.sendTransaction();
}
await subscriberClient.subscriptions.instructions
.subscribe({
merchant: merchantSigner.address,
planId,
tokenMint,
})
.sendTransaction();
const [subscriptionPda] = await findSubscriptionDelegationPda({
planPda,
subscriber: subscriberSigner.address,
});

Collecter un paiement

Le marchand ou un puller whitelisté signe la collecte. Lorsque le plan utilise une liste d'autorisation de destinations, le propriétaire du token account récepteur doit être répertorié dans destinations.

const receiverAta = address('MERCHANT_TOKEN_ACCOUNT_ADDRESS_HERE');
await merchantClient.subscriptions.instructions
.transferSubscription({
caller: merchantOrPullerSigner,
delegator: subscriberSigner.address,
tokenMint,
subscriptionPda,
planPda,
amount: 200_000n,
receiverAta,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
})
.sendTransaction();

Annuler et Révoquer

L'annulation marque l'abonnement comme se terminant. La révocation ferme le PDA de l'abonnement une fois le délai d'expiration de l'annulation écoulé. L'abonné signe les deux transactions.

await subscriberClient.subscriptions.instructions
.cancelSubscription({
subscriber: subscriberSigner,
planPda,
subscriptionPda,
})
.sendTransaction();
// Run this after the cancelled subscription's expiresAtTs has elapsed.
await subscriberClient.subscriptions.instructions
.revokeSubscription({
authority: subscriberSigner,
planPda,
subscriptionPda,
})
.sendTransaction();

Annulation immédiate

Lorsque l'abonné et le propriétaire du plan en cours signent tous les deux, cancelSubscriptionNow fait expirer l'abonnement au moment de l'annulation, plutôt qu'à la fin de la période de facturation. Cette instruction peut également raccourcir une annulation en période de grâce en attente. L'approbation est liée au début de période observé au moment de la signature ; si l'abonnement a changé depuis, la transaction est rejetée (StaleSubscriptionApproval).

import { fetchSubscriptionDelegation } from '@solana/subscriptions';
const subscription = await fetchSubscriptionDelegation(
subscriberClient.rpc,
subscriptionPda,
);
await subscriberClient.subscriptions.instructions
.cancelSubscriptionNow({
subscriber: subscriberSigner,
merchant: merchantSigner,
planPda,
expectedCurrentPeriodStartTs: subscription.data.currentPeriodStartTs,
})
.sendTransaction();

Reprendre un Abonnement

Un abonnement annulé peut être réactivé avant d'être révoqué. Une fois révoqué, le compte d'abonnement est clôturé et l'abonné doit s'abonner à nouveau. La reprise nécessite le SubscriptionAuthority de l'abonné pour le mint du plan, que le programme valide (propriétaire, mint et init_id) et rejette s'il est périmé ou réinitialisé.

L'instruction contient la date d'expiration observée par l'abonné au moment de la signature (expectedExpiresAtTs) ; toute discordance est rejetée (StaleSubscriptionApproval). Ainsi, une reprise signée obsolète ne peut pas annuler une résiliation ultérieure que l'abonné n'a jamais approuvée.

import { fetchSubscriptionDelegation } from '@solana/subscriptions';
const subscription = await fetchSubscriptionDelegation(
subscriberClient.rpc,
subscriptionPda,
);
await subscriberClient.subscriptions.instructions
.resumeSubscription({
subscriber: subscriberSigner,
planPda,
tokenMint,
expectedExpiresAtTs: subscription.data.expiresAtTs,
})
.sendTransaction();

Notes

  • amount est exprimé en unités de base. Pour un token à 6 décimales, 5_000_000 correspond à 5 tokens.
  • Le SDK TypeScript récupère les conditions du plan en temps réel lors de subscribe si vous ne les fournissez pas.
  • L'SubscribeBuilder Rust nécessite les conditions du plan attendues. Récupérez et décodez d'abord le compte du plan, puis transmettez ces champs via SubscribeData.
  • Seul le marchand ou un portefeuille répertorié dans pullers peut collecter les paiements.
  • L'abonné signe les transactions de configuration, d'annulation et de révocation. Le marchand ou le collecteur autorisé signe les transactions de collecte.

Is this page helpful?

Table des matières

Modifier la page
© 2026 Fondation Solana. Tous droits réservés.