前回のブログ記事「Delegation Toolkitとは何か、そして何を構築できるのか?」では、MetaMaskのDelegation Toolkitが開発者に対して、より柔軟でユーザーフレンドリーなdappを作成する力を与える方法を探りました。特に注目すべきユースケースの一つが、ユーザーが友人を最小限の手間でオンボーディングできるソーシャル招待機能の構築です。
このチュートリアルでは、そのようなフローの実装方法を解説します。既存のユーザーが別のユーザーに限定的な権限を委任することで、新規ユーザーがウォレットのセットアップや事前入金といった通常の障壁なしにdappを体験できるようになります。
例えば、AliceがBobに自分が使っているdappを試してほしいとします。AliceはBobに招待状を送り、Bobが一定時間内に彼女のウォレットから少額のETH(例えば0.001 ETH)を受け取れるようにします。Bobはウォレット拡張機能のインストールやガス代の支払いなしに、すぐにdappを使い始めることができます。一方、Aliceは秘密鍵を公開することなく、自分のウォレットを完全にコントロールし続けます。
これが、Delegation Toolkitが実現するスムーズなオンボーディング体験です。許可された委任とセキュアなオフチェーン署名を活用しています。それでは、構築するものの構造を簡単に説明しましょう。
招待者(Inviter)
招待者は、他のユーザーのオンボーディングを支援するために、自分のウォレットへの限定的なアクセスを共有したいユーザーです。このチュートリアルでは、招待者はEIP-4337に基づくスマートコントラクトウォレットであるMetaMask Delegatorアカウントを使用します。新しいDelegatorアカウントを作成するか、すでにセットアップ済みの場合は既存のものを使用できます。デプロイとインフラの管理には、PimlicoのEIP-4337サービスを使用します。
招待状(Invitation)
このコンテキストにおける招待状とは、単純に委任(delegation)のことです。これはDelegatorアカウントによって作成され、オフチェーンに保存される署名済みオブジェクトです。DelegatorアカウントがデプロイされてETHで資金調達されると、別のユーザーが特定の期間内に一定量のトークンを受け取れるようにする委任を作成できます。この仕組みにより、招待された側がウォレットに資金を入れたりガス代を事前に支払ったりする必要がなくなり、オンボーディングがよりスムーズになります。
シンプルさを保つために、*オープン委任(open delegation)*と呼ばれる方式を使用します。これは、署名済み委任にアクセスできる人なら誰でも利用できることを意味します。特定の受取人に紐付けられていないため、招待者の秘密鍵を管理したり公開したりする必要がありません。
招待された側(Invitee)
招待された側は、オンボーディングされる人です。実装によって、Externally Owned Account(EOA)またはSmart Contract Account(SCA)のいずれかを使用できます。このチュートリアルでは、招待された側がクレームリンクをクリックした際に自動的にEOAを生成します。本番環境のdappでは、招待された側がすでにウォレットを接続しているかどうかを検出してそれを使用するのが理想的ですが、そのロジックの実装はこのガイドの範囲外となります。
条件(Conditions)
各招待状には、使用方法や使用タイミングを制御するための条件を含めることができます。これらの条件はcaveatとも呼ばれ、資金上限、利用期限、または委任の使用回数の制限などを指定できます。今回は、基本的な安全性を確保するために、委任を一度だけ利用可能に設定し、最大トークン量(X)と時間枠(Y)を設定します。
招待状の受け入れ
招待された側がクレームリンクをクリックして招待状を受け入れると、委任が利用(redeem)されます。このプロセスにより、委任された資金を転送するためのユーザーオペレーションがブロックチェーン上でトリガーされます。このオペレーションの送信とフローの完了には、再びPimlicoのインフラを利用します。
フローを理解したところで、実装を始めましょう。
前提条件
構築を始める前に、以下の準備が整っていることを確認してください:
Node.js(バージョン18以降)
SepoliaテストネットのInfura RPCエンドポイント(Infura)
Paymaster、Bundler、User Operationsなどの主要概念を含む、EIP-4337アカウント抽象化の基本的な理解
PimlicoのBundlerおよびPaymaster URL
依存関係のインストール
まず、Delegation Toolkitをインストールします:
npm install @metamask/delegation-toolkit次に、すべてのEthereumインタラクション用にViemをインストールします:
npm install viem招待者アカウントの作成
招待者アカウントは、新規ユーザーのオンボーディングを支援するために限定的な権限を共有するウォレットです。dappとの初期インタラクション時に招待された側のガス代をカバーできるだけの暗号資産を保有している必要があります。このチュートリアルでは、Gatorアカウントとも呼ばれるスマートコントラクトアカウントをこの役割に使用します。
なお、Pectraアップグレードにより、EIP-7702をサポートする通常のEOAも権限を委任できるようになりました。これにより、従来のウォレットがスマートコントラクトアカウントに切り替えることなく権限共有に参加できる可能性が開かれました。このアプローチについては、別の記事で詳しく解説します。
まず、プロジェクトのルートにindex.tsというファイルを作成し、以下のコードスニペットを追加します:
import { http, createPublicClient } from "viem";
import { privateKeyToAccount, generatePrivateKey } from "viem/accounts";
import { sepolia as chain } from "viem/chains";
import {
Implementation,
toMetaMaskSmartAccount,
} from "@metamask/delegator-core-viem";
const RPCEndpoint = "<INFURA RPC ENDPOINT HERE>";
const transport = http(RPCEndpoint);
const publicClient = createPublicClient({ transport, chain });
const privateKey = generatePrivateKey();
const owner = privateKeyToAccount(privateKey);
const deploySalt = "0x";
const account = await toMetaMaskSmartAccount({
client: publicClient,
implementation: Implementation.Hybrid,
deployParams: [owner.address, [], [], []],
deploySalt,
signatory: { account: owner },
});
Delegatorアカウントを作成する際には、主に2つのオプションがあります:
ハイブリッドアカウント(Hybrid account):このタイプは署名者が1人だけ必要で、EOAまたはP256互換の署名者を使用できます。implementation: Implementation.Hybridで指定します。
マルチシグアカウント(Multisig account):このオプションでは、Delegatorアカウントの代わりに複数のEOAが署名する必要があります。implementation: Implementation.MultiSigで指定します。
このチュートリアルでは、ハイブリッドアカウントを使用し、署名者として通常のEOAを設定します。上記の例では秘密鍵を生成していますが、これはプログラムで既存のウォレットの秘密鍵を使用するように調整できます。
Delegatorアカウントを作成したところで、重要な点を覚えておいてください:toMetaMaskSmartAccountを使用して作成されたアカウントは*カウンターファクチュアル(counterfactual)*です。つまり、デプロイされるまでオンチェーンには存在しません。account.addressを使用してアカウントアドレスを取得することはできますが、ブロックエクスプローラーで検索しても見つかりません。
ただし、そのアドレスに事前にトークンを送ることは可能です。アカウントがデプロイされると、それらのトークンにアクセスできるようになり、そのアドレスに紐付けられた保留中のアクションをすぐに実行できます。
招待者のDelegatorアカウントをデプロイする
次のステップに進むために、カウンターファクチュアルアカウントをデプロイしてブロックチェーンとインタラクションできるようにする必要があります。ほとんどのdappでは、このステップはユーザーのサインインやサインアップなど、オンボーディング時にトリガーされます。
デプロイを処理するために、Pimlicoのインフラを使用します。PimlicoダッシュボードにアクセスしてAPIキーを作成し、提供されたURLをコピーしてください。このURLはBundler(ユーザーオペレーションのリレー)とPaymaster(ガス代のスポンサー)の両方として機能します。
カウンターファクチュアルアカウントをデプロイするための専用関数がないため、Bundlerにダミーのユーザーオペレーションを送信することでデプロイをトリガーします。このオペレーション自体は成功しませんが、Pimlicoは実行を試みる前にアカウントをデプロイします。これが私たちの目的です。
PimlicoのBundlerとPaymasterをセットアップする
PimlicoのBundler、Paymaster、およびユーザーオペレーションツールとインタラクションするために、@pimlico/permissionlessパッケージをインストールします:
npm install permissionless 次に、PimlicoのBundlerとPaymasterを設定し、Gatorアカウントのデプロイを進めます:
…
import {
createBundlerClient,
createPaymasterClient,
} from "viem/account-abstraction";
import { createPimlicoClient } from "permissionless/clients/pimlico";
import { zeroAddress } from "viem";
const pimlicoURL = process.env.PIMLICO_URL;
const paymasterClient = createPaymasterClient({
transport: http(pimlicoURL),
});
const bundlerClient = createBundlerClient({
transport: http(pimlicoURL),
paymaster: paymasterClient,
});
const pimlicoClient = createPimlicoClient({
transport: http(pimlicoURL),
});;
const { fast: gasPrice } = await pimlicoClient.getUserOperationGasPrice();
const isDeployed = await account.isDeployed();
if (!isDeployed) {
const hash = await bundlerClient.sendUserOperation({
account,
calls: [{ to: zeroAddress }],
...gasPrice,
});
const { receipt } = await bundlerClient.waitForUserOperationReceipt({ hash });
}
上記のスニペットでは、まずPimlicoのPaymasterとBundlerクライアントを初期化し、次にPimlicoクライアント自体を初期化します。その後、Gatorアカウントがすでにデプロイされているかどうかを確認します。デプロイされていない場合は、まだデプロイされていないアカウントを送信者として使用してダミーのユーザーオペレーションを送信します。
PimlicoのBundlerは、ユーザーオペレーションがバンドルされてmempoolに送信されると、トランザクションハッシュを返します。そのハッシュを使用して、トランザクションがマイニングされてオンチェーンで確認されるのを待ちます。
このプロセスの仕組みの詳細については、Pimlicoのドキュメントを参照してください。
最後のステップは、新しくデプロイされたDelegatorアカウントにテスト用ETHを入金することです。MetaMask Sepolia faucetにアクセスして行うことができます。
招待状の作成
招待者のために資金が入ったDelegatorアカウントが設定されたので、次のステップはオープン委任を作成して署名することです。この委任により、招待された側が招待者のアカウントから特定量のテスト用ETHを受け取れるようになります。
委任はDelegationStruct型を使用して定義され、以下の構造を持ちます:
export type DelegationStruct = {
delegate: Hex;
delegator: Hex;
authority: Hex;
caveats: CaveatStruct[];
salt: bigint;
signature: Hex;
};
今回は、委任チェーンの最初のリンクであるルート委任を作成します。ルート委任の上に再委任のチェーンを構築することも可能ですが、このチュートリアルではルート委任のみに焦点を当てます。
ルート委任を作成する方法は2つあります:
事前に委任先のアドレスがわからない場合(このチュートリアルのように)、任意のアカウントが利用できるオープン委任を作成できます。
委任先のアドレスがわかっている場合は、delegateフィールドをそのアドレスに明示的に設定することでクローズド委任を作成できます。
柔軟性とシンプルさのために、オープン委任アプローチを採用します:
...
import {
...
createOpenDelegation,
} from "@metamask/delegator-core-viem";
const caveatBuilder = createCaveatBuilder(account.environment);
const caveats = caveatBuilder
.addCaveat("limitedCalls", 1)
.addCaveat("nativeTokenTransferAmount", BigInt(1))
.addCaveat("timestamp", 0, Math.floor(Date.now() / 1000) + 86_400);
const openDelegation = createOpenDelegation({
from: account.address,
caveats,
});
このステップでは、委任の使用方法を制御するために3つのcaveatを追加しました:
実行制限:limitedCalls caveatを使用して、委任先が招待者の代わりにアクションを実行できる回数を制限します。
トークン転送上限:nativeTokenTransferAmount caveatにより、招待者のアカウントから転送できるネイティブトークンの総量を制限します。
有効期限:タイムスタンプベースのcaveatを追加して、一定時間後に委任が無効になるようにします。この場合、招待状は24時間後に期限切れになります。
他にも多くのcaveatを含めることができますが、今回のユースケースにはこれで十分です。サポートされているcaveatの完全なリストはドキュメントで確認でき、必要に応じてカスタムcaveatを定義することもできます。
委任が定義されたら、最後のステップは署名して保存することです。委任が有効であるためには、委任者によって署名されている必要があります。MetaMask DelegatorアカウントはsignDelegationメソッドを公開しており、これをこの目的に使用します。
保存方法については、任意の方法を選択できます。このチュートリアルでは、シンプルにlocalStorageを使用します。JavaScriptの組み込みJSON.stringifyはBigInt値をサポートしていないため、superjsonライブラリのstringifyを使用して委任をシリアライズします。
npm install superjson...
import { stringify } from "superjson";
const signature = await account.signDelegation({
delegation: openDelegation,
});
localStorage.setItem(
"DELEGATION",
stringify({ delegation: openDelegation, signature })
);
招待状の利用(Redeem)
次に、招待された側の視点に切り替えましょう。つまり、委任を利用(redeem)して、招待者のアカウントから自分のアカウントへのトークン転送などのアクションを実行するユーザーです。
ステップ1:ストレージから委任を取得する
署名済み委任をローカルに保存したので、招待された側はlocalStorageから直接取得できます:
import { parse } from "superjson";
const storedData = localStorage.getItem("DELEGATION");
if (!storedData) {
throw new Error("Delegation not found in storage");
}
const { delegation, signature } = parse(storedData);
ステップ2:実行(execution)を作成する
実行(execution)は、委任先がDelegatorアカウントで実行できる特定のアクションを表します。委任で定義されたすべてのcaveatを遵守する必要があります。
実行オブジェクトは通常、次のような構造を持ちます:
{
target,
value,
callData
}
簡略化のために、SDKのヘルパーメソッドcreateExecutionを使用できます:
import { createExecution } from "@codefi/delegator-core-viem";
const execution = createExecution(
target,
value,
callData
);
今回の場合、スマートコントラクトを呼び出すのではなく、単純にDelegatorから委任先にETHを転送するため、実行は次のようになります:
const execution = createExecution(
delegateAccount.address,
amount
);
ステップ3:UserOperationで実行を送信する
アカウント抽象化の概念に慣れていない場合、この部分は少し複雑に感じるかもしれません。順を追って説明します。
大まかに言えば、私たちの目標は委任の利用に必要なすべてのトランザクションデータをラップしたUserOperationを送信することです。抽象的に見えるかもしれませんが、最終的にはオンチェーンで標準的なEthereumトランザクションが実行されます。
このトランザクションは、Delegator Smart Contract AccountのredeemDelegationsという関数を呼び出します。この関数は以下を担当します:
委任の検証とcaveatの確認
実行の処理方法の決定(redeemモード)。詳細はこちら
目的のアクションの実行(この場合はトークンの転送)
簡単に言えば、redeemDelegationsは委任がまだ有効かどうかを確認し、すべてが問題なければ意図したアクションを実行します。
このトランザクションを送信するために、BundlerクライアントによってProvideされるsendUserOperationメソッドを使用します。これにより、UserOperationの構築と署名の複雑さが抽象化されます:
import { encodeFunctionData } from "viem";
import {
encodeExecutionCalldatas,
encodePermissionContexts,
SINGLE_DEFAULT_MODE,
} from "@codefi/delegator-core-viem";
import { createPimlicoClient } from "permissionless/clients/pimlico";
import { createBundlerClient } from "viem/account-abstraction";
const bundlerClient = createBundlerClient({
transport: http(process.env.NEXT_PUBLIC_BUNDLER_URL),
chain: lineaSepolia,
});
const pimlicoClient = createPimlicoClient({
transport: http(process.env.NEXT_PUBLIC_BUNDLER_URL),
chain: lineaSepolia,
});
const { fast } = await pimlicoClient.getUserOperationGasPrice();
const redeemData = encodeFunctionData({
abi: delegateAccount.abi,
functionName: "redeemDelegations",
args: [
encodePermissionContexts([delegation]),
[SINGLE_DEFAULT_MODE],
encodeExecutionCalldatas([[execution]]),
],
});
const hash = await bundlerClient.sendUserOperation({
account: delegateAccount,
calls: [
{
to: delegateAccount.address,
value: 0n,
data: redeemData,
},
],
paymaster: true,
...fast,
});
await bundlerClient.waitForUserOperationReceipt({
hash,
});