NEU: Helius übernimmt Light Protocol
Banner zu Solana-Abonnements und wiederkehrenden Zahlungen
Blog/Grundlagen

Abonnements und wiederkehrende Zahlungen auf Solana einrichten

ResearcherLostin auf X
19 Min. Lesezeit

Einführung

Wiederkehrende Abrechnungen sind ein grundlegender Bestandteil des Onlinehandels. SaaS-Produkte, API-Plattformen, Lohnabrechnungssysteme und unzählige andere Unternehmen müssen ihre Kunden nach einem planbaren Zeitplan belasten können.

Bisher war dafür auf Solana viel individuelle Infrastruktur nötig. Das neue Solana Subscriptions Delegation Program, auch bekannt als Solana Subscriptions & Allowances Program, löst dieses Problem mit einem quelloffenen, geprüften Onchain-Primitiv für wiederkehrende Zahlungen und Abonnementpläne.

Ein Nutzer kann künftige Token-Transfers jetzt einmalig autorisieren, vorbehaltlich expliziter Onchain-Beschränkungen. Anschließend kann ein genehmigter Händler oder Einzugsdienst Zahlungen initiieren, ohne dass der Nutzer signieren muss. Das Programm ist bereits im Mainnet und Devnet aktiv und unterstützt Mints aus dem ursprünglichen SPL Token Program sowie Token-2022.

Statt dass jede Anwendung ein eigenes Delegationssystem entwickeln und absichern muss, können Entwickler jetzt ein standardisiertes Programm integrieren. Zahlungsabläufe, für die zuvor wochenlange individuelle Entwicklung nötig war, lassen sich nun innerhalb weniger Tage integrieren.

Als Launch-Partner hat Helius dabei geholfen, das Programm zu optimieren. Heute nutzen wir es für die Abrechnung unserer Onchain-Abonnements für API-Pläne. So können Helius-Kunden wiederkehrende Zahlungen in USDC direkt aus ihren Solana-Wallets autorisieren.

In diesem Artikel untersuchen wir die Funktionsweise des Programms und erstellen damit skalierbare Abläufe für wiederkehrende Zahlungen. Wir behandeln die Architektur der Subscription Authority, die PDAs für einzelne Autorisierungen und die drei vom Programm unterstützten Abrechnungsmodelle:

  • Feste Ausgabenlimits
  • Wiederkehrende Delegationen
  • Vom Händler definierte Abonnementpläne

Warum Onchain-Abonnements bisher schwierig waren

Die Token-Programme von Solana unterstützen bereits delegierte Ausgaben. Der Inhaber eines Token-Kontos kann mit den Anweisungen Approve oder ApproveChecked eine andere Adresse autorisieren, bis zu einem festgelegten Limit Token in seinem Namen zu übertragen oder zu verbrennen.

Das Problem: Ein Token-Konto speichert nur einen aktuellen Delegierten und einen delegierten Betrag. Wird ein neuer Delegierter genehmigt, ersetzt er den vorherigen Delegierten und dessen Limit. Das gilt sowohl für Konten des ursprünglichen Token Program als auch für Token-2022. Genehmigt der Nutzer einen zweiten Dienst, ersetzt dieser den ersten. Das native Limit ist ein einzelner Wert. Es kennt weder Abrechnungszeiträume noch zurückgesetzte Limits oder einen Abonnementstatus.

Anwendungen könnten dies umgehen, indem sie für jede Ausgabenbeziehung ein separates Token-Konto erstellen. Dadurch wird das Guthaben des Nutzers jedoch aufgeteilt und die Nutzung von Wallet und Anwendung deutlich komplizierter. Alternativ könnte ein Team ein eigenes Escrow- oder Delegationsprogramm entwickeln. Das würde jedoch erneut den Entwicklungs- und Sicherheitsaufwand verursachen, den das gemeinsame Programm beseitigen soll.

Es fehlte eine Möglichkeit, den einzelnen Delegierten-Slot des Token Program in ein programmierbares Gateway für viele unabhängige Autorisierungen zu verwandeln.

Das neue Subscriptions Delegation Program

Das Subscriptions Delegation Program ergänzt diese fehlende programmierbare Ebene, ohne eines der zugrunde liegenden Token-Programme von Solana zu verändern. Für jedes Paar aus Nutzer und Token-Mint leitet das Programm eine Subscription Authority ab. Diese program-derived address (PDA) wird zum Delegierten des Token-Kontos des Nutzers für diesen spezifischen Mint. Sie wird einmal initialisiert und anschließend für jedes Abonnement und jede Delegation dieses Nutzers und Mints wiederverwendet.

Bei der Initialisierung signiert der Nutzer eine Transaktion, die der Subscription Authority ein Limit von ~18,4 Trillionen beziehungsweise u64::MAX gewährt. Das ist sicher, weil die Subscription Authority eine PDA ist, die nur über das Subscriptions Delegation Program signieren kann. Sie kann nicht eigenständig Token übertragen. 

Bevor das Subscriptions Delegation Program einen CPI-Aufruf an das Token Program signiert, muss es ein gültiges Autorisierungskonto laden und dessen Einschränkungen prüfen. Je nach Autorisierungsmodell können diese Prüfungen Folgendes umfassen:

  • Die Wallet oder der Dienst, die bzw. der den Einzug initiieren darf
  • Den Mint und das Quell-Token-Konto
  • Das verbleibende Gesamtlimit
  • Den im aktuellen Abrechnungszeitraum maximal verfügbaren Betrag
  • Startzeit und Ablauf der Autorisierung
  • Die vom Nutzer akzeptierten Abonnementpläne
  • Den Händler oder genehmigten Einzugsdienst, der die Belastung initiiert
  • Alle im Plan konfigurierten Einschränkungen für das Ziel

Erst wenn diese Prüfungen erfolgreich waren, signiert das Programm als Subscription Authority und führt den Token-Transfer aus. Passt keine aktive Autorisierung zum angeforderten Transfer, schlägt die Transaktion fehl. Das Limit von u64::MAX gehört daher dem programmgesteuerten Gateway und nicht einem einzelnen Händler. Ein Händler erhält nur die Berechtigung, die in seinem spezifischen Delegationskonto definiert ist.

Das Token-Konto hat weiterhin genau einen Delegierten, also die Subscription Authority PDA. Das Programm kann dahinter jedoch viele unabhängige Autorisierungs-PDAs verwalten. Eine neue Autorisierung überschreibt keine bestehenden Autorisierungen. Jede hat einen eigenen Status, eigene Limits, einen eigenen Lebenszyklus und einen eigenen Widerrufspfad.

Drei Autorisierungsmodelle

Das Programm unterstützt drei verschiedene Modelle: feste Delegationen, wiederkehrende Delegationen und Abonnementpläne.

Feste Delegationen

Eine feste Delegation autorisiert eine Wallet oder einen Dienst, bis zu einem festgelegten Gesamtbetrag einzuziehen. Jeder Transfer reduziert das verbleibende Limit. Optional kann die Delegation zu einem bestimmten Unix-Zeitstempel ablaufen.

Dieses Modell eignet sich für begrenzte Agent-Budgets, einmalige Limits, zeitlich beschränkte Kaufberechtigungen und andere Fälle, in denen ein Nutzer sein maximales Gesamtrisiko festlegen möchte.

Wiederkehrende Delegationen

Eine wiederkehrende Delegation legt den Betrag fest, der in jedem Zeitraum eingezogen werden darf. Mit Beginn des nächsten Zeitraums wird der im vorherigen Zeitraum eingezogene Betrag zurückgesetzt.

Der Nutzer kontrolliert die Bedingungen. Dazu gehören der Betrag pro Zeitraum, die Länge des Zeitraums, die Startzeit und der endgültige Ablauf. Damit eignen sich wiederkehrende Delegationen für laufende Beziehungen wie Lohnzahlungen, Zahlungen an Auftragnehmer, wiederkehrende Budgets oder individuelle Abrechnungsvereinbarungen, bei denen der Zahler die Limits festlegt.

Abonnementpläne

Abonnementpläne kehren den Einrichtungsablauf um. Statt dass jeder Nutzer eigene wiederkehrende Bedingungen festlegt, veröffentlicht ein Händler einen wiederverwendbaren Plan mit Betrag, Abrechnungszeitraum, akzeptiertem Mint, zulässigen Einzugsdiensten und optionalen Einschränkungen für das Ziel.

Ein Nutzer prüft und akzeptiert diese Bedingungen. Dadurch entsteht eine an den Plan gebundene Subscription Delegation PDA. Die akzeptierten Abrechnungsbedingungen werden in das Abonnementkonto des Nutzers kopiert. So kann der Händler weder den wesentlichen Preis noch den Abrechnungszeitraum für einen bestehenden Abonnenten unbemerkt ändern. Der Planinhaber oder ein genehmigter Einzugsberechtigter kann dann in jedem Abrechnungszeitraum bis zum Planbetrag einziehen.

Dieser Unterschied ist wichtig:

  • Wiederkehrende Delegationen sind vom Zahler definierte Autorisierungen
  • Abonnementpläne sind vom Händler veröffentlichte Bedingungen, denen der Zahler ausdrücklich zustimmt

Alle drei Modelle verwenden dieselbe Subscription Authority und führen Transfers letztlich über dieselbe zugrunde liegende Delegationsarchitektur aus.

Die Referenzimplementierung

Das Subscriptions Delegation Program wurde von Moonsong Labs in Zusammenarbeit mit der Solana Foundation entwickelt und von Cantina geprüft. Quellcode, Dokumentation und Clients sind im Subscriptions-Repository der Solana Foundation verfügbar.

Das Onchain-Programm ist in no_std Rust mit Pinocchio geschrieben. Pinocchio bietet ein Low-Level-Entwicklungsmodell mit wenigen Abhängigkeiten. Im Vergleich zu einer typischen Anchor-Implementierung kann das Programm damit Compute-Nutzung und Binärgröße genauer steuern.

Das Repository verwendet außerdem Codama, um synchronisierte TypeScript- und Rust-Clients direkt aus der Programmschnittstelle zu generieren. Für TypeScript-Anwendungen ist dies das Hauptpaket:

Code
pnpm add @solana/subscriptions

Außerdem gibt es eine offizielle Demo-Webanwendung, die eine vollständige Implementierung bietet und sich einfach im Devnet verwenden lässt. Das Programm unterstützt SPL Token und Token-2022. Es gibt Onchain-Ereignisse für Lebenszyklen und Transfers aus, die Anwendungen und Indexer mithilfe der veröffentlichten IDL dekodieren können.

Anwendungsfälle: Was Entwickler erstellen können

Das Programm ist überall dort nützlich, wo ein Nutzer die Grenzen einer künftigen Zahlung festlegen kann, bevor der genaue Zeitpunkt des Transfers bekannt ist. Der Nutzer signiert einmal, um eine Autorisierung einzurichten. Anschließend können Händler, Dienste, Empfänger oder Agents Transfers innerhalb dieser Grenzen initiieren.

Da jede Ausgabenvereinbarung durch eine eigene PDA repräsentiert wird, können diese Anwendungsfälle hinter derselben Subscription Authority koexistieren. Ein Nutzer könnte über dasselbe USDC-Token-Konto einen API-Plan bezahlen, einem AI-Agent ein wöchentliches Budget bereitstellen und eine Pauschalzahlung an einen Auftragnehmer autorisieren, ohne dass sich die Autorisierungen gegenseitig beeinflussen.

Wiederkehrende Abrechnung für APIs und Infrastruktur

Abonnementpläne eignen sich ideal für SaaS-Produkte, RPC-Anbieter, Datenplattformen und andere Infrastrukturdienste. Ein Anbieter kann für jede Produktstufe einen separaten Onchain-Plan veröffentlichen und darin den akzeptierten Token-Mint, den Preis, den Abrechnungszeitraum, genehmigte Einzugsdienste und zulässige Zahlungsziele festlegen. So entsteht ein vertrautes Abonnementerlebnis ohne Karten-Zahlungsdienstleister.

Begrenzte Ausgaben für AI-Agents

Autonome Agents müssen APIs, Rechenleistung, Daten, Handelsdienste und andere Ressourcen bezahlen können, ohne jedes Mal die Zustimmung eines Menschen einzuholen. Einem Agent uneingeschränkte Kontrolle über eine finanzierte Wallet zu geben, birgt jedoch ein offensichtliches Sicherheitsrisiko.

Feste Delegationen sind eine sicherere Alternative. Ein Nutzer kann einen Agent autorisieren, bis zu einem bestimmten Token-Betrag auszugeben, und ein festes Ablaufdatum für diese Autorisierung festlegen. Der Nutzer kann die Delegation außerdem vor Ablauf widerrufen.

Wiederkehrende Delegationen erweitern dieses Modell. Ein Agent könnte ein tägliches Budget für API-Anfragen oder ein wöchentliches Betriebsbudget erhalten. Der verfügbare Betrag wird dabei zu Beginn jedes Zeitraums zurückgesetzt.

Onchain-Lohnzahlungen und Zahlungen an Auftragnehmer

Wiederkehrende Delegationen können einzugsbasierte Lohnzahlungen, Pauschalhonorare, Fördermittel und Vereinbarungen mit Auftragnehmern unterstützen. Ein Zahler autorisiert einen Mitarbeiter oder Auftragnehmer, pro Zahlungszeitraum bis zu einem festgelegten Betrag einzuziehen. Die Autorisierung kann den Betrag pro Zeitraum, die Länge des Zeitraums, die Startzeit und den endgültigen Ablauf festlegen. Sobald eine Zahlung fällig wird, übermittelt der Empfänger oder der Lohnabrechnungsdienst die Transfertransaktion.

Das entspricht nicht einer traditionellen auszahlungsbasierten Lohntransaktion. Der Zahler sendet das Geld am Zahltag nicht automatisch. Stattdessen erhält der Empfänger ein eng begrenztes Recht, in jedem Zeitraum den vereinbarten Betrag einzuziehen. So entsteht eine transparente Zahlungsvereinbarung, die beide Parteien Onchain prüfen können.

Transfers und Delegationsaktivitäten lassen sich anhand der vom Programm ausgegebenen Ereignisse nachverfolgen. Damit können Entwickler Dashboards für Lohnzahlungen und Integrationen für die Buchhaltung erstellen.

Einzug von Stablecoin-Rechnungen

Zahlungs-Gateways und B2B-Abrechnungsplattformen können wiederholte Zahlungsanfragen durch dauerhafte, begrenzte Autorisierungen ersetzen. Ein Kunde könnte ein Gateway zu folgenden Einzügen autorisieren:

  • Bis zu einem festen Gesamtbetrag für eine Bestellung
  • Bis zu einem festgelegten Betrag in jedem wöchentlichen oder monatlichen Rechnungszeitraum
  • Den Preis eines standardisierten Händlerplans in jedem Abrechnungszyklus

Dieselbe Architektur kann wiederkehrende Rechnungen, Händler-Acquiring, Nutzungslimits, betriebliche Ausgabenrichtlinien und andere Abläufe unterstützen, bei denen der Zahler Automatisierung ohne den Verlust uneingeschränkter Kontrolle wünscht.

Mikrozahlungen für Inhalte und Medien

Wiederkehrende Delegationen können auch nutzungsbasierte Zahlungsmodelle für Publisher, Streaming-Plattformen, Research-Anbieter und andere Mediendienste unterstützen.

Ein Nutzer kann ein monatliches Ausgabenlimit autorisieren, das beim Zugriff auf kostenpflichtige Inhalte schrittweise ausgeschöpft wird. Das Öffnen eines Artikels könnte beispielsweise 0,10 USDC von einem monatlichen Limit von 10 USDC verbrauchen, während Premiumberichte oder Videostreams höhere Preise haben könnten. Die Plattform übermittelt jede Zahlung beim Zugriff auf den Inhalt.

Dies ermöglicht Modelle nach dem Prinzip „Bezahle, was du liest“, ohne dass für jeden Artikel eine Wallet-Signatur nötig ist oder Nutzer ein starres Alles-oder-nichts-Abonnement abschließen müssen. Publisher können einzelne Zugriffe skalierbar monetarisieren. Nutzer behalten gleichzeitig ein planbares Ausgabenlimit und können die Autorisierung jederzeit widerrufen.

Mit Helius einen Abonnementablauf im Devnet erstellen

In diesem Tutorial erstellen wir den Lebenszyklus eines Händlerabonnements mit den folgenden Schritten:

  • Ein Kunde initialisiert eine Subscription Authority für sein Token-Konto
  • Ein Händler veröffentlicht einen Abonnementplan
  • Der Kunde akzeptiert den Plan
  • Der Händler zieht eine Zahlung ein
  • Der Kunde kündigt das Abonnement

Die Beispiele verwenden @solana/subscriptions@0.4.0, den zum Zeitpunkt der Erstellung neuesten veröffentlichten TypeScript-Client. Festgelegte Paketversionen halten das Tutorial stabil, selbst wenn sich das SDK später ändert.

Wir modellieren zwei Akteure:

RolleVerantwortlichkeit
KundeBesitzt die Token, initialisiert die Subscription Authority, abonniert den Plan und kündigt ihn
HändlerVeröffentlicht den Plan und übermittelt Einzugstransaktionen

Für den Token erstellen wir einen eigenen Devnet-Mint mit sechs Dezimalstellen und geben 100 Test-Token an den Kunden aus. So sind wir nicht von einem separaten Stablecoin-Faucet abhängig und verwenden dennoch dieselbe Basiswertarithmetik wie Token mit sechs Dezimalstellen, etwa USDC.

Lokale JSON-Schlüsselpaare erleichtern das Ausführen des Ablaufs über die Kommandozeile. In einer echten Anwendung würden Kundentransaktionen normalerweise über eine Browser- oder Mobil-Wallet signiert. Der Einzugsdienst des Händlers würde dagegen einen sicher verwalteten Backend-Signierer verwenden.

Voraussetzungen

Für dieses Tutorial benötigst du:

  • Eine aktuelle Node.js-Version
  • pnpm
  • Die Solana CLI
  • Einen kostenpflichtigen Helius-API-Schlüssel
  • Devnet SOL für beide Test-Wallets

Projekt einrichten

Erstelle ein neues Projekt mit einem keys-Verzeichnis für die Schlüsselpaare des Kunden und des Händlers:

Code
mkdir helius-subscriptions-devnet
cd helius-subscriptions-devnet

pnpm init
mkdir -p src keys

Füge "type": "module" zu package.json hinzu und installiere anschließend die Abhängigkeiten:

Code
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

Erstelle tsconfig.json:

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

Wallets für Händler und Kunden erstellen

Generiere für jeden Akteur ein Schlüsselpaar:

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

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

Diese Schlüsselpaare sind nur für das Devnet-Tutorial vorgesehen. Übertrage keine Produktionsschlüssel in ein Repository und speichere den Signierer eines produktiven Händlers nicht als unverschlüsselte JSON-Datei.

Erstelle .gitignore:

.gitignore
node_modules/
.env
keys/
state.json

Erstelle .env und füge deinen Helius-API-Schlüssel hinzu:

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

Beide Wallets benötigen SOL, um Transaktionsgebühren zu bezahlen und ihre jeweiligen PDA-Konten zu erstellen.

Code
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 bietet außerdem über sein Dashboard einen Devnet-Faucet an. Um Devnet SOL über den Helius-Faucet oder Helius RPC anzufordern, benötigst du einen kostenpflichtigen Helius-Plan.

Gemeinsamen Helius-Client erstellen

Jedes Skript benötigt dieselbe Helius-Verbindung, dieselben Programm-Plugins, Konfigurationswerte und Adressen. Wir legen all diese Daten in src/config.ts ab:

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 legt das geladene Schlüsselpaar als Client-Identität und Gebührenzahler fest. Das Helius RPC-Plugin übernimmt die Planung, Übermittlung und Bestätigung von Transaktionen. Die Token- und Subscriptions-Plugins ergänzen die jeweiligen Hilfsfunktionen für Konten und Anweisungen. Jedes Skript gibt für die resultierende Transaktion eine URL zu Orb, dem Block-Explorer von Helius, aus.

Devnet-Test-Mint erstellen

Bevor der Kunde eine Subscription Authority initialisiert, muss bereits ein Token-Konto für den Mint des Plans vorhanden sein. Wir erstellen einen Test-Mint mit sechs Dezimalstellen, geben 100 Token an den Kunden aus und erstellen ein leeres Ziel-Token-Konto für den Händler.

Das Token-Plugin von Solana Kit stellt Hilfsfunktionen zum Erstellen von Mints und zugehörigen Token-Konten sowie zum Minten von Token bereit. Erstelle 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,
);

Führe das Skript aus. Es erstellt state.json mit Informationen zum generierten Mint und einer eindeutigen Plan-ID. Der Kunde besitzt nun 100 Test-Token. Der Händler verfügt über ein leeres Token-Konto, das Abonnementzahlungen empfangen kann.

Subscription Authority des Kunden initialisieren

Eine Subscription Authority wird für ein bestimmtes (customer, mint)-Paar erstellt. Das Token-Konto des Kunden muss vor der Initialisierung bereits vorhanden sein.

Die Initialisierungstransaktion erstellt die Subscription Authority PDA und genehmigt sie als Delegierten für das Token-Konto des Kunden. Dieselbe Authority kann anschließend für jede feste Delegation, wiederkehrende Delegation und jeden Abonnementplan dieses Kunden und Mints wiederverwendet werden. Der Kunde signiert diese Transaktion.

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

Führe das Skript als Kunde aus. Es prüft zuerst, ob die PDA bereits existiert. Dadurch kannst du den Befehl sicher erneut ausführen und vermeidest eine doppelte Initialisierungstransaktion. Nach der Bestätigung verwendet das Token-Konto des Kunden die Subscription Authority als Delegierten des Token Program.

Abonnementplan für den Händler erstellen

Der Händler veröffentlicht nun die Abrechnungsbedingungen, die Kunden akzeptieren können. Eine Plan-PDA wird von der Händleradresse und der Plan-ID abgeleitet.

Ein Plan definiert den Zahlungs-Mint, den Höchstbetrag pro Zeitraum, die Dauer des Zeitraums, genehmigte Einzugsdienste, zulässige Ziele und optionale Offchain-Metadaten.

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

Führe das Skript als Händler aus. Einige Felder sind besonders wichtig:

amount

Token-Werte werden in Basiseinheiten angegeben. Unser Mint hat sechs Dezimalstellen. Daher entsprechen 5 Token 5.000.000 Basiseinheiten. Der Plan erlaubt dem Händler, in jedem Abrechnungszeitraum insgesamt höchstens fünf Token einzuziehen. Der Händler kann den gesamten Betrag in einer einzigen Transaktion oder aufgeteilt auf mehrere kleinere Transaktionen einziehen.

periodHours

Wir verwenden das Minimum von einer Stunde. So können wir den Plan abonnieren, eine Zahlung einziehen, eine Stunde warten und zeigen, wie das Limit zurückgesetzt wird, ohne einen Monat zu warten. Ein produktiver Plan würde das Intervall verwenden, das in den tatsächlichen Abrechnungsbedingungen des Produkts festgelegt ist.

destinations

Die Allowlist für Ziele enthält Wallet-Inhaber, keine Adressen von Token-Konten. Beim Einziehen einer Zahlung prüft das Programm den Inhaber des empfangenden Token-Kontos. Da sich die Wallet unseres Händlers in den Zielen befindet, ist das zugehörige Token-Konto ein gültiger Empfänger.

pullers

Ein Händler darf immer Zahlungen aus seinem eigenen Plan einziehen. Zusätzliche Wallets von Abrechnungsdiensten können zu pullers hinzugefügt werden. Wir lassen diese Liste leer, sodass nur das Schlüsselpaar des Händlers Zahlungen einziehen kann. Mit unserer Konfiguration kann nur der Händler oder eine Wallet aus der Puller-Liste des Plans eine gültige Einzugstransaktion übermitteln. 

Kunden den Plan abonnieren lassen

Der Kunde prüft und akzeptiert nun die aktuellen Planbedingungen des Händlers. Die daraus resultierende Subscription Delegation PDA wird aus der Plan-PDA und der Kundenadresse abgeleitet. 

Das TypeScript-Plugin ruft während subscribe das aktuelle Plankonto ab. Daher müssen wir den erwarteten Betrag, den Zeitraum oder den Erstellungszeitstempel nicht manuell übergeben. Diese Werte werden als Bedingungen in die Transaktion aufgenommen. Erstelle 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,
);

Führe das Skript als Kunde aus. Der Kunde signiert diese Transaktion, um eine neue Ausgabenautorisierung zu akzeptieren. Das Abonnementkonto speichert die akzeptierten Bedingungen. 

Sobald ein Plan erstellt wurde, sind planId, owner, mint, amount, periodHours, createdAt und destinations unveränderlich. Die Bedingungen können also nicht geändert werden. Aktualisiert der Händler später veränderliche Felder des Plans, behalten bestehende Abonnenten die ursprünglich akzeptierten Bedingungen. Neue Abonnenten erhalten dagegen die aktuelle Version des Plans.

Der Kunde hat jetzt ein aktives Abonnement, aber es wurde noch keine Zahlung geleistet.

Händler eine Zahlung einziehen lassen

Der Händler kann im aktuellen Abrechnungszeitraum jetzt bis zum Limit des Plans von fünf Token einziehen. Das Subscriptions Program führt diese Transaktion nach Ablauf eines Timers nicht automatisch aus. Ein Händler-Backend, Abrechnungs-Worker, Cronjob oder genehmigter Puller muss die Einzugstransaktion weiterhin übermitteln. Erstelle 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,
);

Führe das Skript als Händler aus. Während der Ausführung prüft das Programm Folgendes:

  • Der Aufrufer ist der Händler oder ein genehmigter Puller
  • Das Abonnement gehört zum Kunden und zum Plan
  • Das Abonnement ist nicht abgelaufen
  • Der angeforderte Betrag liegt innerhalb des verbleibenden Limits des aktuellen Zeitraums
  • Die Händler-Wallet ist ein genehmigtes Ziel
  • Das empfangende Token-Konto gehört zum genehmigten Ziel
  • Der Mint und das Token Program entsprechen dem akzeptierten Plan

Da diese Transaktion das vollständige Limit von fünf Token einzieht, sollte eine erneute Ausführung im selben einstündigen Zeitraum fehlschlagen. Mit Beginn des nächsten Zeitraums wird das Limit zurückgesetzt und der Händler kann das Einzugsskript erneut ausführen. In einem produktiven Abrechnungssystem würde das entsprechende Skript normalerweise nur ausgeführt, wenn eine Rechnung fällig wird.

Kunde kündigt sein Abonnement

Der Kunde kann das Abonnement ohne Mitwirkung des Händlers kündigen. Beim üblichen Kündigungsablauf wird das Subscription Delegation-Konto nicht sofort geschlossen. Stattdessen wird das Abonnement als auslaufend markiert und erhält einen expiresAtTs-Wert. Nach diesem Ablauf kann der Kunde das Abonnement widerrufen und die PDA schließen. Erstelle 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,
);

Führe das Skript als Kunde aus. Die Ausgabe enthält den Zeitstempel, zu dem die Kündigung wirksam wird. Die standardmäßige cancelSubscription-Anweisung implementiert eine Übergangsfrist bis zum Ende des aktiven Abrechnungszeitraums.

Im Betrieb darf eine Kündigung nicht so behandelt werden, als würde sie die Autorisierung des aktuellen Zeitraums sofort auf null setzen. Ein noch verfügbares Limit des aktuellen Zeitraums kann bis expiresAtTs weiterhin eingezogen werden. Nachdem die Kündigung wirksam geworden ist, kann der Händler keinen weiteren Abrechnungszeitraum beginnen.

Das entspricht dem üblichen Verhalten von Abonnements: Eine Kündigung verhindert die nächste Verlängerung, statt den bereits begonnenen Zeitraum rückwirkend zu beenden.

Praxisbeispiel: Onchain-Abonnementpläne von Helius

Helius war einer der Launch-Partner, die das Subscriptions Delegation Program vor seiner Mainnet-Veröffentlichung mitgestaltet haben. Wir nutzen das Programm für die automatischen USDC-Verlängerungen unserer API-Pläne. Unser Ziel ist es, Kunden, die mit Kryptowährungen bezahlen, den Komfort eines herkömmlichen SaaS-Abonnements zu bieten. Zahlungsautorisierung und Abwicklung bleiben dabei vollständig auf Solana.

Um automatische Zahlungen zu aktivieren, fügt ein Kunde im Bereich „Zahlungsmethode“ des Helius-Abrechnungsdashboards eine Solana-Wallet hinzu. 

Bei der Einrichtung signiert der Kunde eine einmalige Genehmigung für das offizielle Solana Subscriptions Program und autorisiert Helius, Abonnementzahlungen in USDC aus dieser Wallet einzuziehen.

Wenn eine Verlängerungsrechnung fällig wird, übermittelt das Abrechnungssystem von Helius die Einzugstransaktion. Der Kunde muss weder einen Zahlungslink öffnen noch seine Wallet erneut verbinden oder einen weiteren Transfer signieren. Das Subscriptions Delegation Program stellt die wiederverwendbare Onchain-Autorisierung bereit. Helius verwaltet weiterhin den Rechnungszeitplan, den Kontostatus und die Produktberechtigungen.

Ein Kunde kann bis zu drei Wallets verbinden. Für automatische Verlängerungen wird jedoch nur die Wallet verwendet, die als Standardzahlungsmethode festgelegt ist. Helius teilt eine Belastung nicht auf mehrere Wallets auf und weicht nicht auf eine andere verbundene Wallet aus, wenn die Standard-Wallet die Rechnung nicht begleichen kann.

Kunden können die Standard-Wallet im Dashboard ändern. Sie können auch eine Wallet entfernen. Dafür ist eine Signatur erforderlich, die ihre Autorisierung für automatische Zahlungen widerruft. Wird die einzige verbundene Wallet entfernt, verwendet das Konto wieder manuelle Zahlungslinks.

Eine Onchain-Autorisierung garantiert natürlich nicht, dass die Wallet bei Fälligkeit der nächsten Rechnung genügend USDC enthält. In diesem Fall gilt:

  • Helius belastet die Wallet für diese Verlängerung nicht.
  • Der Kunde erhält per E-Mail und im Dashboard einen Zahlungslink.
  • Die Zahlung der Rechnung wird nicht automatisch erneut über die Wallet versucht.
  • Nachdem der Kunde Guthaben hinzugefügt hat, können spätere Verlängerungen wieder automatisch eingezogen werden.

Dieser Fallback hält den Abrechnungsstatus übersichtlich. Ein fehlgeschlagener automatischer Einzug wird zu einer normalen offenen Rechnung statt zu einer endlosen Reihe wiederholter Onchain-Transaktionen.

Die Implementierung zeigt in der Praxis, wie sich das Subscriptions Delegation Program in einen produktiven Abrechnungs-Stack einfügt. Das Programm ersetzt weder Rechnungsstellung, Kontoverwaltung, Benachrichtigungen noch die Durchsetzung von Berechtigungen. Es ersetzt den Teil, bei dem der Kunde zuvor jede Verlängerung autorisieren musste oder ein zentraler Zahlungsdienstleister diese Autorisierung speichern und ausüben musste.

Fazit

Das Solana Subscriptions Program bietet eine standardisierte Möglichkeit, wiederkehrende Zahlungen direkt Onchain zu erstellen. Durch die Kombination aus Subscription Authorities, Händlerplänen und delegierten Token-Transfers können Entwickler Abonnementabrechnungen implementieren, ohne auf Offchain-Zahlungsinfrastruktur oder individuelle Zahlungslogik angewiesen zu sein.

Wenn du wiederkehrende Zahlungen auf Solana entwickelst, ist das Subscriptions Program der ideale Ausgangspunkt. Mit dem TypeScript SDK sowie den RPCs und APIs von Helius lässt sich die Onchain-Abrechnung für Abonnements unkompliziert integrieren. So kannst du dich auf deine Anwendung statt auf die zugrunde liegende Zahlungsmechanik konzentrieren.

Weitere Ressourcen

Helius abonnieren

Bleib bei der Solana-Entwicklung auf dem Laufenden und erhalte Updates, wenn wir neue Beiträge veröffentlichen

Vergrößertes Bild