НОВОЕ: Helius приобретает Light Protocol
Баннер о подписках и регулярных платежах в Solana
Блог/Основы

Настройка подписок и регулярных платежей в Solana

ИсследовательLostin в X
Чтение: 19 мин
Содержание

Введение

Регулярное выставление счетов — основа интернет-коммерции. SaaS-продукты, API-платформы, системы расчёта заработной платы и множество других компаний зависят от возможности взимать с клиентов плату по предсказуемому графику.

До сих пор для реализации такого механизма в Solana приходилось создавать большой объём специализированной инфраструктуры. Новая программа Solana Subscriptions Delegation Program (также известная как Solana Subscriptions & Allowances Program) решает эту проблему с помощью открытого, прошедшего аудит ончейн-примитива для регулярных платежей и планов подписки.

Теперь пользователь может один раз разрешить будущие переводы токенов с учётом явно заданных ончейн-ограничений. После этого одобренный продавец или сборщик платежей может инициировать платежи без подписи пользователя. Программа уже работает в mainnet и devnet и поддерживает минты как исходной SPL Token Program, так и Token-2022.

Теперь разработчики могут интегрировать стандартизированную программу вместо того, чтобы каждое приложение проектировало и защищало собственную систему делегирования. Платёжные сценарии, на специализированную разработку которых раньше уходили недели, теперь можно интегрировать за несколько дней.

Helius выступила партнёром по запуску и помогла усовершенствовать программу. Теперь она обеспечивает он-чейн-оплату подписок на планы API. Клиенты Helius могут разрешать регулярные платежи в USDC непосредственно из своих кошельков Solana.

В этой статье мы рассмотрим принцип работы программы и с её помощью создадим масштабируемые сценарии регулярных платежей. Мы разберём архитектуру Subscription Authority, PDA для отдельных разрешений и три поддерживаемые программой модели выставления счетов:

  • Фиксированные лимиты расходов
  • Регулярные делегирования
  • Планы подписки, заданные продавцом

Почему ончейн-подписки было сложно реализовать

Токен-программы Solana уже поддерживают делегирование расходов. Владелец токен-аккаунта может использовать инструкции Approve или ApproveChecked, чтобы разрешить другому адресу переводить или сжигать токены от его имени в пределах указанного лимита.

Проблема в том, что токен-аккаунт хранит только одного текущего делегата и одну делегированную сумму. При одобрении нового делегата прежний делегат и его лимит заменяются. Это относится к аккаунтам под управлением как исходной Token Program, так и Token-2022. Когда пользователь одобряет второй сервис, он заменяет первый. Нативный лимит — это одно значение без встроенного понятия расчётных периодов, сброса лимитов или состояния подписки.

Приложения могли бы обойти это ограничение, создавая отдельный токен-аккаунт для каждого отношения расходов. Однако это фрагментирует баланс пользователя и значительно усложняет работу с кошельком и приложением. Другой вариант — создать собственную программу эскроу или делегирования, но тогда снова возникает нагрузка по разработке и обеспечению безопасности, которую и должна устранить общая программа.

Не хватало механизма, который превратил бы единственный слот делегата Token Program в программируемый шлюз для множества независимых разрешений.

Знакомство с новой Subscriptions Delegation Program

Subscriptions Delegation Program добавляет недостающий программируемый уровень, не изменяя базовые токен-программы Solana. Для каждой пары (пользователь, минт токена) программа выводит Subscription Authority — производный от программы адрес (PDA), который становится делегатом токен-аккаунта пользователя для данного минта. Он инициализируется один раз, а затем повторно используется каждой подпиской или делегированием, связанными с этим пользователем и минтом.

Во время инициализации пользователь подписывает транзакцию, которая одобряет для Subscription Authority лимит в ~18,4 квинтиллиона, или u64::MAX. Это безопасно, поскольку Subscription Authority — это PDA, который может подписывать только через Subscriptions Delegation Program. Он не может самостоятельно принять решение о переводе токенов. 

Прежде чем подписать CPI-вызов в Token Program, Subscriptions Delegation Program должна загрузить действительный аккаунт разрешения и проверить его ограничения. В зависимости от модели разрешения проверка может включать:

  • Кошелёк или сервис, которому разрешено инициировать списание
  • Минт и исходный токен-аккаунт
  • Оставшийся общий лимит
  • Максимальную сумму, доступную в текущем расчётном периоде
  • Время начала и окончания действия разрешения
  • Планы подписки, принятые пользователем
  • Продавца или одобренного сборщика, инициирующего списание
  • Ограничения на адрес назначения, заданные планом

Только после успешного прохождения этих проверок программа подписывает транзакцию как Subscription Authority и выполняет перевод токенов. Если активное разрешение не соответствует запрошенному переводу, транзакция завершается ошибкой. Поэтому лимит u64::MAX относится к шлюзу под управлением программы, а не к отдельному продавцу. Продавец получает только полномочия, описанные в его конкретном аккаунте делегирования.

У токен-аккаунта по-прежнему ровно один делегат — PDA Subscription Authority, — но программа может разместить за ним множество независимых PDA разрешений. Создание нового разрешения не перезаписывает существующие. У каждого разрешения есть собственное состояние, лимиты, жизненный цикл и способ отзыва.

Три модели разрешений

Программа поддерживает три отдельные модели: фиксированные делегирования, регулярные делегирования и планы подписки.

Фиксированные делегирования

Фиксированное делегирование разрешает кошельку или сервису списать не более заданной общей суммы. Каждый перевод уменьшает оставшийся лимит. При необходимости можно задать срок действия делегирования в виде временной метки Unix.

Эта модель подходит для ограниченных бюджетов агентов, разовых лимитов, временных полномочий на покупки и других случаев, когда пользователь хочет установить максимальную общую сумму риска.

Регулярные делегирования

Регулярное делегирование задаёт сумму, которую можно списать в каждом периоде. В начале следующего периода сумма, списанная в предыдущем, сбрасывается.

Пользователь управляет условиями, включая сумму за период, продолжительность периода, время начала и общий срок действия. Поэтому регулярные делегирования подходят для постоянных отношений: выплаты зарплаты, оплата услуг подрядчиков, регулярные лимиты или индивидуальные соглашения о выставлении счетов, в которых плательщик задаёт ограничения.

Планы подписки

Планы подписки меняют порядок настройки. Вместо того чтобы каждый пользователь определял собственные регулярные условия, продавец публикует многократно используемый план с суммой, расчётным периодом, принимаемым минтом, разрешёнными сборщиками и необязательными ограничениями на адреса назначения.

Пользователь проверяет и принимает эти условия, создавая связанный с планом PDA Subscription Delegation. Принятые условия оплаты копируются в аккаунт подписки пользователя. Поэтому продавец не может незаметно изменить основную цену или расчётный период для существующего подписчика. Затем владелец плана или одобренный сборщик может взимать не более указанной в плане суммы за каждый расчётный период.

Это важное различие:

  • Регулярные делегирования — это разрешения, заданные плательщиком
  • Планы подписки — это опубликованные продавцом условия, которые плательщик явно принимает

Все три модели используют одну Subscription Authority и в конечном счёте выполняют переводы через одну базовую архитектуру делегирования.

Эталонная реализация

Subscriptions Delegation Program спроектировала и создала Moonsong Labs в партнёрстве с Solana Foundation. Аудит выполнила Cantina. Исходный код, документация и клиенты доступны в репозитории подписок Solana Foundation.

Ончейн-программа написана на Rust no_std с использованием Pinocchio. Pinocchio предлагает низкоуровневую модель разработки с минимальным числом зависимостей. По сравнению с типичной реализацией на Anchor это позволяет точнее управлять вычислительными ресурсами и размером бинарного файла.

В репозитории также используется Codama для генерации синхронизированных клиентов TypeScript и Rust непосредственно из интерфейса программы. Основной пакет для приложений TypeScript:

Код
pnpm add @solana/subscriptions

Также доступно официальное демонстрационное веб-приложение со сквозной реализацией, которую легко использовать в devnet. Программа поддерживает SPL Token и Token-2022 и создаёт ончейн-события жизненного цикла и переводов. Приложения и индексаторы могут декодировать их с помощью опубликованного IDL.

Сценарии использования: что могут создать разработчики

Программа полезна во всех случаях, когда пользователь может задать границы будущего платежа, ещё не зная точного времени перевода. Пользователь один раз подписывает транзакцию, чтобы создать разрешение. После этого продавец, сервис, получатель или агент может инициировать переводы в заданных пределах.

Поскольку каждое соглашение о расходах представлено собственным PDA, эти сценарии могут сосуществовать за одной Subscription Authority. Пользователь может оплачивать план API, выделять еженедельный бюджет AI-агенту и разрешить регулярную оплату услуг подрядчика с одного токен-аккаунта USDC, причём разрешения не будут мешать друг другу.

Регулярная оплата API и инфраструктуры

Планы подписки хорошо подходят для SaaS-продуктов, провайдеров RPC, платформ данных и других инфраструктурных сервисов. Провайдер может опубликовать отдельный ончейн-план для каждого уровня продукта, указав принимаемый минт токена, цену, расчётный период, одобренных сборщиков и разрешённые адреса назначения платежей. Это создаёт привычный механизм подписки без процессинга банковских карт.

Ограниченные расходы AI-агентов

Автономным агентам нужна возможность оплачивать API, вычислительные ресурсы, данные, торговые сервисы и другие ресурсы без подтверждения человеком. Однако неограниченный контроль агента над пополненным кошельком создаёт очевидный риск безопасности.

Фиксированные делегирования предлагают более безопасную альтернативу. Пользователь может разрешить агенту потратить не более определённого количества токенов и установить строгий срок действия разрешения. Пользователь также может отозвать делегирование до истечения этого срока.

Регулярные делегирования расширяют эту модель. Агент может получать дневной лимит на запросы к API или еженедельный операционный бюджет, а доступная сумма будет сбрасываться в начале каждого периода.

Ончейн-выплаты зарплаты и оплата подрядчиков

Регулярные делегирования могут поддерживать списание зарплаты, регулярные вознаграждения, гранты и соглашения с подрядчиками. Плательщик разрешает сотруднику или подрядчику получать не более заданной суммы за каждый платёжный период. В разрешении можно указать сумму за период, продолжительность периода, время начала и окончательный срок действия. Когда наступает срок платежа, получатель или сервис расчёта зарплаты отправляет транзакцию перевода.

Это отличается от традиционной выплаты зарплаты на основе отправки средств. Плательщик не переводит средства автоматически в день выплаты. Вместо этого получатель получает строго ограниченное право списывать согласованную сумму в каждом периоде. В результате возникает прозрачное платёжное соглашение, которое обе стороны могут проверить ончейн.

Переводы и действия делегирования можно отслеживать по событиям программы. Это позволяет создавать панели мониторинга зарплат и интеграции с бухгалтерскими системами.

Сбор платежей по счетам в стейблкоинах

Платёжные шлюзы и B2B-платформы выставления счетов могут заменить повторные запросы платежей постоянными ограниченными разрешениями. Клиент может разрешить шлюзу списывать:

  • Не более фиксированной общей суммы по заказу на покупку
  • Не более заданной суммы в каждом недельном или месячном периоде выставления счетов
  • Стоимость стандартизированного плана продавца в каждом расчётном цикле

Та же архитектура может обеспечивать регулярное выставление счетов, эквайринг, лимиты использования, корпоративные политики расходов и другие процессы, в которых плательщику нужна автоматизация без отказа от контроля над неограниченными расходами.

Микроплатежи за контент и медиаматериалы

Регулярные делегирования также могут поддерживать модели оплаты по факту использования для издателей, стриминговых платформ, поставщиков исследований и других медиасервисов.

Пользователь может установить месячный лимит расходов, который постепенно уменьшается при доступе к платному контенту. Например, открытие статьи может списать 0,10 USDC из месячного лимита в 10 USDC, а премиальные отчёты или видеотрансляции могут стоить дороже. Платформа отправляет каждый платёж при доступе к контенту.

Это позволяет реализовать модель «платите за прочитанное» без подписи кошельком для каждой статьи и без обязательной фиксированной подписки по принципу «всё или ничего». Издатели получают масштабируемый способ монетизации отдельных обращений к контенту, а пользователи сохраняют предсказуемый лимит расходов и могут в любой момент отозвать разрешение.

Создание сценария подписки в devnet с Helius

В этой части руководства мы реализуем жизненный цикл подписки продавца в несколько этапов:

  • Клиент инициализирует Subscription Authority для своего токен-аккаунта
  • Продавец публикует план подписки
  • Клиент принимает план
  • Продавец получает платёж
  • Клиент отменяет подписку

В примерах используется @solana/subscriptions@0.4.0 — последняя опубликованная версия клиента TypeScript на момент написания статьи. Фиксация версий пакетов обеспечивает стабильность руководства, даже если SDK изменится позднее.

Мы смоделируем двух участников:

РольОтветственность
КлиентВладеет токенами, инициализирует Subscription Authority, оформляет и отменяет подписку на план
ПродавецПубликует план и отправляет транзакции сбора платежей

Мы создадим пользовательский devnet-минт с шестью знаками после запятой и выпустим для клиента 100 тестовых токенов. Так мы не будем зависеть от отдельного крана стейблкоинов, но сохраним ту же арифметику базовых единиц, которая используется для токенов с шестью знаками после запятой, например USDC.

Локальные пары ключей JSON позволяют легко запускать сценарий из командной строки. В реальном приложении клиентские транзакции обычно подписываются через браузерный или мобильный кошелёк, а сборщик продавца использует безопасно управляемый серверный подписант.

Предварительные требования

Для этого руководства вам потребуются:

  • Актуальная версия Node.js
  • pnpm
  • Solana CLI
  • Платный ключ Helius API
  • SOL в devnet для обоих тестовых кошельков

Настройка проекта

Создайте новый проект с каталогом 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

Эти пары ключей предназначены только для руководства по 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

Обоим кошелькам нужны 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 также предоставляет кран devnet через свою панель управления. Для запроса Devnet SOL через кран Helius или Helius RPC требуется платный план 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 обрабатывает планирование, отправку и подтверждение транзакций, а плагины токенов и подписок добавляют вспомогательные функции для соответствующих аккаунтов и инструкций. Каждый скрипт выводит URL в Orb, обозревателе блоков Helius, для итоговой транзакции.

Создание тестового минта в devnet

Перед инициализацией Subscription Authority у клиента должен существовать токен-аккаунт для минта плана. Мы создадим тестовый минт с шестью знаками после запятой, выпустим 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 тестовыми токенами, а у продавца есть пустой токен-аккаунт для получения платежей по подписке.

Инициализация Subscription Authority клиента

Subscription Authority создаётся для конкретной пары (customer, mint). Перед инициализацией токен-аккаунт клиента уже должен существовать.

Транзакция инициализации создаёт PDA Subscription Authority и одобряет его как делегата токен-аккаунта клиента. Затем ту же authority можно использовать повторно для каждого фиксированного делегирования, регулярного делегирования и плана подписки, связанных с этим клиентом и минтом. Клиент подписывает эту транзакцию.

Создайте 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 для токен-аккаунта клиента.

Создание плана подписки продавца

Теперь продавец публикует условия оплаты, которые могут принять клиенты. 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 Subscription Delegation выводится из 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 становятся неизменяемыми, поэтому условия нельзя изменить. Если позднее продавец обновит изменяемые поля плана, существующие подписчики сохранят первоначально принятые условия, а новые подписчики получат текущую версию плана.

Теперь у клиента есть активная подписка, но платёж ещё не выполнен.

Получение платежа продавцом

Теперь продавец может получить не более предусмотренного планом лимита в пять токенов за текущий расчётный период. Subscriptions Program не выполняет эту транзакцию автоматически по истечении таймера. Сервер продавца, обработчик биллинга, задание 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 соответствуют принятому плану

Поскольку эта транзакция полностью использует лимит в пять токенов, повторный запуск в том же часовом периоде должен завершиться ошибкой. В начале следующего периода лимит сбросится, и продавец сможет снова запустить скрипт сбора. В рабочей системе биллинга эквивалент этого скрипта обычно запускается только при наступлении срока оплаты счёта.

Отмена подписки клиентом

Клиент может отменить подписку без содействия продавца. Стандартный сценарий отмены не закрывает аккаунт 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 до её выпуска в mainnet. Мы используем программу для автоматического продления наших планов API за USDC. Наша цель — предоставить клиентам, которые платят криптовалютой, удобство обычной SaaS-подписки, сохраняя разрешение платежей и расчёты полностью в Solana.

Чтобы включить автоматические платежи, клиент добавляет кошелёк Solana в разделе «Способ оплаты» панели биллинга Helius. 

Во время настройки клиент один раз подписывает одобрение для официальной Solana Subscriptions Program и разрешает Helius собирать платежи за подписку в USDC с этого кошелька.

Когда наступает срок оплаты счёта за продление, система биллинга Helius отправляет транзакцию сбора. Клиенту не нужно открывать платёжную ссылку, повторно подключать кошелёк или подписывать ещё один перевод. Subscriptions Delegation Program предоставляет многократно используемое ончейн-разрешение, а Helius продолжает управлять графиком счетов, состоянием аккаунта и правами доступа к продукту.

Клиент может подключить до трёх кошельков, но для автоматических продлений используется только кошелёк, выбранный способом оплаты по умолчанию. Helius не пытается разделить списание между кошельками или перейти к другому подключённому кошельку, если на кошельке по умолчанию недостаточно средств для оплаты счёта.

Клиенты могут изменить кошелёк по умолчанию в панели управления. Они также могут удалить кошелёк. Для этого требуется подпись, которая отзывает его разрешение на автоматические платежи. При удалении единственного подключённого кошелька аккаунт возвращается к ручной оплате по ссылкам.

Разумеется, ончейн-разрешение не гарантирует, что на кошельке будет достаточно USDC к моменту оплаты следующего счёта. В таком случае:

  • Helius не списывает средства с кошелька за это продление.
  • Клиент получает платёжную ссылку по электронной почте и в панели управления.
  • Попытка оплаты счёта с кошелька не повторяется автоматически.
  • После пополнения клиента последующие продления снова можно оплачивать автоматически.

Этот резервный сценарий упрощает состояние биллинга. Неудачный автоматический сбор превращается в обычный открытый счёт, а не в бесконечную последовательность повторных ончейн-транзакций.

Эта реализация служит практическим примером интеграции Subscriptions Delegation Program в рабочую систему биллинга. Программа не заменяет выставление счетов, управление аккаунтами, уведомления или контроль прав доступа. Она заменяет ту часть процесса, где раньше клиенту приходилось одобрять каждое продление либо централизованный платёжный процессор должен был хранить и использовать такое разрешение.

Заключение

Solana Subscriptions предоставляет стандартизированный способ создания регулярных платежей непосредственно ончейн. Сочетая subscription authorities, планы продавцов и делегированные переводы токенов, разработчики могут реализовать оплату подписок без офчейн-платёжной инфраструктуры и специализированной платёжной логики.

Если вы создаёте регулярные платежи в Solana, Subscriptions — естественная отправная точка. Благодаря SDK TypeScript, RPC и API Helius интеграция ончейн-оплаты подписок не вызывает сложностей. Вы можете сосредоточиться на своём приложении, а не на внутренних механизмах платежей.

Дополнительные ресурсы

Подпишитесь на Helius

Следите за новостями разработки на Solana и получайте уведомления о наших публикациях

Увеличенное изображение