新消息:Helius 收购 Light Protocol
Solana 订阅和定期付款横幅
博客/基础知识

在 Solana 上设置订阅和定期付款

研究员X 上的 Lostin
阅读需 19 分钟

简介

定期计费是互联网商业的基础组成部分。SaaS 产品、API 平台、薪资系统以及无数其他业务,都依赖于按可预测的周期向客户收费的能力。

此前,要在 Solana 上实现这种体验,需要构建大量自定义基础设施。新的 Solana 订阅委托程序(也称为 Solana 订阅与额度程序)通过一个开源且经过审计的链上原语,解决了定期付款和订阅套餐的这一痛点。

现在,用户只需授权一次未来的代币转账(受明确的链上约束限制),获批的商家或收款方之后便可发起付款,无需用户签名。该程序已在 主网和开发网上线,支持原始 SPL Token Program 和 Token-2022 的铸币。

开发者不再需要让每个应用自行设计并保护委托系统,而是可以集成一个标准化程序。过去需要数周自定义开发的付款流程,现在几天内即可完成集成。

作为发布合作伙伴,Helius 协助完善了该程序,目前我们已用它支持链上 API 套餐订阅计费。Helius 客户因此可以直接通过自己的 Solana 钱包授权以 USDC 进行定期付款。

本文将分析该程序的工作方式,并用它构建可扩展的定期付款流程。我们将介绍订阅权限架构、代表各项授权的 PDA,以及该程序支持的三种计费模式:

  • 固定支出额度
  • 定期委托
  • 商家定义的订阅套餐

为什么链上订阅一直难以实现

Solana 的代币程序已经支持委托支出。代币账户所有者可以使用 Approve 或 ApproveChecked 指令,授权其他地址代为转移或销毁代币,但不能超过指定额度。

问题在于,一个代币账户只能存储一个当前受托方和一个委托金额。批准新的受托方会替换之前的受托方及其额度。无论账户由原始 Token Program 还是 Token-2022 管理,情况都是如此。用户批准第二项服务后,第一项服务就会被替换。原生额度只是单个数值,并没有内置计费周期、额度重置或订阅状态等概念。

应用可以为每项支出关系创建单独的代币账户来规避这一问题,但这样做会分散用户余额,并使钱包和应用体验变得复杂得多。团队也可以构建自定义托管或委托程序,但这又会带来共享程序原本旨在消除的开发和安全负担。

真正缺少的是一种方法,能将 Token Program 的单个受托方槽位转变为支持多项独立授权的可编程网关。

全新的订阅委托程序

订阅委托程序在不更改 Solana 任一底层代币程序的情况下,补上了这一缺失的可编程层。对于每个(用户,代币铸币)组合,该程序都会派生一个订阅权限,即一个程序派生地址(PDA),它将成为用户对应铸币代币账户的受托方。该地址只需初始化一次,之后便可由与该用户和铸币相关的每项订阅或委托重复使用。

初始化期间,用户签署一笔交易,以约 1840 京的额度批准订阅权限,即 u64::MAX。这样做是安全的,因为订阅权限是一个只能通过订阅委托程序签名的 PDA。它无法自行决定转移代币。 

在签署对 Token Program 的 CPI 之前,订阅委托程序必须加载有效的授权账户并验证其约束。根据授权模型,这些检查可能包括:

  • 获准发起扣款的钱包或服务
  • 铸币和源代币账户
  • 剩余总额度
  • 当前计费周期内的最大可用金额
  • 授权的开始时间和到期时间
  • 用户接受的订阅套餐
  • 发起收费的商家或获批收款方
  • 套餐配置的任何收款地址限制

只有通过这些检查后,程序才会以订阅权限签名并执行代币转账。如果没有与请求转账匹配的有效授权,交易就会失败。因此,u64::MAX 额度属于程序控制的网关,而非任何单一商家。商家只会获得其特定委托账户所描述的权限。

代币账户仍然只有一个受托方(即订阅权限 PDA),但程序可以在其后放置多个独立的授权 PDA。创建新授权不会覆盖任何现有授权。每项授权都有自己的状态、限制、生命周期和撤销路径。

三种授权模型

该程序支持三种不同的模型:固定委托、定期委托和订阅套餐。

固定委托

固定委托授权钱包或服务扣取不超过指定总额的资金。每次转账都会减少剩余额度,委托还可以选择在指定的 Unix 时间戳到期。

这种模型适用于有上限的代理预算、一次性额度、有时限的购买权限,以及用户希望限定最大总风险敞口的其他场景。

定期委托

定期委托规定每个周期内可以扣取的金额。下一个周期开始时,上一周期已扣取的金额会重置。

用户可以控制各项条款,包括每周期金额、周期长度、开始时间和整体到期时间。因此,定期委托适用于薪资、承包商付款、定期津贴或由付款方设定限额的自定义计费协议等持续性关系。

订阅套餐

订阅套餐颠倒了设置流程。商家无需让每个用户自行定义定期条款,而是可以发布可重复使用的套餐,其中包含金额、计费周期、接受的铸币、获准收款方以及可选的收款地址限制。

用户查看并接受这些条款后,会创建一个与该套餐关联的订阅委托 PDA。接受的计费条款会复制到用户的订阅账户中,防止商家悄然更改现有订阅者的核心价格或计费周期。然后,套餐所有者或获批扣款方可以在每个计费周期内收取不超过套餐金额的款项。

这一差异非常重要:

  • 定期委托是由付款方定义的授权
  • 订阅套餐是由商家发布、付款方明确选择接受的条款

三种模型都使用同一个订阅权限,并最终通过相同的底层委托架构执行转账。

参考实现

订阅委托程序由 Moonsong Labs 与 Solana Foundation 合作设计和构建,并由 Cantina 审计。其源代码、文档和客户端均可在 Solana Foundation 订阅仓库中获取。

链上程序采用 no_std Rust 编写,并使用 Pinocchio。Pinocchio 提供了更底层、依赖更少的开发模型。与典型的 Anchor 实现相比,这使程序能够更精细地管理计算资源用量和二进制文件大小。

该仓库还使用 Codama,直接根据程序接口生成同步的 TypeScript 和 Rust 客户端。对于 TypeScript 应用,主要软件包是:

代码
pnpm add @solana/subscriptions

此外还有一个官方演示 Web 应用,提供可在开发网上轻松使用的端到端实现。该程序同时支持 SPL Token 和 Token-2022,并会发出链上生命周期和转账事件,应用和索引器可以使用已发布的 IDL 对其进行解码。

用例:开发者可以构建什么

只要用户能在尚不确定具体转账时间时,预先界定未来付款的边界,该程序就能发挥作用。用户只需签名一次来建立授权;之后,商家、服务、收款方或代理便可在这些限制内发起转账。

由于每项支出安排都由自己的 PDA 表示,这些用例可以共存于同一个订阅权限之后。用户可以使用同一个 USDC 代币账户支付 API 套餐、为 AI 代理提供每周预算,并授权承包商预付金,而各项授权互不干扰。

API 和基础设施定期计费

订阅套餐天然适合 SaaS 产品、RPC 提供商、数据平台和其他基础设施服务。提供商可以为每个产品层级发布单独的链上套餐,定义接受的代币铸币、价格、计费周期、获批收款方和允许的付款地址。这样无需银行卡处理商也能提供用户熟悉的订阅体验。

AI 代理的限额支出

自主代理需要能够在无需每次征得人工批准的情况下,为 API、计算、数据、交易服务和其他资源付款。然而,让代理不受限制地控制有资金的钱包会带来明显的安全风险。

固定委托提供了更安全的替代方案。用户可以授权代理支出不超过特定数量的代币,并为该授权设置严格的到期时间。用户也可以在委托到期前将其撤销。

定期委托扩展了相同的模型。代理可以获得用于 API 请求的每日额度或每周运营预算,可用金额会在每个周期开始时重置。

链上薪资和承包商付款

定期委托可支持扣款式薪资、预付金、资助和承包商协议。付款方授权员工或承包商在每个薪资周期内收取不超过指定金额的资金。授权可以指定每周期金额、周期长度、开始时间和最终到期时间。付款到期时,收款方或薪资服务会提交转账交易。

这与传统的付款方主动发薪交易不同。付款方不会在发薪日自动发送资金。相反,收款方会获得范围严格受限的权利,可在每个周期内扣取约定金额。由此形成一份透明的付款协议,双方都可以在链上查看。

通过程序发出的事件可以追踪转账和委托活动,从而构建薪资仪表板和会计集成。

稳定币发票收款

支付网关和 B2B 计费平台可以使用该程序,以持久且有上限的授权取代反复发送的付款请求。客户可以授权网关收取:

  • 一份采购订单不超过固定总额的款项
  • 每个每周或每月发票周期内不超过指定金额的款项
  • 每个计费周期内标准化商家套餐的价格

相同的架构还可支持定期发票、商家收单、使用额度、企业支出政策,以及付款方希望实现自动化但不愿放弃无限控制权的其他工作流。

内容和媒体小额付款

定期委托还可以为出版商、流媒体平台、研究提供商和其他媒体服务支持按使用量付费的模式。

用户可以授权一个每月支出额度,并在每次访问付费内容时逐步扣减。例如,打开一篇文章可能会从每月 10 USDC 的额度中扣除 0.10 USDC,而高级报告或视频流的价格可能更高。平台会在内容被访问时提交每笔付款。

这使“按阅读付费”模式成为可能,用户无需为每篇文章进行钱包签名,也不必接受固定且非此即彼的订阅。出版商可以规模化地将每次访问变现,用户则保留可预测的支出上限,并可随时撤销授权。

使用 Helius 在开发网上构建订阅流程

在本教程部分,我们将通过以下步骤构建商家订阅的完整生命周期:

  • 客户为自己的代币账户初始化订阅权限
  • 商家发布订阅套餐
  • 客户接受套餐
  • 商家收取付款
  • 客户取消订阅

示例使用 @solana/subscriptions@0.4.0,这是撰写本文时最新发布的 TypeScript 客户端。固定软件包版本可以让教程保持稳定,即使 SDK 日后发生变化也不受影响。

我们将模拟两个参与方:

角色职责
客户拥有代币、初始化订阅权限、订阅并取消套餐
商家发布套餐并提交收款交易

对于代币,我们将创建一个自定义的六位小数开发网铸币,并向客户发行 100 枚测试代币。这样既无需依赖单独的稳定币水龙头,又保留了 USDC 等六位小数代币所使用的相同基础单位运算方式。

使用本地 JSON 密钥对,可以轻松从命令行运行该流程。在实际应用中,客户交易通常通过浏览器或移动钱包签名,而商家收款方则使用安全管理的后端签名者。

前提条件

本教程假设你具备:

  • 较新版本的 Node.js
  • pnpm
  • Solana CLI
  • 付费 Helius API 密钥
  • 两个测试钱包所需的开发网 SOL

设置项目

创建一个新项目,并添加 keys 目录来存储客户和商家的密钥对:

代码
mkdir helius-subscriptions-devnet
cd helius-subscriptions-devnet

pnpm init
mkdir -p src keys

将 "type": "module" 添加到 package.json,然后安装依赖项:

代码
pnpm add \
  @solana/subscriptions@0.4.0 \
  @solana/kit@6.10.0 \
  @solana/kit-plugin-rpc@0.12.1 \
  @solana/kit-plugin-signer@0.12.1 \
  @solana-program/token@0.13.0 \
  dotenv

pnpm add -D typescript tsx @types/node

创建 tsconfig.json:

tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src"]
}

创建商家和客户钱包

为每个参与方生成一个密钥对:

代码
solana-keygen new \
  --no-bip39-passphrase \
  --outfile keys/merchant.json

solana-keygen new \
  --no-bip39-passphrase \
  --outfile keys/customer.json

这些密钥对仅供开发网教程使用。请勿将生产密钥提交到仓库,也不要将生产环境的商家签名者存储为未加密的 JSON 文件。

创建 .gitignore:

.gitignore
node_modules/
.env
keys/
state.json

创建 .env 并添加你的 Helius API 密钥:

.env
HELIUS_API_KEY=YOUR_HELIUS_API_KEY
MERCHANT_KEYPAIR=./keys/merchant.json
CUSTOMER_KEYPAIR=./keys/customer.json

两个钱包都需要 SOL 来支付交易费并创建各自的 PDA 账户。

代码
export HELIUS_DEVNET_URL="https://devnet.helius-rpc.com/?api-key=YOUR_HELIUS_API_KEY"

solana airdrop 1 \
  "$(solana-keygen pubkey keys/merchant.json)" \
  --url "$HELIUS_DEVNET_URL"

solana airdrop 1 \
  "$(solana-keygen pubkey keys/customer.json)" \
  --url "$HELIUS_DEVNET_URL"

Helius 还通过其仪表板提供开发网水龙头。通过 Helius 水龙头或 Helius RPC 请求开发网 SOL,需要使用付费 Helius 套餐。

创建共享 Helius 客户端

每个脚本都需要相同的 Helius 连接、程序插件、配置值和地址。我们会将它们全部放入 src/config.ts:

config.ts

import "dotenv/config";

import { readFileSync, writeFileSync } from "node:fs";

import {
  address,
  createClient,
  type Address,
} from "@solana/kit";
import { solanaDevnetRpc } from "@solana/kit-plugin-rpc";
import { signerFromFile } from "@solana/kit-plugin-signer";
import {
  associatedTokenProgram,
  tokenProgram,
} from "@solana-program/token";
import { subscriptionsProgram } from "@solana/subscriptions";

function requiredEnv(name: string): string {
  const value = process.env[name];

  if (!value) {
    throw new Error(`Missing ${name} in .env`);
  }

  return value;
}

const apiKey = requiredEnv("HELIUS_API_KEY");

export const HELIUS_RPC_URL =
  `https://devnet.helius-rpc.com/?api-key=${encodeURIComponent(apiKey)}` as const;

export const HELIUS_WS_URL =
  `wss://devnet.helius-rpc.com/?api-key=${encodeURIComponent(apiKey)}` as const;

export const MERCHANT_KEYPAIR =
  process.env.MERCHANT_KEYPAIR ?? "./keys/merchant.json";

export const CUSTOMER_KEYPAIR =
  process.env.CUSTOMER_KEYPAIR ?? "./keys/customer.json";

export const TOKEN_DECIMALS = 6;

// Five tokens when the mint has six decimals.
export const PLAN_AMOUNT = 5_000_000n;

// Keep the devnet period short so we can test multiple billing cycles.
export const PLAN_PERIOD_HOURS = 1n;

const STATE_FILE = "./state.json";

type StateJson = {
  tokenMint: string;
  planId: string;
};

export async function createAppClient(keypairPath: string) {
  return await createClient()
    .use(signerFromFile(keypairPath))
    .use(
      solanaDevnetRpc({
        rpcUrl: HELIUS_RPC_URL,
        rpcSubscriptionsUrl: HELIUS_WS_URL,
      }),
    )
    .use(tokenProgram())
    .use(associatedTokenProgram())
    .use(subscriptionsProgram());
}

export function saveState(
  tokenMint: Address,
  planId: bigint,
): void {
  writeFileSync(
    STATE_FILE,
    JSON.stringify(
      {
        tokenMint,
        planId: planId.toString(),
      },
      null,
      2,
    ),
  );
}

export function loadState(): {
  tokenMint: Address;
  planId: bigint;
} {
  const parsed = JSON.parse(
    readFileSync(STATE_FILE, "utf8"),
  ) as StateJson;

  if (!parsed.tokenMint || !parsed.planId) {
    throw new Error(
      "state.json is missing tokenMint or planId",
    );
  }

  return {
    tokenMint: address(parsed.tokenMint),
    planId: BigInt(parsed.planId),
  };
}

export function printSignature(
  label: string,
  signature: unknown,
): void {
  const value = String(signature);

  console.log(`${label}: ${value}`);
  console.log(
    `Orb: https://orb.helius.dev/tx/${value}?cluster=devnet`,
  );
}

signerFromFile 将加载的密钥对同时设为客户端身份和费用支付方。Helius RPC 插件负责交易规划、提交和确认,代币与订阅插件则添加各自的账户和指令辅助函数。每个脚本都会为生成的交易输出一个 Orb(Helius 的区块浏览器)URL。

创建开发网测试铸币

在初始化订阅权限之前,客户必须已有一个对应套餐铸币的代币账户。我们将创建一个六位小数的测试铸币,向客户发行 100 枚代币,并为商家创建一个空的收款代币账户。

Solana Kit 代币插件提供了用于创建铸币、关联代币账户和铸造代币的辅助函数。创建 src/00-bootstrap.ts:

00-bootstrap.ts
import { generateKeyPairSigner } from "@solana/kit";
import {
  findAssociatedTokenPda,
  TOKEN_PROGRAM_ADDRESS,
} from "@solana-program/token";

import {
  createAppClient,
  CUSTOMER_KEYPAIR,
  MERCHANT_KEYPAIR,
  printSignature,
  saveState,
  TOKEN_DECIMALS,
} from "./config.js";

const [merchantClient, customerClient] =
  await Promise.all([
    createAppClient(MERCHANT_KEYPAIR),
    createAppClient(CUSTOMER_KEYPAIR),
  ]);

const mint = await generateKeyPairSigner();

const createMintResult =
  await merchantClient.token.instructions
    .createMint({
      newMint: mint,
      decimals: TOKEN_DECIMALS,
      mintAuthority: merchantClient.identity.address,
      freezeAuthority: null,
    })
    .sendTransaction();

const fundCustomerResult =
  await merchantClient.token.instructions
    .mintToATA({
      mint: mint.address,
      owner: customerClient.identity.address,
      mintAuthority: merchantClient.identity,
      amount: 100_000_000n,
      decimals: TOKEN_DECIMALS,
    })
    .sendTransaction();

const createMerchantAtaResult =
  await merchantClient.associatedToken.instructions
    .createAssociatedTokenIdempotent({
      mint: mint.address,
      owner: merchantClient.identity.address,
      tokenProgram: TOKEN_PROGRAM_ADDRESS,
    })
    .sendTransaction();

const [customerAta] = await findAssociatedTokenPda({
  mint: mint.address,
  owner: customerClient.identity.address,
  tokenProgram: TOKEN_PROGRAM_ADDRESS,
});

const [merchantAta] = await findAssociatedTokenPda({
  mint: mint.address,
  owner: merchantClient.identity.address,
  tokenProgram: TOKEN_PROGRAM_ADDRESS,
});

// Plan PDAs are derived from the merchant and plan ID.
// Using the current timestamp gives each test run a fresh ID.
const planId = BigInt(Date.now());

saveState(mint.address, planId);

console.log("Mint:", mint.address);
console.log("Plan ID:", planId.toString());

console.log("Customer:", customerClient.identity.address);
console.log("Customer ATA:", customerAta);

console.log("Merchant:", merchantClient.identity.address);
console.log("Merchant ATA:", merchantAta);

printSignature(
  "Create mint",
  createMintResult.context.signature,
);

printSignature(
  "Fund customer",
  fundCustomerResult.context.signature,
);

printSignature(
  "Create merchant ATA",
  createMerchantAtaResult.context.signature,
);

运行脚本。它会创建 state.json,其中包含生成的铸币信息和唯一套餐 ID。客户现在拥有 100 枚测试代币,商家也有一个准备接收订阅付款的空代币账户。

初始化客户的订阅权限

订阅权限针对特定的 (customer, mint) 组合创建。初始化前,客户的代币账户必须已经存在。

初始化交易会创建订阅权限 PDA,并批准其成为客户代币账户的受托方。之后,涉及该客户和铸币的每个固定委托、定期委托和订阅套餐都可以重复使用同一权限。客户需要签署此交易。

创建 src/01-init-authority.ts:

01-init-authority.ts

import {
  findAssociatedTokenPda,
  TOKEN_PROGRAM_ADDRESS,
} from "@solana-program/token";
import {
  fetchMaybeSubscriptionAuthority,
  findSubscriptionAuthorityPda,
} from "@solana/subscriptions";

import {
  createAppClient,
  CUSTOMER_KEYPAIR,
  loadState,
  printSignature,
} from "./config.js";

const customerClient =
  await createAppClient(CUSTOMER_KEYPAIR);

const { tokenMint } = loadState();

const [customerAta] = await findAssociatedTokenPda({
  mint: tokenMint,
  owner: customerClient.identity.address,
  tokenProgram: TOKEN_PROGRAM_ADDRESS,
});

const [subscriptionAuthorityPda] =
  await findSubscriptionAuthorityPda({
    user: customerClient.identity.address,
    tokenMint,
  });

const existing =
  await fetchMaybeSubscriptionAuthority(
    customerClient.rpc,
    subscriptionAuthorityPda,
  );

if (existing.exists) {
  console.log(
    "Subscription Authority already exists:",
    subscriptionAuthorityPda,
  );

  process.exit(0);
}

const result =
  await customerClient.subscriptions.instructions
    .initSubscriptionAuthority({
      tokenMint,
      tokenProgram: TOKEN_PROGRAM_ADDRESS,
      userAta: customerAta,
    })
    .sendTransaction();

console.log(
  "Subscription Authority:",
  subscriptionAuthorityPda,
);

printSignature(
  "Initialize authority",
  result.context.signature,
);

以客户身份运行脚本。脚本会先检查 PDA 是否已存在,因此可以安全地重复运行命令,并避免提交重复的初始化交易。确认后,客户代币账户的 Token Program 受托方将变为订阅权限。

创建商家订阅套餐

商家现在会发布可供客户接受的计费条款。套餐 PDA 根据商家地址和套餐 ID 派生。

套餐定义付款铸币、每周期最大金额、周期时长、获批收款方、允许的收款地址以及可选的链下元数据。

创建 src/02-create-plan.ts:

02-create-plan.ts

import {
  TOKEN_PROGRAM_ADDRESS,
} from "@solana-program/token";
import {
  fetchMaybePlan,
  findPlanPda,
} from "@solana/subscriptions";

import {
  createAppClient,
  loadState,
  MERCHANT_KEYPAIR,
  PLAN_AMOUNT,
  PLAN_PERIOD_HOURS,
  printSignature,
} from "./config.js";

const merchantClient =
  await createAppClient(MERCHANT_KEYPAIR);

const { tokenMint, planId } = loadState();

const [planPda] = await findPlanPda({
  owner: merchantClient.identity.address,
  planId,
});

const existing = await fetchMaybePlan(
  merchantClient.rpc,
  planPda,
);

if (existing.exists) {
  console.log("Plan already exists:", planPda);
  process.exit(0);
}

const result =
  await merchantClient.subscriptions.instructions
    .createPlan({
      planId,
      mint: tokenMint,

      // Five tokens per billing period.
      amount: PLAN_AMOUNT,

      // One-hour periods for this devnet test.
      periodHours: PLAN_PERIOD_HOURS,

      // No scheduled plan-wide end.
      endTs: 0n,

      // The owner of the receiving token account
      // must be included in this list.
      destinations: [
        merchantClient.identity.address,
      ],

      // An empty pullers list means only the merchant
      // can initiate collections.
      pullers: [],

      metadataUri:
        "https://example.com/helius-devnet-plan.json",

      tokenProgram: TOKEN_PROGRAM_ADDRESS,
    })
    .sendTransaction();

console.log("Plan PDA:", planPda);

printSignature(
  "Create plan",
  result.context.signature,
);

以商家身份运行脚本。其中有几个字段尤其重要:

amount

代币数值以基础单位表示。我们的铸币有六位小数,因此 5 枚代币 = 5,000,000 个基础单位。该套餐允许商家在每个计费周期内累计收取最多五枚代币。商家可以通过单笔交易收取全部金额,也可以拆分成多笔较小的交易。

periodHours

我们使用最小值,即一小时。这样,我们便可订阅、收取付款、等待一小时,然后演示额度重置,而无需等待一个月。生产环境套餐应使用产品实际计费条款规定的间隔。

destinations

收款地址允许列表包含的是钱包所有者,而非代币账户地址。收取付款时,程序会检查接收代币账户的所有者。由于我们的商家钱包位于收款地址列表中,因此其关联代币账户是有效的接收方。

pullers

商家始终可以从自己的套餐中收款。可以将其他计费服务钱包添加到 pullers。我们将此列表留空,以便只有商家密钥对能够收取付款。在我们的配置下,只有商家或套餐扣款方列表中的钱包才能提交有效的收款交易。 

让客户订阅

客户现在查看并接受商家的当前套餐条款。生成的订阅委托 PDA 根据套餐 PDA + 客户地址派生。 

TypeScript 插件会在 subscribe 期间获取当前套餐账户,因此我们无需手动传入预期金额、周期或创建时间戳。这些值会作为条款包含在交易中。创建 src/03-subscribe.ts:

03-subscribe.ts

import {
  fetchMaybeSubscriptionDelegation,
  findPlanPda,
  findSubscriptionDelegationPda,
} from "@solana/subscriptions";

import {
  createAppClient,
  CUSTOMER_KEYPAIR,
  loadState,
  MERCHANT_KEYPAIR,
  printSignature,
} from "./config.js";

const [customerClient, merchantClient] =
  await Promise.all([
    createAppClient(CUSTOMER_KEYPAIR),
    createAppClient(MERCHANT_KEYPAIR),
  ]);

const { tokenMint, planId } = loadState();

const [planPda] = await findPlanPda({
  owner: merchantClient.identity.address,
  planId,
});

const [subscriptionPda] =
  await findSubscriptionDelegationPda({
    planPda,
    subscriber: customerClient.identity.address,
  });

const existing =
  await fetchMaybeSubscriptionDelegation(
    customerClient.rpc,
    subscriptionPda,
  );

if (existing.exists) {
  console.log(
    "Customer is already subscribed:",
    subscriptionPda,
  );

  process.exit(0);
}

const result =
  await customerClient.subscriptions.instructions
    .subscribe({
      merchant: merchantClient.identity.address,
      planId,
      tokenMint,
    })
    .sendTransaction();

console.log("Subscription PDA:", subscriptionPda);

printSignature(
  "Subscribe",
  result.context.signature,
);

以客户身份运行脚本。客户签署此交易以接受新的支出授权。订阅账户会存储已接受的条款。 

套餐创建后,planId、owner、mint、amount、periodHours、createdAt 和 destinations 均不可变,也就是说这些条款无法更改。如果商家之后更新套餐中的可变字段,现有订阅者仍保留最初接受的条款,而新订阅者则会收到套餐的当前版本。

客户现在已有有效订阅,但尚未付款。

让商家收取付款

商家现在可以在当前计费周期内收取不超过套餐五枚代币额度的款项。计时器到期时,订阅程序不会自动执行此交易。商家后端、计费工作进程、cron 作业或获批扣款方仍需提交收款交易。创建 src/04-collect.ts:

04-collect.ts

import {
  findAssociatedTokenPda,
  TOKEN_PROGRAM_ADDRESS,
} from "@solana-program/token";
import {
  findPlanPda,
  findSubscriptionDelegationPda,
} from "@solana/subscriptions";

import {
  createAppClient,
  CUSTOMER_KEYPAIR,
  loadState,
  MERCHANT_KEYPAIR,
  PLAN_AMOUNT,
  printSignature,
} from "./config.js";

const [merchantClient, customerClient] =
  await Promise.all([
    createAppClient(MERCHANT_KEYPAIR),
    createAppClient(CUSTOMER_KEYPAIR),
  ]);

const { tokenMint, planId } = loadState();

const [merchantAta] =
  await findAssociatedTokenPda({
    mint: tokenMint,
    owner: merchantClient.identity.address,
    tokenProgram: TOKEN_PROGRAM_ADDRESS,
  });

const [customerAta] =
  await findAssociatedTokenPda({
    mint: tokenMint,
    owner: customerClient.identity.address,
    tokenProgram: TOKEN_PROGRAM_ADDRESS,
  });

const [planPda] = await findPlanPda({
  owner: merchantClient.identity.address,
  planId,
});

const [subscriptionPda] =
  await findSubscriptionDelegationPda({
    planPda,
    subscriber: customerClient.identity.address,
  });

const beforeCustomer =
  await merchantClient.rpc
    .getTokenAccountBalance(customerAta)
    .send();

const beforeMerchant =
  await merchantClient.rpc
    .getTokenAccountBalance(merchantAta)
    .send();

const result =
  await merchantClient.subscriptions.instructions
    .transferSubscription({
      caller: merchantClient.identity,

      // The customer whose balance is being charged.
      delegator: customerClient.identity.address,

      tokenMint,
      subscriptionPda,
      planPda,

      // Collect the full five-token period allowance.
      amount: PLAN_AMOUNT,

      receiverAta: merchantAta,
      tokenProgram: TOKEN_PROGRAM_ADDRESS,
    })
    .sendTransaction();

const afterCustomer =
  await merchantClient.rpc
    .getTokenAccountBalance(customerAta)
    .send();

const afterMerchant =
  await merchantClient.rpc
    .getTokenAccountBalance(merchantAta)
    .send();

console.log(
  "Customer:",
  beforeCustomer.value.uiAmountString,
  "->",
  afterCustomer.value.uiAmountString,
);

console.log(
  "Merchant:",
  beforeMerchant.value.uiAmountString,
  "->",
  afterMerchant.value.uiAmountString,
);

printSignature(
  "Collect payment",
  result.context.signature,
);

以商家身份运行脚本。执行期间,程序会验证:

  • 调用方是商家或获批扣款方
  • 订阅属于该客户和套餐
  • 订阅尚未到期
  • 请求金额不超过当前周期的剩余额度
  • 商家钱包是获准收款地址
  • 接收代币账户属于获准收款地址
  • 铸币和 Token Program 与已接受的套餐一致

由于此交易会收取全部五枚代币额度,在同一个一小时周期内再次运行应会失败。下一个周期开始后,周期额度将重置,商家可以再次运行收款脚本。在生产计费系统中,等效脚本通常只会在发票到期时运行。

客户取消订阅

客户无需商家配合即可取消订阅。标准取消流程不会立即关闭订阅委托账户,而是将订阅标记为即将结束,并分配一个 expiresAtTs。到期时间过去后,客户可以撤销订阅并关闭 PDA。创建 src/05-cancel.ts:

05-cancel.ts

import {
  fetchSubscriptionDelegation,
  findPlanPda,
  findSubscriptionDelegationPda,
} from "@solana/subscriptions";

import {
  createAppClient,
  CUSTOMER_KEYPAIR,
  loadState,
  MERCHANT_KEYPAIR,
  printSignature,
} from "./config.js";

const [customerClient, merchantClient] =
  await Promise.all([
    createAppClient(CUSTOMER_KEYPAIR),
    createAppClient(MERCHANT_KEYPAIR),
  ]);

const { planId } = loadState();

const [planPda] = await findPlanPda({
  owner: merchantClient.identity.address,
  planId,
});

const [subscriptionPda] =
  await findSubscriptionDelegationPda({
    planPda,
    subscriber: customerClient.identity.address,
  });

const result =
  await customerClient.subscriptions.instructions
    .cancelSubscription({
      planPda,
      subscriptionPda,
    })
    .sendTransaction();

const subscription =
  await fetchSubscriptionDelegation(
    customerClient.rpc,
    subscriptionPda,
  );

const expiresAt = new Date(
  Number(subscription.data.expiresAtTs) * 1_000,
);

console.log("Subscription PDA:", subscriptionPda);

console.log(
  "Cancellation effective at:",
  expiresAt.toISOString(),
);

printSignature(
  "Cancel subscription",
  result.context.signature,
);

以客户身份运行脚本。输出中包含取消生效的时间戳。标准 cancelSubscription 指令会在当前计费周期结束前实施宽限期。

从运营角度看,不应将取消视为当前周期的授权立即归零。在 expiresAtTs 之前,当前周期内任何仍可用的额度可能依然可以收取。取消生效后,商家不能开始新的计费周期。

这与常见的订阅行为一致:取消会阻止下次续订,而不会追溯性地终止客户已经进入的当前周期。

实际案例研究:Helius 链上订阅套餐

Helius 是发布前协助塑造订阅委托程序的合作伙伴之一。我们使用该程序支持 API 套餐自动以 USDC 续订。我们的目标是让使用加密货币付款的客户享受传统 SaaS 订阅的便利,同时让付款授权和结算完全在 Solana 上进行。

要启用自动付款,客户可以在 Helius 计费仪表板的“付款方式”部分添加 Solana 钱包。 

设置期间,客户会对官方 Solana 订阅程序进行一次性批准签名,并授权 Helius 从该钱包中以 USDC 收取订阅付款。

续订发票到期时,Helius 的计费系统会提交收款交易。客户无需打开付款链接、重新连接钱包或再次签署转账。订阅委托程序提供可重复使用的链上授权,而 Helius 继续管理发票周期、账户状态和产品权益。

客户最多可以连接三个钱包,但只有标记为默认付款方式的钱包会用于自动续订。如果默认钱包无法支付发票,Helius 不会尝试在多个钱包之间拆分费用,也不会转而使用其他已连接的钱包。

客户可以在仪表板中更改默认钱包。也可以移除钱包,但这需要签名,并会撤销该钱包的自动付款授权。移除唯一连接的钱包后,账户将恢复使用手动付款链接。

当然,链上授权并不能保证下张发票到期时钱包中有足够的 USDC。在这种情况下:

  • Helius 不会从钱包中收取该次续订费用。
  • 客户会通过电子邮件和仪表板收到付款链接。
  • 系统不会自动从钱包重试该发票。
  • 客户充值后,后续续订可以再次自动收款。

这种回退机制让计费状态保持简单明了。自动收款失败后,会转变为普通的未结发票,而不是一系列无限期的链上重试交易。

该实现展示了订阅委托程序如何融入生产计费技术栈的实际案例。该程序不会取代发票开具、账户管理、通知或权益执行。它取代的是过去需要客户授权每次续订,或需要中心化支付处理商存储并行使该授权的环节。

总结

Solana 订阅程序提供了一种直接在链上构建定期付款的标准化方式。通过结合订阅权限、商家套餐和委托代币转账,开发者无需依赖链下支付基础设施或自定义付款逻辑,即可实现订阅计费。

如果你正在 Solana 上构建定期付款,订阅程序是理想的起点。借助 TypeScript SDK 以及 Helius 的 RPC 和 API,集成链上订阅计费非常简单,让你可以专注于应用本身,而非底层付款机制。

更多资源

订阅 Helius

及时了解 Solana 开发的最新动态,并在我们发布新内容时收到更新

放大图片