Skip to main content

Nethereum.Metamask.Blazor

NuGet: Nethereum.Metamask.Blazor | Source: src/Nethereum.Metamask.Blazor/

Nethereum.Metamask.Blazor

Blazor implementation of MetaMask integration with JavaScript interop for wallet interactions and authentication.

Overview​

Nethereum.Metamask.Blazor provides the platform-specific implementation of IMetamaskInterop for Blazor applications. It bridges C# code with the MetaMask browser extension using JavaScript interop, enabling wallet connectivity, transaction signing, and authentication in Blazor apps.

Key Features:

  • JavaScript interop implementation (MetamaskBlazorInterop) for MetaMask wallet communication
  • Automatic event handling for account and network changes via callbacks
  • Integration with Blazor authentication system through EthereumAuthenticationStateProvider
  • Built-in JavaScript library (NethereumMetamask.js) for MetaMask extension interaction
  • Support for all MetaMask RPC methods (transactions, signing, wallet operations)
  • Singleton pattern with MetamaskHostProvider.Current for global access

Installation​

dotnet add package Nethereum.Metamask.Blazor

Or via Package Manager Console:

Install-Package Nethereum.Metamask.Blazor

Dependencies​

Package References:

  • Microsoft.AspNetCore.Components.Authorization 6.0.5
  • Microsoft.AspNetCore.Components.WebAssembly 6.0.2

Project References:

  • Nethereum.Blazor
  • Nethereum.Metamask

Architecture​

Component Stack​

┌─────────────────────────────────────────┐
│ Blazor WebAssembly Application │
│ (Your Components & Services) │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ MetamaskBlazorInterop │
│ (IMetamaskInterop Implementation) │
│ - IJSRuntime bridge │
│ - Event marshalling │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ NethereumMetamask.js │
│ (JavaScript Library) │
│ - window.NethereumMetamaskInterop │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ MetaMask Browser Extension │
│ (window.ethereum) │
└─────────────────────────────────────────┘

Key Components​

MetamaskBlazorInterop

  • C# implementation of IMetamaskInterop using IJSRuntime
  • Calls JavaScript functions via _jsRuntime.InvokeAsync<T>()
  • Serializes/deserializes RPC messages using Newtonsoft.Json
  • Provides [JSInvokable] callback methods for JavaScript events

NethereumMetamask.js

  • JavaScript library providing window.NethereumMetamaskInterop object
  • Wraps window.ethereum (MetaMask provider)
  • Handles event listeners for accountsChanged and chainChanged
  • Converts JavaScript callbacks to .NET method invocations using DotNet.invokeMethodAsync

EthereumAuthenticationStateProvider (from Nethereum.Blazor)

  • Integrates wallet connection with Blazor authentication
  • Creates ClaimsPrincipal with Ethereum address as NameIdentifier
  • Adds "EthereumConnected" role when wallet is connected
  • Automatically updates authentication state on account changes

Quick Start​

1. Add JavaScript Reference​

In your index.html or App.razor, include the NethereumMetamask.js script:

<script src="_content/Nethereum.Metamask.Blazor/NethereumMetamask.js"></script>

2. Configure Services​

In Program.cs:

using Nethereum.Metamask;
using Nethereum.Metamask.Blazor;
using Nethereum.UI;
using Microsoft.AspNetCore.Components.Authorization;

var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");

builder.Services.AddAuthorizationCore();
builder.Services.AddSingleton(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

// Register MetaMask services
builder.Services.AddSingleton<IMetamaskInterop, MetamaskBlazorInterop>();
builder.Services.AddSingleton<MetamaskHostProvider>();

// Add MetaMask as the selected Ethereum host provider
builder.Services.AddSingleton(services =>
{
var metamaskHostProvider = services.GetService<MetamaskHostProvider>();
var selectedHostProvider = new SelectedEthereumHostProviderService();
selectedHostProvider.SetSelectedEthereumHostProvider(metamaskHostProvider);
return selectedHostProvider;
});

await builder.Build().RunAsync();

3. Use in Components​

@page "/wallet"
@inject MetamaskHostProvider MetamaskProvider
@inject AuthenticationStateProvider AuthStateProvider

<AuthorizeView>
<Authorized>
<p>Connected: @context.User.Identity.Name</p>
<button @onclick="Disconnect">Disconnect</button>
</Authorized>
<NotAuthorizing>
<button @onclick="Connect">Connect Wallet</button>
</NotAuthorizing>
</AuthorizeView>

@code {
private async Task Connect()
{
await MetamaskProvider.EnableProviderAsync();

// Notify authentication system
if (AuthStateProvider is EthereumAuthenticationStateProvider ethAuthProvider)
{
await ethAuthProvider.NotifyAuthenticationStateAsEthereumConnected();
}
}

private async Task Disconnect()
{
// MetaMask doesn't support disconnect, but we can clear local state
if (AuthStateProvider is EthereumAuthenticationStateProvider ethAuthProvider)
{
await ethAuthProvider.NotifyAuthenticationStateAsEthereumDisconnected();
}
}
}

Usage Examples​

Example 1: Basic MetaMask Setup​

using Nethereum.Metamask;
using Nethereum.Metamask.Blazor;
using Microsoft.JSInterop;

public class MetamaskService
{
private readonly MetamaskBlazorInterop _metamaskInterop;
private readonly MetamaskHostProvider _hostProvider;

public MetamaskService(IJSRuntime jsRuntime)
{
_metamaskInterop = new MetamaskBlazorInterop(jsRuntime);
_hostProvider = new MetamaskHostProvider(_metamaskInterop);
}

public async Task<bool> CheckAvailabilityAsync()
{
return await _metamaskInterop.CheckMetamaskAvailability();
}

public async Task<string> ConnectAsync()
{
return await _hostProvider.EnableProviderAsync();
}

public async Task<IWeb3> GetWeb3Async()
{
return await _hostProvider.GetWeb3Async();
}
}

Example 2: Handling Account Changes​

@page "/account-watcher"
@inject MetamaskHostProvider MetamaskProvider
@implements IDisposable

<h3>Current Account: @currentAccount</h3>
<p>Network Chain ID: @chainId</p>

@code {
private string currentAccount = "Not connected";
private long chainId = 0;

protected override async Task OnInitializedAsync()
{
// Subscribe to events
MetamaskProvider.SelectedAccountChanged += OnAccountChanged;
MetamaskProvider.NetworkChanged += OnNetworkChanged;

// Get current values
if (MetamaskProvider.Available && MetamaskProvider.Enabled)
{
currentAccount = MetamaskProvider.SelectedAccount;
chainId = MetamaskProvider.SelectedNetworkChainId;
}
}

private async Task OnAccountChanged(string newAccount)
{
currentAccount = newAccount ?? "Not connected";
StateHasChanged();
}

private async Task OnNetworkChanged(long newChainId)
{
chainId = newChainId;
StateHasChanged();
}

public void Dispose()
{
MetamaskProvider.SelectedAccountChanged -= OnAccountChanged;
MetamaskProvider.NetworkChanged -= OnNetworkChanged;
}
}

Example 3: Sending Transactions​

using Nethereum.Metamask;
using Nethereum.Hex.HexTypes;
using Nethereum.RPC.Eth.DTOs;

public class TransactionService
{
private readonly MetamaskHostProvider _provider;

public TransactionService(MetamaskHostProvider provider)
{
_provider = provider;
}

public async Task<string> SendEtherAsync(string toAddress, decimal etherAmount)
{
var web3 = await _provider.GetWeb3Async();
var fromAddress = _provider.SelectedAccount;

var transactionInput = new TransactionInput
{
From = fromAddress,
To = toAddress,
Value = new HexBigInteger(Web3.Web3.Convert.ToWei(etherAmount))
};

// This will prompt MetaMask for user confirmation
var txHash = await web3.Eth.TransactionManager.SendTransactionAsync(transactionInput);
return txHash;
}
}

Example 4: Message Signing with MetaMask​

using Nethereum.Metamask;
using Nethereum.Signer;
using Nethereum.Util;

public class SigningService
{
private readonly MetamaskHostProvider _provider;

public SigningService(MetamaskHostProvider provider)
{
_provider = provider;
}

public async Task<string> SignMessageAsync(string message)
{
// Sign message through MetaMask (personal_sign)
var signature = await _provider.SignMessageAsync(message);
return signature;
}

public async Task<bool> VerifySignatureAsync(string message, string signature, string expectedAddress)
{
var signer = new EthereumMessageSigner();
var recoveredAddress = signer.EncodeUTF8AndEcRecover(message, signature);

return recoveredAddress.Equals(expectedAddress, StringComparison.OrdinalIgnoreCase);
}
}

Example 5: Authentication with Custom Claims​

using Microsoft.AspNetCore.Components.Authorization;
using Nethereum.Blazor;
using Nethereum.UI;
using System.Security.Claims;

public class CustomEthereumAuthStateProvider : EthereumAuthenticationStateProvider
{
public CustomEthereumAuthStateProvider(SelectedEthereumHostProviderService selectedHostProviderService)
: base(selectedHostProviderService)
{
}

public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
var state = await base.GetAuthenticationStateAsync();

if (state.User.Identity.IsAuthenticated)
{
var address = state.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;

// Add custom claims based on address
var claims = new List<Claim>
{
new Claim(ClaimTypes.NameIdentifier, address),
new Claim(ClaimTypes.Role, "EthereumConnected"),
new Claim("ChainId", EthereumHostProvider.SelectedNetworkChainId.ToString())
};

// Add admin role for specific addresses
if (address == "0xYourAdminAddress")
{
claims.Add(new Claim(ClaimTypes.Role, "Admin"));
}

var identity = new ClaimsIdentity(claims, "ethereumConnection");
return new AuthenticationState(new ClaimsPrincipal(identity));
}

return state;
}
}

Example 6: Direct JavaScript Interop Usage​

using Nethereum.Metamask.Blazor;
using Nethereum.JsonRpc.Client.RpcMessages;
using Microsoft.JSInterop;

public class DirectInteropService
{
private readonly MetamaskBlazorInterop _interop;

public DirectInteropService(IJSRuntime jsRuntime)
{
_interop = new MetamaskBlazorInterop(jsRuntime);
}

public async Task<string> GetChainIdAsync()
{
var request = new RpcRequestMessage(1, "eth_chainId");
var response = await _interop.SendAsync(request);

if (response.HasError)
{
throw new Exception($"RPC Error: {response.Error.Message}");
}

return response.Result.ToString();
}

public async Task<string[]> GetAccountsAsync()
{
var selectedAddress = await _interop.GetSelectedAddress();
return new[] { selectedAddress };
}

public async Task<string> EnableAsync()
{
return await _interop.EnableEthereumAsync();
}
}

Example 7: Comprehensive MetaMask Integration Component​

Full-featured component demonstrating authentication, signing, chain switching, and blockchain queries.

@page "/"
@using Nethereum.ABI.EIP712
@using Nethereum.ABI.FunctionEncoding.Attributes
@implements IDisposable
@inject IJSRuntime jsRuntime
@inject SelectedEthereumHostProviderService selectedHostProviderService
@inject NavigationManager _navigationManager
@using Nethereum.Hex.HexTypes
@using Microsoft.AspNetCore.Components.Authorization
@using System.Security.Claims
@using Nethereum.RPC.HostWallet
@using Nethereum.Signer
@using Nethereum.Signer.EIP712

<AuthorizeView Roles="EthereumConnected">
<Authorized>
<div class="card m-1">
<div class="card-body">
<div class="row">
<label class="col-sm-3 col-form-label-lg">Selected Account:</label>
<div class="col-sm-6">
@SelectedAccount
<small class="form-text text-muted">The selected account is bound to the host (ie Metamask) on change</small>
</div>
</div>
<div class="row">
<label class="col-sm-3 col-form-label-lg">Selected Account from Claims Principal</label>
<div class="col-sm-6">
@context?.User?.FindFirst(c => c.Type.Contains(ClaimTypes.NameIdentifier))?.Value
<small class="form-text text-muted">The selected account is bound to the claims principal</small>
</div>
</div>
</div>

<div class="card-body">
<div class="row">
<label class="col-sm-3 col-form-label-lg">Selected Network ChainId:</label>
<div class="col-sm-6">
@SelectedChainId
<small class="form-text text-muted">The selected chain Id</small>
</div>
</div>
</div>
</div>

<div class="card m-1">
<div class="card-body">
<div class="row">
<label class="col-sm-3 col-form-label-lg">Block hash of block number 0:</label>
<div class="col-sm-6">
<button @onclick="@GetBlockHashAsync">Get BlockHash</button>
<div>@BlockHash</div>
<small class="form-text text-muted">With Metamask calls are redirected to its configured node (i.e http://localhost:8545)</small>
</div>
</div>
</div>
</div>

<div class="card m-1">
<div class="card-body">
<div class="row">
<label class="col-sm-3 col-form-label-lg">Sign Typed Data V4:</label>
<div class="col-sm-6">
<button @onclick="@SignV4">Sign</button>
<div>@RecoveredAccount</div>
<small class="form-text text-muted">Converts Typed data to Json and sends it to Metamask to sign it, then uses Nethereum to recover the address</small>
</div>
</div>
</div>
</div>

<div class="card m-1">
<div class="card-body">
<div class="row">
<label class="col-sm-3 col-form-label-lg">Change Chain</label>
<div class="col-sm-6">
<button @onclick="@ChangeChainToMainnet">Change Chain To Mainnet</button>
<small class="form-text text-muted">Changes the chain to Mainnet</small>
</div>
</div>
</div>
</div>

<div class="card m-1">
<div class="card-body">
<div class="row">
<label class="col-sm-3 col-form-label-lg">Sign Message</label>
<div class="col-sm-6">
<button @onclick="@SignAMessage">Sign</button>
<div>@RecoveredAccountMessage</div>
<small class="form-text text-muted">Signs using personal_sign and recovers using Nethereum</small>
</div>
</div>
</div>
</div>

<div class="card m-1">
<div class="card-body">
<div class="row">
<label class="col-sm-3 col-form-label-lg">Add Chain</label>
<div class="col-sm-6">
<button @onclick="@AddChain">Add Chain (Optimism)</button>
<small class="form-text text-muted">Adds a new chain (Optimism) to Metamask</small>
</div>
</div>
</div>
</div>
</Authorized>
<NotAuthorized>
<div>
Please connect to Ethereum!
</div>
</NotAuthorized>
</AuthorizeView>

@code {
[CascadingParameter]
public Task<AuthenticationState> AuthenticationState { get; set; }

bool EthereumAvailable { get; set; }
string SelectedAccount { get; set; }
long SelectedChainId { get; set; }
string BlockHash { get; set; }
string RecoveredAccount { get; set; }
string RecoveredAccountMessage { get; set; }
IEthereumHostProvider _ethereumHostProvider;

protected override void OnInitialized()
{
_ethereumHostProvider = selectedHostProviderService.SelectedHost;
_ethereumHostProvider.SelectedAccountChanged += HostProvider_SelectedAccountChanged;
_ethereumHostProvider.NetworkChanged += HostProvider_NetworkChanged;
_ethereumHostProvider.EnabledChanged += HostProviderOnEnabledChanged;
}

public void Dispose()
{
_ethereumHostProvider.SelectedAccountChanged -= HostProvider_SelectedAccountChanged;
_ethereumHostProvider.NetworkChanged -= HostProvider_NetworkChanged;
_ethereumHostProvider.EnabledChanged -= HostProviderOnEnabledChanged;
}

protected override async Task OnInitializedAsync()
{
EthereumAvailable = await _ethereumHostProvider.CheckProviderAvailabilityAsync();
if (EthereumAvailable)
{
SelectedAccount = await _ethereumHostProvider.GetProviderSelectedAccountAsync();
await GetChainId();
}
}

private async Task HostProviderOnEnabledChanged(bool enabled)
{
if (enabled)
{
await GetChainId();
this.StateHasChanged();
}
}

private async Task GetChainId()
{
var web3 = await _ethereumHostProvider.GetWeb3Async();
var chainId = await web3.Eth.ChainId.SendRequestAsync();
SelectedChainId = (long)chainId.Value;
}

private async Task HostProvider_SelectedAccountChanged(string account)
{
SelectedAccount = account;
await GetChainId();
this.StateHasChanged();
}

private async Task HostProvider_NetworkChanged(long chainId)
{
SelectedChainId = chainId;
this.StateHasChanged();
}

protected async Task GetBlockHashAsync()
{
var web3 = await _ethereumHostProvider.GetWeb3Async();
var block = await web3.Eth.Blocks.GetBlockWithTransactionsByNumber.SendRequestAsync(new HexBigInteger(1));
BlockHash = block.BlockHash;
}

protected async Task SignAMessage()
{
RecoveredAccountMessage = "";
var web3 = await _ethereumHostProvider.GetWeb3Async();
var signature = await web3.Eth.AccountSigning.PersonalSign.SendRequestAsync(new HexUTF8String("Hello"));
RecoveredAccountMessage = new EthereumMessageSigner().EncodeUTF8AndEcRecover("Hello", signature);
}

protected async Task AddChain()
{
var web3 = await _ethereumHostProvider.GetWeb3Async();
var optimismChain = new AddEthereumChainParameter()
{
ChainId = new HexBigInteger(10),
ChainName = "Optimism",
NativeCurrency = new NativeCurrency()
{
Decimals = 18,
Name = "ETH",
Symbol = "ETH"
},
RpcUrls = new List<string> { "https://mainnet.optimism.io", "https://rpc.ankr.com/optimism" },
BlockExplorerUrls = new List<string> { "https://optimistic.etherscan.io/" },
};
try
{
var result = await web3.Eth.HostWallet.AddEthereumChain.SendRequestAsync(optimismChain);
}
catch (Exception ex)
{
// Handle error
}
}

protected async Task ChangeChainToMainnet()
{
try
{
var web3 = await _ethereumHostProvider.GetWeb3Async();
var result = await web3.Eth.HostWallet.SwitchEthereumChain.SendRequestAsync(
new SwitchEthereumChainParameter() { ChainId = new HexBigInteger(1) });
}
catch (Exception ex)
{
// Handle error
}
}

protected async Task SignV4()
{
RecoveredAccount = "";
var web3 = await _ethereumHostProvider.GetWeb3Async();
var chainId = await web3.Eth.ChainId.SendRequestAsync();
if (chainId.Value == 1)
{
var typedData = GetMailTypedDefinition();
var mail = new Mail
{
From = new Person
{
Name = "Cow",
Wallets = new List<string> { "0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826", "0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF" }
},
To = new List<Person>
{
new Person
{
Name = "Bob",
Wallets = new List<string> { "0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB", "0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57", "0xB0B0b0b0b0b0B000000000000000000000000000" }
}
},
Contents = "Hello, Bob!"
};

typedData.Domain.ChainId = 1;
typedData.SetMessage(mail);

var signature = await web3.Eth.AccountSigning.SignTypedDataV4.SendRequestAsync(typedData.ToJson());
RecoveredAccount = new Eip712TypedDataSigner().RecoverFromSignatureV4(typedData, signature);
}
else
{
RecoveredAccount = "Chain Id is not 1, please change your chain to mainnet";
}
}

public TypedData<Domain> GetMailTypedDefinition()
{
return new TypedData<Domain>
{
Domain = new Domain
{
Name = "Ether Mail",
Version = "1",
ChainId = 1,
VerifyingContract = "0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC"
},
Types = MemberDescriptionFactory.GetTypesMemberDescription(typeof(Domain), typeof(Group), typeof(Mail), typeof(Person)),
PrimaryType = nameof(Mail),
};
}
}

API Reference​

MetamaskBlazorInterop​

Blazor WebAssembly implementation of IMetamaskInterop using IJSRuntime.

public class MetamaskBlazorInterop : IMetamaskInterop
{
// Constructor
public MetamaskBlazorInterop(IJSRuntime jsRuntime);

// Properties
public JsonSerializerSettings JsonSerializerSettings { get; set; }

// IMetamaskInterop Implementation
public ValueTask<string> EnableEthereumAsync();
public ValueTask<bool> CheckMetamaskAvailability();
public ValueTask<RpcResponseMessage> SendAsync(RpcRequestMessage rpcRequestMessage);
public ValueTask<RpcResponseMessage> SendTransactionAsync(MetamaskRpcRequestMessage rpcRequestMessage);
public ValueTask<string> SignAsync(string utf8Hex);
public ValueTask<string> GetSelectedAddress();

// JavaScript Invokable Callbacks
[JSInvokable]
public static Task MetamaskAvailableChanged(bool available);

[JSInvokable]
public static Task SelectedAccountChanged(string selectedAccount);

[JSInvokable]
public static Task SelectedNetworkChanged(string chainId);
}

JavaScript API (window.NethereumMetamaskInterop)​

The JavaScript library exposes the following functions:

window.NethereumMetamaskInterop = {
// Enable MetaMask and request account access
EnableEthereum: async () => Promise<string>

// Check if MetaMask is available
IsMetamaskAvailable: () => boolean

// Get current accounts
GetAddresses: async () => Promise<string> // JSON-serialized RpcResponseMessage

// Send generic RPC request
Request: async (message: string) => Promise<string> // JSON-serialized RPC messages

// Sign message with personal_sign
Sign: async (utf8HexMsg: string) => Promise<string> // JSON-serialized RpcResponseMessage
}

Event Flow​

  1. MetaMask Extension Events → JavaScript event listeners
  2. JavaScript → Calls .NET via DotNet.invokeMethodAsync('Nethereum.Metamask.Blazor', methodName, args)
  3. [JSInvokable] Methods → Update MetamaskHostProvider.Current state
  4. MetamaskHostProvider → Raises C# events (SelectedAccountChanged, NetworkChanged)
  5. Your Application → Subscribes to C# events and updates UI

Important Notes​

Script Reference Required​

You MUST include the JavaScript library in your HTML:

<script src="_content/Nethereum.Metamask.Blazor/NethereumMetamask.js"></script>

Without this script, IJSRuntime calls will fail with "function not found" errors.

Singleton Pattern​

MetamaskHostProvider.Current is a static singleton accessed by JavaScript callbacks:

[JSInvokable]
public static async Task SelectedAccountChanged(string selectedAccount)
{
await MetamaskHostProvider.Current.ChangeSelectedAccountAsync(selectedAccount);
}

You should register MetamaskHostProvider as a singleton in DI to ensure the same instance is used.

JSON Serialization​

Uses Newtonsoft.Json for all serialization with DefaultJsonSerializerSettings:

JsonSerializerSettings = DefaultJsonSerializerSettingsFactory.BuildDefaultJsonSerializerSettings();

This ensures compatibility with Nethereum's RPC message formats.

Error Handling​

All RPC errors are converted to RpcResponseException:

if (response.HasError)
{
throw new RpcResponseException(
new RpcError(response.Error.Code, response.Error.Message, response.Error.Data)
);
}

Wrap await calls in try-catch to handle user rejections and RPC errors.

MetaMask Disconnect​

MetaMask does not provide a programmatic disconnect method. Users must disconnect manually through the MetaMask extension UI. You can clear local authentication state but cannot revoke MetaMask's connection.

Browser Support​

Requires a browser with:

  • MetaMask extension installed
  • Access to window.ethereum provider

Works with both Blazor WebAssembly and Blazor Server. The JavaScript interop communicates with the MetaMask extension running in the user's browser.

Authentication Integration​

EthereumAuthenticationStateProvider (from Nethereum.Blazor) creates:

  • ClaimTypes.NameIdentifier: Ethereum address
  • ClaimTypes.Role: "EthereumConnected"

Use [Authorize(Roles = "EthereumConnected")] to protect components.

Dependencies​

  • Nethereum.Metamask - Core abstraction interfaces
  • Nethereum.Blazor - EthereumAuthenticationStateProvider and common Blazor utilities

Used By​

  • Applications using Blazor WebAssembly with MetaMask wallet integration

Similar Packages​

  • Nethereum.WalletConnect - WalletConnect protocol implementation
  • Nethereum.EIP6963WalletInterop - EIP-6963 multi-wallet discovery