NEROChainコミュニティに参加して、今後の情報をお待ちください!

NERO Chainトランザクションの支払い方法

このチュートリアルでは、NERO Chain Paymasterを使用してトランザクションのガス料金に異なる支払い方法を実装する方法を説明します。スポンサー付きトランザクションの代替としてERC20トークン支払いに焦点を当てています。

学習内容

  • NERO Chain Paymasterがサポートするトークンのリストを取得する方法
  • トランザクションに異なる支払いタイプ(スポンサー付き、前払い、後払い)を実装する方法
  • Paymasterの操作のためのトークン承認を処理する方法
  • アプリケーションの支払い選択インターフェースを構築する方法

前提条件

Paymaster支払いタイプの理解

前回のチュートリアルでは、開発者がすべてのガスコストを負担するスポンサー付きトランザクション(タイプ0)の使用方法を学びました。NERO Chain Paymasterは、さらに2つの支払いタイプをサポートしています:

  1. ERC20前払い(タイプ1):ユーザーはERC20トークンでガスコストを前払いします
  2. ERC20後払い(タイプ2):ユーザーはトランザクション実行後にERC20トークンでガスコストを支払います

これらの支払いタイプを実装する前に、まずPaymaster APIからサポートされているトークンを取得する必要があります。

ステップ1:サポートされているトークンの取得

サポートされているトークンを照会するユーティリティ関数を作成しましょう:

// src/utils/aaUtils.ts
import { ethers } from 'ethers';
import { Client, Presets } from 'userop';
import { 
  NERO_CHAIN_CONFIG, 
  AA_PLATFORM_CONFIG, 
  CONTRACT_ADDRESSES,
  API_KEY 
} from '../config';
 
// 過剰なAPI呼び出しを避けるためのキャッシュ
let tokenCache: any[] = [];
let lastFetchTime: number = 0;
const CACHE_DURATION = 5 * 60 * 1000; // 5分
 
// トークンレスポンスデータの変換
const transformTokensResponse = (response: any) => {
  if (!response || !response.tokens) return [];
  
  return response.tokens.map((token: any) => ({
    address: token.token,
    decimal: parseInt(token.decimal),
    symbol: token.symbol,
    type: parseInt(token.type),
    price: token.price ? parseFloat(token.price) : undefined
  }));
};
 
// Paymaster APIからサポートされているトークンを取得
export const getSupportedTokens = async (accountSigner: ethers.Signer) => {
  // まずキャッシュをチェック
  const now = Date.now();
  if (tokenCache.length > 0 && now - lastFetchTime < CACHE_DURATION) {
    console.log("キャッシュされたトークンリストを使用");
    return tokenCache;
  }
  
  try {
    // SimpleAccountビルダーを初期化
    const builder = await Presets.Builder.SimpleAccount.init(
      accountSigner,
      NERO_CHAIN_CONFIG.rpcUrl,
      {
        overrideBundlerRpc: AA_PLATFORM_CONFIG.bundlerRpc,
        entryPoint: CONTRACT_ADDRESSES.entryPoint,
        factory: CONTRACT_ADDRESSES.accountFactory,
      }
    );
    
    // AAウォレットアドレスを取得
    const sender = await builder.getSender();
    
    // トークンを照会するための最小限のUserOp
    const minimalUserOp = {
      sender,
      nonce: "0x0",
      initCode: "0x",
      callData: "0x",
      callGasLimit: "0x88b8",
      verificationGasLimit: "0x33450",
      preVerificationGas: "0xc350",
      maxFeePerGas: "0x2162553062",
      maxPriorityFeePerGas: "0x40dbcf36",
      paymasterAndData: "0x",
      signature: "0x"
    };
    
    // paymaster API呼び出し用のプロバイダーを設定
    const provider = new ethers.providers.JsonRpcProvider(AA_PLATFORM_CONFIG.paymasterRpc);
    
    // paymaster APIからサポートされているトークンを照会
    const tokensResponse = await provider.send("pm_supported_tokens", [
      minimalUserOp,
      API_KEY,
      CONTRACT_ADDRESSES.entryPoint
    ]);
    
    // 結果を変換してキャッシュ
    const tokens = transformTokensResponse(tokensResponse);
    tokenCache = tokens;
    lastFetchTime = now;
    
    console.log(`${tokens.length}個のサポートされているトークンが見つかりました`);
    return tokens;
  } catch (error) {
    console.error("サポートされているトークンの取得エラー:", error);
    
    // 利用可能な場合はキャッシュデータを返し、それ以外は空の配列
    return tokenCache.length > 0 ? tokenCache : [];
  }
};

ステップ2:ERC20トークン承認の処理

ERC20トークンをガスに使用する場合(支払いタイプ1と2)、ユーザーのAAウォレットは最初にトークンペイマスターコントラクトを承認する必要があります:

// src/utils/aaUtils.ts
// 既存のユーティリティ関数に以下を追加
 
// ペイマスターのトークン承認を処理
export const approveTokenForPaymaster = async (
  accountSigner: ethers.Signer,
  tokenAddress: string,
  amount: string = ethers.constants.MaxUint256.toString()
) => {
  try {
    // クライアントを初期化
    const client = await Client.init(NERO_CHAIN_CONFIG.rpcUrl, {
      overrideBundlerRpc: AA_PLATFORM_CONFIG.bundlerRpc,
      entryPoint: CONTRACT_ADDRESSES.entryPoint,
    });
    
    // ビルダーを初期化
    const builder = await Presets.Builder.SimpleAccount.init(
      accountSigner,
      NERO_CHAIN_CONFIG.rpcUrl,
      {
        overrideBundlerRpc: AA_PLATFORM_CONFIG.bundlerRpc,
        entryPoint: CONTRACT_ADDRESSES.entryPoint,
        factory: CONTRACT_ADDRESSES.accountFactory,
      }
    );
    
    // AAウォレットアドレスを取得
    const aaWalletAddress = await builder.getSender();
    
    // トークンコントラクトインターフェースを作成
    const erc20Interface = new ethers.utils.Interface([
      'function approve(address spender, uint256 amount) returns (bool)',
      'function allowance(address owner, address spender) view returns (uint256)'
    ]);
    
    // 現在の許可額を確認
    const provider = new ethers.providers.JsonRpcProvider(NERO_CHAIN_CONFIG.rpcUrl);
    const tokenContract = new ethers.Contract(
      tokenAddress,
      [
        'function allowance(address owner, address spender) view returns (uint256)'
      ],
      provider
    );
    
    const currentAllowance = await tokenContract.allowance(
      aaWalletAddress,
      CONTRACT_ADDRESSES.tokenPaymaster
    );
    
    // 許可額が十分な場合は早期に戻る
    if (!currentAllowance.lt(ethers.utils.parseUnits("100", 18))) {
      console.log("トークンは既に承認されています");
      return true;
    }
    
    console.log("ペイマスターのトークンを承認中...");
    
    // 承認のコールデータを作成
    const approveCallData = erc20Interface.encodeFunctionData(
      'approve',
      [CONTRACT_ADDRESSES.tokenPaymaster, amount]
    );
    
    // 無料ガス用のペイマスターを設定(承認トランザクション用)
    builder.setPaymasterOptions({
      apikey: API_KEY,
      rpc: AA_PLATFORM_CONFIG.paymasterRpc,
      type: "0" // 承認には無料を使用
    });
    
    // ガスパラメータを設定
    const gasParams = {
      callGasLimit: "0x88b8",
      verificationGasLimit: "0x33450",
      preVerificationGas: "0xc350",
      maxFeePerGas: "0x2162553062",
      maxPriorityFeePerGas: "0x40dbcf36",
    };
    
    builder.setCallGasLimit(gasParams.callGasLimit);
    builder.setVerificationGasLimit(gasParams.verificationGasLimit);
    builder.setPreVerificationGas(gasParams.preVerificationGas);
    builder.setMaxFeePerGas(gasParams.maxFeePerGas);
    builder.setMaxPriorityFeePerGas(gasParams.maxPriorityFeePerGas);
    
    // 承認用のUserOperationを作成
    const userOp = await builder.execute(tokenAddress, 0, approveCallData);
    
    // 承認UserOperationを送信
    console.log("承認トランザクションを送信中...");
    const res = await client.sendUserOperation(userOp);
    console.log("承認トランザクションが送信されました。ハッシュ:", res.userOpHash);
    
    // 承認がマイニングされるのを待つ
    const receipt = await res.wait();
    if (!receipt) {
            throw new Error("トランザクションレシートがnullです");
    }
    console.log("トークン承認がブロックで確認されました:", receipt.blockNumber);
    
    return true;
  } catch (error) {
    console.error("ペイマスターのトークン承認エラー:", error);
    throw error;
  }
};

ステップ3:統一されたトランザクション関数の実装

各支払いタイプに対して別々の関数を作成する代わりに、すべてのタイプを処理できる単一の関数を作成しましょう:

// src/utils/aaUtils.ts
/**
 * 設定可能な支払いタイプで操作を実行
 * @param accountSigner ユーザーの署名者
 * @param contractAddress ターゲットコントラクトアドレス
 * @param contractAbi コントラクトABI
 * @param functionName 呼び出す関数
 * @param functionParams 関数のパラメータ
 * @param paymentType 0 = スポンサー付き、1 = 前払い、2 = 後払い
 * @param tokenAddress 支払いタイプ1と2に必要
 * @param options 追加オプション
 */
export const executeOperation = async (
  accountSigner: ethers.Signer,
  contractAddress: string,
  contractAbi: any,
  functionName: string,
  functionParams: any[],
  paymentType: number = 0,
  tokenAddress?: string,
  options?: {
    apiKey?: string;
    gasMultiplier?: number;
  }
) => {
  try {
    // トークン支払いの場合、必要に応じて最初にトークンを承認
    if ((paymentType === 1 || paymentType === 2) && tokenAddress) {
      await approveTokenForPaymaster(accountSigner, tokenAddress);
    } else if ((paymentType === 1 || paymentType === 2) && !tokenAddress) {
      throw new Error(`支払いタイプ${paymentType}にはトークンアドレスが必要です`);
    }
    
    // AAクライアントを初期化
    const client = await Client.init(NERO_CHAIN_CONFIG.rpcUrl, {
      overrideBundlerRpc: AA_PLATFORM_CONFIG.bundlerRpc,
      entryPoint: CONTRACT_ADDRESSES.entryPoint,
    });
    
    // AAビルダーを初期化
    const builder = await Presets.Builder.SimpleAccount.init(
      accountSigner,
      NERO_CHAIN_CONFIG.rpcUrl,
      {
        overrideBundlerRpc: AA_PLATFORM_CONFIG.bundlerRpc,
        entryPoint: CONTRACT_ADDRESSES.entryPoint,
        factory: CONTRACT_ADDRESSES.accountFactory,
      }
    );
    
    // ガスパラメータを設定
    const gasParams = {
      callGasLimit: "0x88b8",
      verificationGasLimit: "0x33450",
      preVerificationGas: "0xc350",
      maxFeePerGas: "0x2162553062",
      maxPriorityFeePerGas: "0x40dbcf36",
    };
    
    // ガスパラメータを設定
    builder.setCallGasLimit(gasParams.callGasLimit);
    builder.setVerificationGasLimit(gasParams.verificationGasLimit);
    builder.setPreVerificationGas(gasParams.preVerificationGas);
    builder.setMaxFeePerGas(gasParams.maxFeePerGas);
    builder.setMaxPriorityFeePerGas(gasParams.maxPriorityFeePerGas);
    
    // 支払いタイプに基づいてpaymasterを設定
    const paymasterOptions: any = {
      apikey: options?.apiKey || API_KEY,
      rpc: AA_PLATFORM_CONFIG.paymasterRpc,
      type: paymentType.toString()
    };
    
    // トークン支払いの場合、トークンアドレスを追加
    if ((paymentType === 1 || paymentType === 2) && tokenAddress) {
      paymasterOptions.token = tokenAddress;
    }
    
    // paymasterオプションを設定
    builder.setPaymasterOptions(paymasterOptions);
    
    // コントラクトインスタンスを作成
    const contract = new ethers.Contract(
      contractAddress,
      contractAbi,
      ethers.getDefaultProvider()
    );
    
    // 関数呼び出しをエンコード
    const callData = contract.interface.encodeFunctionData(
      functionName,
      functionParams
    );
    
    // ログ用の人間が読める形式の支払いタイプ
    const paymentTypeNames = ["スポンサー付き", "前払い", "後払い"];
    console.log(`${paymentTypeNames[paymentType]}支払いでUserOperationを送信中...`);
    
    // UserOperationを作成
    const userOp = await builder.execute(contractAddress, 0, callData);
    
    // UserOperationを送信
    const res = await client.sendUserOperation(userOp);
    console.log("UserOperationがハッシュで送信されました:", res.userOpHash);
    
    // トランザクションが含まれるのを待つ
    const receipt = await res.wait();
    if (!receipt) {
        throw new Error("トランザクションレシートがnullです");
    }
    console.log("トランザクションがブロックでマイニングされました:", receipt.blockNumber);
 
    return {
        userOpHash: res.userOpHash,
        transactionHash: receipt.transactionHash,
        receipt: receipt
    };
  } catch (error) {
    console.error(`支払いタイプ${paymentType}での操作実行エラー:`, error);
    throw error;
  }
};

ステップ4:NFTミント用の統一関数の使用

統一された操作実行者を使用するようにNFTミント関数を更新しましょう:

// src/utils/aaUtils.ts
 
// 汎用的なNFT ABI
const NFT_ABI = [
    "function mint(address to, string memory uri) external",
    "function tokenURI(uint256 tokenId) external view returns (string memory)",
    "function balanceOf(address owner) external view returns (uint256)"
];
 
export const mintNFT = async (
  accountSigner: ethers.Signer,
  recipientAddress: string,
  metadataUri: string,
  paymentType: number = 0,
  tokenAddress?: string,
  options?: {
    apiKey?: string;
    gasMultiplier?: number;
  }
) => {
  try {
    // 指定された支払いタイプでミント関数を実行
    return await executeOperation(
      accountSigner,
      CONTRACT_ADDRESSES.nftContract,
      NFT_ABI,
      'mint',
      [recipientAddress, metadataUri],
      paymentType,
      tokenAddress,
      options
    );
  } catch (error) {
    console.error("NFTミントエラー:", error);
    throw error;
  }
};

ステップ5:支払いタイプセレクターの作成

ユーザーが好みの支払い方法を選択できるようにするために、支払いタイプとトークンを選択するためのコンポーネントを作成しましょう:

// src/components/PaymentTypeSelector.tsx
import React, { useState, useEffect } from 'react';
import { getSigner, getSupportedTokens } from '../utils/aaUtils';
 
interface Token {
  address: string;
  symbol: string;
  type: number;
  decimal: number;
}
 
interface PaymentTypeSelectorProps {
  onPaymentTypeChange: (type: number, token?: string) => void;
  disabled?: boolean;
}
 
const PaymentTypeSelector: React.FC<PaymentTypeSelectorProps> = ({
  onPaymentTypeChange,
  disabled = false
}) => {
  const [paymentType, setPaymentType] = useState<number>(0);
  const [selectedToken, setSelectedToken] = useState<string>('');
  const [tokens, setTokens] = useState<Token[]>([]);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [error, setError] = useState<string | null>(null);
  
  // コンポーネントマウント時にサポートされているトークンをロード
  useEffect(() => {
    const loadTokens = async () => {
      try {
        setIsLoading(true);
        setError(null);
        
        const signer = await getSigner();
        const supportedTokens = await getSupportedTokens(signer);
        
        setTokens(supportedTokens);
      } catch (error: any) {
        console.error("トークンのロードエラー:", error);
        setError(error.message || "サポートされているトークンのロードに失敗しました");
      } finally {
        setIsLoading(false);
      }
    };
    
    loadTokens();
  }, []);
  
  // 支払いタイプでトークンをフィルタリング
  const availableTokens = tokens.filter(token => 
    paymentType === 0 || token.type === paymentType
  );
  
  // 支払いタイプ変更の処理
  const handlePaymentTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
    const type = parseInt(e.target.value);
    setPaymentType(type);
    
    // 無料に切り替えるときにトークン選択をリセット
    if (type === 0) {
      setSelectedToken('');
      onPaymentTypeChange(type);
    } else if (selectedToken && tokens.find(t => t.address === selectedToken)?.type === type) {
      // 選択されたトークンが新しいタイプをサポートしている場合は維持
      onPaymentTypeChange(type, selectedToken);
    } else {
      // トークン選択をクリア
      setSelectedToken('');
    }
  };
  
  // トークン選択変更の処理
  const handleTokenChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
    const token = e.target.value;
    setSelectedToken(token);
    
    if (token) {
      onPaymentTypeChange(paymentType, token);
    }
  };
  
  return (
    <div className="payment-type-selector">
      <div className="form-group">
        <label>ガス支払い方法:</label>
        <select
          value={paymentType}
          onChange={handlePaymentTypeChange}
          disabled={disabled || isLoading}
          className="select-field"
        >
          <option value={0}>スポンサー付き(無料ガス)</option>
          <option value={1}>ERC20トークンで前払い</option>
          <option value={2}>ERC20トークンで後払い</option>
        </select>
        
        <p className="help-text">
          {paymentType === 0 
            ? "ガス料金はアプリケーションによってスポンサーされます。" 
            : paymentType === 1 
              ? "ERC20トークンでガスを前払いします余剰分は返金されます。" 
              : "トランザクション実行後に正確なガスコストを支払います。"}
        </p>
      </div>
      
      {paymentType > 0 && (
        <div className="form-group">
          <label>トークンを選択:</label>
          {isLoading ? (
            <p>サポートされているトークンをロード中...</p>
          ) : error ? (
            <p className="error-text">{error}</p>
          ) : availableTokens.length === 0 ? (
            <p className="warning-text">この支払いタイプで利用可能なトークンがありません。</p>
          ) : (
            <select
              value={selectedToken}
              onChange={handleTokenChange}
              disabled={disabled || availableTokens.length === 0}
              className="select-field"
            >
              <option value="">トークンを選択</option>
              {availableTokens.map(token => (
                <option key={token.address} value={token.address}>
                  {token.symbol}
                </option>
              ))}
            </select>
          )}
        </div>
      )}
    </div>
  );
};
 
export default PaymentTypeSelector;

ステップ6:NFTミントコンポーネントでの支払いセレクターの使用

統一された関数と支払いセレクターを使用するようにNFTミントコンポーネントを更新しましょう:

// src/components/NFTMinter.tsx
import React, { useState } from 'react';
import { ethers } from 'ethers';
import { getSigner, mintNFT } from '../utils/aaUtils';
import { NERO_CHAIN_CONFIG } from '../config';
import PaymentTypeSelector from './PaymentTypeSelector';
 
const NFT_ABI = [
  "function mint(address to, string memory uri) external",
  "function tokenURI(uint256 tokenId) external view returns (string memory)",
  "function balanceOf(address owner) external view returns (uint256)"
];
 
interface MintOptions {
  paymentType: number;
  tokenAddress?: string;
}
 
const NFTMinter: React.FC = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [txHash, setTxHash] = useState('');
  const [mintOptions, setMintOptions] = useState<MintOptions>({ paymentType: 0 });
  
  // 支払いタイプ選択の処理
  const handlePaymentTypeChange = (type: number, token?: string) => {
    setMintOptions({ paymentType: type, tokenAddress: token });
  };
  
  // 適切な支払い方法でのNFTミントの処理
  const handleMint = async () => {
    try {
      setIsLoading(true);
      setTxHash('');
      
      // ブラウザウォレットからサイナーを取得
      const signer = await getSigner();
      const userAddress = await signer.getAddress();
      
      // 例としてのNFTメタデータURI
      const metadataUri = "ipfs://bafkreiabag3ztnhe5pg7js3cokbq3id2b3t6evbncbpzzh2c5sdioxngoe";
      
      // 選択された支払いタイプとトークンでNFTをミント
      const result = await mintNFT(
        signer, 
        userAddress, 
        metadataUri,
        mintOptions.paymentType,
        mintOptions.tokenAddress
      );
      
      // 表示用にトランザクションハッシュを設定
      setTxHash(result.transactionHash);
      alert("NFTが正常にミントされました!");
    } catch (error: any) {
      console.error("NFTミントエラー:", error);
      alert("NFTのミントに失敗しました: " + error.message);
    } finally {
      setIsLoading(false);
    }
  };
  
  return (
    <div className="nft-minter">
      <h2>NFTをミント</h2>
      
      <PaymentTypeSelector
        onPaymentTypeChange={handlePaymentTypeChange}
        disabled={isLoading}
      />
      
      <div className="mint-button-container">
        <button
          onClick={handleMint}
          disabled={isLoading || (mintOptions.paymentType > 0 && !mintOptions.tokenAddress)}
        >
          {isLoading ? "ミント中..." : "NFTをミント"}
        </button>
      </div>
      
      {txHash && (
        <div className="transaction-info">
          <p>トランザクション成功!</p>
          <a
            href={`${NERO_CHAIN_CONFIG.explorer}/tx/${txHash}`}
            target="_blank"
            rel="noopener noreferrer"
          >
            エクスプローラーで表示
          </a>
        </div>
      )}
    </div>
  );
};
 
export default NFTMinter;

支払いタイプの比較

機能タイプ0(無料)タイプ1(前払い)タイプ2(後払い)
ガス支払い者開発者ユーザー(ERC20で事前)ユーザー(ERC20で事後)
ユーザー体験シームレス良好良好
開発者コスト高い低い低い
ガス見積もり固定固定動的
最適な用途オンボーディング、NFT一般ユーザーパワーユーザー
ユーザーがトークンを必要とするか不要必要(トランザクション前)必要(トランザクション前)
トークン承認が必要か不要必要必要

ベストプラクティス

  1. トークン承認フロー: トークン支払いを試みる前に、常にトークン承認を処理する
  2. 支払いタイプの選択: すべての支払いオプションを提供するが、最もユーザーフレンドリーなもの(タイプ0)をデフォルトにする
  3. エラー処理: トークン関連の問題に対して明確なエラーメッセージを提供する
  4. トークン残高チェック: 操作を試みる前に、ユーザーが十分なトークン残高を持っているか確認する
  5. UI フィードバック: プロセスの各ステップに対してローディング状態と成功/失敗メッセージを表示する

各支払いタイプを使用するタイミング

  • タイプ0(無料/スポンサー付き): オンボーディング、NFTミント、カジュアルユーザー向け
  • タイプ1(前払い): ユーザーがネイティブトークンを持っていないがERC20トークンを持っている場合の通常のトランザクション向け
  • タイプ2(後払い): 正確なガス支払いが必要な場合や、正確なコストが重要な場合に使用

次のステップ

NERO Chain Paymasterで利用可能なさまざまな支払い方法を理解したところで、次はどのトークンがサポートされているかについてもっと学びたいかもしれません。トークンサポート確認チュートリアルに進んで、詳細なトークン情報を取得して表示する方法を学びましょう。