Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

구독 및 수금

이 가이드는 구독자가 한 번 인증하면 서비스 제공자가 EIP-7702 계정 추상화를 통해 각 청구 주기마다 자동으로 수금하는 구독 결제 시스템을 구축하는 과정을 안내합니다.

무엇을 만들게 되나요

전체 구독 라이프사이클: 구독자가 한 번 위임 및 구독하고, 제공자가 일정에 따라 수금하며(반복 동작을 증명하기 위해 두 번째 주기 표시), 구독자가 취소합니다.

데모

step 1. Subscriber delegates EOA to SubscriptionManager (EIP-7702)
        tx: 0x7702...aaaa

step 2. Subscriber registers subscription (10 USDT0 / 30 days)
        subscriptionId: 0xabc...
        nextChargeAt:   2026-05-23T12:00:00Z

step 3. Provider calls collect() on day 30
        collected:    10 USDT0
        gas cost:     ~0.000050 USDT0
        nextChargeAt: 2026-06-22T12:00:00Z

step 4. Provider calls collect() on day 60
        collected:    10 USDT0
        gas cost:     ~0.000050 USDT0
        nextChargeAt: 2026-07-22T12:00:00Z

step 5. Subscriber cancels
        subscription: inactive

개요

구독자:
─── Subscriber ───────────────────────────────────────
// One-time setup: delegate EOA to the subscription contract
signAuthorization(delegateContract)
sendTransaction({ type: 4, authorizationList: [signedAuth] })
 
// Subscribe: set billing terms on own EOA
sendTransaction({ to: self, data: subscribe(subscriptionId, provider, amount, interval) })
 
// Cancel: revoke billing access at any time
sendTransaction({ to: self, data: cancelSubscription(subscriptionId) })
서비스 제공자:
─── Service Provider ────────────────────────────────
// Each billing cycle: collect payment from subscriber's EOA
// The delegate contract verifies caller, billing schedule, and amount
sendTransaction({ to: subscriberEOA, data: collect(subscriptionId) })
 
// Automate with a cron job matching the billing interval
// The contract reverts if called before the interval has elapsed

위임 컨트랙트

구독 청구는 구독자의 EOA를 청구 조건을 강제하는 컨트랙트로 위임하여 작동합니다. EIP-7702를 통해 구독자의 계정은 일시적으로 컨트랙트 로직을 획득하므로, 서비스 제공자는 구독자가 매번 서명할 필요 없이 각 청구 주기마다 결제를 수금할 수 있습니다.

기존에 배포된 컨트랙트를 사용하거나 직접 배포할 수 있습니다. 아래 예시는 세 가지 작업을 지원하는 최소한의 SubscriptionManager 컨트랙트입니다:

  • subscribe: subscriptionId에 대한 청구 조건을 등록합니다.
  • collect: 제공자가 해당 subscriptionId에 대해 다음 예정된 결제를 수금합니다.
  • cancelSubscription: 구독자가 특정 구독을 취소합니다.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
 
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
 
/// @title SubscriptionManager (example)
/// @notice Delegate contract for EIP-7702 subscription billing.
///         Runs on the subscriber's EOA via delegation.
contract SubscriptionManager {
    struct Subscription {
        address provider;
        uint256 amount;
        uint256 interval;
        uint256 nextChargeAt;
        bool active;
    }
 
    // Keyed by subscriptionId.
    // Storage is already per subscriber EOA under delegation.
    mapping(bytes32 => Subscription) public subscriptions;
 
    IERC20 public immutable usdt0;
 
    event SubscriptionCreated(
        bytes32 indexed subscriptionId,
        address indexed provider,
        uint256 amount,
        uint256 interval,
        uint256 nextChargeAt
    );
 
    event SubscriptionCollected(
        bytes32 indexed subscriptionId,
        address indexed provider,
        uint256 amount,
        uint256 collectedAt
    );
 
    event SubscriptionCancelled(bytes32 indexed subscriptionId);
 
    constructor(address _usdt0) {
        usdt0 = IERC20(_usdt0);
    }
 
    /// @notice Register a subscription. Called by the subscriber on their own EOA.
    function subscribe(
        bytes32 subscriptionId,
        address provider,
        uint256 amount,
        uint256 interval
    ) external {
        require(msg.sender == address(this), "subscriber only");
        require(provider != address(0), "invalid provider");
        require(amount > 0, "invalid amount");
        require(interval > 0, "invalid interval");
        require(!subscriptions[subscriptionId].active, "already exists");
 
        uint256 nextChargeAt = block.timestamp + interval;
 
        subscriptions[subscriptionId] = Subscription({
            provider: provider,
            amount: amount,
            interval: interval,
            nextChargeAt: nextChargeAt,
            active: true
        });
 
        emit SubscriptionCreated(subscriptionId, provider, amount, interval, nextChargeAt);
    }
 
    /// @notice Collect a payment for a specific subscription. Called by the service provider.
    function collect(bytes32 subscriptionId) external {
        Subscription storage sub = subscriptions[subscriptionId];
 
        require(sub.active, "not active");
        require(msg.sender == sub.provider, "not provider");
        require(block.timestamp >= sub.nextChargeAt, "too early");
 
        sub.nextChargeAt += sub.interval;
 
        require(usdt0.transfer(sub.provider, sub.amount), "transfer failed");
 
        emit SubscriptionCollected(subscriptionId, sub.provider, sub.amount, block.timestamp);
    }
 
    /// @notice Cancel a specific subscription. Called by the subscriber.
    function cancelSubscription(bytes32 subscriptionId) external {
        require(msg.sender == address(this), "subscriber only");
        require(subscriptions[subscriptionId].active, "not active");
 
        delete subscriptions[subscriptionId];
 
        emit SubscriptionCancelled(subscriptionId);
    }
}

구성

// config.ts
import { ethers } from "ethers";
 
export const STABLE_TESTNET_RPC = "https://rpc.testnet.stable.xyz";
export const CHAIN_ID = 2201;
export const USDT0_ADDRESS = "0x78Cf24370174180738C5B8E352B6D14c83a6c9A9";
export const SUBSCRIPTION_MANAGER = "0xYourDeployedSubscriptionManager";
 
export const provider = new ethers.JsonRpcProvider(STABLE_TESTNET_RPC);
export const subscriberWallet = new ethers.Wallet(process.env.SUBSCRIBER_KEY!, provider);

1단계: 구독자의 EOA 위임 (EIP-7702)

구독자는 EIP-7702 인증에 서명하여 자신의 EOA를 SubscriptionManager로 위임합니다. 이후 구독자의 EOA는 위임 컨트랙트의 로직을 실행합니다.

// delegate.ts
import { subscriberWallet, provider, CHAIN_ID, SUBSCRIPTION_MANAGER } from "./config";
 
const authorization = {
  chainId: CHAIN_ID,
  address: SUBSCRIPTION_MANAGER,
  nonce: await provider.getTransactionCount(subscriberWallet.address),
};
 
const signedAuth = await subscriberWallet.signAuthorization(authorization);
 
const tx = await subscriberWallet.sendTransaction({
  type: 4,
  to: subscriberWallet.address,
  authorizationList: [signedAuth],
  maxPriorityFeePerGas: 0n,
});
 
const receipt = await tx.wait(1);
console.log("Delegation tx:", receipt.hash);
npx tsx delegate.ts
Delegation tx: 0x7702...aaaa

2단계: 구독 등록 (구독자)

구독자는 자신의 EOA에서 subscribe()를 호출합니다. EOA가 위임되어 있으므로 이는 SubscriptionManager.subscribe를 실행합니다.

// subscribe.ts
import { ethers } from "ethers";
import { subscriberWallet } from "./config";
 
const subscriptionManager = new ethers.Interface([
  "function subscribe(bytes32 subscriptionId, address provider, uint256 amount, uint256 interval)",
]);
 
const serviceProvider = "0xServiceProviderAddress";
const monthlyAmount = ethers.parseUnits("10", 6); // 10 USDT0
const interval = 30 * 24 * 60 * 60;              // 30 days in seconds
 
// Derive a unique subscriptionId from provider + plan name + local nonce
const subscriptionId = ethers.solidityPackedKeccak256(
  ["address", "string", "uint256"],
  [serviceProvider, "pro-monthly", 1]
);
 
const tx = await subscriberWallet.sendTransaction({
  to: subscriberWallet.address, // call self (delegate code executes)
  data: subscriptionManager.encodeFunctionData("subscribe", [
    subscriptionId,
    serviceProvider,
    monthlyAmount,
    interval,
  ]),
  maxPriorityFeePerGas: 0n,
});
 
const receipt = await tx.wait(1);
console.log("Subscription registered, tx:", receipt.hash);
console.log("Subscription ID:", subscriptionId);
npx tsx subscribe.ts
Subscription registered, tx: 0xabcd...1234
Subscription ID: 0xfedc...9876

3단계: 결제 수금 (서비스 제공자)

각 청구 주기마다 서비스 제공자는 구독자의 EOA에서 collect(subscriptionId)를 호출합니다. 위임 로직은 USDT0를 전송하기 전에 호출자, 청구 일정, 금액을 검증합니다.

// collect.ts
import { ethers } from "ethers";
 
const provider = new ethers.JsonRpcProvider("https://rpc.testnet.stable.xyz");
const providerWallet = new ethers.Wallet(process.env.PROVIDER_KEY!, provider);
 
const subscriptionManager = new ethers.Interface([
  "function collect(bytes32 subscriptionId)",
]);
 
const subscriberEOA = "0xSubscriberEOAAddress";
const subscriptionId = "0xYourSubscriptionId";
 
const tx = await providerWallet.sendTransaction({
  to: subscriberEOA, // subscriber's EOA (runs delegate code)
  data: subscriptionManager.encodeFunctionData("collect", [subscriptionId]),
  maxPriorityFeePerGas: 0n,
});
 
const receipt = await tx.wait(1);
console.log("Payment collected, tx:", receipt.hash);
console.log("Gas used:", receipt.gasUsed.toString());
 
// In production, run this on a cron schedule matching the billing interval.
// The delegate contract will revert if called before the interval has elapsed.
npx tsx collect.ts
Payment collected, tx: 0x8f3a...2d41
Gas used: 52000

collect() 호출은 Stable에서 대략 50k-55k 가스(21k 기본 + 7702 위임 오버헤드 + ERC-20 transfer)가 소요됩니다. 1 gwei 기본 수수료 기준으로, 이는 제공자가 청구 주기당 약 0.000050 USDT0를 지불하는 것입니다.

4단계: 구독 취소 (구독자)

구독자는 자신의 EOA에서 cancelSubscription(subscriptionId)를 호출하여 해당 특정 구독에 대한 청구 접근 권한을 취소합니다.

// cancel.ts
import { ethers } from "ethers";
import { subscriberWallet } from "./config";
 
const subscriptionManager = new ethers.Interface([
  "function cancelSubscription(bytes32 subscriptionId)",
]);
 
const subscriptionId = "0xYourSubscriptionId";
 
const tx = await subscriberWallet.sendTransaction({
  to: subscriberWallet.address,
  data: subscriptionManager.encodeFunctionData("cancelSubscription", [subscriptionId]),
  maxPriorityFeePerGas: 0n,
});
 
const receipt = await tx.wait(1);
console.log("Subscription cancelled, tx:", receipt.hash);
npx tsx cancel.ts
Subscription cancelled, tx: 0xdef0...5678

보안 모델

구독자는 위임 컨트랙트가 자신의 EOA에서 자금을 인출하도록 인증하는 것입니다. 그 인증이 정확히 무엇을 포함하는지, 그리고 노출을 어떻게 제한할지 이해하세요.

구독자가 인증하는 것. SubscriptionManager에 위임함으로써 구독자는 컨트랙트의 로직에 자신의 EOA에 대한 완전한 실행 권한을 부여합니다. 위임은 컨트랙트에 코딩된 조건 하에서만 자금을 전송할 수 있습니다: 호출자가 등록된 제공자이고, 간격이 경과했으며, 금액이 저장된 구독과 일치하는 경우입니다. 컨트랙트 코드가 그러한 동작을 허용하지 않기 때문에, 다른 주소로 전송하거나 간격 검사를 우회할 수 없습니다.

완화해야 할 실패 모드.
  • 악의적인 위임 업그레이드: SubscriptionManager가 관리자에 의해 구현이 변경될 수 있는 프록시인 경우, 인증은 사실상 해당 관리자를 신뢰하는 것입니다. 불변 컨트랙트 또는 투명하고 타임락이 적용된 업그레이드를 가진 프록시에만 위임하세요.
  • 제공자 침해: 제공자의 키가 유출되면, 공격자는 주기당 금액까지 조기 결제를 수금할 수 있습니다. 구독자는 구독별로 spendingLimit을 설정하고 인증되지 않은 SubscriptionCollected 이벤트를 모니터링해야 합니다.
  • 위임 교체: 다른 위임으로 다시 구독하면 구독 상태가 지워집니다. 기능별로 하나의 위임을 사용하는 대신, 단일 위임 하에서 여러 함수(구독, 일괄 결제, 지출 한도)를 지원하는 모듈식 위임을 사용하세요.
  • 재생 가능한 서명: 모든 서명은 구독자의 EOA에 연결된 EIP-7702 nonce를 사용하므로, 체인 간 또는 위임 간에 재생할 수 없습니다.
권장 가드레일.
  • 프로덕션 사용 전에 위임 컨트랙트를 감사하세요.
  • 구독별 금액을 구독자의 잔액 대비 작게 유지하세요.
  • SubscriptionCreated / SubscriptionCollected 이벤트를 모니터링하고 구독자에게 표시하세요.
  • 구독자에게 자신의 EOA에서 cancelSubscription(subscriptionId)을 호출하는 명확한 "취소" UI를 제공하세요.

중요한 고려 사항

  • 지속적인 위임: EIP-7702 위임은 구독자가 명시적으로 변경하거나 지울 때까지 지속됩니다. 각 청구 주기마다 재위임이 필요하지 않습니다.
  • EOA당 단일 위임: 구독자가 나중에 다른 컨트랙트로 위임하면, 구독 위임 로직이 교체되어 수금이 실패합니다. 단일 위임 하에서 여러 함수(구독, 일괄 결제, 지출 한도, 세션 키)를 지원하는 모듈식 위임 컨트랙트를 사용하세요.
  • 일정 동작: 이 예시는 각 성공적인 수금마다 nextChargeAt을 한 간격씩 진행합니다. 둘 이상의 청구 기간이 경과한 경우, 반복적인 collect() 호출로 한 번에 한 기간씩 따라잡을 수 있습니다. 제품에 다른 정책이 필요한 경우 로직을 확장하세요.
  • 감사된 위임 사용: 감사를 받은 컨트랙트에만 위임하세요.

다음 권장 사항

  • 구독 청구 개념 — 풀 기반 청구 모델을 이해하세요.
  • 계정 추상화 — 일괄 결제, 지출 한도, 세션 키가 하나의 위임 하에서 어떻게 결합되는지 확인하세요.
  • EIP-7702 개념 — 이를 가능하게 하는 위임 모델을 검토하세요.