3MIKAN
仮想通貨直コン

EIP-6963で複数walletを検出・選択する実装

window.ethereumの競合を避け、EIP-6963のannounce/request event、provider registry、明示選択、再接続、EIP-1193 listener cleanup、legacy fallbackを実装します。

3MIKANのブランドキャラクターが三つの架空providerを同列に集め、その中から一つのconnectorを選んで接続する記事画像

複数のbrowser walletをinstallした環境でwindow.ethereumだけを読むと、最後に読み込まれたwalletが上書きする競合へ戻ります。EIP-6963は、各walletが持つEIP-1193 providerをwindow eventでannounceし、DAppが候補を並べて利用者に選ばせる仕組みです。

ただし、複数walletを見つけられたことと、表示された名前が本物だと証明できたことは同じではありません。この記事では、見つけたprovider、利用者が選んだprovider、RPCを送るproviderを同じ一つへ対応させます。重複announce、late announce、metadata spoofing、前回選択、accountsChanged、legacy fallbackまで、外部walletへ触れないmockで確認します。

基準日は2026年8月30日です。EIP-6963とEIP-1193はいずれもFinalです。実在extensionの対応状況、account、残高、public RPC、署名、transactionは検証対象にしていません。

window.ethereumはEIP-1193の必須配置ではない

EIP-1193が標準化するのは、providerのrequest methodとeventです。browser上のwindow.ethereumは歴史的な慣習であり、EIP-1193の仕様には含まれません。

複数extensionが同じpropertyへinjectすると、読み込み順は不安定です。「先にinstallしたwallet」「利用者が前回選んだwallet」「画面に表示したwallet」と、実際にwindow.ethereumへ残ったproviderが一致する保証はありません。

EIP-6963は、共有propertyを奪い合う代わりに二つのeventを使います。

event 送信側 内容 実装上の要点
eip6963:announceProvider wallet infoとEIP-1193 provider 同じproviderが複数回announceしても候補を増やさない
eip6963:requestProvider DApp detailなし announce listenerを登録した後でdispatchする

walletとDAppのどちらが先に実行されても、walletはrequestを受けて再announceできます。DAppは初期探索が終わったように見えても、announce listenerをページ存続中は残します。これにより、遅れてinjectされたproviderもpollingなしで追加できます。

listenerを先に登録してからrequestする

最小のevent flowは次の順序です。

const providers = new Map<string, EIP6963ProviderDetail>()

window.addEventListener('eip6963:announceProvider', (event) => {
  const { info, provider } = (event as CustomEvent<EIP6963ProviderDetail>).detail
  providers.set(info.uuid, { info, provider })
})

window.dispatchEvent(new Event('eip6963:requestProvider'))

先にrequestすると、まだlistenerを持たないDAppは同期的なannounceを取りこぼします。また、一定時間後にannounce listenerを外すと、late announceを扱えません。

ここで残すのはdiscovery用window listenerです。後で選択したproviderへ付けるaccountsChangedなどのlistenerは、provider切替時に解除します。二種類のlistener寿命を混ぜないでください。

複数providerのannounceをregistryへ集め、利用者が選んだ一つだけへrequestを送る流れ
discoveryは候補を収集し、selectionはrequest先を一つに固定します。

provider detailをそのまま信用しない

announce eventのdetailは、次の二つを持ちます。

interface EIP6963ProviderInfo {
  uuid: string
  name: string
  icon: string
  rdns: string
}

interface EIP6963ProviderDetail {
  info: EIP6963ProviderInfo
  provider: EIP1193Provider
}

各fieldの用途とsecurity boundaryを分けます。

field 仕様上の役割 信用してよい範囲 過信すると起きること
uuid page lifetime中のprovider session識別 同じUUIDの重複announceを検出 異なるproviderが同じUUIDなら改ざん・衝突候補
name 利用者へ表示するalias textとして表示 同名を同一walletだと扱う
icon data URIの画像 img要素のsrcとして表示 SVGをHTMLへ展開してscriptを実行させる
rdns sessionをまたいで安定させる意図の識別子 前回選択の候補照合 feature detectionや真正性保証に使う
provider EIP-1193 requestとevent 選択後のruntime object 別providerへrequestを送る、propertyを上書きする

EIP-6963はrdnsがunknown、incorrect、他walletの模倣になり得ると説明し、追加の検証なしにfeature detectionへ使わないよう求めています。nameiconも同じく自己申告です。

UUIDの重複と競合を分ける

同じwalletはrequestへ応答して同じdetailを再announceします。同じUUID、同じprovider object、同じinfoなら単なる重複として無視できます。

一方、同じUUIDなのにprovider objectかmetadataが変わった場合は、後着を上書き採用しません。この記事のregistryは既存候補をconflictedにし、選択不可へ変えます。

const existing = providers.get(info.uuid)

if (existing?.provider === provider && sameInfo(existing.info, info)) {
  return 'duplicate'
}
if (existing) {
  providers.set(info.uuid, { ...existing, conflicted: true })
  return 'conflict'
}
providers.set(info.uuid, { info: Object.freeze({ ...info }), provider })

同じrdnsでUUIDが異なる候補は、勝手に一つへまとめません。どちらが本物かをmetadataだけでは決められないため、両方を表示し、自動復元を止めます。

iconはimgとしてだけ描画する

EIP-6963のiconはdata URIです。SVGはJavaScriptを含められるため、文字列をinnerHTMLへ渡さず、許可したdata image形式だけをimg要素のsrcへ設定します。wallet名とrdnstextContentで描画します。

const allowed = /^data:image\/(png|webp|avif|jpeg|svg\+xml)(;[^,]*)?,/i

if (allowed.test(info.icon)) {
  image.src = info.icon
}
name.textContent = info.name
rdns.textContent = info.rdns

provider objectをspreadして新しいobjectへ混ぜたり、prototypeを走査してwallet種別を推測したりもしません。選択したruntime objectを、そのままEIP-1193境界として保持します。

前回選択はrdnsを候補として保存する

uuidはpage lifetime中のsession識別子なので、reload後の永続keyにはしません。この記事のsampleは、前回選んだrdnsだけをlocal storageへ保存します。

{
  "rdns": "dev.example.wallet"
}

次回のannounceで、競合していない同じrdns一件だけあれば、その候補をpreselectします。0件または2件以上なら復元しません。

preselectしても、eth_requestAccountsは自動送信しません。利用者が接続buttonを押した時点で、画面に選択中として示したproviderへだけrequestします。保存値は利用者の好みであり、walletの真正性、install状態、認可済みaccountの証拠ではありません。

広告やsponsor表示を追加する場合も、announce順や利用者が選んだ状態とは別の領域へ置きます。広告契約を理由にdefault選択、先頭固定、自動接続を行わない設計が必要です。

provider選択時にsession listenerを付け替える

選択後は、同じproviderへrequestとeventを対応させます。

function selectProvider(next: EIP6963ProviderDetail) {
  cleanupSelectedProvider?.()

  const onAccountsChanged = (accounts: string[]) => setAccounts(accounts)
  const onChainChanged = (chainId: string) => setChainId(chainId)
  const onDisconnect = (error: ProviderRpcError) => setDisconnected(error)

  next.provider.on('accountsChanged', onAccountsChanged)
  next.provider.on('chainChanged', onChainChanged)
  next.provider.on('disconnect', onDisconnect)

  cleanupSelectedProvider = () => {
    next.provider.removeListener('accountsChanged', onAccountsChanged)
    next.provider.removeListener('chainChanged', onChainChanged)
    next.provider.removeListener('disconnect', onDisconnect)
  }
}

accountsChangedは、そのproviderが現在DAppへ公開するaccount配列の変更です。chainChangedはhexadecimal chain IDを返します。別walletを選んだ後も古いlistenerが残ると、画面のaccountやchainが選択中providerと食い違います。

ReactやAstroのcomponent破棄、SPA navigation、provider切替では、同じfunction referenceをremoveListenerへ渡します。anonymous functionを登録して別のanonymous functionで外そうとしても解除できません。

disconnectを三つに分ける

「切断」は少なくとも三つあります。

  1. EIP-1193 disconnect: providerが全chainへRPCを処理できない状態
  2. DAppのselection解除: どのproviderへ送るかをlocal UIから外す操作
  3. wallet側のsite permission解除: account公開や接続許可をwalletで変更する操作

injected providerはprogrammatic disconnectを提供しない場合があります。wagmiのshimDisconnectはstorageでDApp側の切断状態を模擬します。DAppの「切断」buttonを、wallet permissionの削除やprovider自体のnetwork disconnectと表示しないでください。

request errorを接続成功と分ける

選択したproviderへeth_requestAccountsを送るときは、EIP-1193 error codeを保存します。

code 意味 UIでの扱い
4001 利用者がrequestを拒否 接続済みにせず、再試行は利用者操作から始める
4100 methodまたはaccountが未認可 permission状態を見直す
4200 method非対応 provider capabilityとして分ける
4900 全chainからdisconnected provider connectivityを待つ
4901 指定chainへ未接続 selected chainとrequest先を照合する

4001を「walletが見つからない」、4901を「accountが未認可」と表示すると、直す場所が変わってしまいます。discovery、selection、authorization、chain connectivityを別のstateとして持ちます。

legacy fallbackはannounceがない時だけ使う

EIP-6963は、discoveryが失敗した場合だけwindow.ethereumをfallbackとして使うことを推奨しています。

window.setTimeout(() => {
  if (registry.eip6963Count === 0 && isEip1193(window.ethereum)) {
    registry.addLegacy(window.ethereum)
  }
}, 500)

legacy providerを表示するときは、「選ばれたwallet」ではなく「identity未確定のfallback」と示します。複数extension環境では、window.ethereumが利用者の意図したwalletである保証は戻りません。

fallback表示後に正しいEIP-6963 announceが届いたら、この記事のregistryはlegacy候補を外してEIP-6963候補へ置き換えます。初期timeoutを「探索完了」と決め付けず、window listenerを維持する理由です。

wagmiを使う場合も境界は同じ

wagmi Core 3.4.0createConfigは、multiInjectedProviderDiscoveryを既定でtrueにし、EIP-6963 providerをinjected connectorへ変換します。

const config = createConfig({
  chains: [mainnet, sepolia],
  transports: {
    [mainnet.id]: http(),
    [sepolia.id]: http(),
  },
  multiInjectedProviderDiscovery: true,
})

libraryへ任せる場合は、connector list、保存したcurrent connector、reconnect結果をlibraryのstateから読みます。raw window event registryとlibrary discoveryを同時にselectionの正本にすると、同じproviderが別IDで二重表示される設計になり得ます。どちらがdiscoveryとpersistenceを所有するかを一つに決めてください。

injected({ target })window.ethereumを直接指定する経路は、EIP-6963の複数候補選択とは別です。fallbackとして使う場合は、複数wallet環境で正しいwalletを保証しないことをUIへ残します。

mock demoで8条件を再現する

EIP-6963 provider discovery mockは、ページ内のEventTargetと架空providerだけを使います。install済みextensionを列挙せず、account request、RPC、署名、transactionを外へ送りません。

demoでは、次の操作を確認できます。

case 入力 期待結果
複数provider requestへ2件が応答 announce順で2件を保持
重複announce 同じUUID・provider・info 件数を増やさない
UUID競合 同じUUID・異なるprovider 候補を選択不可にする
同じrdns 異なるUUIDで同じrdns 両方を残し、自動復元しない
late announce 初期表示後に3件目 listenerが追加を受け取る
legacy fallback announce 0件 待機後にidentity未確定で一件表示
provider切替 AからBへ選択変更 Aの3 listenerを外し、Bへ付ける
request error mockで拒否・disconnect 40014900を別stateにする

自動testは8/8です。mock addressとchain IDはUI stateの分岐確認用であり、実在accountやnetworkの証拠ではありません。

症状から直す層を決める

症状 先に見る証拠 修正候補
install済みなのに0件 listener登録時刻、request dispatch、late announce listenerを先に付け、ページ中維持する
別walletが開く selected UUID、requestしたprovider object、legacy使用有無 window.ethereum単独依存を外す
同じwalletが複数表示 UUID、provider object、info一致 同一announceをdedupeする
reload後に別候補を選ぶ 保存rdns、同じrdnsの件数 一意でなければpreselectしない
accountが勝手に戻る listenerを付けたproviderとcleanup回数 旧providerのlistenerを解除する
接続button後も未接続 error code、eth_requestAccounts結果 400141004900を分ける
fallback後に候補が増えない announce listenerが残っているか timeoutでlistenerを外さない

walletの検出は、EIP-7702 delegationの設定とは別です。addressへcodeを委任する仕組みはEIP-7702 delegationの確認、署名前にdomain・type・messageを固定する手順はEIP-712の実装、選択したwalletへ複数callを渡す境界はwallet_sendCallsとERC-7821で分けています。

productionへ入れる前のchecklist

  1. announce listenerをrequestより先に登録したか
  2. discovery listenerをpage lifetime中維持したか
  3. UUID v4、name、data URI icon、rdns、EIP-1193 methodをvalidationしたか
  4. 重複UUIDと競合UUIDを分けたか
  5. name、icon、rdnsをidentityやfeature detectionへ使っていないか
  6. iconをimg要素で描画し、metadataをinnerHTMLへ渡していないか
  7. 前回選択は一意な候補のpreselectに限定したか
  8. account requestを利用者の明示操作より前に送っていないか
  9. provider切替とcomponent破棄でlistenerを解除したか
  10. legacy fallbackをannounce 0件の時だけ使ったか
  11. sponsorや広告がdefault選択と並び順へ影響していないか
  12. browser、OS、wallet version、確認日を実機検証の証跡へ残したか

EIP-6963が解決するのは、複数providerを発見して利用者が選べる入口です。表示metadataだけでwalletを認証したり、account permission、chain一致、署名内容、transaction結果まで保証したりはしません。discoveryの後も、選択・request・event・結果を同じproviderへ結び続けることがfrontend実装の責任です。

確認した一次情報