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 논스를 사용하므로 체인 간 또는 위임 간에 재생되지 않습니다.
권장 보호 장치.
  • 프로덕션 사용 전에 위임 계약을 감사하십시오.
  • 구독자의 잔액에 비해 구독당 금액을 작게 유지하십시오.
  • SubscriptionCreated / SubscriptionCollected 이벤트를 모니터링하고 구독자에게 표시하십시오.
  • 구독자에게 자신의 EOA에서 cancelSubscription(subscriptionId)를 호출하는 명확한 "취소" UI를 제공하십시오.

중요 고려사항

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

다음 권장 사항

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