Dapp wallet integration comes down to two layers: an inner JSON-RPC provider that speaks EIP-1193, and an outer transport layer that gets a wallet connected in the first place, whether that's an injected extension, WalletConnect, or an embedded wallet. Support injected providers plus WalletConnect plus MetaMask Connect as your default transport set, then build a provider adapter and handle lifecycle events (accountsChanged, chainChanged) before anything else. Get that skeleton right and every wallet you add afterward slots into the same pattern.
TL;DR:
- Support injected providers and WalletConnect as the primary transport options, ensuring compatibility across both desktop and mobile devices.
- Use a provider adapter that exposes a consistent API for connecting, disconnecting, and retrieving accounts, regardless of the underlying transport method.
- Always listen for wallet events like account and chain changes immediately after connection to maintain accurate app state and handle network switches properly.
- Store the wallet connector type in local storage to enable reliable auto-reconnection, but avoid silently re-triggering permission prompts on each page load.
- Show clear transaction previews, validate chain IDs, and request scoped permissions to reduce signature risks and improve user trust during signing flows.
Table of Contents
- What Is the Two-Layer Architecture for Wallet Integration?
- How Does the EIP-1193 Provider Layer Work?
- Injected Providers vs. WalletConnect vs. MetaMask Connect: Which Transport Wins?
- Which SDKs Simplify Wallet Connector Setup?
- How Should You Handle Wallet Lifecycle Events and Reconnection?
- What Security and UX Rules Reduce Signature-Request Risk?
- What Does a Minimal Connect-Sign-Send Flow Look Like?
- Where Should You Read Next on Wallet Standards?
- How Do You Handle Permissions and Authorization Scopes?
- What's the Best Way to Persist and Restore Wallet Sessions?
- How Should You Test Wallet Integrations Across Devices?
- How Can You Optimize Performance for Wallet and Network Calls?
- Lessons From Building Wallet Flows at Omnirout
- Key Standards and Docs to Bookmark
- Sources
What Is the Two-Layer Architecture for Wallet Integration?
Every wallet connection your dapp handles splits into two jobs that have nothing to do with each other. The inner layer is a JSON-RPC provider object, a set of methods and events defined by EIP-1193, that lets your app request accounts, read chain IDs, and send transactions. The outer layer is the transport: however that provider object actually gets into your app's hands, whether injected by a browser extension, piped in through a WalletConnect session, or handed off by an embedded wallet SDK.
Your UI layer should never talk to a specific wallet. It should talk to a normalized provider adapter, which talks to whichever transport delivered the connection. That separation is what saves you from writing bespoke logic for every wallet brand.
- Injected providers work well for desktop users with browser extensions already installed.
- Transport-based flows (WalletConnect, embedded wallets) cover mobile users and anyone without an extension.
- A provider adapter should expose one consistent interface (
connect,disconnect,getAccounts,getChainId) regardless of which transport supplied the underlying provider.
How Does the EIP-1193 Provider Layer Work?
The EIP-1193 standard defines the request-based interface nearly every EVM wallet now implements. Your app calls provider.request() with a method name and params, and the provider returns a promise. That's the entire contract, which is why it has held up across so many wallet implementations.
Methods you'll call constantly:
eth_requestAccountsprompts the user to connect and returns an array of addresses.eth_accountsreturns currently connected accounts without triggering a prompt.eth_chainIdreturns the active chain as a hex string.eth_sendTransactionsubmits a transaction object for signing and broadcast.eth_signTypedData_v4handles structured data signing for permits and off-chain approvals.
Events matter just as much as methods. accountsChanged fires with a new address array whenever the user switches accounts in their wallet, and an empty array means they disconnected. chainChanged fires with a new hex chain ID whenever they switch networks. Both can fire without any action from your app, so you need listeners attached the moment a provider connects, not just handlers triggered by your own UI actions.
Always feature-detect before calling a method. Not every wallet supports every RPC call, and a provider that lacks wallet_switchEthereumChain shouldn't crash your connect flow.
Pro Tip: Never assume window.ethereum is MetaMask. Multiple extensions can inject onto the same property, so check provider.isMetaMask, provider.isCoinbaseWallet, or better, use EIP-6963 discovery instead of guessing.
Injected Providers vs. WalletConnect vs. MetaMask Connect: Which Transport Wins?
Each outer-layer transport solves a different distribution problem, and picking one over another usually comes down to whether your user is on desktop, mobile, or hasn't installed a wallet at all.
Injected extensions (MetaMask, Rabby, Coinbase Wallet's extension) inject a provider directly onto window. They're fast and require zero pairing overhead, but detection got messy once users started running multiple extensions simultaneously, each overwriting or competing for window.ethereum. EIP-6963 fixed this by defining a discovery event standard, so your dapp can enumerate every injected wallet present instead of guessing which one owns the global object.
WalletConnect solves the desktop-to-mobile gap. The dapp generates a pairing URI, renders it as a QR code on desktop or fires it as a deep link on mobile, and the wallet app scans or opens it. The WalletConnect pairing flow returns an approval promise that resolves with the connected accounts once the user confirms inside their wallet app. That single pattern, one promise, one resolution point, is why WalletConnect integrations tend to be simpler than they sound. WalletConnect's own network overview points to broad app coverage as the reason pairing this way alongside injected support maximizes how many users you can actually onboard, since a meaningful share of wallet holders are mobile-only.
MetaMask Connect and embedded wallets skip pairing entirely. Embedded wallet infrastructure lets users authenticate with email or a social login and get a non-custodial wallet generated behind the scenes, no seed phrase screen, no extension install.
- Injected: fastest for existing crypto users, but requires an extension already installed.
- WalletConnect: best cross-device coverage, adds one pairing step.
- Embedded/MetaMask Connect: lowest onboarding friction, ideal for first-time users.
Covering all three gets you the desktop power user, the mobile-first trader, and the newcomer who has never held a private key.
Which SDKs Simplify Wallet Connector Setup?
Writing your own connector logic for every wallet is a solved problem, and solving it yourself just means maintaining edge cases someone else already patched. Three library families cover almost every case you'll run into:
- Wagmi gives you React hooks (
useAccount,useConnect,useDisconnect) built around a connectors array, and it's the default choice for EVM-only dapps that want state management handled for them. - Web3-Onboard dynamically imports wallet modules on demand and exposes EIP-1193-compliant providers, which keeps your initial bundle light while still supporting dozens of wallets through a modular config.
- Wallet-adapter libraries (Solana's wallet-adapter, similar packages for Aptos) mirror the same connector pattern for non-EVM chains, since neither ecosystem uses EIP-1193 natively.
A typical Wagmi setup passes a connectors array containing an injected connector and a WalletConnect connector configured with your projectId. Multi-chain apps supporting both EVM and Solana should keep connector configuration separate, an evmInitial object and a solanaInitial object rather than one merged config, since mixing them tends to produce chain ID mismatches during signing.
Pro Tip: Wrap whichever SDK you pick behind your own provider abstraction from day one. Swapping Wagmi for something else later is painful if your components call Wagmi hooks directly instead of your own normalized interface.
How Should You Handle Wallet Lifecycle Events and Reconnection?
Wallet connection isn't a one-time event, it's ongoing state that changes underneath your app without warning. Treat it that way from the start.
- On page load, check for a previously connected session and attempt a silent reconnect using
eth_accounts(no prompt) rather thaneth_requestAccounts(which always prompts). - Attach
accountsChangedandchainChangedlisteners immediately after any successful connection, and update global app state the moment they fire. - Debounce chain mismatch warnings. If a user switches networks mid-session, prompt them to switch back rather than silently failing a transaction.
- Before firing a WalletConnect deep link on mobile, show an explicit "Open Wallet" button instead of triggering the redirect automatically. Users disoriented by an abrupt app switch tend to abandon the flow.
- Apply retry with backoff on failed signature requests caused by network flakiness, but surface a clear error immediately when the failure is a user rejection, those two failure modes need different UX.
Auto-reconnect should never silently re-request permissions the user already granted. Store the last-connected wallet type locally and restore that specific connector, not a generic "try everything" reconnect loop.
What Security and UX Rules Reduce Signature-Request Risk?
Every signature request is a moment where a user can be tricked into approving something they didn't intend, so your job is making intent unmistakable before the wallet popup even opens.
- Show a human-readable transaction preview (what's being sent, to which contract, on which network) inside your own UI before triggering the wallet prompt.
- Validate the returned
chainIdand address against what you expected once control comes back from the wallet, don't assume the user stayed on the network you started with. - Request the narrowest permission scope possible, and re-request only when a new action genuinely needs broader access, not as a blanket "connect once, ask for everything" pattern.
- Log failed connection and signature attempts with enough context (error code, wallet type, chain ID) to actually debug production issues.
Hardware wallet users add another layer of friction here, since signing with a Ledger requires physical confirmation on the device itself, so your preview screen needs to match exactly what appears on that hardware display.
Pro Tip: Never let your app request eth_sign for arbitrary data. It's deprecated for good reason: unlike signTypedData, it can be used to sign anything, including malicious payloads disguised as harmless hashes.
What Does a Minimal Connect-Sign-Send Flow Look Like?
A working integration checklist, in the order you'll actually build it:
- Initialize your connectors (injected + WalletConnect) and expose a single
connect()function from your provider adapter. - Render a Connect button that shows a wallet list; on mobile, trigger a deep link, on desktop, render the WalletConnect QR code.
- Wait on the approval promise, then call
eth_requestAccountsto confirm the connection and pull the active address. - Check
eth_chainIdagainst your app's expected network, and prompt a chain switch viawallet_switchEthereumChainif it doesn't match. - Build the transaction, call
eth_sendTransaction, and wait for a receipt before updating your UI to a confirmed state.
A few things worth flagging as you wire this up:
- Wrap every wallet-facing call in a try/catch that distinguishes user rejection from a genuine RPC error.
- Never assume a transaction succeeded just because the wallet returned a hash, wait for the receipt.
- If the receipt takes longer than expected, point users toward speeding up a stuck transaction rather than leaving them guessing.
Where Should You Read Next on Wallet Standards?
Start with EIP-1193 for the provider interface itself, then EIP-6963 for discovery. From there, the WalletConnect docs cover pairing in depth, and Web3-Onboard plus Wagmi's connect wallet guide show working connector configurations you can adapt directly.
How Do You Handle Permissions and Authorization Scopes?
Permission handling is where a lot of otherwise solid integrations lose user trust. When your dapp calls eth_requestAccounts, the wallet is granting account visibility, not blanket transaction authority. Every subsequent action, a token approval, a signature, a transfer, requires its own explicit prompt inside the wallet itself. Your job is making sure the user understands what each prompt actually authorizes before it appears.
Token approvals deserve particular care. An approve() call that grants unlimited spending allowance to a contract is a common attack surface, and plenty of legitimate dapps request it out of laziness rather than necessity. Request the exact amount needed for the current transaction where the contract design allows it, and explain in your UI why a broader approval is being requested when it genuinely is.
Some wallets, particularly MetaMask's more recent permission system, support scoped permission requests through wallet_requestPermissions, letting you ask for narrower capabilities than blanket account access. Where that's available, use it. Where it isn't, compensate with clear in-app messaging: show exactly which contract, which function, and which asset a pending approval affects before the wallet prompt opens.
Revocation matters just as much as granting. Point users toward revoking stale approvals periodically, especially after interacting with a contract they no longer use, since old unlimited approvals sitting on a wallet are a liability that has nothing to do with your dapp's current security posture but everything to do with user safety.
What's the Best Way to Persist and Restore Wallet Sessions?
Session persistence is what separates a dapp that feels reliable from one that makes users reconnect every single visit. Store the connector type (not the private data) in localStorage the moment a connection succeeds, injected, WalletConnect, or embedded, so a returning visit knows which transport to attempt first.
On load, attempt a silent reconnect using eth_accounts, which returns currently authorized accounts without triggering a wallet popup. If that returns an empty array, the user disconnected or revoked access elsewhere, and you should clear your stored session rather than repeatedly attempting a reconnect that will keep failing quietly.
WalletConnect sessions behave differently than injected ones. They carry their own expiration and require the SDK's session-restore method rather than a simple eth_accounts call, so build a separate restore path for WalletConnect connections instead of forcing every transport through identical logic.
- Never auto-reconnect by silently calling
eth_requestAccounts, that always triggers a fresh permission prompt and feels broken to returning users. - Clear stored session data on explicit disconnect, and on any
accountsChangedevent that returns an empty array. - Set a reasonable session timeout for sensitive actions (large transfers, approvals) even if the underlying wallet session hasn't expired, since a browser tab left open for days shouldn't retain full transaction authority without re-confirmation.
How Should You Test Wallet Integrations Across Devices?
Wallet bugs cluster at the edges: the browser without any extension installed, the mobile in-app browser with quirky provider injection, the desktop user running three wallet extensions at once. Testing needs to target those edges deliberately, not just the happy path where MetaMask is the only extension in a fresh browser profile.
Run through your connect flow with zero wallets installed to confirm your empty state actually guides users toward WalletConnect or an embedded wallet instead of failing silently. Then test with multiple injected wallets active simultaneously, since EIP-6963 discovery issues tend to surface exactly there, when two extensions both claim to be the default provider.
Mobile testing needs its own pass entirely. WalletConnect deep links behave differently across iOS and Android, and in-app browsers inside wallet apps sometimes inject their own provider that behaves subtly differently from the same wallet's browser extension. Test the deep-link handoff specifically: does your app correctly resume state when the wallet app returns control after approval, or does the user land back on a stale connect screen?
Network switching deserves a dedicated test path too. Force a chain mismatch mid-session (connect on one network, switch inside the wallet, return to the dapp) and confirm your chainChanged listener catches it and updates state rather than letting a transaction attempt silently target the wrong network. Cross-chain flows compound this risk further, so if your dapp touches bridging between networks, test chain-switch handling on both source and destination chains independently.

How Can You Optimize Performance for Wallet and Network Calls?
Wallet integration performance problems usually trace back to one habit: calling the blockchain more often than the UI actually needs.
Lazy-load your transport libraries. Web3-Onboard and similar SDKs dynamically import wallet modules on demand rather than bundling every supported wallet upfront, which keeps your initial page load fast for the majority of visitors who'll only ever use one wallet type. Don't undo that benefit by eagerly initializing every connector on mount, initialize on user interaction instead.
Cache read-only RPC calls aggressively. eth_chainId and eth_accounts rarely change mid-session, so there's no reason to refetch them on every render, poll only when a lifecycle event actually fires. Batch RPC calls where your provider supports it rather than firing sequential requests for balance, nonce, and gas estimate one after another.
Debounce gas estimation. Recalculating gas on every keystroke in an amount input hammers your RPC provider for no benefit, wait until the user pauses typing before requesting a fresh estimate. And avoid polling for transaction receipts on a tight interval, an exponential backoff (checking every 2 seconds, then 4, then 8) reduces load without meaningfully delaying the moment your UI reflects confirmation.
Finally, keep your RPC provider choice separate from your wallet provider. A slow public RPC endpoint will make your entire dapp feel sluggish regardless of how clean your wallet integration code is, route reads through a dedicated node provider rather than whatever endpoint the connected wallet happens to default to.

Lessons From Building Wallet Flows at Omnirout
Building multichain wallet flows for a non-custodial exchange taught us one thing repeatedly: users forgive a slower quote far more readily than they forgive an unclear signature prompt. Omnirout's routing layer has to reconcile wallet state across more than 30 chains without ever taking custody, which means the provider abstraction layer isn't optional infrastructure, it's the whole product.
— Emanuele
Key Standards and Docs to Bookmark
- EIP-1193: Ethereum Provider JavaScript API
- EIP-6963: Wallet Discovery
- WalletConnect
- MetaMask Embedded Wallets
- Web3-Onboard
- Wagmi connect wallet guide
If you're building the swap execution side of a multichain wallet flow rather than just the connection layer, Omnirout's aggregator is worth studying as a working example of how route comparison and non-custodial wallet control coexist in production.
Sources
- MetaMask Embedded Wallets
- EIP-1193: Ethereum Provider JavaScript API
- EIP-6963: Wallet Discovery
- WalletConnect
- Web3-Onboard
