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";
 
// The subscriber sends this transaction itself, so the authorization must use
// the next nonce: the account nonce increments before authorizations are checked.
const txNonce = await provider.getTransactionCount(subscriberWallet.address);
 
const signedAuth = await subscriberWallet.authorize({
  chainId: CHAIN_ID,
  address: SUBSCRIPTION_MANAGER,
  nonce: txNonce + 1,
});
 
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 논스를 사용하므로 체인 간 또는 위임 간에 재생할 수 없습니다.
권장 안전 장치.
  • 프로덕션 사용 전에 위임 계약을 감사하십시오.
  • 구독자 잔액에 비해 구독당 금액을 적게 유지하십시오.
  • SubscriptionCreated / SubscriptionCollected 이벤트를 모니터링하고 구독자에게 표시하십시오.
  • 구독자에게 자신의 EOA에서 cancelSubscription(subscriptionId)를 호출하는 명확한 "취소" UI를 제공하십시오.

중요한 고려 사항

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

다음 권장 사항

  • 구독 청구 개념: 풀 기반 청구 모델을 이해합니다.
  • 계정 추상화: 위임된 EOA에서 여러 호출을 하나의 트랜잭션으로 일괄 처리합니다.
  • 세션 키: 감사된 스마트 계정에 범위가 지정된 만료 키를 부여합니다.
  • EIP-7702 개념: 이를 가능하게 하는 위임 모델을 검토합니다.