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

複数の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 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へ使わないよう求めています。nameやiconも同じく自己申告です。
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名とrdnsもtextContentで描画します。
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を三つに分ける
「切断」は少なくとも三つあります。
- EIP-1193
disconnect: providerが全chainへRPCを処理できない状態 - DAppのselection解除: どのproviderへ送るかをlocal UIから外す操作
- 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.0のcreateConfigは、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 | 4001と4900を別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結果 |
4001、4100、4900を分ける |
| 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
- announce listenerをrequestより先に登録したか
- discovery listenerをpage lifetime中維持したか
- UUID v4、name、data URI icon、rdns、EIP-1193 methodをvalidationしたか
- 重複UUIDと競合UUIDを分けたか
- name、icon、rdnsをidentityやfeature detectionへ使っていないか
- iconをimg要素で描画し、metadataを
innerHTMLへ渡していないか - 前回選択は一意な候補のpreselectに限定したか
- account requestを利用者の明示操作より前に送っていないか
- provider切替とcomponent破棄でlistenerを解除したか
- legacy fallbackをannounce 0件の時だけ使ったか
- sponsorや広告がdefault選択と並び順へ影響していないか
- browser、OS、wallet version、確認日を実機検証の証跡へ残したか
EIP-6963が解決するのは、複数providerを発見して利用者が選べる入口です。表示metadataだけでwalletを認証したり、account permission、chain一致、署名内容、transaction結果まで保証したりはしません。discoveryの後も、選択・request・event・結果を同じproviderへ結び続けることがfrontend実装の責任です。
確認した一次情報
- EIP-6963 Multi Injected Provider Discovery (Final)確認日: 2026/08/30
- EIP-1193 Ethereum Provider JavaScript API (Final)確認日: 2026/08/30
- EIP-6963 reviewed source commit確認日: 2026/08/30
- EIP-1193 reviewed source commit確認日: 2026/08/30
- wagmi Core createConfig multiInjectedProviderDiscovery確認日: 2026/08/30
- wagmi injected connector確認日: 2026/08/30
- wagmi 3.4.0 release確認日: 2026/08/30
- Astro client-side scripts確認日: 2026/08/30



