新着:HeliusがLight Protocolを買収
Solanaのサブスクリプションと継続課金のバナー
ブログ/基礎

Solanaでサブスクリプションと継続課金を設定する方法

リサーチャーXのLostin
読了時間:19分

はじめに

継続課金は、インターネットコマースの基盤です。SaaS製品、APIプラットフォーム、給与システムをはじめ、数え切れないほど多くのビジネスが、予測可能なスケジュールで顧客に請求できる仕組みに依存しています。

これまでSolanaでこの体験を実現するには、多くのカスタムインフラを構築する必要がありました。新しいSolana Subscriptions Delegation Program(Solana Subscriptions & Allowances Programとも呼ばれます)は、継続課金とサブスクリプションプラン向けのオープンソースかつ監査済みのオンチェーンプリミティブにより、この課題を解決します。

ユーザーは、将来のトークン送金を一度承認できるようになりました(明示的なオンチェーン制約が適用されます)。その後は、承認済みの加盟店または回収者が、ユーザーの署名なしで支払いを開始できます。このプログラムはすでにメインネットとdevnetの両方で稼働しており、従来のSPL Token ProgramとToken-2022の両方のmintに対応しています。

各アプリケーションが独自に委任システムを設計して保護する代わりに、開発者は標準化されたプログラムを統合できるようになりました。以前は数週間のカスタム開発が必要だった決済フローを、数日で統合できます。

ローンチパートナーとして、Heliusはプログラムの改善に協力しました。現在は、オンチェーンでのAPIプランのサブスクリプション請求にこのプログラムを利用しています。これにより、Heliusのお客様はSolanaウォレットから直接、USDCでの継続課金を承認できます。

この記事では、プログラムの仕組みを確認し、スケーラブルな継続課金フローを構築します。Subscription Authorityのアーキテクチャ、個々の承認を表すPDA、そしてプログラムが対応する次の3つの課金モデルを取り上げます。

  • 固定利用枠
  • 継続的な委任
  • 加盟店が定義するサブスクリプションプラン

オンチェーンサブスクリプションが難しかった理由

Solanaのトークンプログラムは、すでに支出の委任に対応しています。トークンアカウントの所有者は、 **ApproveまたはApproveChecked**命令を使用し、指定した上限まで、別のアドレスが自分に代わってトークンを送金またはバーンすることを承認できます。

問題は、トークンアカウントには現在のdelegateと委任額をそれぞれ1つしか保存できないことです。新しいdelegateを承認すると、それまでのdelegateと利用枠が置き換えられます。これは、従来のToken ProgramとToken-2022のどちらで管理されるアカウントにも当てはまります。ユーザーが2つ目のサービスを承認すると、最初のサービスが置き換えられます。ネイティブの利用枠は単一の値であり、請求期間、利用枠のリセット、サブスクリプション状態といった概念は組み込まれていません。

支出関係ごとに個別のトークンアカウントを作成すれば回避できますが、ユーザーの残高が分散し、ウォレットとアプリケーションの体験が大幅に複雑になります。チームが独自のエスクローまたは委任プログラムを構築する方法もありますが、それでは共有プログラムによって解消するはずだった開発とセキュリティの負担が再び生じます。

不足していたのは、Token Programの単一のdelegateスロットを、複数の独立した承認に対応するプログラム可能なゲートウェイへ変える仕組みでした。

新しいSubscriptions Delegation Program

Subscriptions Delegation Programは、Solanaの基盤となるどちらのトークンプログラムも変更せずに、この不足していたプログラム可能なレイヤーを追加します。プログラムは、(ユーザー、トークンmint)の各ペアに対してSubscription Authorityを導出します。これは、その特定のmintに対するユーザーのトークンアカウントのdelegateとなるプログラム派生アドレス(PDA)です。一度初期化すると、そのユーザーとmintに関連付けられたすべてのサブスクリプションまたは委任で再利用されます。

初期化時に、ユーザーはSubscription Authorityに対して~1,840京、つまりu64::MAXの利用枠を承認するトランザクションに署名します。Subscription AuthorityはSubscriptions Delegation Programを介してのみ署名できるPDAであるため、これは安全です。PDAが独自にトークンの送金を決定することはできません。 

Token ProgramへのCPIに署名する前に、Subscriptions Delegation Programは有効な承認アカウントを読み込み、その制約を検証する必要があります。承認モデルに応じて、次の項目が確認されます。

  • 引き落としの開始を許可されたウォレットまたはサービス
  • mintと送金元トークンアカウント
  • 残りの総利用枠
  • 現在の請求期間で利用可能な最大額
  • 承認の開始時刻と有効期限
  • ユーザーが承諾したサブスクリプションプラン
  • 請求を開始する加盟店または承認済み回収者
  • プランで設定された送金先の制限

これらの確認に合格した後でのみ、プログラムはSubscription Authorityとして署名し、トークン送金を実行します。要求された送金に一致する有効な承認がない場合、トランザクションは失敗します。したがって、u64::MAXの利用枠は個別の加盟店ではなく、プログラムが管理するゲートウェイに属します。加盟店に付与されるのは、その加盟店固有の委任アカウントに記述された権限だけです。

トークンアカウントのdelegateは引き続き1つだけ(つまりSubscription Authority PDA)ですが、プログラムはその背後に複数の独立した承認PDAを配置できます。新しい承認を作成しても、既存の承認は上書きされません。各承認には独自の状態、上限、ライフサイクル、取り消し方法があります。

3つの承認モデル

プログラムは、固定委任、継続的な委任、サブスクリプションプランという3つの異なるモデルに対応しています。

固定委任

固定委任では、ウォレットまたはサービスに対し、定義された総額までの引き落としを承認します。送金のたびに残りの利用枠が減少し、必要に応じて指定したUnixタイムスタンプで委任を失効させることもできます。

このモデルは、上限付きのエージェント予算、1回限りの利用枠、期限付きの購入権限など、ユーザーが合計エクスポージャーの上限を定めたい場合に役立ちます。

継続的な委任

継続的な委任では、各期間に引き落とせる金額を指定します。次の期間が始まると、前の期間に引き落とされた金額がリセットされます。

期間ごとの金額、期間の長さ、開始時刻、全体の有効期限などの条件はユーザーが管理します。このため、継続的な委任は、給与、業務委託費、定期的な利用枠、支払者が上限を定義するカスタム請求契約など、継続的な関係に適しています。

サブスクリプションプラン

サブスクリプションプランでは、設定フローが逆になります。各ユーザーが独自の継続条件を定義する代わりに、加盟店が金額、請求期間、受け付けるmint、許可された回収者、任意の送金先制限を含む再利用可能なプランを公開します。

ユーザーがその条件を確認して承諾すると、プランに紐づくSubscription Delegation PDAが作成されます。承諾された請求条件はユーザーのサブスクリプションアカウントにコピーされるため、加盟店が既存の契約者に対する基本価格や請求期間を密かに変更することはできません。その後、プラン所有者または承認済みの引き落とし実行者は、請求期間ごとにプランの金額を上限として回収できます。

この違いは重要です。

  • 継続的な委任は、支払者が定義する承認です
  • サブスクリプションプランは、加盟店が公開し、支払者が明示的に参加を選択する条件です

3つのモデルはすべて同じSubscription Authorityを使用し、最終的には同じ基盤の委任アーキテクチャを通じて送金を実行します。

リファレンス実装

Subscriptions Delegation Programは、Moonsong LabsがSolana Foundationと提携して設計・開発し、Cantinaが監査しました。ソースコード、ドキュメント、クライアントはすべてSolana Foundationのsubscriptionsリポジトリで公開されています。

オンチェーンプログラムは、Pinocchioを使用してno_std Rustで記述されています。Pinocchioは、より低レベルで依存関係の少ない開発モデルを提供します。一般的なAnchor実装と比べて、コンピュート使用量とバイナリサイズをより細かく管理できます。

このリポジトリではCodamaも使用し、プログラムインターフェースから同期されたTypeScriptクライアントとRustクライアントを直接生成しています。TypeScriptアプリケーション向けの主要パッケージは次のとおりです。

コード
pnpm add @solana/subscriptions

devnetで簡単に利用できるエンドツーエンド実装を提供する公式デモWebアプリケーションもあります。プログラムはSPL TokenとToken-2022の両方に対応し、オンチェーンのライフサイクルイベントと送金イベントを出力します。アプリケーションやインデクサーは、公開されたIDLを使用してこれらをデコードできます。

ユースケース:開発者が構築できるもの

このプログラムは、実際の送金時点が決まる前に、ユーザーが将来の支払い範囲を定義できるあらゆる場面で役立ちます。ユーザーは一度署名して承認を設定します。その後、加盟店、サービス、受取人、エージェントが、その上限内で送金を開始できます。

各支出契約はそれぞれ独自のPDAで表されるため、同じSubscription Authorityの背後で複数のユースケースを共存させられます。ユーザーは同じUSDCトークンアカウントからAPIプランの料金を支払い、AIエージェントに週次予算を付与し、業務委託の継続報酬を承認できます。各承認が互いに干渉することはありません。

APIとインフラの継続課金

サブスクリプションプランは、SaaS製品、RPCプロバイダー、データプラットフォームなどのインフラサービスに適しています。プロバイダーは製品ティアごとに個別のオンチェーンプランを公開し、受け付けるトークンmint、価格、請求期間、承認済み回収者、許可された支払先を定義できます。カード決済代行業者を必要とせず、使い慣れたサブスクリプション体験を実現できます。

AIエージェント向けの上限付き支出

自律型エージェントには、人間の承認を求めずにAPI、コンピュート、データ、取引サービスなどのリソースへ支払う機能が必要です。しかし、資金の入ったウォレットをエージェントが無制限に操作できるようにすると、明らかなセキュリティリスクが生じます。

固定委任は、より安全な代替手段です。ユーザーは、特定のトークン量を上限としてエージェントによる支出を承認し、その承認に厳格な有効期限を設定できます。期限前に委任を取り消すこともできます。

継続的な委任では、同じモデルを拡張できます。エージェントにAPIリクエスト用の日次利用枠や週次運用予算を付与し、各期間の開始時に利用可能額をリセットできます。

オンチェーン給与と業務委託費の支払い

継続的な委任は、引き落とし方式の給与、継続報酬、助成金、業務委託契約に対応できます。支払者は、従業員または請負業者に対し、支払期間ごとに指定額を上限として回収することを承認します。承認では、期間ごとの金額、期間の長さ、開始時刻、最終有効期限を指定できます。支払期日になると、受取人または給与サービスが送金トランザクションを送信します。

これは従来のプッシュ方式の給与トランザクションとは異なります。支払日に支払者から資金が自動送金されるわけではありません。代わりに、受取人には各期間に合意済みの金額を引き落とす、範囲を厳密に限定した権限が付与されます。その結果、両当事者がオンチェーンで確認できる透明な支払契約が実現します。

送金と委任のアクティビティは、プログラムが出力するイベントを通じて追跡できるため、給与ダッシュボードや会計連携を構築できます。

ステーブルコインによる請求書回収

決済ゲートウェイやB2B請求プラットフォームは、このプログラムを利用し、繰り返し行う支払リクエストを、継続的かつ上限付きの承認に置き換えられます。顧客は、ゲートウェイに次の回収を承認できます。

  • 発注書に対する固定総額までの回収
  • 週次または月次の各請求期間における指定額までの回収
  • 各請求サイクルにおける標準化された加盟店プランの価格

同じアーキテクチャで、継続請求書、加盟店決済、使用量枠、法人支出ポリシーなど、支払者が無制限の制御権を手放さずに自動化を求めるワークフローを実現できます。

コンテンツとメディアのマイクロペイメント

継続的な委任は、パブリッシャー、ストリーミングプラットフォーム、リサーチプロバイダーなどのメディアサービスにおける従量課金モデルにも対応できます。

ユーザーは月間の支出枠を承認し、有料コンテンツへアクセスするたびに少額ずつ消費できます。たとえば、記事を開くと10 USDCの月間利用枠から0.10 USDCを消費し、プレミアムレポートや動画ストリームにはより高い価格を設定できます。プラットフォームは、コンテンツへのアクセス時に各支払いを送信します。

これにより、記事ごとのウォレット署名を求めたり、固定のオール・オア・ナッシング型サブスクリプションを強制したりせずに、「読んだ分だけ支払う」モデルを実現できます。パブリッシャーは個々のアクセスイベントを収益化するスケーラブルな手段を得られます。一方、ユーザーは予測可能な支出上限を維持し、いつでも承認を取り消せます。

Heliusを使ってDevnetにサブスクリプションフローを構築する

このチュートリアルでは、次の手順で加盟店サブスクリプションのライフサイクルを構築します。

  • 顧客が自身のトークンアカウント用のSubscription Authorityを初期化する
  • 加盟店がサブスクリプションプランを公開する
  • 顧客がプランを承諾する
  • 加盟店が支払いを回収する
  • 顧客がサブスクリプションを解約する

例では、執筆時点で公開されている最新のTypeScriptクライアントである@solana/subscriptions@0.4.0を使用します。パッケージのバージョンを固定すると、後でSDKが変更されてもチュートリアルを安定して利用できます。

次の2つのアクターをモデル化します。

役割責任
顧客トークンを所有し、Subscription Authorityを初期化し、プランを契約および解約します
加盟店プランを公開し、回収トランザクションを送信します

トークンには、小数点以下6桁のカスタムdevnet mintを作成し、顧客へ100テストトークンを発行します。これにより、別のステーブルコインfaucetに依存せず、USDCのような小数点以下6桁のトークンと同じ基本単位の計算を維持できます。

ローカルのJSONキーペアを使用すると、コマンドラインから簡単にフローを実行できます。実際のアプリケーションでは通常、顧客のトランザクションはブラウザまたはモバイルウォレットで署名し、加盟店側の回収者は安全に管理されたバックエンド署名者を使用します。

前提条件

このチュートリアルでは、次のものが必要です。

  • 最新のNode.jsリリース
  • pnpm
  • Solana CLI
  • 有料のHelius APIキー
  • 両方のテストウォレット用のDevnet 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"]
}

加盟店と顧客のウォレットを作成する

各アクターのキーペアを1つずつ生成します。

コード
solana-keygen new \
  --no-bip39-passphrase \
  --outfile keys/merchant.json

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

これらのキーペアはdevnetチュートリアル専用です。本番環境のキーをリポジトリへコミットしたり、本番環境の加盟店署名者を暗号化されていない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

両方のウォレットに、トランザクション手数料の支払いとそれぞれのPDAアカウントの作成に必要なSOLが必要です。

コード
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は、ダッシュボードから利用できるdevnet faucetも提供しています。Helius faucetまたはHelius RPCからDevnet 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は、読み込んだキーペアをクライアントIDと手数料支払者の両方に設定します。Helius RPCプラグインはトランザクションの計画、送信、確認を処理し、tokenプラグインとsubscriptionsプラグインはそれぞれのアカウントおよび命令ヘルパーを追加します。各スクリプトは、生成されたトランザクションのOrb(Heliusのブロックエクスプローラー)URLを出力します。

Devnetテストmintを作成する

Subscription Authorityを初期化する前に、顧客はプランのmintに対応する既存のトークンアカウントを持っている必要があります。小数点以下6桁のテストmintを作成し、顧客に100トークンを発行して、加盟店用の空の送金先トークンアカウントを作成します。

Solana Kitのtokenプラグインは、mint、関連トークンアカウントの作成、トークン発行用のヘルパーを提供します。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,
);

スクリプトを実行します。生成されたmintに関する情報と一意のプランIDを含むstate.jsonが作成されます。これで顧客は100テストトークンを所有し、加盟店にはサブスクリプション料金を受け取る準備が整った空のトークンアカウントがあります。

顧客のSubscription Authorityを初期化する

Subscription Authorityは、特定の(customer, mint)ペアに対して作成されます。初期化前に、顧客のトークンアカウントがすでに存在している必要があります。

初期化トランザクションはSubscription Authority PDAを作成し、顧客のトークンアカウントのdelegateとして承認します。その後、同じauthorityを、その顧客とmintに関連するすべての固定委任、継続的な委任、サブスクリプションプランで再利用できます。このトランザクションには顧客が署名します。

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がすでに存在するかを確認します。これにより、コマンドを安全に再実行でき、重複する初期化トランザクションの送信を回避できます。確認後、顧客のトークンアカウントでは、Subscription AuthorityがToken Programのdelegateになります。

加盟店のサブスクリプションプランを作成する

次に加盟店は、顧客が承諾できる請求条件を公開します。Plan PDAは、加盟店アドレスとプランIDから導出されます。

プランでは、支払いに使用するmint、期間ごとの最大額、期間の長さ、承認済み回収者、許可された送金先、任意のオフチェーンメタデータを定義します。

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

トークンの値は基本単位で表します。このmintは小数点以下6桁なので、5トークン = 5,000,000基本単位です。このプランでは、加盟店は各請求期間に累計で最大5トークンを回収できます。加盟店は全額を1つのトランザクションで回収することも、複数の小さなトランザクションに分割することもできます。

periodHours

最小値である1時間を使用します。これにより、契約して支払いを回収し、1時間待ってから、1か月待たずに利用枠がリセットされることを確認できます。本番環境のプランでは、製品の実際の請求条件で定義された間隔を使用します。

destinations

送金先の許可リストには、トークンアカウントのアドレスではなく、ウォレット所有者を登録します。支払いの回収時に、プログラムは受取側トークンアカウントの所有者を確認します。加盟店のウォレットが送金先に含まれているため、その関連トークンアカウントは有効な受取先です。

pullers

加盟店は常に、自身のプランから回収できます。追加の請求サービス用ウォレットをpullersに追加できます。このリストは空のままにし、加盟店のキーペアだけが支払いを回収できるようにします。この設定では、加盟店またはプランのpullerリストに含まれるウォレットだけが、有効な回収トランザクションを送信できます。 

顧客が契約する

次に顧客は、加盟店の現在のプラン条件を確認して承諾します。作成されるSubscription Delegation 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は不変となり、条件を変更できません。加盟店が後でプランの変更可能なフィールドを更新しても、既存の契約者には当初承諾した条件が維持されます。一方、新規契約者にはプランの現行バージョンが適用されます。

これで顧客のサブスクリプションは有効になりましたが、支払いはまだ行われていません。

加盟店が支払いを回収する

加盟店は現在の請求期間中、プランの5トークンの利用枠まで回収できます。Subscriptions Programは、タイマーが期限を迎えてもこのトランザクションを自動実行しません。加盟店のバックエンド、請求ワーカー、cronジョブ、または承認済みpullerが、回収トランザクションを送信する必要があります。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,
);

加盟店としてスクリプトを実行します。実行中に、プログラムは次の項目を検証します。

  • 呼び出し元が加盟店または承認済みpullerであること
  • サブスクリプションが顧客とプランに属していること
  • サブスクリプションが失効していないこと
  • 要求額が現在の期間の残り利用枠内であること
  • 加盟店ウォレットが承認済みの送金先であること
  • 受取側トークンアカウントが承認済みの送金先に属していること
  • mintとToken Programが承諾済みプランと一致すること

このトランザクションでは5トークンの利用枠を全額回収するため、同じ1時間の期間内に再度実行すると失敗するはずです。次の期間が始まると期間利用枠がリセットされ、加盟店は回収スクリプトを再度実行できます。本番の請求システムでは通常、このスクリプトに相当する処理は請求書の支払期日が来たときにだけ実行されます。

顧客がサブスクリプションを解約する

顧客は加盟店の協力なしでサブスクリプションを解約できます。標準の解約フローでは、Subscription Delegationアカウントをすぐには閉じません。代わりに、サブスクリプションを終了予定としてマークし、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は、メインネットでのリリース前にSubscriptions Delegation Programの設計に協力したローンチパートナーの1社です。このプログラムを使用して、APIプランのUSDCによる自動更新に対応しています。暗号資産で支払うお客様に従来のSaaSサブスクリプションの利便性を提供しながら、支払いの承認と決済をすべてSolana上で完結させることが目標です。

自動支払いを有効にするには、顧客がHelius請求ダッシュボードのPayment MethodセクションからSolanaウォレットを追加します。 

設定時に、顧客は公式のSolana Subscriptions Programに対する1回限りの承認に署名し、そのウォレットからUSDCでサブスクリプション料金を回収する権限をHeliusに付与します。

更新請求書の支払期日になると、Heliusの請求システムが回収トランザクションを送信します。顧客は支払いリンクを開いたり、ウォレットを再接続したり、別の送金に署名したりする必要はありません。Subscriptions Delegation Programが再利用可能なオンチェーン承認を提供し、Heliusは引き続き請求書のスケジュール、アカウント状態、製品利用権を管理します。

顧客は最大3つのウォレットを接続できますが、自動更新に使用されるのはデフォルトの支払い方法として指定されたウォレットだけです。デフォルトウォレットで請求書を支払えない場合でも、Heliusが複数のウォレットに請求を分割したり、接続された別のウォレットへ切り替えたりすることはありません。

顧客はダッシュボードからデフォルトウォレットを変更できます。ウォレットの削除も可能です。削除には署名が必要で、そのウォレットの自動支払い承認が取り消されます。接続された唯一のウォレットを削除すると、アカウントは手動の支払いリンクに戻ります。

もちろん、オンチェーン承認があっても、次回の請求書の支払期日にウォレットへ十分なUSDCがあるとは限りません。この場合は次のように処理されます。

  • Heliusはその更新についてウォレットへ請求しません。
  • 顧客はメールとダッシュボードで支払いリンクを受け取ります。
  • 請求書に対してウォレットへの再試行は自動的に行われません。
  • 顧客が残高を補充すると、それ以降の更新は再び自動回収できます。

このフォールバックにより、請求状態をシンプルに保てます。自動回収の失敗は、オンチェーンで無期限に再試行される一連のトランザクションではなく、通常の未払い請求書として扱われます。

この実装は、Subscriptions Delegation Programを本番環境の請求スタックへ組み込む方法の実践的な例です。このプログラムは、請求書発行、アカウント管理、通知、利用権の適用に代わるものではありません。これまで顧客が更新のたびに承認するか、中央集権型の決済代行業者が承認を保存して行使する必要があった部分を置き換えます。

まとめ

Solana Subscriptionsプログラムは、継続課金を直接オンチェーンで構築するための標準化された手段を提供します。Subscription Authority、加盟店プラン、委任されたトークン送金を組み合わせることで、開発者はオフチェーン決済インフラやカスタムの決済ロジックに依存せずにサブスクリプション課金を実装できます。

Solanaで継続課金を構築するなら、Subscriptionsプログラムが最適な出発点です。TypeScript SDKとHeliusのRPCおよびAPIを使用すれば、オンチェーンサブスクリプション課金を簡単に統合できます。基盤となる決済メカニズムではなく、アプリケーションの開発に集中できます。

関連リソース

Heliusを購読

Solana開発の最新情報や新しい記事の公開通知を受け取れます

拡大画像