web3_webview
web3_webview is a Flutter bridge between a DApp running inside an InAppWebView and your host app. It injects an EIP-1193 / EIP-6963 compliant window.ethereum provider (via ethers.min.js + generated script) and routes all wallet operations – connect, sign, sendTransaction, chain management – to native Dart dialogs and web3dart.
Security-first fork: private-key validation, XSS-safe JS injection, EIP-1193 error codes,
PermissionRequestdefault-deny, non-blockingeth_sendTransaction, and correct EIP-712 padding. See Security.
Architecture
flowchart LR
DApp -- window.ethereum.request --> JSBridge[JS Provider\nprovider_script.dart]
JSBridge -- flutter_inappwebview callHandler\nethereumRequest --> WebView[Web3WebView\nweb3_webview_eip1193.dart]
WebView --> Provider[EthereumProvider\nsingleton + Web3Client]
Provider --> Signer[Web3Signer\nPrivateKeySigner | external]
Signer --> Signing[SigningHandler\nEIP-191 / EIP-712]
Signer --> Tx[TransactionHandler\nsend + estimate + gas]
Provider --> Dialog[WalletDialogService\nbottom sheet]
Provider -- jsonEncode + evaluateJavascript --> JSBridge
Core modules:
| Module | File | Responsibility |
|---|---|---|
Web3WebView |
lib/web3_webview_eip1193.dart |
Wraps flutter_inappwebview, injects ethers.min.js + provider script at AT_DOCUMENT_START (Android re-inject on onLoadStart), supports read-only when no signer, forwards onPermissionRequest as DENY by default |
EthereumProvider |
lib/ethereum/ethereum_provider.dart |
Singleton, Web3Client + Client lifecycle, handleRequest dispatcher, 12s eth_blockNumber cache, chain registry, event emit via jsonEncode. New: Web3Signer? abstraction – PrivateKeySigner / external / null (read-only) |
Web3Signer |
lib/signer/web3_signer.dart |
Abstract address, signMessage(method,from,msg,pw), sendTransaction(tx); implement for WalletConnect / Secure Enclave etc. |
PrivateKeySigner |
lib/signer/private_key_signer.dart |
Local EthPrivateKey impl via SigningHandler + TransactionHandler, auto-updates on chain switch via getters |
ProviderScriptGenerator |
lib/provider/provider_script.dart |
Generates JS EthereumProvider extends EventTarget, jsonEncode-escapes chainId/accounts/isConnected/info, handles request/send/sendAsync, EIP-6963 announce |
SigningHandler |
lib/signing/signing_handler.dart |
personal_sign/eth_sign/eth_signTypedData* with full EIP-712 v3/v4 validation, struct hashing, 32-byte padding for bool/address/bytesN |
TransactionHandler |
lib/transaction/transaction_handler.dart |
Validate to/value/data, estimateGas + 20% BigInt buffer, signTransaction(chainId), sendRawTransaction returns hash immediately (EIP-1193), optional waitForConfirmation |
WalletDialogService |
lib/ethereum/wallet_dialog_service.dart |
Bottom sheets for connect/sign/tx/switch/add, requestFrom = controller.getUrl().host, WalletDialogTheme |
Key Features
- Wallet Connection – user-confirmed
eth_requestAccounts; returns[]oneth_accountsuntil connected - DApp Integration –
window.ethereum+window.web3.currentProvider,isMetaMask=true, EIP-6963 discovery - Read-only mode – omit
privateKey/signerand DApp still loads;eth_call,eth_getBalance,eth_blockNumberetc. work, signing throws4100 - External signer – implement
Web3Signerfor WalletConnect, Secure Enclave, biometrics – no private key in RAM - Transaction Lifecycle – confirmation dialog,
eth_estimateGasbuffered, non-blocking hash return, fire-and-forget receipt polling (opt-in blocking) - Message Signing –
personal_sign(EIP-191 prefix),eth_sign(raw keccak),eth_signTypedData/v1/v3/v4(EIP-712) - Chain Management –
wallet_switchEthereumChain/wallet_addEthereumChainwith idempotency checks - Two-way Bridge –
flutter_inappwebviewcallHandler↔evaluateJavascript, XSS-safe viajsonEncode - Multi-chain – any EVM via
NetworkConfig
Requirements
- Flutter
>=3.24.0, Dart^3.5.0 flutter_inappwebview ^6.1.5,web3dart ^2.7.3
Installation
dependencies:
web3_webview: ^1.0.11
import 'package:web3_webview/web3_webview.dart';
Quick Start
privateKey is now optional. If omitted and no signer is provided, the WebView runs in read-only mode (only RPC reads succeed; signing throws 4100). signer takes precedence over privateKey.
Do not hardcode keys. Load from
flutter_secure_storage/ Keychain / Keystore and clear on lock. Or useWeb3Signerto avoid storing keys at all. See Security.
A. Read-only (no signer)
Web3WebView(
web3WalletConfig: Web3WalletConfig(
currentNetwork: ethMainnet,
supportNetworks: [ethMainnet, bscMainnet],
name: 'MyDApp',
),
initialUrlRequest: URLRequest(url: WebUri('https://metamask.github.io/test-dapp/')),
)
// DApp loads, eth_call/eth_getBalance work, eth_sendTransaction -> {code:4100}
B. Local private key (legacy, still supported)
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class DappScreen extends StatefulWidget {
const DappScreen({super.key});
@override State<DappScreen> createState() => _DappScreenState();
}
class _DappScreenState extends State<DappScreen> {
String? _pk;
@override void initState() {
super.initState();
const storage = FlutterSecureStorage();
storage.read(key: 'evm_pk').then((v) => setState(() => _pk = v));
}
@override Widget build(BuildContext context) {
if (_pk == null) return const Center(child: CircularProgressIndicator());
return Web3WebView(
web3WalletConfig: Web3WalletConfig(
privateKey: _pk!, // 0x + 64 hex, validated -32602 on invalid
currentNetwork: ethMainnet,
supportNetworks: [ethMainnet, bscMainnet],
name: 'MyDApp Wallet',
id: 'com.example.mydapp',
dialogTheme: WalletDialogTheme(primaryColor: Color(0xFF3B82F6)),
onError: (method, params, message) => debugPrint('[$method] $message'),
),
initialUrlRequest: URLRequest(url: WebUri('https://metamask.github.io/test-dapp/')),
onPermissionRequest: (ctrl, req) async => PermissionResponse(
resources: req.resources, action: PermissionResponseAction.DENY,
),
);
}
}
C. External signer (WalletConnect / Secure Enclave) — recommended
import 'package:web3_webview/web3_webview.dart';
class WalletConnectSigner extends Web3Signer {
final String _addr;
WalletConnectSigner(this._addr);
@override String get address => _addr;
@override Future<String> signMessage(String method, String from, dynamic message, String pw) {
// delegate to WalletConnect / enclave
return walletConnect.sign(method, from, message);
}
@override Future<String> sendTransaction(Map<String, dynamic> tx) {
return walletConnect.sendTransaction(tx);
}
}
Web3WebView(
web3WalletConfig: Web3WalletConfig(
signer: WalletConnectSigner('0xAbc...'),
currentNetwork: ethMainnet,
),
initialUrlRequest: URLRequest(url: WebUri('https://app.uniswap.org/')),
)
Handling Web3WalletConfig
| Field | Type | Required | Notes |
|---|---|---|---|
privateKey |
String? |
no* | 0x + 64 hex or 64 hex; validated with -32602 on invalid. *Required only for local signing; omit for read-only / external signer |
signer |
Web3Signer? |
no* | external signer (WalletConnect etc.), takes precedence over privateKey. If both null → read-only |
currentNetwork |
NetworkConfig? |
no | defaults to 0x1 |
supportNetworks |
List<NetworkConfig>? |
no | defaults to [eth, bsc] |
name/icon/id |
String? |
no | EIP-6963 info (icon is data-URI), id → rdns |
dialogTheme |
WalletDialogTheme? |
no | colors, text styles, paddings |
onError |
void Function(JsonRpcMethod, List?, String)? |
no | invoked before JS error is thrown, receives EIP-1193 code in message |
NetworkConfig { chainId (0x hex), chainName, nativeCurrency?, rpcUrls, blockExplorerUrls? }
Read-only check: config.isReadOnly / config.hasSigner; provider: EthereumProvider().isReadOnly.
Signer & Read-only mode
lib/signer/web3_signer.dart:14:
abstract class Web3Signer {
String get address; // EIP-55
Future<String> signMessage(String method, String from, dynamic message, String password);
Future<String> sendTransaction(Map<String, dynamic> txParams); // -> tx hash
Future<Uint8List> signTransaction(Transaction tx, int chainId) => throw UnsupportedError(...);
}
- Read-only:
Web3WalletConfig()with noprivateKey/signer→EthereumProvider.isReadOnly==true,window.ethereum.isConnected==false,eth_accounts==[].eth_call,eth_getBalance,eth_blockNumber,eth_chainId,eth_estimateGas,wallet_switchEthereumChainetc. still work viaWeb3Client. Signing methods throw4100(unauthorized) which surfaces ascode:4100in DApp. - PrivateKey:
PrivateKeySignerlib/signer/private_key_signer.dart:11is auto-created fromprivateKey; usesSigningHandler/TransactionHandlerinternally and auto-updateschainId/Web3Clienton network switch. - External: implement
Web3Signerand passWeb3WalletConfig(signer: ...). Dialogs (showConnectWallet/showSignMessage/showTransactionConfirm) are still shown byEthereumProviderbefore delegating; your signer only does crypto/RPC.
Supported JSON-RPC
EthereumProvider.handleRequest (lib/ethereum/ethereum_provider.dart:189) dispatches:
| Method | Params | Behaviour |
|---|---|---|
eth_requestAccounts |
[] |
Bottom sheet → connect + accountsChanged events, returns [address] or 4001; throws 4100 in read-only |
eth_accounts |
[] |
[] until connected (or read-only) |
eth_chainId |
[] |
state.chainId (hex) |
net_version |
[] |
decimal chainId |
eth_blockNumber |
[] |
12s cached HexUtils.numberToHex(blockNumber) |
eth_call |
[tx, block] |
makeRPCCall('eth_call') (no signing) |
eth_sendTransaction |
[tx] |
Dialog → signer.sendTransaction → hash immediately, polling fire-and-forget; 4100 in read-only |
eth_getBalance |
[addr, tag] |
getBalance → hex |
eth_getBlockByNumber/Hash |
[id, withTx] |
makeRPCCall → Map? |
eth_getTransactionByHash/Receipt |
[hash] |
makeRPCCall → Map? |
eth_getCode/StorageAt/TransactionCount |
[...] |
getCode/getStorage/getTransactionCount or RPC fallback |
eth_gasPrice / eth_estimateGas |
[]/[tx] |
hex; estimate buffered +20% via BigInt (works in read-only) |
personal_sign |
[hexMsg, addr, pw?] |
dialog → signer.signMessage; 4100 in read-only |
eth_sign |
[addr, hexMsg] |
raw keccak256; 4100 in read-only |
eth_signTypedData[*_v1/v3/v4] |
[addr, typed] |
EIP-712; 4100 in read-only |
wallet_switchEthereumChain |
[{chainId}] |
idempotent, dialog if needed → chainChanged (works in read-only) |
wallet_addEthereumChain |
[NetworkConfig] |
dialog → add + auto-switch |
wallet_getPermissions |
[] |
['eth_accounts','eth_chainId','personal_sign'] |
wallet_revokePermissions |
[] |
true |
| other | throws UnsupportedMethodException(4200) |
Unknown method surfaces to JS as {code:4200, message} and to onError.
Error codes (lib/exceptions.dart:2)
WalletException.code follows EIP-1193 + JSON-RPC:
4001– user rejected4100– unauthorized (no signer / read-only)4200– unsupported method4900– disconnected-32601– method not found-32602– invalid params-32603– internal
Web3WebView (lib/web3_webview_eip1193.dart:825) maps WalletException.code into the thrown {code,message} consumed by provider_script.dart:127 _processError.
Permissions & WebView
onPermissionRequestdefaults toDENY(lib/web3_webview_eip1193.dart:1019). OverrideWeb3WebView.onPermissionRequestto grant selectively, neverGRANTall.shouldInterceptRequest/onReceivedServerTrustAuthRequestare forwarded; implement cert pinning in host if needed.- Provider injected as two
UserScriptatAT_DOCUMENT_START; on Android re-evaluated inonLoadStart.
Signing details (lib/signing/signing_handler.dart:1)
personal_signaccepts0xhex or UTF-8, decodes for UI, signs prefixed.eth_signsignskeccak256(hexOrUtf8)directly (dangerous – dialog still shown).- Typed data:
typesmust containEIP712Domain; checksprimaryType,domainwhitelist (name,version,chainId,verifyingContract,salt), circular deps, array lengths, and ABI 32-byte padding forbool/address/bytesN/uint/int.
Transaction details (lib/transaction/transaction_handler.dart:1)
- Requires
to(0x42 chars), validatesvalue/dataas0xhex. getTransactionCount(from)as nonce,estimateGas+20% with BigInt (*120/100),getGasPrice()orgasPriceoverride.handleTransaction({waitForConfirmation=false})– default returns hash; settrueto block up to 30×10s polling and throw onreceipt.status==falseor timeout.
Theming (light + auto dark)
// Light (default)
final light = WalletDialogTheme(primaryColor: Color(0xFF6366F1));
// Dark – auto-used when Theme.brightness == dark, or pass custom
final dark = WalletDialogTheme.dark(primaryColor: Color(0xFF818CF8));
Web3WalletConfig(
dialogTheme: light,
darkDialogTheme: dark, // optional – if null auto-derived from light
)
- Auto dark:
WalletDialogServicechecksTheme.of(context).brightness– no extra code needed;BottomSheetDialogbarrier0.42, cards adapt (surface #1E293B,border #334155,text #F1F5F9). - Premium redesign (1.0.12): 20 radius, 16 card, handle 36x4, origin chip with avatar, hero amount card, warning amber/red,
FilledButton52h/14 radius +OutlinedButton,ExpansionTilehex. - Custom per-dialog still via
WalletDialogTheme(primaryColor:…, borderRadius:…), consumed byWalletDialogService(lib/ethereum/wallet_dialog_service.dart:7).
Try the example: AppBar dark toggle → all eth_requestAccounts / personal_sign / eth_sendTransaction dialogs switch instantly.
Custom UI (fully replace any dialog)
You can keep the premium dialogs (theming only) or completely replace them to match your app branding – no fork needed.
// 1. Define builders once (or per-WebView)
final myBuilders = WalletDialogBuilders(
connect: (ctx, {required address, required host, required appName, required controller, required theme}) async {
return showDialog<bool>(
context: ctx,
barrierDismissible: false,
builder: (c) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
title: Text('$appName muốn kết nối', style: theme.headerStyle),
content: Text('Host: $host\nWallet: $address'),
actions: [
TextButton(onPressed: () => Navigator.pop(c, false), child: Text('Từ chối')),
FilledButton(onPressed: () => Navigator.pop(c, true), style: FilledButton.styleFrom(backgroundColor: theme.primaryColor), child: Text('Kết nối')),
],
),
);
},
// sign, transaction, switchNetwork, addNetwork – same pattern
sign: (ctx, {required message, required address, required host, required controller, required theme}) => ...,
transaction: (ctx, {required txParams, required host, required controller, required theme}) => ...,
);
// 2. Pass via config (per-WebView) – takes precedence
Web3WebView(
web3WalletConfig: Web3WalletConfig(
privateKey: pk,
dialogTheme: light,
darkDialogTheme: dark,
dialogBuilders: myBuilders, // <-- all dialogs now use your widgets
),
)
// 3. Or globally: WalletDialogService.instance.setBuilders(myBuilders);
- What you receive:
BuildContext(forshowDialog/Navigator),host,address/message/txParams,controller(forgetUrl()), and the effectiveWalletDialogTheme(already resolved for light/dark + your custom colors). - What to return:
Future<bool?>–true= confirm,false/null= reject/cancel (maps to EIP-11934001). - Fallback: any
nullbuilder falls back to the premium default; you can override onlyconnectand keepsignpremium. - Theming still works: your custom widget can use
theme.primaryColor / textColor / borderColorto stay consistent withdialogTheme/darkDialogTheme.
Example with 4 tabs (read-only / privateKey / signer / custom) is in example/lib/main.dart:56 – the “Custom” tab uses AlertDialog as above.
Security
- Private key / Signer: never log, never commit, store in
flutter_secure_storagewith biometrics, wipe on logout. PreferWeb3Signer(WalletConnect / Secure Enclave) over embedding keys.EthereumProvider.initialize(lib/ethereum/ethereum_provider.dart:63) validatesprivateKeyif provided;Web3WebViewshows error, not WebView, on invalid format. Read-only mode needs no key.Clipboardusesflutter/serviceswithout logging (lib/utils/app_utils.dart:5). - XSS: all JS interpolation uses
jsonEncode(lib/provider/provider_script.dart:13,lib/web3_js_bridge_callback.dart:5,lib/ethereum/ethereum_provider.dart:841). - Origin: dialogs display
controller.getUrl().host; consider allowlist / phishing check beforeshowConnectWallet. - RPC:
rpcUrls.firstis used; use HTTPS + API key, pin certs viaonReceivedServerTrustAuthRequest. - Singleton:
EthereumProvideris a singleton; multipleWeb3WebViewshare state – avoid mounting two simultaneously, calldispose()(lib/ethereum/ethereum_provider.dart:259) on screen dispose (already done inWeb3WebView).
Limitations
- Singleton
EthereumProvider– not multi-account, not multi-chain concurrent. ethers.min.js(464 KB) bundled atpackages/web3_webview/assets/ethers.min.js, injected twice.- No
eth_subscribe/eth_getLogspolling; unsupported methods throw4200. LoadingHelperis global ref-counted overlay (lib/utils/loading.dart:3).
Changelog
See CHANGELOG.md. 1.0.13 adds WalletDialogBuilders for fully custom UI (dialogBuilders), 1.0.12 premium + auto dark, 1.0.11 Web3Signer + read-only.
License & Thanks
- License: see
LICENSE(BSD). - Thanks:
PositionExchange/flutter-web3-provider,ethers.jsv6 docs.
Libraries
- ethereum/ethereum_provider
- ethereum/wallet_dialog_builders
- ethereum/wallet_dialog_service
- exceptions
- json_rpc_method
- models/eip6963_provider_info
- models/models
- models/network_config
- models/wallet_state
- models/web3_wallet_config
- provider/provider_script
- signer/private_key_signer
- signer/signer
- signer/web3_signer
- signing/signing_handler
- transaction/transaction_handler
- utils/address_utils
- utils/app_utils
- utils/bigint_utils
- utils/hex_utils
- utils/loading
- utils/validation_utils
- web3_js_bridge_callback
- web3_webview
web3_webview– Flutter EIP-1193 bridge for DApps in InAppWebView.- web3_webview_eip1193
- EIP-1193 WebView widget.
- widgets/bottom_sheet_dialog