💱

JPYC SDKを触ってみた:Next.jsでECサイト決済を実装してみる(支払い編)

に公開

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

前回の記事では、Web2エンジニアでも理解できるように、JPYC SDKを使ってウォレット接続〜残高表示までを実装しました。

https://zenn.dev/komlock_lab/articles/96bbc400b9bae7

今回はその続きとして、実際にJPYCで支払うところまで含めたECサイト風の決済フローを作っていきます。

背景:ブロックチェーンでの「支払い」ってどういうこと?

Web2では、クレカやPayPayなどの決済処理はAPI経由でサーバーが金額を引き落とす仕組みです。
一方Web3では、「支払い=トークン送金」そのものになります。

ただ、ここで課題があります。
ユーザーがトークン(JPYCなど)を送るには、ガス代 (ブロックチェーンのネットワーク利用料です)が必要です。
つまりJPYCで払いたいだけなのに、ガス代となる別のトークンも持っていないと送金できません。

そこで今回は、ユーザーがガス代を持っていなくても支払いできるガスレス方式を採用します。
この仕組みを実現するのが、EIP-3009 で定義されているtransferWithAuthorizationという関数です。

今回のゴール

まずは、今回作るガスレス決済フローの動きを見てみましょう👇

このように、ユーザーがウォレットを接続し、
ガス代を持っていなくてもJPYCで支払いが完了 するのが今回のゴールです。

前回はステップ2〜4を実装しました。
今回は 1(支払い方法選択)と 5(実際の支払い処理) を追加して、全体の流れを完成させます。

  1. ユーザーが「JPYCで支払う」を選択
  2. 「ウォレットを接続」ボタンが表示される
  3. ユーザーがウォレットを接続すると、JPYC残高を自動取得
  4. サイト上に「あなたのJPYC残高: 5,000 JPYC」と表示
  5. 残高が商品代金より多ければ「購入確定」ボタンを押せる

今回使う主要ライブラリ

今回の構成も Next.js 15 + React 19 をベースにし、ウォレット接続から支払い実行までを一貫して実装します。

ライブラリ 役割
RainbowKit MetaMaskなどのウォレットを接続するためのUIコンポーネント。ボタン1つで接続機能を実装できる。
wagmi Ethereum対応DAppでウォレット接続・残高取得・送金などを行うReact Hooks集。
JPYC SDK React版 JPYCの残高確認や送金をReactフックで簡単に実装できるライブラリ(フロントエンド用)
@tanstack/react-query データフェッチやキャッシュ管理を行うライブラリ。非同期状態(読み込み中・成功・エラー)を統一的に扱える。JPYC SDKの内部でも利用されている。
viem Ethereum開発のための軽量・高速・型安全なTypeScriptライブラリ
JPYC SDK Core版 JPYCのコントラクト操作を簡単に扱うNode.js向けライブラリ。バックエンド側で使用。

実装

それでは決済画面の実装を始めていきます。
今回は、サンプル商品A・Bを購入するケースを想定しています。

決済画面は /payment に作成し、購入が完了すると /complete に遷移して注文完了メッセージを表示します。

UIはShopify風の購入手続き画面を再現

まずはUIから。
構成は「お届け先」「支払い方法」「注文内容」で、Shopifyの購入手続き画面を意識しています👇

購入手続き画面

注文完了画面

フロント部分では主に次のような処理を行っています。

  • クレジットカードは今回関係ないので利用不可
  • JPYCを選択しウォレットを接続し、JPYCの残高を表示
  • 残高が足りれば「お支払いを確定する」ボタンが有効化
  • 残高不足または未接続の場合はボタンが無効化
  • トランザクション実行中はローディングを表示し、ウォレット操作や2重送信防止
  • 購入完了後は /complete に遷移し、注文完了画面を表示

ウォレット接続や残高表示の実装は前回の記事で説明したので、今回は割愛します。

UI部分のコードは以下で確認できます👇

最初にハマったこと

まず最初は、EIP-3009のような仕組みを使わず、シンプルにJPYC SDK React版の useTransfer hookを使って、フロントエンドからそのまま送金を行う実装を試してみました。単にJPYCを送ってバックエンドで注文完了処理すればいけるのではと思っていたからです。

const { transfer } = useTransfer();
await transfer({ to: merchantAddress, amount: 100 });

// このあとバックエンド側で注文完了処理を走らせる想定です。(例:注文データの保存、在庫更新、支払い状態を"paid"に変更 など)

これでJPYCを送ることはできるのですが、実際に決済として使うには不十分でした。

  1. ユーザーがガス代を負担する必要がある

    支払額 + ガス代がかかるため、ユーザー体験としては良くないし、混乱を招く。

  2. バックエンドで注文とトランザクションを紐付けにくい

    どの注文に対して支払いが行われたのかをサーバー側で管理できない。

  3. 二重送信や再試行時の整合性が取れない

    useTransfer は純粋な送金処理なので、アプリとしての「決済」ロジックには不向き。

つまり、「送金」≠「決済」 なんです。

ここで参考になったのが、mameta さんの記事です。

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

EIP-3009 のtransferWithAuthorizationを利用したガスレス送金の設計パターンが非常にわかりやすく、これをベースにJPYC SDK × EIP-3009 構成で決済フローを再設計しました。

EIP-3009とは

EIP-3009は、ユーザーが署名を発行し、他者(サーバーなど)がその署名を使って送金できる仕組みを定義した規格です。

これにより、

  • ユーザーは署名するだけ(ガス代不要)
  • サーバーが transferWithAuthorization を実行
  • 注文と支払いを安全に紐付け可能

Web2でいうと「ユーザーがクレカ支払いを承認 → サーバーが決済を実行する」に近いです。
通常の送金ではユーザー自身がトランザクションを送る必要がありますが、
EIP-3009では署名だけで済むため、UXがWeb2に近づくのが最大のメリットです。

支払いフロー全体の流れ

支払いフローは次のような流れになります👇

このフローで重要なのは次の3点です:

  1. ユーザーは署名のみを行う(ガス代不要)
    • ウォレットでEIP-712署名を承認するだけでOK。
    • 実際のトランザクション送信は行いません。
  2. サーバー(リレイヤー)が実際にトランザクションを実行
    • transferWithAuthorization() を呼び出し、ガス代を負担。
    • ブロックチェーン上で送金を確定させる。
  3. 署名と注文をpaymentIdで安全に紐付け
    • フロントとバックエンドが同じIDを共有。
    • 二重送信やリプレイ攻撃を防止。

このフローを通じて、ユーザーは署名だけで支払いを完了でき、実際のトランザクションはサーバーが代理送信します。

支払いリクエスト設計:paymentId と nonce

src/app/api/orders/route.ts

const paymentId = crypto.randomUUID();
const nonce = keccak256(toHex(paymentId));

ここでは、アプリケーション側の決済ID(paymentId)と、ブロックチェーン側の署名ID(nonce)を1対1で紐付けています。

  • paymentId はアプリケーションで生成する 注文の識別子(UUID)。
  • nonce は paymentId をハッシュ化した値で、ブロックチェーン上での一意な署名識別子として使われます。

この2つを紐付けることで、

  • 同一注文の複数署名防止
  • リプレイ攻撃防止
  • コントラクト改修なしの一意性保証

が実現できます。

フロントエンド:署名を作成し、サーバーへ送信する

支払いフローは次の2つのカスタムフックで構成しています。

useSignTransferAuthorization.ts

ユーザーが支払い署名を行うフックです。
内部では EIP-712 の Typed Data 署名を生成しています。

JPYC SDK React版にこのようなフックは現時点では含まれていませんが、
これを使えば「署名をどう作るの?」という疑問を一気に解消できます。

つまり、「支払い時に署名が必要」と言われても具体的にどうすれば良いか分からない……
そんな場面でも、このフックをそのまま使えば、署名生成のロジックを意識せずに済みます。

src/hooks/useSignTransferAuthorization.ts

'use client';

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

/**
 * EIP-3009 transferWithAuthorization の署名を生成するカスタムフック
 *
 * このフックの役割:
 * 1. バックエンドから受け取ったnonceを使用(自分では生成しない)
 * 2. EIP-712署名を生成
 * 3. 署名パラメータ(v, r, s)を返す
 *
 * なぜフロントでnonce生成しないのか:
 * - セキュリティ: バックエンドがnonceの一意性を保証
 * - リプレイ攻撃防止: 同じorderIdで複数回署名できないようにする
 */

interface SignTransferAuthorizationParams {
  nonce: Hex; // バックエンドから受け取ったnonce
  to: string; // 送金先アドレス
  value: bigint; // 送金額(wei単位)
  validAfter?: number; // 有効期間の開始(Unix timestamp)
  validBefore?: number; // 有効期間の終了(Unix timestamp)
}

interface SignTransferAuthorizationResult {
  v: number; // ECDSA署名のv
  r: Hex; // ECDSA署名のr
  s: Hex; // ECDSA署名のs
  validAfter: number;
  validBefore: number;
}

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

  const mutation = useMutation({
    mutationFn: async ({
      nonce,
      to,
      value,
      validAfter,
      validBefore,
    }: SignTransferAuthorizationParams): Promise<SignTransferAuthorizationResult> => {
      if (!address) {
        throw new Error('Wallet not connected');
      }
      if (!chainId) {
        throw new Error('Chain ID not found');
      }

      // 1. 有効期間のデフォルト値を設定
      // validAfter: 現在時刻 - 60秒(時刻のズレを考慮)
      // validBefore: 現在時刻 + 1時間(1時間後に無効)
      const now = Math.floor(Date.now() / 1000);
      const _validAfter = validAfter ?? now - 60; // 60秒前から有効
      const _validBefore = validBefore ?? now + 3600; // 1時間後まで有効

      // 2. JPYCコントラクトアドレス(JPYC Prepaid)
      // NOTE: これはJPYC Prepaidのアドレスです、通常のJPYCとは異なるコントラクトアドレスを使用しています
      const jpycAddress = '0x431D5dfF03120AFA4bDf332c61A6e1766eF37BDB';

      // 3. EIP-712のTypedDataを構築
      // これはJPYCコントラクトが要求する署名フォーマット
      // 注意: versionは '1' を使用(JPYCコントラクトの仕様)
      const domain = {
        name: 'JPY Coin',
        version: '1',
        chainId,
        verifyingContract: jpycAddress as Hex,
      };

      const types = {
        TransferWithAuthorization: [
          { name: 'from', type: 'address' },
          { name: 'to', type: 'address' },
          { name: 'value', type: 'uint256' },
          { name: 'validAfter', type: 'uint256' },
          { name: 'validBefore', type: 'uint256' },
          { name: 'nonce', type: 'bytes32' },
        ],
      };

      const message = {
        from: address,
        to: to as Hex,
        value,
        validAfter: BigInt(_validAfter),
        validBefore: BigInt(_validBefore),
        nonce,
      };

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

      // 4. 署名を分解(v, r, s)
      // 署名は65バイト: r(32) + s(32) + v(1)
      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 {
        v,
        r,
        s,
        validAfter: _validAfter,
        validBefore: _validBefore,
      };
    },
  });

  return {
    signTransferAuthorization: mutation.mutateAsync,
    isLoading: mutation.isPending,
    error: mutation.error,
    reset: mutation.reset,
  };
}

useJPYCPayment.ts

署名データをバックエンドの /api/orders/[paymentId]/authorize に送信し、

トランザクションの完了を監視するhookです。

src/hooks/useJPYCPayment.ts

import { useMutation } from '@tanstack/react-query';
import { useWaitForTransactionReceipt } from 'wagmi';

export const useJPYCPayment = () => {
  const mutation = useMutation(async ({ paymentId, authorization }) => {
    const res = await fetch(`/api/orders/${paymentId}/authorize`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(authorization),
    });
    return res.json();
  });

  const waitForTx = useWaitForTransactionReceipt({
    hash: mutation.data?.txHash,
    enabled: !!mutation.data?.txHash,
  });

  return { pay: mutation.mutateAsync, receipt: waitForTx.data };
};

  1. paymentIdnonce を生成
  2. useSignTransferAuthorization で署名を作成
  3. useJPYCPayment で署名をサーバーへ送り、トランザクションの完了を監視

この3ステップで「ガスレス決済の体験」を実現しています。

署名を受け取って送金を実行する

最後に、フロントから送信された署名データ(v, r, s 含む)を受け取り、
リレイヤーとして送金を代行するのが /api/orders/[paymentId]/authorize API です。
ここでは**リレイヤー(サーバーウォレット)**がユーザーの代わりに送金を実行します。
ユーザーは署名を渡すだけで、ガス代を持っていなくても支払いが完了します。

src/app/api/orders/[paymentId]/authorize/route.ts

import { type NextRequest, NextResponse } from 'next/server';
import { createWalletClient, http, type Hex } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
import { sepolia } from 'viem/chains';
import { JPYC } from '@jpyc/sdk-core';
import { Uint256, Uint8 } from 'soltypes';

// リレイヤー(サーバーウォレット)の秘密鍵
// このウォレットが transferWithAuthorization を実行し、代わりにガス代を支払います。
const BACKEND_PRIVATE_KEY = process.env.BACKEND_PRIVATE_KEY as Hex;

export async function POST(
  request: NextRequest,
  { params }: { params: Promise<{ paymentId: string }> }
) {
  try {
    const { paymentId } = await params;
    const body = await request.json();
    const { from, to, value, validAfter, validBefore, nonce, v, r, s } = body;

    // 1. paymentId と nonce の対応を DB に保存
    // (AuthorizationUsed イベントで後から照合できるようにする)
    // await db.payments.update({ ... });

    // 2. サーバーウォレットの設定
    if (!BACKEND_PRIVATE_KEY) {
      throw new Error('BACKEND_PRIVATE_KEY is not set');
    }

    // サーバーウォレットを生成
    const account = privateKeyToAccount(BACKEND_PRIVATE_KEY);
    const client = createWalletClient({
      account,
      chain: sepolia,
      transport: http(),
    });

    // 3. JPYC SDK インスタンスを作成
    const jpyc = new JPYC({ client });

    // 4. transferWithAuthorization を実行
    // ユーザー署名を検証し、リレイヤーが送金を代行
    const txHash = await jpyc.transferWithAuthorization({
      from: from as Hex,
      to: to as Hex,
      value: Number(value) / 1e18, // wei → JPYC(decimals補正)
      validAfter: Uint256.from(validAfter.toString()),
      validBefore: Uint256.from(validBefore.toString()),
      nonce: nonce as Hex,
      v: Uint8.from(String(v)),
      r: r as Hex,
      s: s as Hex,
    });

    // 5. DB更新(完了状態と txHash を保存)
    // await db.payments.update({ ... });

    return NextResponse.json({
      success: true,
      paymentId,
      txHash,
      message: 'Payment processed successfully by relayer wallet',
    });
  } catch (error) {
    console.error('支払い処理エラー:', error);
    return NextResponse.json(
      {
        success: false,
        error: error instanceof Error ? error.message : 'Unknown error',
        details: error instanceof Error ? error.stack : String(error),
      },
      { status: 500 }
    );
  }
}

現時点ではDB保存などは省略していますが、
実際の運用では「注文テーブル」と「トランザクション結果」を紐付ける想定です。

【WEB2エンジニア向け】初めてのWEB3用語解説

今回もいくつか、専門用語が出てきたので、簡単に解説します。

  • スマートコントラクト(Smart Contract)

ブロックチェーン上で自動実行されるプログラムです。
送金、NFTの発行、投票などのルールをコードとして記述し、公開・実行します。

  • 秘密鍵

ブロックチェーン上では、ウォレット=鍵ペア(公開鍵と秘密鍵) で構成されています。
公開鍵:ウォレットアドレスとして使われる(誰にでも知られてOK)
秘密鍵:トランザクション署名に使う(絶対に漏らしてはいけない)

秘密鍵を持つ者は、そのウォレットの資産を完全に操作できる権限を持ちます。
つまり、秘密鍵が漏洩するとウォレットの資金をすべて奪われる可能性があるので取り扱いには慎重を期しましょう。

  • 署名(Signature)

ウォレットを使って「この取引を自分が承認しました」と証明する電子署名です。
スマートコントラクト上で取引を実行するために必須のステップになります。

  • nonce(ナンス)

1回限りの一意な識別子です。
同じ署名を再利用できないようにするために使われます。

  • リレイヤー(Relayer)

ユーザーが作成した署名を受け取り、代わりにブロックチェーン上でトランザクションを実行する代理サーバーのことです。
ガス代をリレイヤー側が肩代わりすることで、ユーザーはガス代を持っていなくても送金できます。

  • EIP-712(Typed Data署名)

EIP-712 は、署名の内容を「構造化データ(Typed Data)」として定義する規格です。
署名対象の内容(送金先・金額・期限など)をユーザーが目で確認でき、
悪意あるトランザクションを防止できます。

デモ環境(Vercel)

今回も実際に決済の動作を確認できる検証環境を公開しています👇
※ Sepoliaテストネット上で動作する検証環境です。実際の送金は発生しません。

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

実際にこちらで決済を試し、Etherscanを見るとトランザクションの流れがよくわかると思います。

まとめ

JPYC SDKとEIP-3009を組み合わせることで、ユーザーはガス代を意識せずに支払いが完了します。
つまり、Web2のUXを保ったままJPYCの決済を安全に実現できるということです。

観点 内容
導入のしやすさ SDKを使えばウォレット接続から支払いまでの実装をシンプルに構成できる。
UXの自然さ ガスレス決済により「JPYCを選んで支払う」という直感的な体験。
ビジネス的メリット 日本円建ての安定トークンで、為替リスクを気にせず導入可能。

JPYCは「ブロックチェーンを使った決済」を特別なものにせず、
“既存のECサイトにもう1つの支払い手段を追加する”感覚で導入できるのが特徴です。
Web3を深く知らなくても、SDKを使えばJPYC決済を安全かつシンプルに導入可能です。

今回の実装を通して、“JPYCによる支払いは現実的な選択肢になりつつある”
と感じてもらえたら嬉しいです。

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

Komlock lab

Discussion