결제구독

구독 플랜

구독 플랜을 통해 판매자는 사용자가 수락할 수 있는 청구 조건을 게시할 수 있습니다. 사용자가 구독하면, 판매자 또는 승인된 수금자가 각 청구 기간마다 플랜 금액까지 수금할 수 있습니다.

이 가이드는 전체 흐름을 구성 요소로 보여줍니다. 판매자가 플랜을 생성하고, 구독자가 이를 수락하며, 판매자 또는 수금자가 생성된 구독 PDA에서 결제를 수금합니다.

설치

pnpm add @solana/subscriptions @solana/kit @solana/kit-plugin-rpc @solana/kit-plugin-signer @solana-program/token

플랜 생성

판매자가 플랜을 소유합니다. 플랜 PDA는 판매자 주소와 planId에서 파생됩니다.

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가 변경되지 않는 한, 플랜의 최종 청구 기간 중에도 편집이 가능합니다.
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와 구독자 주소로부터 도출됩니다.

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();

구독 재개

취소된 구독은 해지되기 전에 재활성화할 수 있습니다. 해지된 후에는 구독 계정이 닫히며, 구독자는 다시 구독해야 합니다. 재개를 위해서는 플랜의 민트에 대한 구독자의 SubscriptionAuthority가 필요하며, 프로그램은 이를 (소유자, 민트, init_id) 기준으로 검증하고, 오래되었거나 재초기화된 경우 거부합니다.

await subscriberClient.subscriptions.instructions
.resumeSubscription({
subscriber: subscriberSigner,
planPda,
tokenMint,
})
.sendTransaction();

참고 사항

  • amount는 기본 단위 기준입니다. 소수점 6자리 토큰의 경우, 5_000_0005 토큰을 의미합니다.
  • TypeScript SDK는 subscribe 호출 시 플랜 약관을 생략하면 최신 값을 자동으로 가져옵니다.
  • Rust SubscribeBuilder는 예상되는 플랜 약관이 필요합니다. 먼저 플랜 계정을 가져와 디코딩한 후, 해당 필드를 SubscribeData를 통해 전달하세요.
  • 결제 수금은 판매자 또는 pullers에 등록된 지갑만 수행할 수 있습니다.
  • 구독자는 설정, 취소, 해지 트랜잭션에 서명합니다. 판매자 또는 승인된 수금자가 수금 트랜잭션에 서명합니다.

Is this page helpful?

목차

페이지 편집