订阅计划

订阅计划允许商家发布用户可以接受的计费条款。用户订阅后,商家或经授权的拉取方可以在每个计费周期内收取最高不超过计划金额的款项。

本指南以构建模块的形式展示完整流程。商家创建计划,订阅者接受计划,然后商家或拉取方从生成的订阅 PDA 中收取付款。

安装

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

创建计划

商家拥有该计划。计划 PDA 由商家地址和 planId 派生而来。

赞助商可以在商家保留计划所有权的情况下,通过传入可选的 payer 参数来为计划的 rent 提供资金。删除计划时,退款将归还给所有者而非付款方,因此请在链下管理赞助权限。

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,
});

更新计划

商家可以在创建后更新可变的计划字段。现有订阅者保留他们接受的条款,而新订阅者则接受当前的计划条款。

以下规则适用:

  • 有限的 endTs 只能缩短,不能延长或清除 (PlanEndTsCannotExtend)。
  • Sunset 计划中,您可以移除拉取方(新集合必须是当前集合的子集)以撤销已泄露的拉取方;状态、endTs 和元数据将保持冻结。
  • 只要 endTs 未更改,在计划最终计费周期内均可进行编辑。
  • 该指令携带签名时观察到的计划状态(expectedCreatedAtexpectedEndTsexpectedPullersexpectedMetadataUri)。如果当前计划状态与之不符,更新将被拒绝(StalePlanApproval),因此过期的已签名更新无法恢复已移除的拉取方或撤销后续编辑。插件客户端的 updatePlan 会自动为您获取最新状态;如需手动构建,请自行获取计划并传入相应字段。
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();

订阅

订阅者接受当前计划条款。订阅 PDA 由计划 PDA 和订阅者地址派生而来。

若要将权限初始化与订阅操作合并为单笔交易,请将哨兵值 UNKNOWN_INIT_ID(由 TypeScript SDK 导出)作为 expectedSubscriptionAuthorityInitId 传入。程序仅在权限于当前 slot 内创建时才予以接受,因此该哨兵值适用于全新注册;若用户的权限创建于更早的 slot,则必须传入真实的 initId(插件客户端会自动为您获取),否则调用将以 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,
});

收取付款

商户或已列入白名单的拉取方签署收款操作。当计划使用目标地址白名单时,接收方 token account 的所有者必须列于 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();

取消与撤销

取消操作将订阅标记为即将结束。撤销操作则在取消到期后关闭订阅的 PDA。订阅者需对两笔交易进行签名。

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();

立即取消

当订阅者与当前计划所有者同时签名时,cancelSubscriptionNow 会在取消时立即使订阅到期,而非等到账单周期结束。它还可以缩短待处理的宽限期取消。该批准与签名时观察到的周期开始时间绑定;若订阅自那以后已发生变更,交易将被拒绝(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();

恢复订阅

已取消的订阅在被撤销之前可以重新激活。一旦撤销,订阅账户将被关闭,订阅者必须重新 订阅。恢复订阅需要订阅者提供该计划铸币的 SubscriptionAuthority,程序将对其进行验证(所有者、铸币及 init_id),若已过期或被重新初始化则拒绝操作。

该指令携带订阅者签名时观察到的到期时间(expectedExpiresAtTs);若不匹配则会被拒绝(StaleSubscriptionApproval),因此过期的已签名恢复操作无法清除订阅者从未批准的后续取消。

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();

注意事项

  • amount 以基本单位表示。对于 6 位小数的代币,5_000_000 表示 5 个代币。
  • TypeScript SDK 在调用 subscribe 时,若未传入计划条款,将自动获取最新条款。
  • Rust 的 SubscribeBuilder 需要预期的计划条款。请先获取并解码计划账户,再通过 SubscribeData 传入相应字段。
  • 只有商户或在 pullers 中列出的钱包才能收款。
  • 订阅者签署设置、取消和撤销交易;商户或已授权的拉取方签署收款交易。

Is this page helpful?

Table of Contents

Edit Page
©️ 2026 Solana 基金会版权所有