
Solana에서 구독 및 반복 결제 설정하기
목차
- 소개
- 온체인 구독 구현이 어려웠던 이유
- 새로운 Subscriptions Delegation Program 소개
- 세 가지 승인 모델
- 고정 위임
- 반복 위임
- 구독 요금제
- 참조 구현
- 사용 사례: 개발자가 구축할 수 있는 것
- 반복 API 및 인프라 청구
- AI 에이전트의 지출 한도 설정
- 온체인 급여 및 계약자 지급
- 스테이블코인 청구서 수금
- 콘텐츠 및 미디어 소액 결제
- Helius로 Devnet에서 구독 흐름 구축하기
- 사전 준비 사항
- 프로젝트 설정
- 판매자 및 고객 지갑 생성
- 공유 Helius 클라이언트 생성
- Devnet 테스트 민트 생성
- 고객의 Subscription Authority 초기화
- 판매자 구독 요금제 생성
- 고객의 구독 처리
- 판매자의 결제 수금
- 고객의 구독 취소
- 실제 사례 연구: Helius 온체인 구독 요금제
- 결론
- 추가 자료
소개
반복 청구는 인터넷 상거래의 핵심 기반입니다. SaaS 제품, API 플랫폼, 급여 시스템을 비롯한 수많은 비즈니스가 예측 가능한 일정에 따라 고객에게 요금을 청구하는 기능에 의존합니다.
지금까지 Solana에서 이러한 경험을 구현하려면 상당한 맞춤형 인프라를 구축해야 했습니다. 새로운 Solana Subscriptions Delegation Program(Solana Subscriptions & Allowances Program이라고도 함)은 반복 결제와 구독 요금제를 위한 오픈 소스 기반의 감사를 완료한 온체인 프리미티브로 이 문제를 해결합니다.
이제 사용자는 명시적인 온체인 제약 조건에 따라 향후 토큰 전송을 한 번 승인할 수 있습니다. 이후 승인된 판매자나 수금자는 사용자의 서명 없이 결제를 시작할 수 있습니다. 이 프로그램은 이미 메인넷과 devnet에서 운영 중이며, 기존 SPL Token Program과 Token-2022의 민트를 모두 지원합니다.
이제 각 애플리케이션이 자체 위임 시스템을 설계하고 보호하는 대신 표준화된 프로그램을 통합할 수 있습니다. 이전에는 맞춤형 개발에 몇 주가 걸렸던 결제 흐름을 며칠 만에 통합할 수 있습니다.
출시 파트너인 Helius는 프로그램 개선에 기여했으며, 현재 온체인 API 요금제 구독 청구에 이 프로그램을 사용합니다. Helius 고객은 Solana 지갑에서 직접 USDC 반복 결제를 승인할 수 있습니다.
이 글에서는 프로그램의 작동 방식을 살펴보고 확장 가능한 반복 결제 흐름을 구축합니다. Subscription Authority 아키텍처, 개별 승인을 나타내는 PDA, 그리고 프로그램이 지원하는 세 가지 청구 모델을 다룹니다.
- 고정 지출 한도
- 반복 위임
- 판매자가 정의한 구독 요금제
온체인 구독 구현이 어려웠던 이유
Solana의 토큰 프로그램은 이미 지출 위임을 지원합니다. 토큰 계정 소유자는 Approve 또는 ApproveChecked 명령어를 사용해 다른 주소가 지정된 한도까지 자신을 대신하여 토큰을 전송하거나 소각하도록 승인할 수 있습니다.
문제는 토큰 계정이 현재 위임 대상 하나와 위임 금액 하나만 저장한다는 점입니다. 새로운 위임 대상을 승인하면 이전 위임 대상과 한도가 대체됩니다. 이는 기존 Token Program과 Token-2022에서 관리하는 계정 모두에 적용됩니다. 사용자가 두 번째 서비스를 승인하면 첫 번째 서비스가 대체됩니다. 기본 한도는 단일 값이며 청구 기간, 한도 초기화 또는 구독 상태에 대한 내장 개념이 없습니다.
애플리케이션은 각 지출 관계마다 별도의 토큰 계정을 만들어 이 문제를 우회할 수 있습니다. 하지만 이 방식은 사용자의 잔액을 분산시키고 지갑과 애플리케이션 경험을 훨씬 복잡하게 만듭니다. 또는 팀이 맞춤형 에스크로나 위임 프로그램을 구축할 수도 있지만, 그러면 공유 프로그램이 없애려는 개발 및 보안 부담이 다시 생깁니다.
필요했던 것은 Token Program의 단일 위임 슬롯을 여러 독립적인 승인을 위한 프로그래밍 가능한 게이트웨이로 전환하는 방법이었습니다.
새로운 Subscriptions Delegation Program 소개
Subscriptions Delegation Program은 Solana의 기본 토큰 프로그램을 변경하지 않고도 이처럼 부족했던 프로그래밍 가능 계층을 추가합니다. 프로그램은 각 (사용자, 토큰 민트) 쌍에 대해 해당 민트의 사용자 토큰 계정에서 위임 대상이 되는 프로그램 파생 주소(PDA), 즉 Subscription Authority를 파생합니다. 한 번 초기화한 뒤 해당 사용자와 민트에 연결된 모든 구독 또는 위임에서 재사용합니다.
초기화 과정에서 사용자는 Subscription Authority에 약 1,840경 또는 u64::MAX의 한도를 승인하는 트랜잭션에 서명합니다. Subscription Authority는 Subscriptions Delegation Program을 통해서만 서명할 수 있는 PDA이므로 안전합니다. 자체적으로 토큰 전송을 결정할 수 없습니다.
Token Program으로 CPI를 서명하기 전에 Subscriptions Delegation Program은 유효한 승인 계정을 로드하고 제약 조건을 검증해야 합니다. 승인 모델에 따라 다음 항목을 확인할 수 있습니다.
- 인출을 시작할 수 있는 지갑 또는 서비스
- 민트와 소스 토큰 계정
- 남은 총한도
- 현재 청구 기간에 사용할 수 있는 최대 금액
- 승인의 시작 시각과 만료 시각
- 사용자가 수락한 구독 요금제
- 청구를 시작하는 판매자 또는 승인된 수금자
- 요금제에 설정된 모든 수신 주소 제한
이러한 검사를 모두 통과한 후에만 프로그램이 Subscription Authority로 서명하고 토큰 전송을 실행합니다. 요청된 전송과 일치하는 활성 승인이 없으면 트랜잭션이 실패합니다. 따라서 u64::MAX 한도는 개별 판매자가 아니라 프로그램이 제어하는 게이트웨이에 속합니다. 판매자는 자신의 특정 위임 계정에 명시된 권한만 받습니다.
토큰 계정에는 여전히 정확히 하나의 위임 대상, 즉 Subscription Authority PDA만 있습니다. 하지만 프로그램은 그 뒤에 여러 독립적인 승인 PDA를 배치할 수 있습니다. 새 승인을 생성해도 기존 승인을 덮어쓰지 않습니다. 각 승인은 자체 상태, 한도, 수명 주기, 취소 경로를 갖습니다.
세 가지 승인 모델
프로그램은 고정 위임, 반복 위임, 구독 요금제라는 세 가지 모델을 지원합니다.
고정 위임
고정 위임은 지갑이나 서비스가 정해진 총금액까지 인출할 수 있도록 승인합니다. 전송할 때마다 남은 한도가 줄어들며, 지정된 Unix 타임스탬프에 선택적으로 만료되도록 설정할 수 있습니다.
이 모델은 한도가 정해진 에이전트 예산, 일회성 한도, 기간이 제한된 구매 권한 등 사용자가 최대 총노출액을 정하려는 상황에 유용합니다.
반복 위임
반복 위임은 각 기간에 인출할 수 있는 금액을 지정합니다. 다음 기간이 시작되면 이전 기간에 인출한 금액이 초기화됩니다.
사용자는 기간별 금액, 기간 길이, 시작 시각, 전체 만료 시각을 포함한 조건을 제어합니다. 따라서 반복 위임은 급여, 계약자 지급, 반복 수당 또는 지급인이 한도를 정하는 맞춤형 청구 계약과 같은 지속적인 관계에 적합합니다.
구독 요금제
구독 요금제는 설정 흐름을 반대로 구성합니다. 각 사용자가 자신의 반복 조건을 정의하는 대신 판매자가 금액, 청구 기간, 허용 민트, 허용 수금자, 선택적 수신 주소 제한이 포함된 재사용 가능한 요금제를 게시합니다.
사용자는 해당 조건을 검토하고 수락해 요금제에 연결된 Subscription Delegation PDA를 생성합니다. 수락된 청구 조건은 사용자의 구독 계정에 복사되므로 판매자가 기존 구독자의 핵심 가격이나 청구 기간을 몰래 변경할 수 없습니다. 이후 요금제 소유자 또는 승인된 인출자가 각 청구 기간에 요금제 금액까지 수금할 수 있습니다.
이 차이는 중요합니다.
- 반복 위임은 지급인이 정의하는 승인입니다
- 구독 요금제는 판매자가 게시하고 지급인이 명시적으로 동의하는 조건입니다
세 모델 모두 동일한 Subscription Authority를 사용하며, 최종적으로 같은 기본 위임 아키텍처를 통해 전송을 실행합니다.
참조 구현
Subscriptions Delegation Program은 Moonsong Labs가 Solana Foundation과 협력하여 설계하고 구축했으며 Cantina의 감사를 받았습니다. 소스 코드, 문서, 클라이언트는 모두 Solana Foundation 구독 저장소에서 확인할 수 있습니다.
온체인 프로그램은 Pinocchio를 사용하는 no_std Rust로 작성되었습니다. Pinocchio는 더 낮은 수준에서 작동하고 의존성이 적은 개발 모델을 제공합니다. 일반적인 Anchor 구현과 비교하면 컴퓨팅 사용량과 바이너리 크기를 더 세밀하게 관리할 수 있습니다.
또한 저장소는 Codama를 사용해 프로그램 인터페이스에서 동기화된 TypeScript 및 Rust 클라이언트를 직접 생성합니다. TypeScript 애플리케이션의 주요 패키지는 다음과 같습니다.
pnpm add @solana/subscriptionsdevnet에서 손쉽게 사용할 수 있는 엔드투엔드 구현을 제공하는 공식 데모 웹 애플리케이션도 있습니다. 프로그램은 SPL Token과 Token-2022를 모두 지원하며, 애플리케이션과 인덱서가 공개된 IDL로 디코딩할 수 있는 온체인 수명 주기 및 전송 이벤트를 내보냅니다.
사용 사례: 개발자가 구축할 수 있는 것
이 프로그램은 정확한 전송 시점을 알기 전에 사용자가 향후 결제 범위를 정할 수 있는 모든 상황에 유용합니다. 사용자가 한 번 서명해 승인을 설정하면 판매자, 서비스, 수신자 또는 에이전트가 해당 한도 내에서 전송을 시작할 수 있습니다.
각 지출 약정은 자체 PDA로 표현되므로 이러한 사용 사례가 동일한 Subscription Authority 뒤에서 함께 존재할 수 있습니다. 사용자는 동일한 USDC 토큰 계정으로 API 요금제를 결제하고, AI 에이전트에 주간 예산을 제공하며, 계약자 선급금을 승인할 수 있습니다. 각 승인은 서로 간섭하지 않습니다.
반복 API 및 인프라 청구
구독 요금제는 SaaS 제품, RPC 제공업체, 데이터 플랫폼 및 기타 인프라 서비스에 적합합니다. 제공업체는 제품 등급별로 별도의 온체인 요금제를 게시하고 허용 토큰 민트, 가격, 청구 기간, 승인된 수금자, 허용 결제 수신 주소를 정의할 수 있습니다. 카드 결제 처리업체 없이도 익숙한 구독 경험을 제공할 수 있습니다.
AI 에이전트의 지출 한도 설정
자율 에이전트는 사람의 승인을 요청하지 않고도 API, 컴퓨팅, 데이터, 거래 서비스 및 기타 리소스에 비용을 지불할 수 있어야 합니다. 하지만 자금이 있는 지갑을 에이전트가 제한 없이 제어하도록 하면 명백한 보안 위험이 발생합니다.
고정 위임은 더 안전한 대안을 제공합니다. 사용자는 에이전트가 특정 토큰 금액까지 지출하도록 승인하고 해당 승인에 명확한 만료 시각을 설정할 수 있습니다. 만료 전에 위임을 취소할 수도 있습니다.
반복 위임은 동일한 모델을 확장합니다. 에이전트는 API 요청을 위한 일일 한도나 주간 운영 예산을 받고, 각 기간이 시작될 때 사용 가능 금액을 초기화할 수 있습니다.
온체인 급여 및 계약자 지급
반복 위임은 인출 기반 급여, 선급금, 보조금, 계약자 계약을 지원할 수 있습니다. 지급인은 직원이나 계약자가 급여 기간마다 지정된 금액까지 수금하도록 승인합니다. 승인에는 기간별 금액, 기간 길이, 시작 시각, 최종 만료 시각을 지정할 수 있습니다. 지급일이 되면 수신자나 급여 서비스가 전송 트랜잭션을 제출합니다.
이는 기존의 전송 기반 급여 트랜잭션과 다릅니다. 지급인이 급여일에 자금을 자동으로 보내지 않습니다. 대신 수신자는 각 기간에 합의된 금액을 인출할 수 있는 엄격히 제한된 권한을 받습니다. 그 결과 양측 모두 온체인에서 확인할 수 있는 투명한 지급 계약이 만들어집니다.
프로그램이 내보내는 이벤트를 통해 전송과 위임 활동을 추적할 수 있으므로 급여 대시보드와 회계 통합을 구축할 수 있습니다.
스테이블코인 청구서 수금
결제 게이트웨이와 B2B 청구 플랫폼은 이 프로그램을 사용해 반복적인 결제 요청을 지속적이고 제한된 승인으로 대체할 수 있습니다. 고객은 게이트웨이가 다음 금액을 수금하도록 승인할 수 있습니다.
- 구매 주문의 정해진 총금액까지
- 주간 또는 월간 청구 기간마다 지정된 금액까지
- 각 청구 주기마다 표준화된 판매자 요금제의 가격
동일한 아키텍처로 반복 청구서, 판매자 매입, 사용량 한도, 기업 지출 정책 등 지급인이 무제한 제어권을 넘기지 않으면서 자동화를 원하는 워크플로를 지원할 수 있습니다.
콘텐츠 및 미디어 소액 결제
반복 위임은 게시자, 스트리밍 플랫폼, 리서치 제공업체 및 기타 미디어 서비스를 위한 사용량 기반 결제 모델도 지원할 수 있습니다.
사용자는 유료 콘텐츠에 접근할 때마다 점진적으로 차감되는 월간 지출 한도를 승인할 수 있습니다. 예를 들어 기사 하나를 열면 월 10 USDC 한도에서 0.10 USDC가 차감되고, 프리미엄 보고서나 동영상 스트림에는 더 높은 가격이 적용될 수 있습니다. 플랫폼은 콘텐츠에 접근할 때마다 결제를 제출합니다.
따라서 기사마다 지갑 서명을 요구하거나 사용자를 고정된 전부 아니면 전무 방식의 구독에 묶지 않고도 ‘읽은 만큼 결제’하는 모델을 구현할 수 있습니다. 게시자는 개별 접근 이벤트로 수익을 창출할 수 있는 확장 가능한 방법을 확보하고, 사용자는 예측 가능한 지출 상한을 유지하면서 언제든 승인을 취소할 수 있습니다.
Helius로 Devnet에서 구독 흐름 구축하기
이 튜토리얼에서는 다음 단계에 따라 판매자 구독의 전체 수명 주기를 구축합니다.
- 고객이 자신의 토큰 계정에 대한 Subscription Authority를 초기화합니다
- 판매자가 구독 요금제를 게시합니다
- 고객이 요금제를 수락합니다
- 판매자가 결제를 수금합니다
- 고객이 구독을 취소합니다
예제에서는 작성 시점에 공개된 최신 TypeScript 클라이언트인 @solana/subscriptions@0.4.0을 사용합니다. 패키지 버전을 고정하면 나중에 SDK가 변경되더라도 튜토리얼을 안정적으로 유지할 수 있습니다.
두 역할을 모델링합니다.
| 역할 | 책임 |
| 고객 | 토큰을 소유하고 Subscription Authority를 초기화하며 요금제를 구독하고 취소합니다 |
| 판매자 | 요금제를 게시하고 수금 트랜잭션을 제출합니다 |
토큰으로는 소수점 이하 6자리의 맞춤형 devnet 민트를 생성하고 고객에게 테스트 토큰 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 keyspackage.json에 "type": "module"를 추가한 뒤 의존성을 설치합니다.
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/nodetsconfig.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을 생성합니다.
node_modules/
.env
keys/
state.json.env을 생성하고 Helius API 키를 추가합니다.
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에 넣습니다.
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 플러그인은 트랜잭션 계획, 제출, 확인을 처리하고, 토큰 및 구독 플러그인은 각각의 계정 및 명령어 헬퍼를 추가합니다. 각 스크립트는 결과 트랜잭션의 Orb(Helius 블록 탐색기) URL을 출력합니다.
Devnet 테스트 민트 생성
Subscription Authority를 초기화하기 전에 고객에게 요금제 민트의 기존 토큰 계정이 있어야 합니다. 소수점 이하 6자리의 테스트 민트를 생성하고 고객에게 토큰 100개를 발행한 뒤 판매자를 위한 비어 있는 수신 토큰 계정을 생성합니다.
Solana Kit 토큰 플러그인은 민트와 연결 토큰 계정을 생성하고 토큰을 발행하는 헬퍼를 제공합니다. src/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,
);스크립트를 실행합니다. 생성된 민트 정보와 고유한 요금제 ID가 포함된 state.json이 생성됩니다. 이제 고객은 테스트 토큰 100개를 소유하며 판매자에게는 구독 결제를 받을 준비가 된 비어 있는 토큰 계정이 있습니다.
고객의 Subscription Authority 초기화
Subscription Authority는 특정 (customer, mint) 쌍에 대해 생성됩니다. 초기화하기 전에 고객의 토큰 계정이 이미 존재해야 합니다.
초기화 트랜잭션은 Subscription Authority PDA를 생성하고 이를 고객 토큰 계정의 위임 대상으로 승인합니다. 이후 동일한 권한을 해당 고객과 민트가 관련된 모든 고정 위임, 반복 위임, Subscription Plan에서 재사용할 수 있습니다. 고객이 이 트랜잭션에 서명합니다.
src/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 위임 대상으로 설정됩니다.
판매자 구독 요금제 생성
이제 판매자가 고객이 수락할 수 있는 청구 조건을 게시합니다. Plan PDA는 판매자 주소와 요금제 ID에서 파생됩니다.
요금제는 결제 민트, 기간별 최대 금액, 기간 길이, 승인된 수금자, 허용 수신 주소, 선택적 오프체인 메타데이터를 정의합니다.
src/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
토큰 값은 기본 단위로 표시됩니다. 이 민트의 소수점 이하 자릿수는 6자리이므로 토큰 5개는 기본 단위로 5,000,000입니다. 요금제에 따라 판매자는 각 청구 기간에 누적 최대 토큰 5개를 수금할 수 있습니다. 판매자는 이 금액 전체를 단일 트랜잭션으로 수금하거나 여러 개의 작은 트랜잭션으로 나눌 수 있습니다.
periodHours
최솟값인 1시간을 사용합니다. 따라서 구독하고 결제를 수금한 뒤 한 시간을 기다리면 한 달을 기다리지 않고도 한도가 초기화되는 것을 확인할 수 있습니다. 프로덕션 요금제에서는 제품의 실제 청구 조건에 정의된 간격을 사용해야 합니다.
destinations
수신 주소 허용 목록에는 토큰 계정 주소가 아니라 지갑 소유자가 포함됩니다. 결제를 수금할 때 프로그램은 수신 토큰 계정의 소유자를 확인합니다. 판매자 지갑이 수신 주소에 포함되어 있으므로 연결 토큰 계정은 유효한 수신 계정입니다.
pullers
판매자는 항상 자신의 요금제에서 수금할 수 있습니다. 추가 청구 서비스 지갑은 pullers에 추가할 수 있습니다. 판매자 키페어만 결제를 수금할 수 있도록 이 목록은 비워 둡니다. 이 구성에서는 판매자 또는 요금제의 인출자 목록에 포함된 지갑만 유효한 수금 트랜잭션을 제출할 수 있습니다.
고객의 구독 처리
이제 고객이 판매자의 현재 요금제 조건을 검토하고 수락합니다. 생성되는 Subscription Delegation PDA는 요금제 PDA + 고객 주소에서 파생됩니다.
TypeScript 플러그인은 subscribe 실행 중 현재 요금제 계정을 가져오므로 예상 금액, 기간, 생성 타임스탬프를 수동으로 전달할 필요가 없습니다. 이러한 값은 조건으로 트랜잭션에 포함됩니다. src/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 작업 또는 승인된 인출자가 수금 트랜잭션을 제출해야 합니다. src/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이 수락된 요금제와 일치하는지
이 트랜잭션은 토큰 5개의 한도 전체를 수금하므로 동일한 1시간 기간 내에 다시 실행하면 실패해야 합니다. 다음 기간이 시작되면 기간 한도가 초기화되고 판매자는 수금 스크립트를 다시 실행할 수 있습니다. 프로덕션 청구 시스템에서는 일반적으로 청구서의 결제 기한이 되었을 때만 이 스크립트와 같은 작업을 실행합니다.
고객의 구독 취소
고객은 판매자의 협조 없이 구독을 취소할 수 있습니다. 표준 취소 흐름은 Subscription Delegation 계정을 즉시 닫지 않습니다. 대신 구독을 종료 예정으로 표시하고 expiresAtTs를 할당합니다. 만료 시각이 지나면 고객은 구독 승인을 취소하고 PDA를 닫을 수 있습니다. src/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 명령어는 활성 청구 기간이 끝날 때까지 유예 기간을 적용합니다.
운영 측면에서 취소를 현재 기간의 승인 한도를 즉시 0으로 만드는 것으로 간주해서는 안 됩니다. 현재 기간에 사용 가능한 한도가 남아 있다면 expiresAtTs까지 계속 수금할 수 있습니다. 취소가 적용된 후에는 판매자가 다음 청구 기간을 시작할 수 없습니다.
이는 취소 시 고객이 이미 시작한 기간을 소급해 종료하는 대신 다음 갱신을 중단하는 일반적인 구독 방식과 일치합니다.
실제 사례 연구: Helius 온체인 구독 요금제
Helius는 메인넷 출시 전에 Subscriptions Delegation Program의 설계에 기여한 출시 파트너 중 하나입니다. Helius는 이 프로그램을 사용해 API 요금제의 자동 USDC 갱신을 지원합니다. 결제 승인과 정산을 모두 Solana에서 유지하면서 암호화폐로 결제하는 고객에게 기존 SaaS 구독의 편의성을 제공하는 것이 목표입니다.
자동 결제를 활성화하려면 고객이 Helius 청구 대시보드의 결제 수단 섹션에서 Solana 지갑을 추가합니다.
설정 중 고객은 공식 Solana Subscriptions Program에 대한 일회성 승인에 서명하고 Helius가 해당 지갑에서 USDC로 구독 결제를 수금하도록 승인합니다.
갱신 청구서의 결제 기한이 되면 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 개발 소식을 확인하고 새 게시물 알림을 받아보세요


