はじめに

初めまして。 『DApps開発入門』という本や色々記事を書いているかるでねです。

以下でも情報発信しているので、興味ある記事があればぜひ読んでみてください!

ERC7811は、DAppがウォレットへアカウントを渡し、ウォレットが把握している資産をチェーン別に返すための提案です。 DAppがメソッド名と引数を送り、その結果を受け取る呼び出し形式がJSON-RPCです。 ERC7811は新しいJSON-RPCメソッドの wallet_getAssets を使い、ネイティブトークン、ERC20トークン、ERC721 NFTを同じ応答から見つけられるようにします。

ウォレットが資産を返す全体像

DAppがERC20トークンの残高を読む場合、通常はトークンコントラクトのアドレスを知ったうえで balanceOf 関数を呼び出します。 しかし、この方法では、DAppがまだ知らないトークンコントラクトを見つけられません。 複数チェーンを対象にすると、どのチェーンのどのコントラクトへ問い合わせるかもDApp側で用意することになります。

ウォレットは、接続されたアカウントと、自身が収集した資産情報をすでに持っていることがあります。 ブラウザ上のDAppからウォレットへJSON-RPC要求を運ぶ部品がProviderです。 ERC7811では、DAppがEIP1193互換のProviderへ wallet_getAssets を送り、ウォレットがアカウントの資産候補を返します。

DAppの要求をProviderがウォレットへ渡し、ウォレットが複数チェーンの資産一覧を返す

チェーン上の履歴を収集し、検索用の資産候補を作る処理がIndexerです。 ウォレットが参照する情報源は、オンチェーンRPC、Indexer、ウォレット内部のキャッシュのように実装ごとに異なります。 ERC7811がそろえるのは、DAppから送る要求とウォレットが返す応答の形です。 資産をどう発見したかや、返した残高がオンチェーン上の最新値であることまでは証明しません。

ウォレットが返す範囲

要求には対象の account を1つ指定します。 ウォレットは、そのアカウントについて自分が提供できる資産を、EIP155のチェーンIDごとに分けて返します。

フィルタを省略した場合、提案ではウォレットが利用可能な全資産を返すことを推奨しています。 ただし、利用可能とみなす全資産の範囲は定義されていません。 ウォレットがまだ発見していない資産、スパムとして除外した資産、Indexerへ反映されていない直近の送付結果が欠ける可能性があります。

そのため、空の配列は、そのウォレットが現在返せる候補がないことを表します。 対象アカウントが何も保有していないことのオンチェーン証明にはなりません。

既知コントラクトへの照会

ERC20の balanceOf 関数は、既知のトークンコントラクトへアカウントを渡し、そのコントラクトが管理する残高を返します。 ERC721の balanceOf 関数も、既知のNFTコントラクト内でアカウントが持つNFT数を返します。 個別のNFTを確認する場合は、さらに tokenId と ownerOf 関数が必要です。

一方、 wallet_getAssets はウォレットからコントラクトアドレスや tokenId の候補を先に受け取ります。 未知の資産を見つける起点にはERC7811を使い、送付直前の残高確認には対象コントラクトを使う、という役割分担ができます。

リクエストとレスポンス

wallet_getAssets の要求は、対象アカウントと任意のフィルタで構成されます。 ウォレットはフィルタを評価し、チェーンIDをキーとする資産配列を返します。 要求から応答までの間には、JSON-RPCの引数形式、フィルタの優先順位、空結果の意味という、DAppとウォレットが実装時にそろえる境界もあります。

paramsの正規化からフィルタ適用、チェーン別の資産応答までの流れ

paramsの包み方

ERC7811は WalletGetAssetsRequest という要求オブジェクトを定義しています。 一方、JSON-RPC 2.0とEIP1193は、 params に位置指定の配列と名前指定のオブジェクトの両方を許します。 ERC7811は、完全なJSON-RPC要求でどちらを使うかまで固定していません。

サンプルでは、Reownの公開実装でも使われている1要素の配列へ要求オブジェクトを入れます。

ts
const response = await provider.request({
  method: "wallet_getAssets",
  params: [{
    account: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
    assetTypeFilter: ["native", "erc20"],
    chainFilter: ["0x1", "0xa"],
  }],
});

params[0] を要求オブジェクトとして読むのは、サンプル固有の実装規約です。 接続先が名前指定のオブジェクトを要求する場合、このコードとは互換になりません。 ウォレット側は受理する包み方を最初の検証処理で1つに決め、異なる形式にはJSON-RPCの -32602 Invalid params を返します。

公開実装が採用している包み方は、ERC7811の要件そのものではありません。 別の公開型定義には、現行Draftと異なる assetFilter の形や資産タイプ表記もあるため、実装例の採用実績と仕様準拠を分けて確認します。

フィルタの優先順位

要求に指定できるフィルタは以下です。

フィルタ 指定する値 適用条件
assetFilter チェーン別の address と type 存在する場合は、このフィルタだけを使う
assetTypeFilter native、 erc20、 erc721 assetFilter がない場合に使う
chainFilter 0x1、 0xa のようなチェーンID assetFilter がない場合に使う

assetFilter がある場合、ウォレットは assetTypeFilter と chainFilter を無視します。 これは、 assetFilter 自身がチェーン、アドレス、資産タイプをすでに指定しているためです。

例えば、以下の要求はEthereum Mainnet上のネイティブトークンだけを対象にします。 assetTypeFilter に erc20 があっても、 chainFilter にOptimismの 0xa があっても結果へ影響しません。

json
{
  "account": "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
  "assetFilter": {
    "0x1": [{ "address": "native", "type": "native" }]
  },
  "assetTypeFilter": ["erc20"],
  "chainFilter": ["0xa"]
}

DAppが特定の画面でERC20トークンだけを表示するなら、 assetTypeFilter を指定します。 1つのチェーンしか使わない画面では chainFilter も加えると、ウォレットとその情報源が処理する資産数を減らせます。

チェーン別の資産データ

応答のキーには、 0x1 のような16進表記のEIP155チェーンIDを使います。 各キーの値は、そのチェーンで見つかった Asset の配列です。

json
{
  "0x1": [
    { "address": "native", "balance": "0xde0b6b3a7640000", "type": "native" },
    {
      "address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
      "balance": "0xf4240",
      "type": "erc20",
      "metadata": { "name": "USD Coin", "symbol": "USDC", "decimals": 6 }
    },
    {
      "address": "0x1111111111111111111111111111111111111111",
      "balance": "0x1",
      "type": "erc721",
      "metadata": {
        "name": "Example NFT",
        "symbol": "ENFT",
        "tokenId": "0x2a",
        "tokenURI": "ipfs://example"
      }
    }
  ]
}

balance は10進文字列ではなく、 0x から始まる16進文字列です。 DAppは精度を失わないよう BigInt へ変換し、ERC20トークンでは metadata.decimals を使って表示桁を決めます。

type は提案の型定義に合わせて小文字の native、 erc20、 erc721 を使います。 提案内の一部のJSON例には大文字の ERC20 と ERC721 もありますが、これは型定義と一致していません。 相互運用時は大文字小文字を推測で吸収せず、ウォレットとDAppで採用する表記をそろえます。

metadata の扱いにもDraft(草案の状態)である提案内の不一致があります。 共通の Asset 型では必須に見える一方、説明文では任意です。 さらに、 erc20 と erc721 を返す場合は、資産タイプごとに最低限の項目を含めるよう定めています。 ERC20規格自体では name、 symbol、 decimals は任意ですが、ERC7811で erc20 を返すウォレットには、この3項目を応答へ含める責任があります。

type metadata の最低限の項目
native なし
erc20 name、 symbol、 decimals
erc721 name、 symbol、 tokenId。 tokenURI は任意

ERC7811の AssetType は、この3種以外の文字列も将来のERCで追加できる形です。 以下のサンプルは既知の3種だけを扱う製品固有の実装とし、未知の資産タイプを推測で表示せず -32602 で拒否します。 拡張タイプへ対応する実装では、そのERCが定める metadata を追加してから受理します。

空結果とNFTの並べ方

ERC7811は、要求した資産を持たない場合にそのチェーンを省略するのか、空配列を返すのか、ゼロ残高の Asset を返すのかを定めていません。 同じ Asset が複数回届いた時の重複排除も実装側の判断です。

ERC721では、 metadata に1つの tokenId を入れます。 しかし、同じコレクションのNFTを1件ずつ返すか、コレクション単位でまとめるかまでは決まっていません。 サンプル実装では、チェーンID、コントラクトアドレス、 tokenId の組を1つのNFTとして重複排除します。

この方針にすると、空配列は応答に資産候補がないことを表し、ゼロ残高はウォレットが明示的に返したゼロとして区別できます。 どちらも不保有の証明には使わず、送付や担保評価の前に対象コントラクトへ再照会します。

対応可否と利用権限

ウォレットが wallet_getAssets を実装していても、現在のチェーンやアカウントで呼べるとは限りません。 DAppはEIP5792の対応可否とCAIP25の利用権限を別々に確認します。 さらに、CAIP25には版による識別子の違いがあり、文字列をそのまま比較すると認可済みの要求を誤って拒否する可能性があります。

capability確認、CAIP25の認可、呼び出し経路、エラー別回復の判断順

チェーンごとの対応可否

DAppは wallet_getCapabilities を呼び、対象チェーンの assetDiscovery.supported を確認します。

json
{
  "0x1": { "assetDiscovery": { "supported": true } },
  "0x0": { "assetDiscovery": { "supported": true } }
}

対象チェーンのキーに情報があれば、その値を使います。 対象チェーンがなければ、全チェーン共通の対応可否を表す 0x0 を確認します。 キャッシュ済みの情報と現在の wallet_getCapabilities 応答が異なる場合、DAppは現在の応答を優先します。

対応可否が true でも、呼び出す権限があるとは限りません。 assetDiscovery は機能の有無を表し、CAIP25のスコープは現在のセッションで許可されたメソッド、チェーン、アカウントを表します。

認可されたアカウントとチェーン

ERC7811が参照する固定版のCAIP25と現行CAIP25では、データの形が異なります。

対象 ERC7811が参照する固定版 現行CAIP25
スコープ sessionScopes scopes
チェーン eip155:1 のようなスコープ chains: ["1"] またはチェーン別スコープ
アカウント eip155:1:0x... accounts: ["0x..."]
スコープ固有の追加情報 scopedProperties capabilities
セッション全体の追加情報 sessionProperties properties

ERC7811のチェーンIDは 0x1 のような16進文字列ですが、CAIP25側では 1 のような10進文字列を使います。 そのため、両方を整数へ変換してから同じチェーンかを確認します。

固定版のCAIP10アカウントは、 eip155:1:0x... を名前空間、チェーン、アドレスへ分解します。 現行CAIP25のアカウントは、スコープが表すチェーンと生のアドレスを組み合わせて確認します。 EVMアドレスはEIP55のチェックサムを検証できる形で受理し、比較用には大文字小文字をそろえます。

現行CAIP25は eip155:0 を任意のEIP155チェーンへ適用するnull referenceと説明しています。 しかし、同じ版にはbare eip155 の例とnamespace scopeを廃止した変更履歴もあります。 ERC7811自身は、この版境界で認可を確定できない場合のエラーコードを定めていません。 サンプルでは、合意した版と表現を確認できないnamespace-wide認可を推測で展開せず、認可を証明できない状態としてEIP1193の 4100 Unauthorized で停止します。 明示的な chains または0以外のチェーン別スコープだけを認可判定へ使います。

一般のCAIP10仕様は、すべての名前空間に共通する正規化方法を定めていません。 アドレスを小文字へ変換して比較する方針は、EIP155名前空間のEVMアドレスに限定したサンプル実装です。

アカウント、チェーン、セッションが変わった場合、DAppは以前の対応可否と資産応答を破棄します。 CAIP311の wallet_sessionChanged 通知を受けた時や、切断後にCAIP312の wallet_getSession で権限を再取得した時も同じです。 ただし、CAIP311とCAIP312もDraftであり、接続先が採用するCAIP25の版と一緒に確認します。

ウォレットサービス経路

CAIP345は、ウォレットが指定したHTTPのJSON-RPCエンドポイントへ wallet_getAssets を送る経路を定めています。 DAppは完全なJSON-RPC要求を POST し、サービスはCORSや Cache-Control を使ってブラウザからの要求とキャッシュを制御できます。 サービスはウォレットの運営者だけでなく、第三者が運営する場合もあります。 要求の id には応答との対応だけに使う 0 の設定が推奨され、ウォレットが指定したURLのクエリパラメータも変更せず送ります。 該当メソッドを処理するウォレットサービスが指定されていない場合は、EIP1193 Provider経由でウォレットへ直接送ります。 CAIP345はHTTP要求が失敗した後の自動再送を定めていないため、サービス失敗時の回復方針はDApp側で決めます。 ウォレット側は、CAIP345に対応しないDAppがメソッドを直接送った場合にも処理できる経路を残します。

ただし、CAIP345の記載は旧CAIP25の sessionProperties と scopedProperties を使っています。 現行CAIP25の properties と capabilities へどう写すかは、CAIP345で規範的に更新されていません。 DAppはプロパティ名を推測で読み替えず、ウォレットと合意した版だけを読みます。

CAIP345は、メソッドを特定のアカウントへ割り当てる仕組みも持ちません。 ウォレットサービスのURLを選べた場合も、対象アカウントがCAIP25のスコープに含まれるかを別に確認します。 TypeScriptサンプルは、この未確定な版境界をコードへ持ち込まないため、EIP1193 Providerへ直接送る経路だけを実装します。

エラーごとの回復

EIP1193とJSON-RPCのエラーは、原因ごとにDAppの処理を変えます。

コード 意味 DAppの処理
4100 アカウントまたはメソッドが未認可 利用者へ再認可を案内する
4200 メソッド未対応 再送せず、既知コントラクトへの照会へ切り替える
4900 Providerが全チェーンから切断 接続を回復してから1度だけ再試行する
4901 対象チェーンから切断 対象チェーンへ接続してから1度だけ再試行する
-32602 引数が不正 コードまたは入力を直すまで再送しない

タイムアウトや未知のエラーでは、自動再試行を繰り返しません。 遅れて届いた古い応答と新しい要求が競合する可能性があるため、画面上の再試行操作へ戻します。 この回復方針はEIP1193とJSON-RPCを使ったサンプルの判断であり、ERC7811固有の規則ではありません。

TypeScriptサンプル実装

ERC7811には公式の参考実装と適合テストがありません。 そこで、提案の型と優先順位を確認できる小さな実装を作ります。 コードは小文字の資産タイプ、16進文字列の残高、チェーン別の配列を共通型として使います。

共通型をウォレットの絞り込み、Provider、DApp、テストへつなぐ実装構成

まず、要求と資産の型を定義します。

ts
export type Hex = `0x${string}`;
export type Address = Hex;
export type ChainId = Hex;
export type AssetType = "native" | "erc20" | "erc721";

export type AssetIdentifier = {
  address: Address | "native";
  type: AssetType;
};

export type WalletGetAssetsRequest = {
  account: Address;
  assetFilter?: Record<ChainId, AssetIdentifier[]>;
  assetTypeFilter?: AssetType[];
  chainFilter?: ChainId[];
};

export type NativeAsset = {
  address: "native";
  balance: Hex;
  type: "native";
};

export type Erc20Asset = {
  address: Address;
  balance: Hex;
  type: "erc20";
  metadata: { name: string; symbol: string; decimals: number };
};

export type Erc721Asset = {
  address: Address;
  balance: Hex;
  type: "erc721";
  metadata: {
    name: string;
    symbol: string;
    tokenId: Hex;
    tokenURI?: string;
  };
};

export type Asset = NativeAsset | Erc20Asset | Erc721Asset;
export type WalletGetAssetsResponse = Record<ChainId, Asset[]>;

Asset を判別可能なunion型にすると、 type を確認した後に使える metadata が決まります。 例えば、 asset.type === "erc20" の分岐内では decimals を安全に参照できます。 また、 balance はJavaScriptの number へ変換せず、16進文字列のまま受け渡します。

ウォレット側のフィルタ処理

ウォレット側は、JSON-RPC要求を受け取った直後に params と要求オブジェクトを検証します。 以下のコードは1要素の配列だけを受理し、不正な入力を -32602 で止めます。

ts
export class RpcError extends Error {
  constructor(public readonly code: number, message: string) {
    super(message);
  }
}

const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
const HEX_QUANTITY = /^0x(?:0|[1-9a-fA-F][0-9a-fA-F]*)$/;
const ASSET_TYPES = new Set<AssetType>(["native", "erc20", "erc721"]);

function isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function isAddress(value: unknown): value is Address {
  return typeof value === "string" && ADDRESS.test(value);
}

function isChainId(value: unknown): value is ChainId {
  return typeof value === "string" && HEX_QUANTITY.test(value);
}

function isAssetType(value: unknown): value is AssetType {
  return typeof value === "string" && ASSET_TYPES.has(value as AssetType);
}

export function parseWalletGetAssetsParams(
  params: unknown,
): WalletGetAssetsRequest {
  if (!Array.isArray(params) || params.length !== 1 || !isRecord(params[0])) {
    throw new RpcError(-32602, "params must be [WalletGetAssetsRequest]");
  }

  const request = params[0];
  if (!isAddress(request.account)) {
    throw new RpcError(-32602, "account must be a 20-byte address");
  }

  if (
    request.chainFilter !== undefined &&
    (!Array.isArray(request.chainFilter) || !request.chainFilter.every(isChainId))
  ) {
    throw new RpcError(-32602, "chainFilter contains an invalid chain ID");
  }

  if (
    request.assetTypeFilter !== undefined &&
    (!Array.isArray(request.assetTypeFilter) ||
      !request.assetTypeFilter.every(isAssetType))
  ) {
    throw new RpcError(-32602, "assetTypeFilter contains an unknown type");
  }

  if (request.assetFilter !== undefined) {
    if (!isRecord(request.assetFilter)) {
      throw new RpcError(-32602, "assetFilter must be a record");
    }

    for (const [chainId, identifiers] of Object.entries(request.assetFilter)) {
      if (!isChainId(chainId) || !Array.isArray(identifiers)) {
        throw new RpcError(-32602, "assetFilter contains an invalid chain");
      }

      for (const identifier of identifiers) {
        if (
          !isRecord(identifier) ||
          !(identifier.address === "native" || isAddress(identifier.address)) ||
          !isAssetType(identifier.type)
        ) {
          throw new RpcError(-32602, "assetFilter contains an invalid asset");
        }
      }
    }
  }

  return request as WalletGetAssetsRequest;
}

HEX_QUANTITY は 0x0 または先頭に不要なゼロを持たない16進数だけを受理します。 ERC7811の Hex 型はアドレス長や先頭ゼロまで定めていないため、この検証はサンプル独自の入力契約です。

assetFilter の内部では、チェーンIDごとに address と type を検証します。 入力を検証した後の処理は、値が存在するかを何度も確認せず、フィルタの優先順位へ集中できます。

続いて、CAIP25の版差を正規化した認可情報を用意します。

ts
import { getAddress } from "viem";

type AuthorizedScope = {
  methods: Set<string>;
  accountChains: Set<string>;
};

export type Authorization = {
  scopes: AuthorizedScope[];
};

export function normalizeChainId(chainId: ChainId | string): bigint {
  if (chainId.startsWith("0x")) {
    if (!isChainId(chainId)) {
      throw new RpcError(-32602, "invalid hex chain ID");
    }
    return BigInt(chainId);
  }

  if (!/^(?:0|[1-9][0-9]*)$/.test(chainId)) {
    throw new RpcError(-32602, "invalid decimal chain ID");
  }
  return BigInt(chainId);
}

function normalizeEvmAddress(address: string): string {
  if (!ADDRESS.test(address)) {
    throw new RpcError(-32602, "invalid EVM address");
  }
  return getAddress(address).toLowerCase();
}

function accountChainKey(chainId: bigint, account: string): string {
  return `${chainId}:${normalizeEvmAddress(account)}`;
}

export function addLegacyScope(
  authorization: Authorization,
  input: { methods: string[]; accounts: string[] },
): void {
  const scope: AuthorizedScope = {
    methods: new Set(input.methods),
    accountChains: new Set(),
  };

  for (const accountId of input.accounts) {
    const match = /^eip155:([0-9]+):(0x[0-9a-fA-F]{40})$/.exec(accountId);
    if (!match) {
      throw new RpcError(-32602, "invalid EIP155 CAIP-10 account");
    }
    scope.accountChains.add(
      accountChainKey(normalizeChainId(match[1]), match[2]),
    );
  }
  authorization.scopes.push(scope);
}

export function addCurrentScope(
  authorization: Authorization,
  scopeKey: string,
  input: { chains?: string[]; accounts: string[]; methods: string[] },
): void {
  if (scopeKey === "eip155:0") {
    throw new RpcError(4100, "ambiguous namespace-wide CAIP-25 authorization");
  }

  const chainIds = new Set((input.chains ?? []).map(normalizeChainId));
  const chainScope = /^eip155:([0-9]+)$/.exec(scopeKey);
  if (chainScope) {
    chainIds.add(normalizeChainId(chainScope[1]));
  } else if (scopeKey !== "eip155") {
    throw new RpcError(-32602, "unsupported CAIP-25 scope");
  }
  if (chainIds.size === 0) {
    throw new RpcError(-32602, "CAIP-25 scope has no explicit chain");
  }

  authorization.scopes.push({
    methods: new Set(input.methods),
    accountChains: new Set(
      [...chainIds].flatMap((chainId) =>
        input.accounts.map((account) => accountChainKey(chainId, account)),
      ),
    ),
  });
}

export function isAuthorized(
  authorization: Authorization,
  account: Address,
  chainIds: ChainId[],
): boolean {
  if (chainIds.length === 0) {
    const accountSuffix = `:${normalizeEvmAddress(account)}`;
    return authorization.scopes.some(
      (scope) =>
        scope.methods.has("wallet_getAssets") &&
        [...scope.accountChains].some((entry) => entry.endsWith(accountSuffix)),
    );
  }

  return chainIds.every((chainId) =>
    authorization.scopes.some(
      (scope) =>
        scope.methods.has("wallet_getAssets") &&
        scope.accountChains.has(
          accountChainKey(normalizeChainId(chainId), account),
        ),
    ),
  );
}

normalizeChainId 関数は、ERC7811の 0x1 とCAIP25の 1 をどちらも 1n へ変換します。 固定版CAIP25のCAIP10アカウントは、正規表現で eip155、10進チェーンID、EVMアドレスへ分解します。 現行CAIP25では chains と accounts が分かれますが、 eip155:8453 のようなチェーン別スコープではスコープキー自身もチェーンを表します。 そのため、 addCurrentScope 関数はスコープキーと値を一緒に受け取ります。 現行CAIP25は eip155:0 を任意チェーンへ適用するnull referenceと説明しています。 一方、同じ版にはbare eip155 の例とnamespace scopeを廃止した変更履歴があり、どのnamespace-wide表現を受理するか一意に確定できません。 ERC7811は、この曖昧さに対する固有のエラーコードを定めていません。 サンプルは eip155:0 を数値0や任意チェーンへ変換せず、認可を証明できない状態としてEIP1193の 4100 Unauthorized で停止します。 ウォレットと合意した版に基づく明示的な chains または0以外のチェーン別スコープだけを受理します。

メソッドと、チェーン・アカウントの組はスコープごとに保存します。 要求した各チェーンについて、同じスコープ内に wallet_getAssets、対象アカウント、対象チェーンがそろう場合だけ認可するため、別々のスコープにある権限を組み合わせません。 フィルタも保有候補も空で確認対象のチェーンが0件になる場合でも、メソッドとアカウントが同じスコープにあることは確認します。

normalizeEvmAddress 関数は、 viem の getAddress 関数で混在した大文字小文字のチェックサムを検証し、比較用に小文字へ変換します。 この処理はEVMアドレス同士の比較にだけ使います。 画面へ表示するアドレスや、他の名前空間のアカウント識別子には適用しません。

資産の絞り込みでは、 assetFilter の有無を最初に確認します。

ts
function assetKey(chainId: ChainId, asset: Asset): string {
  const address = asset.address.toLowerCase();
  if (asset.type === "erc721") {
    return `${chainId}:${address}:erc721:${asset.metadata.tokenId}`;
  }
  return `${chainId}:${address}:${asset.type}`;
}

function deduplicate(
  assetsByChain: WalletGetAssetsResponse,
): WalletGetAssetsResponse {
  const result: WalletGetAssetsResponse = {};

  for (const [chainId, assets] of Object.entries(assetsByChain) as [
    ChainId,
    Asset[],
  ][]) {
    const seen = new Set<string>();
    result[chainId] = assets.filter((asset) => {
      const key = assetKey(chainId, asset);
      if (seen.has(key)) return false;
      seen.add(key);
      return true;
    });
  }

  return result;
}

export function filterAssets(
  allAssets: WalletGetAssetsResponse,
  request: WalletGetAssetsRequest,
): WalletGetAssetsResponse {
  const assets = deduplicate(allAssets);

  if (request.assetFilter !== undefined) {
    const result: WalletGetAssetsResponse = {};
    for (const [chainId, identifiers] of Object.entries(
      request.assetFilter,
    ) as [ChainId, AssetIdentifier[]][]) {
      const candidates = assets[chainId] ?? [];
      result[chainId] = candidates.filter((asset) =>
        identifiers.some(
          (identifier) =>
            identifier.type === asset.type &&
            identifier.address.toLowerCase() === asset.address.toLowerCase(),
        ),
      );
    }
    return result;
  }

  const allowedChains = request.chainFilter
    ? new Set(request.chainFilter.map(normalizeChainId))
    : undefined;
  const allowedTypes = request.assetTypeFilter
    ? new Set(request.assetTypeFilter)
    : undefined;
  const result: WalletGetAssetsResponse = {};

  for (const [chainId, chainAssets] of Object.entries(assets) as [
    ChainId,
    Asset[],
  ][]) {
    if (allowedChains && !allowedChains.has(normalizeChainId(chainId))) {
      continue;
    }
    result[chainId] = chainAssets.filter(
      (asset) => !allowedTypes || allowedTypes.has(asset.type),
    );
  }
  return result;
}

export function handleWalletGetAssets(
  params: unknown,
  allAssets: WalletGetAssetsResponse,
  authorization: Authorization,
): WalletGetAssetsResponse {
  const request = parseWalletGetAssetsParams(params);
  const requestedChains = request.assetFilter
    ? (Object.keys(request.assetFilter) as ChainId[])
    : request.chainFilter ?? (Object.keys(allAssets) as ChainId[]);

  if (!isAuthorized(authorization, request.account, requestedChains)) {
    throw new RpcError(4100, "wallet_getAssets is not authorized");
  }
  return filterAssets(allAssets, request);
}

assetFilter の分岐は結果を作った時点で return します。 そのため、後ろにある chainFilter と assetTypeFilter の処理へ進まず、提案の優先順位をそのまま表せます。

deduplicate 関数は、ERC20とネイティブトークンをチェーン、アドレス、タイプで区別します。 ERC721ではさらに tokenId を加えるため、同じコレクションに属する別のNFTを消しません。 これはERC7811が定める動作ではなく、空結果と重複の意味を安定させるサンプル独自の方針です。

DApp側の呼び出し

DApp側は、対象チェーンまたは 0x0 の対応可否を確認してから wallet_getAssets を呼びます。 以下のコードは、未対応時だけ既知コントラクトへの照会へ切り替え、認可と接続のエラーを別の結果として返します。

ts
type Provider = {
  request(args: { method: string; params?: unknown }): Promise<unknown>;
};

type CapabilityMap = Record<
  ChainId,
  { assetDiscovery?: { supported?: boolean } }
>;

type DiscoveryResult =
  | { status: "ok"; assets: WalletGetAssetsResponse }
  | { status: "reauthorize" }
  | { status: "fallback" }
  | { status: "reconnect"; chainOnly: boolean }
  | { status: "invalid-request" };

function supportsAssetDiscovery(
  capabilities: CapabilityMap,
  chainId: ChainId,
): boolean {
  if (Object.hasOwn(capabilities, chainId)) {
    return capabilities[chainId]?.assetDiscovery?.supported === true;
  }
  return capabilities["0x0"]?.assetDiscovery?.supported === true;
}

function rpcCode(error: unknown): number | undefined {
  if (typeof error !== "object" || error === null || !("code" in error)) {
    return undefined;
  }
  return typeof error.code === "number" ? error.code : undefined;
}

function validateAsset(value: unknown): Asset {
  if (!isRecord(value) || !isChainId(value.balance) || !isAssetType(value.type)) {
    throw new RpcError(-32602, "wallet returned an invalid asset");
  }

  if (value.type === "native") {
    if (value.address !== "native") {
      throw new RpcError(-32602, "native asset must use the native address");
    }
    return value as NativeAsset;
  }

  if (!isAddress(value.address) || !isRecord(value.metadata)) {
    throw new RpcError(-32602, "token metadata is missing");
  }
  normalizeEvmAddress(value.address);

  const { name, symbol } = value.metadata;
  if (typeof name !== "string" || typeof symbol !== "string") {
    throw new RpcError(-32602, "token name or symbol is invalid");
  }

  if (value.type === "erc20") {
    const decimals = value.metadata.decimals;
    if (typeof decimals !== "number" || !Number.isInteger(decimals) || decimals < 0) {
      throw new RpcError(-32602, "ERC20 decimals is invalid");
    }
    return value as Erc20Asset;
  }

  const { tokenId, tokenURI } = value.metadata;
  if (
    !isChainId(tokenId) ||
    (tokenURI !== undefined && typeof tokenURI !== "string")
  ) {
    throw new RpcError(-32602, "ERC721 metadata is invalid");
  }
  return value as Erc721Asset;
}

export function validateWalletGetAssetsResponse(
  value: unknown,
): WalletGetAssetsResponse {
  if (!isRecord(value)) {
    throw new RpcError(-32602, "wallet returned an invalid response");
  }

  const result: WalletGetAssetsResponse = {};
  for (const [chainId, assets] of Object.entries(value)) {
    if (!isChainId(chainId) || !Array.isArray(assets)) {
      throw new RpcError(-32602, "wallet returned an invalid chain entry");
    }
    result[chainId] = assets.map(validateAsset);
  }
  return result;
}

export async function discoverAssets(
  provider: Provider,
  account: Address,
  chainId: ChainId,
): Promise<DiscoveryResult> {
  const capabilities = (await provider.request({
    method: "wallet_getCapabilities",
    params: [account],
  })) as CapabilityMap;

  if (!supportsAssetDiscovery(capabilities, chainId)) {
    return { status: "fallback" };
  }

  try {
    const assets = await provider.request({
      method: "wallet_getAssets",
      params: [{
        account,
        assetTypeFilter: ["native", "erc20", "erc721"],
        chainFilter: [chainId],
      } satisfies WalletGetAssetsRequest],
    });

    return {
      status: "ok",
      assets: validateWalletGetAssetsResponse(assets),
    };
  } catch (error) {
    switch (rpcCode(error)) {
      case 4100:
        return { status: "reauthorize" };
      case 4200:
        return { status: "fallback" };
      case 4900:
        return { status: "reconnect", chainOnly: false };
      case 4901:
        return { status: "reconnect", chainOnly: true };
      case -32602:
        return { status: "invalid-request" };
      default:
        throw error;
    }
  }
}

supportsAssetDiscovery 関数は、対象チェーンのキーが存在するかを先に確認し、キーがない場合だけ 0x0 を読みます。 対象チェーンが明示的に false なら、全チェーン共通の true で上書きしません。 false の時は wallet_getAssets を送らないため、未対応メソッドのエラーを待たずに既知コントラクトへの照会へ切り替えられます。

4100 の結果では、DAppが利用者へアカウントまたはメソッドの再認可を求めます。 4900 と 4901 では、接続を回復した後に呼び出し元が1度だけ再試行します。 -32602 は同じ入力を送り直しても成功しないため、コードか入力を直すまで停止します。

validateWalletGetAssetsResponse 関数は、応答がチェーン別の配列であり、各資産の type、 balance、 metadata が期待する形かを実行時に検証する関数です。 型アサーションだけではウォレットから届いた値を検証できないため、実運用ではZodのようなスキーマライブラリで同じ境界を実装します。

検証後も name、 symbol、 tokenURI は未信頼の文字列です。 画面ではHTMLとして挿入せず、文字数を制限してテキストとして表示します。 tokenURI は自動取得せず、許可するURIスキーム、応答サイズ、Content-Type、タイムアウトを別の処理で確認します。

仕様条件を確認するテスト

Vitestでは、関数が成功することだけでなく、提案の優先順位と記事固有の境界を分けて固定します。 代表的なテストは以下です。

ts
import { describe, expect, it, vi } from "vitest";
import {
  addCurrentScope,
  addLegacyScope,
  discoverAssets,
  filterAssets,
  isAuthorized,
  normalizeChainId,
  parseWalletGetAssetsParams,
  validateWalletGetAssetsResponse,
  type WalletGetAssetsResponse,
  type Authorization,
} from "./erc7811";

const ACCOUNT = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045";
const OTHER = "0x1111111111111111111111111111111111111111";
const assets: WalletGetAssetsResponse = {
  "0x1": [
    { address: "native", balance: "0x10", type: "native" },
    {
      address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
      balance: "0xf4240",
      type: "erc20",
      metadata: { name: "USD Coin", symbol: "USDC", decimals: 6 },
    },
  ],
  "0xa": [{ address: "native", balance: "0x20", type: "native" }],
};

describe("wallet_getAssets", () => {
  it("1要素のparamsだけを受理する", () => {
    expect(
      parseWalletGetAssetsParams([{ account: ACCOUNT, chainFilter: ["0x1"] }]),
    ).toEqual({ account: ACCOUNT, chainFilter: ["0x1"] });

    expect(() => parseWalletGetAssetsParams({ account: ACCOUNT })).toThrowError(
      expect.objectContaining({ code: -32602 }),
    );
    expect(() => parseWalletGetAssetsParams([{
      account: ACCOUNT,
      assetTypeFilter: ["erc1155"],
    }])).toThrowError(expect.objectContaining({ code: -32602 }));
  });

  it("assetFilterが他の2つのfilterより優先される", () => {
    const result = filterAssets(assets, {
      account: ACCOUNT,
      assetFilter: { "0x1": [{ address: "native", type: "native" }] },
      assetTypeFilter: ["erc20"],
      chainFilter: ["0xa"],
    });

    expect(result).toEqual({
      "0x1": [{ address: "native", balance: "0x10", type: "native" }],
    });
  });

  it("未対応の資産typeを応答として受理しない", () => {
    expect(() => validateWalletGetAssetsResponse({
      "0x1": [{ address: "native", balance: "0x1", type: "erc1155" }],
    })).toThrowError(expect.objectContaining({ code: -32602 }));
  });

  it("hexとdecimalのchain IDを同じ値として比較できる", () => {
    expect(normalizeChainId("0x1")).toBe(1n);
    expect(normalizeChainId("1")).toBe(1n);
    expect(normalizeChainId("0xa")).toBe(10n);
  });

  it("固定版と現行版のaccount表現を同じ認可へ変換する", () => {
    const legacy: Authorization = { scopes: [] };
    addLegacyScope(legacy, {
      methods: ["wallet_getAssets"],
      accounts: [`eip155:1:${ACCOUNT}`, `eip155:10:${OTHER}`],
    });

    const current: Authorization = { scopes: [] };
    addCurrentScope(current, "eip155:1", {
      accounts: [ACCOUNT.toLowerCase()],
      methods: ["wallet_getAssets"],
    });

    expect(isAuthorized(legacy, ACCOUNT, ["0x1"])).toBe(true);
    expect(isAuthorized(legacy, ACCOUNT, ["0xa"])).toBe(false);
    expect(isAuthorized(current, ACCOUNT, ["0x1"])).toBe(true);
    expect(isAuthorized(current, ACCOUNT, ["0xa"])).toBe(false);
    expect(isAuthorized(current, ACCOUNT, [])).toBe(true);

    expect(() => addCurrentScope({ scopes: [] }, "eip155:0", {
      accounts: [ACCOUNT],
      methods: ["wallet_getAssets"],
    })).toThrowError(expect.objectContaining({ code: 4100 }));
  });

  it("別々のscopeにあるmethodとaccountを組み合わせない", () => {
    const authorization: Authorization = { scopes: [] };
    addCurrentScope(authorization, "eip155:1", {
      accounts: [ACCOUNT],
      methods: [],
    });
    addCurrentScope(authorization, "eip155:1", {
      accounts: [OTHER],
      methods: ["wallet_getAssets"],
    });

    expect(isAuthorized(authorization, ACCOUNT, ["0x1"])).toBe(false);
  });

  it("対象chainと0x0のcapabilityを順に確認する", async () => {
    const provider = {
      request: vi.fn()
        .mockResolvedValueOnce({ "0x0": { assetDiscovery: { supported: true } } })
        .mockResolvedValueOnce({ "0x1": [] }),
    };

    await expect(discoverAssets(provider, ACCOUNT, "0x1")).resolves.toEqual({
      status: "ok",
      assets: { "0x1": [] },
    });
  });

  it("対象chainのfalseを0x0のtrueで上書きしない", async () => {
    const provider = {
      request: vi.fn().mockResolvedValueOnce({
        "0x1": { assetDiscovery: { supported: false } },
        "0x0": { assetDiscovery: { supported: true } },
      }),
    };

    await expect(discoverAssets(provider, ACCOUNT, "0x1")).resolves.toEqual({
      status: "fallback",
    });
    expect(provider.request).toHaveBeenCalledTimes(1);
  });

  it.each([
    [4100, { status: "reauthorize" }],
    [4200, { status: "fallback" }],
    [4900, { status: "reconnect", chainOnly: false }],
    [4901, { status: "reconnect", chainOnly: true }],
    [-32602, { status: "invalid-request" }],
  ])("error code %sを別の結果へ変換する", async (code, expected) => {
    const provider = {
      request: vi.fn()
        .mockResolvedValueOnce({ "0x1": { assetDiscovery: { supported: true } } })
        .mockRejectedValueOnce({ code }),
    };

    await expect(discoverAssets(provider, ACCOUNT, "0x1")).resolves.toEqual(expected);
  });
});

2つ目のテストでは、 assetTypeFilter と chainFilter が assetFilter と矛盾する値になっています。 それでもEthereum Mainnetのネイティブトークンだけが返るため、 assetFilter の早期 return を確認できます。

チェーンIDのテストは、ERC7811の16進表記とCAIP25の10進表記を同じ整数へ変換できることを固定します。 認可テストでは、固定版の eip155:1:0x... と現行版のチェーン別スコープを同じ判断へ変換し、チェーン不一致を拒否します。 namespace-wide表現が不明確な eip155:0 は、チェーンID 0や任意チェーンとして受理せず、EIP1193の 4100 Unauthorized で停止することも確認します。 さらに、メソッドとアカウントが別々のスコープにある場合を拒否し、権限の組み合わせを防ぎます。

未知の資産タイプを使う要求と応答のテストは、このサンプルが既知3種だけを扱う境界を固定します。 ERC1155のような拡張へ対応する時は、この失敗ケースを新しい型と metadata の検証へ置き換えます。

エラーの表形式テストでは、同じ wallet_getAssets の失敗でもDAppが返す状態を分けています。 特に 4200 だけが既知コントラクトへの照会へ進み、 4100 は利用者の再認可を待ちます。

このテストはERC7811公式の適合テストではありません。 提案が定めるフィルタの優先順位と、サンプルが追加した入力検証、重複排除、回復方針を区別して確認するためのテストです。

安全に使うための境界

ERC7811のSecurity Considerationsは空欄です。 そのため、資産一覧を受け取れたことから、残高の真正性、情報の鮮度、価格、送付可能性を判断できません。 DAppとウォレットは、提案がまだ定めていない境界を実装側で補います。

ERC7811が定める要求と応答の形式と実装側が決める信頼境界

応答の真正性と鮮度

wallet_getAssets は、ウォレットが把握している資産候補を返します。 Indexerの反映待ち、キャッシュ、チェーンの再編成、送付直後の更新遅延によって、オンチェーンの現在値と異なる可能性があります。

DAppは、資産選択画面の候補表示にERC7811の応答を使えます。 一方、送付、担保評価、決済のような不可逆な処理へ進む直前には、対象コントラクトまたは信頼するRPCへ残高と所有者を再照会します。 アカウントやチェーンが変わった時は、以前の応答を別の利用者やチェーンへ流用せず破棄します。

資産一覧のプライバシー

複数チェーンの残高やNFTをまとめた一覧は、利用者の識別や行動推定に使われる可能性があります。 ウォレットは、接続中のセッションで認可したアカウント、メソッド、チェーンだけを返します。 DAppも画面に必要な chainFilter や assetTypeFilter を指定し、無条件の全件取得を避けます。

CAIP345のウォレットサービスを使う場合、HTTPサービスの運営者もアカウントとフィルタを読めます。 アプリ内のProviderだけで完結する経路とは第三者への開示範囲が異なるため、DAppは送信先を利用者へ示します。 プロキシを挟む場合は、ウォレットサービスに加えてプロキシ運営者も同じ要求を読めます。

metadataの表示

name と symbol は、ウォレットまたはウォレットが利用する情報源から届きます。 DAppはこれらをHTMLとして挿入せず、テキストとして表示し、画面を崩さない長さに制限します。

tokenURI は外部データへの参照です。 受信直後に自動取得すると、DApp利用者のIPアドレスが参照先へ伝わり、大きな応答や異なるContent-Typeを受け取る可能性があります。 取得する場合は許可するURIスキーム、応答サイズ、Content-Type、タイムアウトを確認し、資産一覧を読む処理から分離します。

サイズとDraftの未確定事項

現在のERC7811は、以下を定めていません。

  • ページングと件数上限

    資産を何件ずつ返すかや、続きを取得するカーソルがありません。

  • タイムアウトと再試行

    ウォレットやIndexerが応答できない時の待ち時間と再送回数は実装側が決めます。

  • スパム資産の除外

    どの資産をスパムと判断するかや、除外した事実を応答へ示す方法がありません。

  • 価格と並び順

    推定価値の降順は推奨されていますが、価格情報源、評価通貨、更新時刻、同額時の順序は決まっていません。

  • 複数アカウント

    1回の要求で指定できる account は1つです。

  • 変更通知と差分更新

    ERC7811が定めるのは1回の要求と応答です。 資産が増減した時の購読、変更通知、差分だけを取得する方法、キャッシュ期間は定めていません。

フィルタなしの要求は大きな応答になる可能性があります。 DAppは必要なチェーンと資産タイプへ絞り、ウォレットは独自の上限、キャッシュ期間、失敗時の動作を公開します。 これらの実装方針をERC7811の保証として表示せず、Draftの更新時に差分を確認します。 提案履歴では2025年6月10日の誤記訂正以後に本文更新がないため、公開前と実装更新時にはDraftの差分を改めて確認します。

最後に

ERC7811は、DAppが未知の資産コントラクトを総当たりする代わりに、ウォレットが把握する資産候補をチェーン別に受け取る起点を作ります。 実装では、 assetFilter の優先順位、資産タイプごとの metadata、対応可否と認可の分離、CAIP25の識別子正規化、受信値の再検証が中心になります。 一方、ページング、応答の真正性と鮮度、CAIP345と現行CAIP25の対応は確定していないため、ウォレット固有の動作と提案の要件を分けて実装します。

ERC7811と関連仕様の一次資料は以下です。

他でも色々記事を書いているのでぜひよろしければ読んでいってください!