Skip to content

Routerコントラクト: 事前承認ベースの分配送金実装(AA対応) #48

@yu23ki14

Description

@yu23ki14

一行説明

EIP-4337スマートアカウント(AA)ユーザー向けに、事前承認を利用した分配送金機能を実装する

詳細

背景

  • issue #15で実装するtransferWithPermitはEOAユーザーにとって便利(オフチェーン署名でガスレス承認)
  • しかし、EIP-4337のスマートアカウントユーザーはpermit署名ができないため、通常のapproveが必要
  • AAユーザーがapproveとRouterの実行を1つのUserOperationでバッチ実行できるよう、permit不要の関数が必要

実装内容

事前にFORTokenへapproveされていることを前提として、トークンの分配送金を実行する関数を追加。

関数シグネチャ:

function transferWithDistribution(
    address from,
    address recipient,
    uint256 amount
) external whenNotPaused nonReentrant;

処理フロー:

  1. 入力値の検証(amount、recipient)
  2. 分配額の計算(fundRatio、burnRatio、recipientAmount)
  3. 3つの宛先へのtransferFrom実行(fund、burn、recipient)
  4. TransferWithDistributionイベントの発行

AAユーザーのユースケース

Pimlico等のバンドラーで、approve + transferWithDistributionを1つのUserOperationとしてバッチ実行:

await smartAccount.executeBatch([
  // 1. FORTokenへの承認
  { to: forToken, data: approve(router, amount) },
  // 2. 分配送金の実行
  { to: router, data: transferWithDistribution(from, recipient, amount) }
]);

EOAユーザーのユースケース

事前にapproveしておけば、EOAユーザーも使用可能(ただしpermit版の方が便利):

// 事前承認(1回のみ、初回または追加承認時)
await forToken.approve(router, maxAmount);
// 分配送金実行
await router.transferWithDistribution(account, recipient, amount);

要件

  • transferWithDistribution(address from, address recipient, uint256 amount)関数の実装
  • 入力値検証(amount != 0、recipient != address(0))
  • 分配計算ロジックの実装(issue #15と共通化)
  • 3つの宛先へのtransferFrom実行
  • TransferWithDistributionイベントの発行(issue #15と共通イベント)
  • whenNotPausedおよびnonReentrantモディファイアの適用
  • Hardhatでコンパイル成功
  • 包括的なテストの作成(正常系、異常系、エッジケース)

補足

Issue #15との共通化ポイント

以下の要素はtransferWithPermit(issue #15)と共通化できます:

  1. 分配計算ロジック

    uint256 fundAmount = (amount * fundRatio) / 10000;
    uint256 burnAmount = (amount * burnRatio) / 10000;
    uint256 recipientAmount = amount - fundAmount - burnAmount;

    → 内部関数 _calculateDistribution(uint256 amount) として共通化を検討

  2. トークン送金処理

    if (fundAmount > 0) token.transferFrom(from, fundWallet, fundAmount);
    if (burnAmount > 0) token.transferFrom(from, BURN_ADDRESS, burnAmount);
    if (recipientAmount > 0) token.transferFrom(from, recipient, recipientAmount);

    → 内部関数 _executeDistribution(address from, address recipient, uint256 amount) として共通化を検討

  3. TransferWithDistributionイベント

    • 両関数で同じイベント定義を使用
  4. 入力値検証

    • amount != 0、recipient != address(0)のチェックは共通
  5. カスタムエラー

    • InvalidAmount()InvalidRecipient()は共通

リファクタリング案

// 内部関数(共通化)
function _calculateDistribution(uint256 amount) 
    internal 
    view 
    returns (uint256 fundAmount, uint256 burnAmount, uint256 recipientAmount) 
{
    fundAmount = (amount * fundRatio) / 10000;
    burnAmount = (amount * burnRatio) / 10000;
    recipientAmount = amount - fundAmount - burnAmount;
}

function _executeDistribution(
    address from,
    address recipient,
    uint256 amount,
    uint256 fundAmount,
    uint256 burnAmount,
    uint256 recipientAmount
) internal {
    IERC20 token = IERC20(forToken);
    
    if (fundAmount > 0) {
        require(token.transferFrom(from, fundWallet, fundAmount), "Fund transfer failed");
    }
    if (burnAmount > 0) {
        require(token.transferFrom(from, BURN_ADDRESS, burnAmount), "Burn transfer failed");
    }
    if (recipientAmount > 0) {
        require(token.transferFrom(from, recipient, recipientAmount), "Recipient transfer failed");
    }
}

// 公開関数(permit版)
function transferWithPermit(...) external whenNotPaused nonReentrant {
    // 入力値検証
    if (amount == 0) revert InvalidAmount();
    if (recipient == address(0)) revert InvalidRecipient();
    
    // Permit実行
    IERC20Permit(forToken).permit(from, address(this), amount, deadline, v, r, s);
    
    // 分配計算
    (uint256 fundAmount, uint256 burnAmount, uint256 recipientAmount) = _calculateDistribution(amount);
    
    // 分配実行
    _executeDistribution(from, recipient, amount, fundAmount, burnAmount, recipientAmount);
    
    // イベント発行
    emit TransferWithDistribution(msg.sender, from, recipient, amount, fundAmount, burnAmount, recipientAmount);
}

// 公開関数(事前承認版)
function transferWithDistribution(...) external whenNotPaused nonReentrant {
    // 入力値検証
    if (amount == 0) revert InvalidAmount();
    if (recipient == address(0)) revert InvalidRecipient();
    
    // 分配計算
    (uint256 fundAmount, uint256 burnAmount, uint256 recipientAmount) = _calculateDistribution(amount);
    
    // 分配実行
    _executeDistribution(from, recipient, amount, fundAmount, burnAmount, recipientAmount);
    
    // イベント発行
    emit TransferWithDistribution(msg.sender, from, recipient, amount, fundAmount, burnAmount, recipientAmount);
}

実装順序の提案

  1. Issue #15を実装(transferWithPermit
  2. このissueを実装する際に、共通ロジックを内部関数として抽出
  3. 両方の関数が同じ内部関数を使用するようリファクタリング

技術スタック

  • Solidity ^0.8.28
  • OpenZeppelin Contracts(IERC20、ReentrancyGuard)
  • EIP-4337対応

依存関係

関連Issue

フロントエンド実装時の考慮事項

// ウォレットタイプに応じた実装切り替え
if (isEOAWallet(account)) {
  // EOAユーザー: permit版を優先(ガスレス承認)
  const { v, r, s } = await signPermit(...);
  await router.transferWithPermit(from, recipient, amount, deadline, v, r, s);
} else if (isSmartAccount(account)) {
  // AAユーザー: 事前承認版をバッチ実行
  await smartAccount.executeBatch([
    { to: forToken, data: approveCall },
    { to: router, data: transferWithDistributionCall }
  ]);
}

テストケース

Issue #15と同様の包括的なテストが必要:

  • 正常系: 標準的な比率での送金成功
  • 分配検証: 各宛先への正確な分配額
  • エラーハンドリング: ゼロ額、ゼロアドレス受取人、残高不足、承認不足
  • Pausable: 一時停止中のコントラクトでrevert
  • イベント発行: TransferWithDistributionイベントの検証
  • エッジケース: from == recipient、大きな額、比率境界値

推定工数

1-2日(issue #15の実装パターンを流用できるため)

Metadata

Metadata

Assignees

Labels

contractSmart contract development

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions