💰

JPYC決済の実装パターンを考える:オフチェーン決済IDとオンチェーンTxを紐付けるためには

に公開

はじめに

JPYCは、USDCと同様にEIP-2612 (permit)EIP-3009 (Authorization系)といった規格を実装しており、さまざまな決済機能があります。

しかし、これらの機能を実際の決済システム(ECサイト、POSレジ、サブスクリプション)に組み込む際の課題の1つとして、「オフチェーンで管理される決済ID(例:order_123)と、ブロックチェーン上で実行されるトランザクションの紐付け方法」という問題です。

この記事は、JPYC決済の導入を考えている開発者に向けて
「どの注文に対する支払いだったのか?」ということをブロックチェーン上で追えるようにするための具体的なパターンをいくつか解説します。

JPYCの機能だけで開発できるパターンは詳しく、そしてPermit2やAccount Abstraction(AA)、CREATE2など応用的な方法は紹介程度にサクッと説明していきます。

またJPYCの関数はこちらに全て解説しておりますので、参考されてみて下さい。

https://zenn.dev/mameta29/articles/4dcb803377b4ae

JPYC決済の基礎:なぜtransferでは不十分か?

まず、基本となるERC20の関数と、それが決済において抱える課題を簡単に確認します。

1. transfer: シンプルだが、情報がない

transfer(to, amount)は最も基本的な送金機能ですが、オンチェーンのデータには「誰から(from)」「誰に(to)」「いくら(amount)」という情報しか含まれません。

これだと、事業者が受け取った送金が「ECサイトのどの注文ID(paymentId)のものか」を特定できません(難しいです)。同じ金額の注文が同時に複数入った場合、区別が難しくなります。

2. approve + transferFrom: 「決済コントラクト」をかませる

この問題を解決する標準的な方法が、approve(承認)とtransferFromの組み合わせです。

  1. ユーザーが、事業者の「決済ゲートウェイ・コントラクト」に対し、approveで送金を許可
  2. 決済ゲートウェイ・コントラクトが、transferFromを使ってユーザーのウォレットから事業者のウォレットへ資金を移動させる

この方法の最大の利点は、transferFromを**カスタム関数でラップ(包む)**できることです。

// 決済ゲートウェイ・コントラクトの例
contract PaymentGateway {
    IERC20 public jpyc;
    address public merchantWallet;

    event PaymentSettled(bytes32 indexed paymentId, address indexed customer, uint256 amount);

    // ユーザーがこの関数を呼び出す *paymentIdを引数に渡せる
    function executePayment(bytes32 paymentId, uint256 amount) external {
        // 1. ユーザー(msg.sender)からこのコントラクトへ資金を移動
        jpyc.transferFrom(msg.sender, merchantWallet, amount);
        
        // 2. ★ 決済IDを含めたイベントを発行 ★
        emit PaymentSettled(paymentId, msg.sender, amount);
    }
}

このように、executePaymentのようなカスタム関数にpaymentIdを持たせて、オンチェーンのイベントにpaymentIdを明示的に記録できます。これが、決済ID紐付けの基本的な方法です。


しかし、この方法ではユーザーは「approve(1回目)」と「executePayment(2回目)」の2回のトランザクションが必要になり、UXが悪いです。

これを解決するのが前述した、permitEIP-3009です。

【主要パターン1】 permit + transferFrom:イベントで決済IDを刻む

これは、前述のapprove + transferFromの課題を、EIP-2612のpermitを使って解決する最も標準的なパターンです。approve操作をガスレスの「オフチェーン署名」に置き換えます。

コンセプト

  1. ユーザー(オフチェーン):
    ECサイト上で、JPYCを決済ゲートウェイ・コントラクトに資産の移転を許可するpermit署名をする。ガス代はかからない
  2. リレイヤー(オフチェーン):
    事業者のバックエンド(リレイヤー)が、このpermit署名と、紐付けたいpaymentIdを受け取る。
  3. ゲートウェイ(オンチェーン):
    リレイヤーが、paymentIdpermit署名を引数にして、決済ゲートウェイ・コントラクトのexecutePaymentWithPermit(下記コード参照)のような関数を呼び出す(ガス代はリレイヤー負担)。
  4. コントラクト内部の動作:
    a. jpyc.permit(...)を呼び出し、署名を使ってガスレスでapproveする
    b. jpyc.transferFrom(...)で、許可された資金をユーザーから事業者へ移転
    c. emit PaymentSettled(paymentId, ...)で、paymentIdを含むイベントを発行👉ブロックチェーン上に決済IDを記録できる

コードイメージ (Solidity)

import "@openzeppelin/contracts/token/ERC20/extensions/draft-IERC2612.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";

contract PaymentGatewayWithPermit {
    IERC2612 public immutable jpyc;
    address public immutable merchantWallet;

    event PaymentSettled(bytes32 indexed paymentId, address indexed customer, uint256 amount);

    constructor(address jpycAddress, address merchant) {
        jpyc = IERC2612(jpycAddress);
        merchantWallet = merchant;
    }

    // リレイヤーがこの関数を叩く
    function executePaymentWithPermit(
        bytes32 paymentId, // オフチェーンの決済ID
        address customer,
        uint256 amount,
        // EIP-2612 permit 署名
        uint256 deadline,
        uint8 v,
        bytes32 r,
        bytes32 s
    ) external {
        // 1. 署名を検証し、Approveを実行
        jpyc.permit(customer, address(this), amount, deadline, v, r, s);

        // 2. 許可された資金をユーザーから事業者へ移動
        jpyc.transferFrom(customer, merchantWallet, amount);

        // 3. 決済IDをオンチェーンイベントとして記録
        emit PaymentSettled(paymentId, customer, amount);
    }
}

メリット (Pros)

  • 監査性: 自作したコントラクトからpaymentIdが明示的にemitされるため、後からトランザクション+決済IDを追えたり、インデックスが簡単にできる。
  • 柔軟性: executePaymentWithPermit関数内で、手数料の計算、返金ロジックの追加、他のコントラクトの呼び出しなど、複雑なビジネスロジックなど、カスタマイズで実装可能。

デメリット (Cons)

  • コスト: メリットの裏返しではあるが、コントラクトを独自にデプロイしなければならなかったり、そもそも資金を扱うコードなので監査が必要になったりする。
  • 信頼が必要: ユーザーは、この(事業者が用意した)決済ゲートウェイ・コントラクトを信頼するしかない。もし悪意のあるコードである場合にはユーザーの資産が抜かれてしまう可能性がある。

【主要パターン2】 EIP-3009 (transferWithAuthorization):nonceを決済IDとして使う

これは、EIP-3009のnonceの仕様を活用し、カスタムコントラクト無しで決済IDを紐付ける、自分的におすすめなパターンです。

コンセプト

EIP-3009のtransferWithAuthorization関数は、リプレイ攻撃を防ぐためにbytes32 nonceという引数を取ります。EIP-2612のnonceがインクリメント(連番)であるのに対し、EIP-3009のnonce任意の32バイト値を指定できます。

  1. バックエンド(オフチェーン):
    決済要求が発生した際、UUIDv4などでユニークなpaymentIdを生成する。
    bytes32 nonce = keccak256(abi.encodePacked(paymentId)); のように、paymentIdのハッシュ値を計算し、これをnonceとする。
    このpaymentId ↔ nonceの対応をデータベースに保存します。
  2. ユーザー(オフチェーン):
    transferWithAuthorization(またはreceiveWithAuthorization)のためのEIP-712署名を行います。この署名には、バックエンドが指定したnonceが含まれます。ガス代は不要
  3. リレイヤー(オンチェーン):
    リレイヤーがユーザーの署名とnonceを使って、JPYCコントラクトのtransferWithAuthorizationを直接呼び出す(ガス代はリレイヤー負担)。
  4. JPYCコントラクト(オンチェーン):
    送金が実行され、JPYCコントラクト自体が標準イベントであるAuthorizationUsed(address indexed authorizer, bytes32 indexed nonce)を発行する。👉ブロックチェーン上にnonceとして決済IDを記録できる

オンチェーンとオフチェーン情報の紐付け

事業者のインデクサー(監視システム)は、JPYCコントラクトが発行するAuthorizationUsedイベントを監視します。

イベントでnonce(例: 0x...abcdef) をキャッチできたら、データベースを検索し、「このnonceは、どのpaymentId(例: order_123)に対応していたか」を逆引きして突合します。

メリット (Pros)

  • コントラクト不要: 追加のカスタムコントラクトをデプロイする必要がない。JPYCトークンコントラクトの標準機能だけで完結するのは大きいメリット。
  • シンプル&低コスト: コントラクト不要にも通じますが、システムがシンプルになり、デプロイや監査のコストがない。

デメリット (Cons)

  • 突合の複雑さ: 「誰に(to)」「いくら(amount)」という情報を知るには、同じトランザクションハッシュ(txHash)内で発生したTransferイベントも併せて監視し、2つのイベントを突合させる必要がある。RPCの不具合などがあった場合はどうするかなど、考えるべき部分は多い。

はい、承知いたしました。
いただいたフィードバックを元に、「参考パターン」のセクションを修正・改善します。


【参考パターン1】 Permit2 + witness:署名に決済IDを入れ込む

ここからは、技術的にも難易度が段階が上がりますが、参考程度に上記以外のいくつかのパターンをご紹介します。

フロー

Permit2は「規格」ではなく、Uniswapが各チェーンにデプロイした単一のスマートコントラクトです。EIP-2612 (permit) に未対応のトークンでも、署名ベースの承認移転を可能にします。

  1. 【初回のみ・オンチェーン】 ユーザー → Permit2
    ユーザーは、最初の一度だけ、自分のJPYC(または他のトークン)をPermit2コントラクト自体に対して、オンチェーンでapproveを実行します。(ガス代が必要です)
  2. 【決済ごと・オフチェーン】 ユーザー → 事業者
    以降の決済では、ユーザーはpermitWitnessTransferFromのための「Permit2用の署名」をオフチェーンで行い、事業者に渡します。(ユーザーのガス代は不要です)
  3. 【決済ごと・オンチェーン】 事業者 → Permit2
    事業者はその署名を受け取り、ガス代を負担してPermit2コントラクトのpermitWitnessTransferFrom関数を呼び出します。

witness とは?

Permit2のpermitWitnessTransferFromという特別な関数は、送金署名に「追加の文脈(= witness」を暗号学的に含めることができます。

コンセプト:
paymentIdのハッシュ値を、このwitnessデータとして署名に含めます。

オンチェーンとオフチェーン情報の紐付け:
Permit2コントラクトは、署名を検証する際、「送金内容」だけでなく「witness(=paymentIdのハッシュ)」も一致しているか検証します。

これによって、「この署名は、このpaymentIdの決済以外では絶対に使えない」ということを暗号学的に保証できます。パターン1(permittransferFromのラッパー)では、paymentIdは単なる関数の引数でしたが、Permit2+witnessでは、paymentIdが署名そのものと結びくようになります。

メリット (Pros)

  • セキュリティ: 署名と決済IDが暗号学的に紐付くため、署名の使い回し(リプレイ攻撃)や文脈外で何かしら悪用しようとした時にも防止できます。
  • トークンの汎用性: これはJPYCに限らず、EIP-2612未対応のトークンでも、同じPermit2のフローで決済システムを構築できます。

デメリット (Cons)

  • 複雑さ: EIP-712の署名データ構造(witnessTypeStringの定義など)が非常に複雑になり、フロントエンドでの署名生成の難易度は上がります。
  • 外部依存: UniswapのPermit2という外部コントラクトに依存します。
  • 初回approveの手間: ユーザーは最初に一度だけ、Permit2コントラクトへのオンチェーンapprove(ガス代が発生)を実行する必要があります。

【参考パターン2】 Account Abstraction (ERC-4337):userOpHashで決済全体を追跡する

これは何か?

Account Abstraction (AA) またはERC-4337は、ユーザーのウォレット自体をスマートコントラクト(スマートコントラクトウォレット, SCW)にする仕組みです。

  • 従来のウォレット(EOA)では、ユーザーは「トランザクション(Tx)」に署名しました。
  • SCWでは、ユーザーは「ユーザーオペレーション(UserOperation, userOp)」という「やりたいことの指示書」に署名します。
  • このuserOpを「バンドラー」と呼ばれるリレイヤーが拾い集め、ブロックチェーン上の「EntryPoint」という単一のコントラクトを通じて実行します。

オンチェーンとオフチェーン情報の紐付け

この仕組みでは、userOpHash というハッシュ値が、オフチェーンのpaymentIdとオンチェーンの実行結果を紐付けるために機能します。

  1. 発行時(POSアプリ側):
    • 決済アプリがuserOp(例:「私のウォレットから、A店のウォレットへ100JPYC送る」)を作る。
    • このuserOpをバンドラーに送信する(eth_sendUserOperation)。
    • このRPCの戻り値として得られる userOpHash(一意のID)を取得する。
    • この時点で、オフチェーンDBに (paymentId, userOpHash) を保存し、ステータスをPendingにする。
  2. 確定時(インデクサー側):
    • バンドラーによってuserOpが実行されると、EntryPointコントラトはUserOperationEvent(bytes32 indexed userOpHash, ...)というイベントを発行する。
    • インデクサーはこのイベントを監視して、**DBに保存したuserOpHash**を見つけたら、対応するpaymentIdのステータスをSettledに更新する。

メリット (Pros)

  • 一貫して追跡できる: userOpHashという単一のIDで、オフチェーンでの発行リクエスト時からオンチェーンでの確定時まで、処理全体を一貫して追える。
  • AAのいろんな機能: ユーザーのガス代肩代わり(Paymaster)、複数操作のバッチ処理、ソーシャルリカバリーなど、AAでできるさまざまなUXを決済に組み込める。

デメリット (Cons)

  • インフラが必要: ユーザーがSCW(ERC-4337準拠)を使っている必要がある。
  • エコシステムへの依存: バンドラーやPaymasterといったAAインフラの安定稼働に依存します。(ちなみに、現状JPYCでガス代を直接支払えるようなPaymasterはまだないはずです)

【参考パターン3】 1決済1アドレス (CREATE2 Proxyパターン)

アーキテクチャ

これは、1回の決済ごとに専用の送金先アドレスをCREATE2によって事前計算で生成し、ユーザーがそこに送金した後、コントラクトの「実体」をデプロイして資金を回収する方式です。

こちらのOpenZeppelinの記事を参考にしています

https://www.openzeppelin.com/news/getting-the-most-out-of-create2?utm_source=chatgpt.com

  • ロジックコントラクト (Logic Contract): 決済ロジック(資金回収、返金など)を持つ大元のコントラクト。
  • プロキシ (Proxy): ロジックコントラクトの呼び出しを中継する代理コントラクト。creation_code(生成コード)が常に同じである点が重要。
  • ファクトリー (Factory): CREATE2を使い、指定したsaltでProxyのインスタンスをデプロイするためのコントラクト。

オンチェーンとオフチェーン情報の紐付けフロー

  1. 【準備】
    「ロジックコントラクト」と「ファクトリーコントラクト」を事前にデプロイしておきます。
  2. 【アドレスの事前計算】
    オフチェーンでpaymentIdが発行されると、事業者はpaymentIdに紐づくsalt(一意な値)を生成します。CREATE2の仕組み(hash(factory_address, salt, proxy_creation_code))を使い、決済専用の「送金先アドレス」をオフチェーンで事前計算します。この「paymentId ↔ 決済専用アドレス」の対応をDBに保存します。
  3. 【ユーザー送金 (Counterfactual)】
    ユーザーは、ステップ2で事前計算された「送金先アドレス」(まだ実体はデプロイされていない)宛に、JPYCをtransferします。
  4. 【入金の検知】
    オフチェーンの監視システムがJPYCのTransferイベントを監視します。入金先がDBに保存されている「送金先アドレス」のいずれかと一致した場合、対応するpaymentIdの決済が実行されたと特定します。
  5. 【コントラクトの実体化と資金回収】
    事業者は「ファクトリー」を呼び出し、ステップ2で使ったsaltを指定して、該当のアドレスにProxyコントラクトの実体をデプロイします。デプロイ後、Proxyの**初期化関数(initialize)**を呼び出し、入金されたJPYCを事業者のウォレットへ回収します。

メリット (Pros)

  • transfer関数で決済IDと紐付けできる: CREATE2により「決済IDと1対1で紐づくアドレス」を事前に特定でき、ユーザーはシンプルなtransferを実行するだけで済む。

デメリット (Cons)

  • ガス代: 最終的に「Proxyデプロイ + 資金回収」のトランザクションが必要になり、L2であっても他のパターンに比べてガス代が高くなる。
  • 複雑: CREATE2のアドレス計算、ファクトリーパターンの管理、資金回収の実行管理など、システム全体の設計・運用がかなり複雑。

結論と推奨パターン

JPYC決済で現実世界(オフチェーン)の決済IDとオンチェーンTxを紐付ける方法を見てきました。どのパターンを選ぶかは、システムの要件と許容できる複雑さによります。

特に複雑な決済システムを必要としないのであれば個人的には【パターン2】EIP-3009の特に noncepaymentIdハッシュとして使う というのが難しいこと考えず、独自でコントラクトをデプロイする必要もないのでいいよなぁと思います。もしコントラクトを自分で書いてそれをデプロイして実際に使用するとなれば監査も必要になります。なるべくコントラクトを書かなくてもよい実装がJPYCにはされているので、ぜひ決済システムの開発に活用されてみて下さい!

JPYCが色々な場面で使えるようになると、ブロックチェーンと現実世界との境界を意識することなく決済やRWAなどのやり取りが普通になり、何か中央集権的な存在に干渉されない、個々人がより自由な世の中になると信じています。

GitHubで編集を提案

Discussion