구독 플랜을 통해 판매자는 사용자가 수락할 수 있는 청구 조건을 게시할 수 있습니다. 사용자가 구독하면, 판매자 또는 승인된 수금자가 각 청구 기간마다 플랜 금액까지 수금할 수 있습니다.
이 가이드는 전체 흐름을 구성 요소로 보여줍니다. 판매자가 플랜을 생성하고, 구독자가 이를 수락하며, 판매자 또는 수금자가 생성된 구독 PDA에서 결제를 수금합니다.
설치
pnpm add @solana/subscriptions @solana/kit @solana/kit-plugin-rpc @solana/kit-plugin-signer @solana-program/token
플랜 생성
판매자가 플랜을 소유합니다. 플랜 PDA는 판매자 주소와 planId에서 파생됩니다.
스폰서는 선택적 payer를 전달하여 플랜의 rent를 대납할 수 있으며, 가맹점은 플랜 소유자로 유지됩니다. 플랜을 삭제하면 환불은 payer가 아닌 소유자에게 이루어지므로, 스폰서십 검증은 오프체인에서 처리하세요.
import { address, createClient } from '@solana/kit';import { solanaLocalRpc } from '@solana/kit-plugin-rpc';import { signer } from '@solana/kit-plugin-signer';import { findPlanPda, subscriptionsProgram } from '@solana/subscriptions';const merchantClient = createClient().use(signer(merchantSigner)).use(solanaLocalRpc({ rpcUrl: 'http://127.0.0.1:8899' })).use(subscriptionsProgram());const planId = 1n;const tokenMint = address('TOKEN_MINT_ADDRESS_HERE');const amount = 5_000_000n;const periodHours = 720n;const metadataUri = 'https://example.com/plan.json';const destinations = [merchantSigner.address];const pullers = [address('PULLER_WALLET_ADDRESS_HERE')];await merchantClient.subscriptions.instructions.createPlan({planId,mint: tokenMint,amount,periodHours,endTs: 0n,destinations,pullers,metadataUri,}).sendTransaction();const [planPda] = await findPlanPda({owner: merchantSigner.address,planId,});
플랜 업데이트
판매자는 생성 후 변경 가능한 플랜 필드를 업데이트할 수 있습니다. 기존 구독자는 자신이 수락한 조건을 유지하며, 새로운 구독자는 현재 플랜 조건을 수락합니다.
몇 가지 규칙이 적용됩니다:
- 유한한
endTs는 단축만 가능하며, 연장하거나 초기화할 수 없습니다 (PlanEndTsCannotExtend). Sunset플랜에서는 풀러를 제거할 수 있으며 (새 집합은 현재 집합의 부분집합이어야 함), 손상된 풀러의 접근을 차단할 수 있습니다. 상태,endTs, 메타데이터는 고정된 상태로 유지됩니다.endTs가 변경되지 않는 한, 플랜의 최종 청구 기간 중에도 편집이 가능합니다.- 이 명령어는 서명 시점에 확인된 플랜 상태(
expectedCreatedAt,expectedEndTs,expectedPullers,expectedMetadataUri)를 함께 전달합니다. 실제 플랜이 더 이상 일치하지 않으면 업데이트가 거부(StalePlanApproval)되므로, 오래된 서명된 업데이트로는 제거된 puller를 복원하거나 이후 편집을 되돌릴 수 없습니다. 플러그인 클라이언트의updatePlan은 최신 상태를 자동으로 가져오며, 수동으로 빌드할 경우 플랜을 직접 조회하여 필드를 전달하세요.
import { PlanStatus } from '@solana/subscriptions';const updatedMetadataUri = 'https://example.com/updated-plan.json';const updatedPullers = [address('NEW_PULLER_WALLET_ADDRESS_HERE')];await merchantClient.subscriptions.instructions.updatePlan({owner: merchantSigner,planPda,status: PlanStatus.Active,endTs: 0n,pullers: updatedPullers,metadataUri: updatedMetadataUri,}).sendTransaction();
구독
구독자는 현재 플랜 약관에 동의합니다. 구독 PDA는 플랜 PDA와 구독자 주소로부터 도출됩니다.
권한 초기화와 구독을 단일 트랜잭션으로 묶으려면, TypeScript SDK에서 내보낸 센티넬 값 UNKNOWN_INIT_ID를 expectedSubscriptionAuthorityInitId로 전달하세요. 프로그램은 해당 권한이 현재 slot에서 생성된 경우에만 수락하므로, 이 센티넬은 신규 가입에 적합합니다. 이전 slot에서 권한이 생성된 기존 사용자는 실제 initId를 전달해야 하며(플러그인 클라이언트가 자동으로 조회함), 그렇지 않으면 StaleSubscriptionAuthority 오류가 발생합니다.
import { createClient } from '@solana/kit';import { solanaLocalRpc } from '@solana/kit-plugin-rpc';import { signer } from '@solana/kit-plugin-signer';import { findAssociatedTokenPda, TOKEN_PROGRAM_ADDRESS } from '@solana-program/token';import {fetchMaybeSubscriptionAuthority,findSubscriptionAuthorityPda,findSubscriptionDelegationPda,subscriptionsProgram,} from '@solana/subscriptions';const subscriberClient = createClient().use(signer(subscriberSigner)).use(solanaLocalRpc({ rpcUrl: 'http://127.0.0.1:8899' })).use(subscriptionsProgram());const [subscriberAta] = await findAssociatedTokenPda({mint: tokenMint,owner: subscriberSigner.address,tokenProgram: TOKEN_PROGRAM_ADDRESS,});const [subscriptionAuthorityPda] = await findSubscriptionAuthorityPda({user: subscriberSigner.address,tokenMint,});const subscriptionAuthority = await fetchMaybeSubscriptionAuthority(subscriberClient.rpc,subscriptionAuthorityPda,);if (!subscriptionAuthority.exists) {await subscriberClient.subscriptions.instructions.initSubscriptionAuthority({tokenMint,tokenProgram: TOKEN_PROGRAM_ADDRESS,userAta: subscriberAta,}).sendTransaction();}await subscriberClient.subscriptions.instructions.subscribe({merchant: merchantSigner.address,planId,tokenMint,}).sendTransaction();const [subscriptionPda] = await findSubscriptionDelegationPda({planPda,subscriber: subscriberSigner.address,});
결제 수금
판매자 또는 화이트리스트에 등록된 풀러가 수금에 서명합니다. 플랜에 수신지 허용
목록이 사용되는 경우, 수신자 token account의 소유자가 destinations에 등록되어
있어야 합니다.
const receiverAta = address('MERCHANT_TOKEN_ACCOUNT_ADDRESS_HERE');await merchantClient.subscriptions.instructions.transferSubscription({caller: merchantOrPullerSigner,delegator: subscriberSigner.address,tokenMint,subscriptionPda,planPda,amount: 200_000n,receiverAta,tokenProgram: TOKEN_PROGRAM_ADDRESS,}).sendTransaction();
취소 및 해지
취소는 구독을 종료 예정으로 표시합니다. 해지는 취소 만료 기간이 경과한 후 구독 PDA를 닫습니다. 구독자는 두 트랜잭션 모두에 서명합니다.
await subscriberClient.subscriptions.instructions.cancelSubscription({subscriber: subscriberSigner,planPda,subscriptionPda,}).sendTransaction();// Run this after the cancelled subscription's expiresAtTs has elapsed.await subscriberClient.subscriptions.instructions.revokeSubscription({authority: subscriberSigner,planPda,subscriptionPda,}).sendTransaction();
즉시 취소
구독자와 현재 플랜 소유자가 모두 서명하면, cancelSubscriptionNow는 청구 기간 종료 시점이 아닌 취소 시점에 구독을 만료시킵니다. 또한 보류 중인 유예 기간 취소를 단축할 수도 있습니다. 승인은 서명 시점에 확인된 기간 시작 시각에 바인딩되며, 그 이후 구독이 변경된 경우 트랜잭션은 거부(StaleSubscriptionApproval)됩니다.
import { fetchSubscriptionDelegation } from '@solana/subscriptions';const subscription = await fetchSubscriptionDelegation(subscriberClient.rpc,subscriptionPda,);await subscriberClient.subscriptions.instructions.cancelSubscriptionNow({subscriber: subscriberSigner,merchant: merchantSigner,planPda,expectedCurrentPeriodStartTs: subscription.data.currentPeriodStartTs,}).sendTransaction();
구독 재개
취소된 구독은 해지되기 전에 재활성화할 수 있습니다. 해지된 후에는 구독 계정이
닫히며, 구독자는 다시 구독해야 합니다. 재개를 위해서는 플랜의
민트에 대한 구독자의 SubscriptionAuthority가 필요하며, 프로그램은 이를
(소유자, 민트, init_id) 기준으로 검증하고, 오래되었거나 재초기화된 경우
거부합니다.
이 명령어는 구독자가 서명 시점에 확인한 만료 시각(expectedExpiresAtTs)을 함께 전달합니다. 값이 일치하지 않으면 거부(StaleSubscriptionApproval)되므로, 오래된 서명된 재개 요청으로는 구독자가 승인하지 않은 이후의 취소를 해제할 수 없습니다.
import { fetchSubscriptionDelegation } from '@solana/subscriptions';const subscription = await fetchSubscriptionDelegation(subscriberClient.rpc,subscriptionPda,);await subscriberClient.subscriptions.instructions.resumeSubscription({subscriber: subscriberSigner,planPda,tokenMint,expectedExpiresAtTs: subscription.data.expiresAtTs,}).sendTransaction();
참고 사항
amount는 기본 단위 기준입니다. 소수점 6자리 토큰의 경우,5_000_000는5토큰을 의미합니다.- TypeScript SDK는
subscribe호출 시 플랜 약관을 생략하면 최신 값을 자동으로 가져옵니다. - Rust
SubscribeBuilder는 예상되는 플랜 약관이 필요합니다. 먼저 플랜 계정을 가져와 디코딩한 후, 해당 필드를SubscribeData를 통해 전달하세요. - 결제 수금은 판매자 또는
pullers에 등록된 지갑만 수행할 수 있습니다. - 구독자는 설정, 취소, 해지 트랜잭션에 서명합니다. 판매자 또는 승인된 수금자가 수금 트랜잭션에 서명합니다.
Is this page helpful?