🐷

JPYCサブスク決済を実装してみた(EIP-3009 / EIP-2612)

に公開

こんにちは!Web3特化の開発会社 Komlock lab でエンジニアをしている小原です。

前回の記事で、JPYCを使ったサブスクリプション決済の実現可能性について、いくつかのアプローチを検討しました。
https://zenn.dev/komlock_lab/articles/09a3e262dfd94c

今回はまず実際にEIP-3009を使った実装に挑戦しました。

結論から言うと、技術的には実装できたものの、UXの問題で実用は難しいと判断しました
最終的にはEIP-2612を使う方向に切り替えることにしています

この記事では、実装プロセスや課題、最終的にEIP-2612に切り替えた理由をまとめています。

EIP-3009 / EIP-2612とは?

EIP-3009とは?

EIP-3009 は transferWithAuthorization という関数を定義したトークン転送仕様です。
ユーザーが署名(Authorization)を作成し、それを 第三者(リレイヤー)がガス代を肩代わりして実行できる のが特徴です。
これにより、ユーザーはガス代を持たなくてもトークン送金が可能になります。

EIP-2612とは?

EIP-2612 は permit 関数を利用し、署名だけで「このアドレスに〇〇 JPYCまで引き落として良い」という権限(allowance)を付与できる仕組みです。

本来は approve トランザクションで権限を設定する必要がありますが、permitermit を使えば ガスレスで承認が完了します。

なお、permit はあくまで 権限の付与にすぎません。
実際の支払い(毎月の引き落とし)は transferFrom() によって実行されます。

EIP-3009でサブスク実装

まず最初に試したのが EIP-3009(transferWithAuthorization)を使った方式です。
実装自体は問題なく動きました。ただ実際に触ってみて気づいたのは、UX が決定的に破綻しているということでした。

EIP-3009 の仕組み上、「1つの署名 = 1つの送金」
となるため、サブスクと相性が悪すぎます。

例え

  • 3ヶ月プラン → メタマスク署名3回
  • 6ヶ月プラン → 署名6回
  • nonce管理 × 決済回数
  • validAfter / validBefore も毎回セット

という具合で、プランが長くなるほど署名地獄が濃くなっていきます。

ユーザー側の違和感が強すぎて、これを本番運用するのは無理と判断しました。

全体図

やっていることとしては、
フロントで複数月分の署名を生成して、API で DB に保存し、Cronで毎日決済日をチェックし、署名を使ってtransferWithAuthorizationを叩いています。

モデルがすでに複雑

EIP-3009 は月ごとに別々の署名を持つ必要があり、DB も自然とこうなります。

model SubscriptionPayment {
id String @id @default(uuid())
subscriptionId String

nonce String
validAfter BigInt
validBefore BigInt
signatureV Int
signatureR String
signatureS String

scheduledDate DateTime
status SubscriptionPaymentStatus
}

つまり、月の数だけ署名を保存 → 決済失敗時のリトライ処理も別々に管理する必要があります。

結果、実装はできるけど本番運用は無理という結論に至りました。

このタイミングで mameta さんからも
それpermit(EIP-2612)にした方がいいですよ
とアドバイスをいただきました。

これは permit がベストという意味ではなく、
そもそもブロックチェーン上でサブスクを成立させること自体が難しく、EIP-3009 では特に運用が成り立たないため、Permit は現状取り得る中での苦肉の策
という前提での話でした。

EIP-2612でサブスク実装

EIP-3009 と違い、EIP-2612 は
複数ヶ月ぶんの承認を 1回の署名にまとめられるというのが最大のメリットです。

  • 3ヶ月プラン → 署名1回
  • 6ヶ月プラン → 署名1回
  • validAfter / validBefore の管理も不要(一方で期限管理はdeadlineに一本化されるため、細かい有効期間を設定できないという側面もある)
  • 複数月分の allowance を一括で設定可能

この時点で UX が別物になります。

全体図

全体の構成は EIP-3009 版とほぼ同じですが、EIP-2612 の場合は署名が 1 回で済み、サブスク生成時にpermitを実行してallowanceを設定しておき、Cronでは毎月のtransferFromを実行するだけのシンプルなフローになります。

モデルもシンプルになった

EIP-3009 では月ごとに別々の署名を保存する必要があるため、SubscriptionPayment に 大量のフィールドを毎月分持たせていました。
EIP-2612では署名は1回だけ なので、Subscription テーブルに 単一の permit 署名だけ保存すればOKになります。

model Subscription {
id String @id @default(uuid())
customerAddress String
merchantAddress String
amount Decimal
totalAmount Decimal
totalMonths Int

// 署名1つだけ
permitDeadline BigInt
permitV Int
permitR String
permitS String
permitExecuted Boolean @default(false)
}

キャンセル処理(permit=0 でのガスレス解約)

EIP-2612 では、ユーザーが「もうこのサブスクを止めたい」と思ったときも、
permit() を使って allowance を 0 に上書きするだけで解約できます。

この仕組みのおかげで、

  • 解約時もトランザクション不要
  • 署名1回で即時キャンセル
  • transferFrom() が以後すべて拒否される

という、一般的な Web2 のキャンセル体験にかなり近い UX を実現できます。

デモ環境(Vercel)

EIP-2612方式で実際に動作するサブスク体験を用意しています。
署名1回でXヶ月分の承認→毎月 transferFrom(承認された額の中から、自動引き落としする関数)」という流れを確認できます。

https://jpyc-viewer.vercel.app/subscription

サブスクプラン契約画面
/subscription

サブスク一覧画面
/subscriptions

サブスク詳細画面(毎月の決済状況の詳細とキャンセル)

/subscriptions/XXXXX

デモは、以下の構成で動かしています。

  • Next.js(App Router / Route Handler):フロント+サーバー処理
  • wagmi / JPYC SDK:署名生成
  • PostgreSQL(Neon)+Prisma:署名とサブスク情報を保存
  • Vercel Cron:毎日 00:00 UTC にAPIを叩き決済日にtransferFrom()が発火

EIP-2612の実装の中身について

1. フロントエンド:1回の署名だけで承認を完了する

EIP-2612 では、複数月分の合計金額を1回の署名で承認できます。
まずはその署名を生成するカスタムフックを作成しました。

src/hooks/useSignPermit.ts

'use client';

import { useAccount, useSignTypedData } from 'wagmi';
import { useMutation } from '@tanstack/react-query';
import type { Hex, Address } from 'viem';
import { useNonces } from '@jpyc/sdk-react';

/**
 * EIP-2612 permit の署名を生成するカスタムフック
 *
 * このフックの役割:
 * 1. JPYC SDKのuseNoncesでユーザーのnonceを取得
 * 2. EIP-712 Permit署名を生成
 * 3. 署名パラメータ(v, r, s, deadline)を返す
 *
 * permitの利点:
 * - ユーザーは1回の署名だけで複数月分の承認が可能
 * - approve()の代わりに署名で済むのでガス代が不要
 */

interface SignPermitParams {
  spender: Address; // 承認先アドレス(加盟店)
  value: bigint; // 承認額(wei単位)
  deadline: bigint; // 有効期限(Unix timestamp)
}

interface SignPermitResult {
  deadline: bigint;
  v: number; // ECDSA署名のv
  r: Hex; // ECDSA署名のr
  s: Hex; // ECDSA署名のs
}

export function useSignPermit() {
  const { address, chainId } = useAccount();
  const { signTypedDataAsync } = useSignTypedData();

  // JPYCコントラクトアドレス(JPYC Prepaid on Sepolia)
  const jpycAddress = '0x431D5dfF03120AFA4bDf332c61A6e1766eF37BDB' as const;

  // JPYC SDKのuseNoncesを使用してユーザーの現在のnonceを取得
  const {
    data: nonce,
    refetch: refetchNonce,
    isPending: isNonceLoading,
  } = useNonces({
    owner: address as `0x${string}`,
    skip: !address,
  });

  const mutation = useMutation({
    mutationFn: async ({
      spender,
      value,
      deadline,
    }: SignPermitParams): Promise<SignPermitResult> => {
      if (!address) {
        throw new Error('Wallet not connected');
      }
      if (!chainId) {
        throw new Error('Chain ID not found');
      }

      // nonceを再取得(最新の値を使用)
      const { data: currentNonce } = await refetchNonce();
      if (currentNonce === undefined) {
        throw new Error('Failed to get nonce');
      }

      // EIP-2612のTypedDataを構築
      const domain = {
        name: 'JPY Coin',
        version: '1', // JPYCのversionは'1'
        chainId,
        verifyingContract: jpycAddress,
      };

      const types = {
        Permit: [
          { name: 'owner', type: 'address' },
          { name: 'spender', type: 'address' },
          { name: 'value', type: 'uint256' },
          { name: 'nonce', type: 'uint256' },
          { name: 'deadline', type: 'uint256' },
        ],
      };

      const message = {
        owner: address,
        spender,
        value,
        nonce: currentNonce,
        deadline,
      };

      // ユーザーに署名してもらう
      const signature = await signTypedDataAsync({
        domain,
        types,
        primaryType: 'Permit',
        message,
      });

      // 署名を分解(v, r, s)
      const r = signature.slice(0, 66) as Hex;
      const s = `0x${signature.slice(66, 130)}` as Hex;
      const v = Number(`0x${signature.slice(130, 132)}`);

      return {
        deadline,
        v,
        r,
        s,
      };
    },
  });

  return {
    signPermit: mutation.mutateAsync,
    isLoading: mutation.isPending || isNonceLoading,
    error: mutation.error,
    reset: mutation.reset,
    nonce,
  };
}

2. 申込処理:1回の署名で複数月分を承認する

ユーザーが3ヶ月や6ヶ月などのプランを選ぶと、次の handleSubscribe() が呼ばれます。

ここでは

  • 契約月数の deadline を設定
  • 合計金額を permit 署名
  • 署名をバックエンドに保存

これで全期間の承認が完了します。

申込処理(抜粋)

src/app/subscription/page.tsx

// 申込処理
const handleSubscribe = async () => {
  if (!address) {
    alert('ウォレットを接続してください');
    return;
  }

  setIsProcessing(true);
  setLoadingStep('設定情報を取得中...');

  try {
    // 設定情報を取得(リレイヤーアドレス、マーチャントアドレス)
    const configResponse = await fetch('/api/config');
    if (!configResponse.ok) {
      throw new Error('Failed to get config');
    }
    const { relayerAddress, merchantAddress } = await configResponse.json();

    if (!relayerAddress || !merchantAddress) {
      throw new Error('Config is incomplete');
    }

    // 1. permit署名(合計金額分を1回だけ承認)
    setLoadingStep('署名を生成中...');
    const now = Math.floor(Date.now() / 1000);

    const contractDurationSeconds = selectedMonths * 30 * 24 * 60 * 60; // 契約期間
    const bufferSeconds = 3 * 24 * 60 * 60; // バッファ3日
    const deadline = BigInt(now + contractDurationSeconds + bufferSeconds);

    // permit署名生成(wei単位)
    const permitSignature = await signPermit({
      spender: relayerAddress as `0x${string}`,
      value: parseUnits(totalAmount.toString(), 18),
      deadline,
    });

    // 2. バックエンドへ送信(DB保存)
    setLoadingStep('トランザクションを送信中...');
    const createResponse = await fetch('/api/subscriptions', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        customerAddress: address,
        merchantAddress,
        planName: selectedPlan,
        amount: monthlyAmount,
        totalAmount,
        billingCycle: 'monthly',
        totalMonths: selectedMonths,
        permitSignature: {
          deadline: permitSignature.deadline.toString(),
          v: permitSignature.v,
          r: permitSignature.r,
          s: permitSignature.s,
        },
      }),
    });

    if (!createResponse.ok) {
      const errorData = await createResponse.json();
      throw new Error(errorData.error || 'サブスクリプション作成に失敗しました');
    }

    setLoadingStep('トランザクション確認待ち...');
    const result = await createResponse.json();
    console.log('サブスクリプション作成完了:', result);

    alert(
      `申込完了!\nサブスクID: ${result.subscription.id}\n次回決済日: ${new Date(
        result.subscription.nextBillingDate
      ).toLocaleDateString('ja-JP')}`
    );
  } catch (error) {
    console.error('エラー:', error);
    alert(
      `エラーが発生しました: ${
        error instanceof Error ? error.message : '不明なエラー'
      }`
    );
  } finally {
    setIsProcessing(false);
    setLoadingStep('');
  }
};

3. バックエンド: 毎月 transferFrom

この処理は、サブスクの決済日が来た支払いを毎日まとめて処理するためのものです。
やっていることはシンプルで、

  • その日に支払うべきレコードを取得
  • キャンセル済み/期限切れのサブスクはスキップ
  • permit が実行済みか確認(サブスク作成時に実行済み)
  • transferFrom() で毎月の引き落としを実行
  • 成功したら DB を更新(現在サイクル・次回決済日など)
  • 失敗したら payment を failed にして retryCount を増やす

という 6 ステップです。

Cron(抜粋)

src/app/api/subscriptions/execute-pending/route.ts

import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import {
  createPublicClient,
  createWalletClient,
  http,
  type Address,
  type Hex,
} from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { sepolia } from 'viem/chains';
import { JPYC } from '@jpyc/sdk-core';

const jpyc = new JPYC({ client: walletClient });

// 3. 各支払いを実行
const results = [];

for (const payment of pendingPayments) {
  const { subscription } = payment;

  // サブスクがキャンセル済みならスキップ
  if (
    subscription.status === 'cancelled' ||
    subscription.status === 'expired'
  ) {
    console.log(
      `スキップ: サブスクがキャンセル/期限切れ (${subscription.id})`
    );
    await prisma.subscriptionPayment.update({
      where: { id: payment.id },
      data: { status: 'cancelled' },
    });
    continue;
  }

  try {
    console.log(
      `決済実行中: ${payment.id} (${subscription.customerAddress}${subscription.merchantAddress}), cycle ${payment.cycleNumber}`
    );

    // permit() はサブスク生成時に実行済みである必要がある
    if (!(subscription as any).permitExecuted) {
      console.error(
        `permit() が実行されていません: ${subscription.id}. サブスク生成時に permit() が失敗した可能性があります。`
      );
      throw new Error('permit() not executed for this subscription');
    }

    // transferFrom() を実行
    console.log(`transferFrom() 実行中: ${payment.id}`);

    const hash = await jpyc.transferFrom({
      from: subscription.customerAddress as Address,
      to: subscription.merchantAddress as Address,
      value: Number(payment.amount.toString()),
    });

    console.log(`transferFrom() トランザクション送信: ${hash}`);

    // トランザクションの完了を待つ
    const receipt = await publicClient.waitForTransactionReceipt({ hash });

    if (receipt.status === 'success') {
      // 成功: ステータスを更新
      await prisma.$transaction(async (tx: any) => {
        await tx.subscriptionPayment.update({
          where: { id: payment.id },
          data: {
            status: 'completed',
            txHash: hash,
            executedAt: new Date(),
          },
        });

        // サブスクの currentCycle を更新
        const updatedSubscription = await tx.subscription.update({
          where: { id: subscription.id },
          data: {
            currentCycle: {
              increment: 1,
            },
          },
        });

        // 全ての決済が完了したか確認
        if (
          updatedSubscription.currentCycle >=
          updatedSubscription.totalMonths
        ) {
          await tx.subscription.update({
            where: { id: subscription.id },
            data: {
              status: 'expired',
              endDate: new Date(),
            },
          });
          console.log(`サブスク完了: ${subscription.id}`);
        } else {
          // 次回決済日を更新
          const nextPayment = await tx.subscriptionPayment.findFirst({
            where: {
              subscriptionId: subscription.id,
              status: 'pending',
            },
            orderBy: {
              cycleNumber: 'asc',
            },
          });

          if (nextPayment) {
            await tx.subscription.update({
              where: { id: subscription.id },
              data: {
                nextBillingDate: nextPayment.scheduledDate,
              },
            });
          }
        }
      });

      results.push({
        paymentId: payment.id,
        status: 'success',
        txHash: hash,
      });

      console.log(`決済完了: ${payment.id}`);
    } else {
      // トランザクション送信されたが failed
      await prisma.subscriptionPayment.update({
        where: { id: payment.id },
        data: {
          status: 'failed',
          txHash: hash,
          errorMessage: 'Transaction reverted on-chain',
        },
      });

      results.push({
        paymentId: payment.id,
        status: 'failed',
        error: 'Transaction reverted',
      });

      console.log(`決済失敗(on-chain revert): ${payment.id}`);
    }
  } catch (error) {
    console.error(`決済失敗: ${payment.id}`, error);

    const errorMessage =
      error instanceof Error ? error.message : 'Unknown error';

    await prisma.subscriptionPayment.update({
      where: { id: payment.id },
      data: {
        status: 'failed',
        errorMessage,
        retryCount: payment.retryCount + 1,
      },
    });

    results.push({
      paymentId: payment.id,
      status: 'failed',
      error: error instanceof Error ? error.message : 'Unknown error',
    });
  }
}

各方式のサブスク決済における欠点

EIP-3009 と EIP-2612 を実装してみて、サブスク決済ならではの弱点がそれぞれ明確に見えてきました。

EIP-3009

EIP-3009 はガスレスで安全に単発送金できるのが強みですが、サブスクとは構造的に噛み合いません。

  • 決済回数ぶん、毎月署名が必要
    (例:6ヶ月プラン → 署名6回)

  • nonce / validAfter / validBefore を月ごとに管理する必要がある

  • どれか1つの署名が欠けると その後の決済がすべて止まる

実装は可能ですが、サブスクと根本的に相性が悪いです。

EIP-2612

EIP-2612は署名1回で複数月ぶんの承認ができ、UX の大幅改善が可能です。
ただし、運用上の制約があります。

合計額を一括で承認する必要がある

承認額は「月額 × 契約月数」でまとめるため、金額が大きくなりやすいです。
これは心理的にもセキュリティ的にも負担があり、承認額がそのまま上限として引き出せてしまう点は注意が必要です。

両方式に共通する欠点

一番大きいのが、どちらの方式でも完全な自動更新は実現できません。

理由はシンプルで、

  • EIP-3009 → 毎月新しい署名が必要
  • EIP-2612 → transferFrom() で allowance が減り、契約終了でゼロになる

つまり 許可された金額は有限であり、追加署名なしで永続的に引き落とすことはできない という構造的制約があります。

現実的な落としどころ

  • 3 / 6 / 12ヶ月などの 固定期間型サブスク
  • 契約終了前に 再承認のお願いを通知

現時点ではこれが最も現実的な運用になる気がします。

次に狙う未来:Account Abstraction(AA)

もしAA Wallet + Session Key を使うなら、サブスク決済の実現し得る体験は大きく変わるかもしれません。

Session Key があれば

Session Key は、ウォレット用の 期間限定のアクセス権のようなものです。
↓のようなことができるようになります。

  • 1回の承認で条件付き権限を付与できる
  • 「月1,000 JPYCまで」「3回まで」「90日まで」など細かい上限設定が可能
  • Relayer が勝手に実行できる(ユーザー署名不要)
  • ガス代もユーザー不要(Paymaster)

つまり本当にサブスクっぽいサブスクが作れます

これは本気で触りたい領域です。今回はスコープ外なので見送りましたが、次に必ず取り組みたい部分です。

まとめ

結論:JPYCサブスクは EIP-2612が今のところ現実解だった。

  • EIP-3009 → UX が致命的で採用困難
  • EIP-2612 → 署名1回で済むので実用可能
  • DB構成もシンプルになり実装しやすい
  • リスクはあるが対策すれば許容範囲ではある
  • 自動更新はまだ難しい
  • AA によって真のサブスクが実現しそう

今回の記事が、同じように JPYCのサブスク決済を検討している方の参考になれば幸いです。

コードはすべて GitHub に公開しています。
https://github.com/br-to/jpyc-viewer

記事が参考になった方は、ぜひ Zenn のフォローや
X(@brto_0224) のフォローもよろしくお願いします! 🙌

参考資料

https://eips.ethereum.org/EIPS/eip-3009
https://eips.ethereum.org/EIPS/eip-2612
https://eips.ethereum.org/EIPS/eip-4337
https://github.com/jcam1/jpyc-sdk

Komlock lab

Discussion