This is an enterprise-only feature. Please contact us to enable.
WebViewWidget that runs the Dynamic SDK and returns results (including message and transaction signatures) over a JS bridge. No wallet SDK in your Flutter app.
The basic Flutter flow is the recommended default.
No SDK in your app. Your app links no wallet SDK: no CocoaPods, no native crypto, no Gradle dep. It needs a hidden
WebViewWidget pointed at headless.html and your URL scheme. All WalletConnect / MetaMask / Phantom logic (and the wallet list itself) comes from that hosted view. Redeploy the page to update wallets; the app never changes.View fireblocks_headless_connect.dart (copy-paste ready)
View fireblocks_headless_connect.dart (copy-paste ready)
fireblocks_headless_connect.dart
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter/widgets.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'app_config.dart';
import 'models.dart';
/// Runs the hosted Fireblocks connect logic (the Dynamic SDK) inside a HIDDEN
/// [WebViewWidget], so the app renders its own native wallet list and keeps
/// every bit of connection logic in the web layer.
///
/// For WalletConnect-protocol wallets (MetaMask, Rainbow, Trust, …) the
/// pairing is relay-based — the engine mints a URI, we open the wallet via
/// deeplink, the user approves, and the approval promise resolves over a
/// WebSocket — none of which needs a visible page. Wallets with no such path
/// come back as [ConnectFallbackRequired] so the caller opens the visible
/// [FireblocksConnect] flow.
///
/// ## Setup
///
/// 1. Wrap your home screen with [FireblocksEngineHost]:
/// ```dart
/// home: FireblocksEngineHost(child: ExampleScreen()),
/// ```
///
/// 2. Warm up at app launch (done for you in `main.dart`):
/// ```dart
/// FireblocksHeadlessConnect.shared.prewarm();
/// ```
///
/// 3. Connect a wallet:
/// ```dart
/// final result = await FireblocksHeadlessConnect.shared.connect(
/// walletKey: 'metamask',
/// chain: 'evm',
/// );
/// switch (result) {
/// case ConnectSuccess(:final wallet): ...
/// case ConnectFallbackRequired(:final reason): ...
/// case ConnectFailure(:final code, :final message): ...
/// }
/// ```
class FireblocksHeadlessConnect {
FireblocksHeadlessConnect._();
static final shared = FireblocksHeadlessConnect._();
/// The no-UI engine page. `returnScheme` tells Phantom where to redirect
/// after the user approves — must match your registered app URL scheme.
/// Sourced from [AppConfig] so it can be overridden with `--dart-define`
/// without touching source.
String engineUrl = AppConfig.engineUrl;
static const _startupTimeout = Duration(seconds: 20);
static const _signTimeout = Duration(seconds: 60);
// Universal-link hosts that must be opened externally from inside WebView.
// Kept identical to the Android/iOS native harnesses so wallet-open
// behavior stays in parity across platforms.
static const _walletHosts = {
'phantom.app',
'phantom.com',
'link.metamask.io',
'metamask.app.link',
'link.trustwallet.com',
'rnbwapp.com',
'rainbow.me',
'www.okx.com',
'link.okx.com',
'zerion.io',
};
// Never opened, whether reached via a nav-delegate interception or an
// engine-supplied `deeplink` URL — script / data / local-file vectors, plus
// the authority-bearing schemes (`intent:`, …) Android hands to
// `startActivity(ACTION_VIEW)` with no further checks. Mirrors
// `BLOCKED_REDIRECT_SCHEMES` in `src/config.ts` (#27) exactly — keep the
// two lists in sync if either changes.
static const _blockedSchemes = {
'javascript',
'data',
'vbscript',
'file',
'blob',
'about',
'intent',
'android-app',
'market',
'content',
'chrome',
'chrome-extension',
'moz-extension',
'ftp',
'ws',
'wss',
};
/// The engine's own origin (`scheme://host:port`). Inbound bridge messages
/// are only trusted while the WebView's current page is on this origin —
/// see [_handleMessage].
late final Uri _trustedOrigin = Uri.parse(engineUrl);
/// Updated on every navigation (including SPA `pushState`); starts `null`
/// so no message is trusted before the first real navigation event lands.
Uri? _currentOrigin;
late final WebViewController _controller = _buildController();
/// The hidden WebView widget. Mount via [FireblocksEngineHost] before calling
/// any other methods.
Widget get widget => WebViewWidget(controller: _controller);
bool _ready = false;
final _pendingReady = <VoidCallback>[];
final _connectHandlers = <String, void Function(ConnectResult)>{};
final _signHandlers = <String, void Function(SignResult)>{};
final _sendTxHandlers = <String, void Function(SendResult)>{};
final _startupTimers = <String, Timer>{};
final _signTimers = <String, Timer>{};
final _secureRandom = Random.secure();
List<HeadlessWallet> _wallets = [];
void Function(List<HeadlessWallet>)? _onWallets;
/// Called whenever the engine delivers (or re-delivers) the wallet
/// catalogue. Setting this replays the last-known list immediately if the
/// catalogue was already received, so a listener that subscribes late
/// (e.g. a screen that mounts after the engine is already warm) doesn't
/// spin forever waiting for a message that already happened.
set onWallets(void Function(List<HeadlessWallet>)? callback) {
_onWallets = callback;
if (callback != null && _wallets.isNotEmpty) callback(_wallets);
}
void Function(List<HeadlessWallet>)? get onWallets => _onWallets;
// MARK: – Public API
/// Build and load the hidden WebView ahead of time so the first connect
/// isn't slowed by SDK init + relay negotiation. Safe to call more than once.
void prewarm() {
_controller; // ignore: unnecessary_statements — triggers lazy init
}
/// Connect [walletKey] through the headless engine. Resolves once.
Future<ConnectResult> connect({
required String walletKey,
String? chain,
}) {
// A previous attempt is still in flight — reset to a fresh engine state so
// the new wallet gets a clean slate (avoids stuck WC/Dynamic sessions).
if (_connectHandlers.isNotEmpty) _resetEngine();
final requestId = _generateRequestId('req');
final completer = Completer<ConnectResult>();
_connectHandlers[requestId] = completer.complete;
_scheduleStartupTimeout(requestId);
void work() => _drive(requestId: requestId, walletKey: walletKey, chain: chain);
if (_ready) {
work();
} else {
_pendingReady.add(work);
}
return completer.future;
}
/// Abort every in-flight connect (e.g. user backed out of the wallet list).
/// Any pending [connect] call resolves with a cancelled failure so callers
/// never await a [Future] that would otherwise never complete.
void cancel() {
_safeRunJavaScript("window.headlessConnect && window.headlessConnect.cancel('');");
_drainConnectHandlers(const ConnectFailure(code: 'cancelled', message: 'Cancelled'));
}
/// Sign [message] with the currently-connected wallet. Resolves once.
///
/// Requires the engine URL to point to a build that includes sign support.
Future<SignResult> sign({required String message}) {
final requestId = _generateRequestId('sign');
final completer = Completer<SignResult>();
_signHandlers[requestId] = completer.complete;
_scheduleSignTimeout(requestId, _signHandlers);
final params = json.encode({'requestId': requestId, 'message': message});
_safeRunJavaScript('window.headlessConnect && window.headlessConnect.sign($params);');
return completer.future;
}
/// Send a transaction with the currently-connected wallet — EVM only.
/// Resolves once.
///
/// Calls `eth_sendTransaction`: the wallet signs AND broadcasts in one
/// step. Uses `eth_sendTransaction` rather than `eth_signTransaction`
/// because the latter isn't reliably implemented by mobile wallets —
/// MetaMask rejects it outright — while `eth_sendTransaction` is the
/// method every wallet actually supports.
///
/// [transaction] format: JSON string
/// `{"to":"0x…","value":"0x0","data":"0x","chainId":"0x1"}` — `chainId` is
/// required; the engine verifies it against the wallet's own active network
/// before sending and fails with `chain_mismatch` rather than silently
/// sending on the wrong chain.
///
/// On success, [SendSuccess.txHash] is the on-chain transaction hash — it
/// has already been submitted, there is nothing further to broadcast.
Future<SendResult> sendTransaction({required String transaction}) {
final requestId = _generateRequestId('sendtx');
final completer = Completer<SendResult>();
_sendTxHandlers[requestId] = completer.complete;
_signTimers[requestId] = Timer(_signTimeout, () {
_signTimers.remove(requestId);
_sendTxHandlers.remove(requestId)?.call(
const SendFailure(SignError(code: 'timeout', message: 'Wallet did not respond in time')),
);
});
final params = json.encode({'requestId': requestId, 'transaction': transaction});
// `sendTx` is a newer addition to the engine than everything else this
// class calls — a deployment that predates it would otherwise leave this
// request silently unanswered until the 60s timeout, indistinguishable
// from a dead wallet. Self-report immediately instead, straight over the
// same `walletNative` channel _handleMessage already listens on.
_safeRunJavaScript(
'if (window.headlessConnect && window.headlessConnect.sendTx) {'
' window.headlessConnect.sendTx($params);'
'} else if (window.walletNative && window.walletNative.postMessage) {'
' window.walletNative.postMessage(JSON.stringify({'
' type: "sentTxFailed",'
' requestId: ${json.encode(requestId)},'
' code: "unsupported_engine",'
' message: "engine build predates sendTx — redeploy connections.dynamic.dev"'
' }));'
'}',
);
return completer.future;
}
/// Tear down the session. Clears localStorage so stale Dynamic SDK state
/// doesn't bleed into the next connect, then reloads the engine page.
/// Any handlers still pending are drained with a cancelled result first.
Future<void> disconnect() async {
_drainConnectHandlers(const ConnectFailure(code: 'disconnected', message: 'Disconnected'));
_drainSignHandlers();
_ready = false;
_pendingReady.clear();
_wallets = [];
_onWallets?.call(_wallets);
// Clear storage before reload — equivalent to the iOS ephemeral WebView teardown.
await _safeRunJavaScript('localStorage.clear(); sessionStorage.clear();');
try {
await _controller.reload();
} catch (_) {
// Page may not have finished its initial load yet — ignore, the
// subsequent loadRequest inside _buildController already covers cold start.
}
}
/// Forward a Phantom (or other redirect-wallet) return URL from the app's
/// deep-link handler into the engine. Returns `true` if the URL was consumed.
///
/// Call this from your app's `app_links` listener:
/// ```dart
/// AppLinks().uriLinkStream.listen((uri) {
/// FireblocksHeadlessConnect.shared.handleReturnUrl(uri);
/// });
/// ```
bool handleReturnUrl(Uri uri) {
if (uri.host.toLowerCase() != 'phantom-headless') return false;
void deliver() {
final js =
'window.headlessConnect && window.headlessConnect.handleReturnURL(${json.encode(uri.toString())});';
_safeRunJavaScript(js);
}
// On a cold start (app launched *by* this deep link) the engine page
// hasn't finished loading yet — `window.headlessConnect` wouldn't exist
// and the call would silently no-op. Queue it behind the same `ready`
// gate `connect()` uses so it fires once the engine is actually up.
if (_ready) {
deliver();
} else {
_pendingReady.add(deliver);
}
return true;
}
// MARK: – Private
WebViewController _buildController() {
return WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
// `walletNative` matches the Android @JavascriptInterface name (and the
// generic `window.walletNative.postMessage` fallback checked by the web
// bridge) — no engine changes needed for Flutter.
..addJavaScriptChannel(
'walletNative',
onMessageReceived: (msg) => _handleMessage(msg.message),
)
..setNavigationDelegate(NavigationDelegate(
// Tracks the page actually loaded, for the origin check in
// _handleMessage — `onNavigationRequest` alone tells us what was
// *requested*, not what's live once redirects/SPA routing settle.
onPageStarted: _updateCurrentOrigin,
onUrlChange: (change) => _updateCurrentOrigin(change.url),
onNavigationRequest: _onNavigationRequest,
))
..loadRequest(Uri.parse(engineUrl));
}
void _updateCurrentOrigin(String? url) {
_currentOrigin = url == null ? null : Uri.tryParse(url);
}
/// Deny-by-default, including subframes — mirrors the bridge fix in PR #26
/// ("Navigation is deny-by-default, including subframes"). Only the
/// engine's own origin is ever allowed to load, in any frame; a
/// cross-origin subframe (e.g. an XSS'd `<iframe>` on the engine's own
/// page) is refused outright rather than handed to url_launcher, and a
/// cross-origin *main*-frame navigation either hands off to an installed
/// app (subject to [_blockedSchemes]) or is dropped — it never gets to
/// load inside this privileged WebView and inherit the `walletNative`
/// channel.
///
/// Known gap: `webview_flutter`'s `onNavigationRequest` has historically
/// not fired for every subframe load on Android (`shouldOverrideUrlLoading`
/// skips some iframe navigations) — this closes the main-frame redirect
/// path with certainty; treat subframe coverage as defense-in-depth, not
/// a proven guarantee, until verified on-device per platform.
NavigationDecision _onNavigationRequest(NavigationRequest req) {
final uri = Uri.tryParse(req.url);
if (uri == null) {
debugPrint('[FireblocksHeadlessConnect] blocked unparsable navigation target');
return NavigationDecision.prevent;
}
if (_isEngineOrigin(uri)) return NavigationDecision.navigate;
if (!req.isMainFrame) {
debugPrint('[FireblocksHeadlessConnect] blocked cross-origin subframe: ${req.url}');
return NavigationDecision.prevent;
}
if (_isBlockedScheme(uri)) {
debugPrint('[FireblocksHeadlessConnect] blocked scheme in navigation: ${req.url}');
return NavigationDecision.prevent;
}
if (_shouldOpenExternally(uri)) _openExternally(uri);
return NavigationDecision.prevent;
}
bool _isEngineOrigin(Uri uri) =>
uri.scheme.toLowerCase() == _trustedOrigin.scheme.toLowerCase() &&
uri.host.toLowerCase() == _trustedOrigin.host.toLowerCase() &&
_effectivePort(uri) == _effectivePort(_trustedOrigin);
int _effectivePort(Uri uri) =>
uri.hasPort ? uri.port : (uri.scheme.toLowerCase() == 'https' ? 443 : 80);
/// Never-allowed schemes — see [_blockedSchemes]'s doc comment.
bool _isBlockedScheme(Uri uri) => _blockedSchemes.contains(uri.scheme.toLowerCase());
bool _shouldOpenExternally(Uri uri) {
final scheme = uri.scheme.toLowerCase();
if (scheme != 'http' && scheme != 'https') return true; // custom schemes (fbapp://, metamask://)
final host = uri.host.toLowerCase();
return _walletHosts.any((h) => host == h || host.endsWith('.$h'));
}
/// Cryptographically-random per-call request id — deliberately not a
/// sequential counter. A guessable id lets whatever's currently loaded in
/// the WebView (attacker-controlled content, if the origin/navigation
/// checks above were ever bypassed) forge a reply that completes a
/// *different* pending request. Mirrors the per-connect UUIDs PR #26 added
/// to the iOS/Android bridges; implemented with `dart:math`'s
/// `Random.secure()` rather than a `uuid` package dependency.
String _generateRequestId(String prefix) {
final bytes = List<int>.generate(16, (_) => _secureRandom.nextInt(256));
final hex = bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
return '$prefix-$hex';
}
void _drive({required String requestId, required String walletKey, String? chain}) {
final params = <String, dynamic>{'requestId': requestId, 'walletKey': walletKey};
if (chain != null) params['chain'] = chain;
_safeRunJavaScript(
'window.headlessConnect && window.headlessConnect.connect(${json.encode(params)});',
);
}
void _resetEngine() {
_drainConnectHandlers(const ConnectFailure(code: 'reset', message: 'Superseded by a new connect request'));
_ready = false;
_pendingReady.clear();
try {
_controller.reload();
} catch (_) {
// Ignore — next connect() will retry once the reload settles.
}
}
/// Every inbound bridge message is checked against the engine's own origin
/// before being trusted — mirrors PR #26 ("Origin-check every inbound
/// message against the engine URL; drop anything else"). `webview_flutter`
/// injects the `walletNative` channel into every frame on the page, and
/// doesn't expose which frame a given message came from, so this checks
/// the *page* (via [_currentOrigin], updated by the navigation delegate)
/// rather than the sender — the deny-by-default nav delegate in
/// [_onNavigationRequest] is what stops a cross-origin frame from loading
/// in the first place; this is the second layer, not a substitute for it.
void _handleMessage(String jsonStr) {
final origin = _currentOrigin;
if (origin == null || !_isEngineOrigin(origin)) {
debugPrint('[FireblocksHeadlessConnect] dropped bridge message from untrusted origin: $origin');
return;
}
final Map<String, dynamic> obj;
try {
obj = json.decode(jsonStr) as Map<String, dynamic>;
} catch (_) {
return;
}
switch (obj['type'] as String?) {
case 'ready':
_ready = true;
final work = List<VoidCallback>.of(_pendingReady);
_pendingReady.clear();
for (final fn in work) {
fn();
}
case 'wallets':
final raw = obj['wallets'];
if (raw is List) {
_wallets = raw.whereType<Map<String, dynamic>>().map(HeadlessWallet.fromJson).toList();
_onWallets?.call(_wallets);
}
case 'deeplink':
// Engine produced a WalletConnect / MetaMask URI — open the wallet.
_openDeeplink(obj['requestId'] as String?, obj['url'] as String?);
case 'opening':
// Wallet being opened via WebView navigation (Phantom redirect protocol).
_cancelStartupTimer(obj['requestId'] as String?);
case 'connected':
final address = obj['address'] as String?;
// A `connected` message with no address used to succeed with `''`
// silently — Finding 19 in the PR #26 stack. Fail loud instead: an
// engine bug (or a message that slipped past the origin check above)
// should never read to the caller as "the user connected wallet ''".
_finishConnect(
obj['requestId'] as String?,
(address == null || address.isEmpty)
? const ConnectFailure(
code: 'malformed_result',
message: 'Engine sent connected with no address')
: ConnectSuccess(WalletConnection(
address: address,
chain: obj['chain'] as String? ?? '',
walletName: obj['walletName'] as String? ?? '',
walletImage: obj['walletImage'] as String? ?? '',
)),
);
case 'fallback':
_finishConnect(
obj['requestId'] as String?,
ConnectFallbackRequired(obj['reason'] as String? ?? ''),
);
case 'error':
_finishConnect(
obj['requestId'] as String?,
ConnectFailure(
code: obj['code'] as String? ?? 'unknown',
message: obj['message'] as String? ?? '',
),
);
case 'signed':
_finishSign(obj['requestId'] as String?, SignSuccess(obj['signature'] as String? ?? ''));
case 'signFailed':
_finishSign(
obj['requestId'] as String?,
SignFailure(SignError(
code: obj['code'] as String? ?? 'unknown',
message: obj['message'] as String? ?? '',
)),
);
case 'sentTx':
_finishSendTx(obj['requestId'] as String?, SendSuccess(obj['txHash'] as String? ?? ''));
case 'sentTxFailed':
_finishSendTx(
obj['requestId'] as String?,
SendFailure(SignError(
code: obj['code'] as String? ?? 'unknown',
message: obj['message'] as String? ?? '',
)),
);
// 'event' — diagnostic timeline; hook up logging / analytics here if wanted.
}
}
/// Attempt to open a wallet deeplink produced by the engine. On success the
/// startup timer is cancelled (the user is now away in their wallet app).
/// On failure the engine is told immediately via `onDeeplinkFailed` so it
/// can fall back to the visible flow rather than waiting out the full
/// startup timeout — the startup timer itself is left running as a backstop
/// in case the engine doesn't respond to that call.
Future<void> _openDeeplink(String? requestId, String? url) async {
if (url == null) return;
final uri = Uri.tryParse(url);
if (uri == null) return;
// Belt-and-braces: this URL comes from the engine, which has already
// passed the origin check by the time this fires, but native enforces
// the scheme block-list on every hand-off to the OS regardless of
// source — see [_blockedSchemes]'s doc comment.
if (_isBlockedScheme(uri)) {
debugPrint('[FireblocksHeadlessConnect] blocked scheme in deeplink: $url');
return;
}
var opened = false;
try {
opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {
opened = false;
}
if (opened) {
_cancelStartupTimer(requestId);
} else if (requestId != null) {
_safeRunJavaScript(
'window.headlessConnect && window.headlessConnect.onDeeplinkFailed && '
'window.headlessConnect.onDeeplinkFailed(${json.encode(requestId)});',
);
}
}
Future<void> _openExternally(Uri uri) async {
try {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {
// No app to handle it — nothing to recover to from a bare navigation
// interception (there's no requestId here), matching Android's stance.
}
}
void _finishConnect(String? requestId, ConnectResult result) {
if (requestId == null) return;
final handler = _connectHandlers.remove(requestId);
_cancelStartupTimer(requestId);
if (handler == null) {
debugPrint('[FireblocksHeadlessConnect] dropped connect reply for unknown requestId: $requestId');
return;
}
handler(result);
}
void _finishSign(String? requestId, SignResult result) {
if (requestId == null) return;
_signTimers.remove(requestId)?.cancel();
final handler = _signHandlers.remove(requestId);
if (handler == null) {
debugPrint('[FireblocksHeadlessConnect] dropped sign reply for unknown requestId: $requestId');
return;
}
handler(result);
}
void _finishSendTx(String? requestId, SendResult result) {
if (requestId == null) return;
_signTimers.remove(requestId)?.cancel();
final handler = _sendTxHandlers.remove(requestId);
if (handler == null) {
debugPrint('[FireblocksHeadlessConnect] dropped sendTx reply for unknown requestId: $requestId');
return;
}
handler(result);
}
void _drainConnectHandlers(ConnectResult result) {
final handlers = Map<String, void Function(ConnectResult)>.of(_connectHandlers);
_connectHandlers.clear();
for (final id in handlers.keys) {
_cancelStartupTimer(id);
}
for (final handler in handlers.values) {
handler(result);
}
}
void _drainSignHandlers() {
for (final timer in _signTimers.values) {
timer.cancel();
}
_signTimers.clear();
const result = SignFailure(SignError(code: 'disconnected', message: 'Disconnected'));
final signHandlers = Map<String, void Function(SignResult)>.of(_signHandlers);
_signHandlers.clear();
for (final handler in signHandlers.values) {
handler(result);
}
const sendResult = SendFailure(SignError(code: 'disconnected', message: 'Disconnected'));
final sendHandlers = Map<String, void Function(SendResult)>.of(_sendTxHandlers);
_sendTxHandlers.clear();
for (final handler in sendHandlers.values) {
handler(sendResult);
}
}
void _scheduleStartupTimeout(String requestId) {
_startupTimers[requestId]?.cancel();
_startupTimers[requestId] = Timer(_startupTimeout, () {
_startupTimers.remove(requestId);
_finishConnect(requestId, const ConnectFallbackRequired('headless startup timeout'));
});
}
void _cancelStartupTimer(String? requestId) {
if (requestId == null) return;
_startupTimers.remove(requestId)?.cancel();
}
void _scheduleSignTimeout(
String requestId,
Map<String, void Function(SignResult)> handlers,
) {
_signTimers[requestId] = Timer(_signTimeout, () {
_signTimers.remove(requestId);
handlers.remove(requestId)?.call(
const SignFailure(SignError(code: 'timeout', message: 'Wallet did not respond in time')),
);
});
}
Future<void> _safeRunJavaScript(String js) async {
try {
await _controller.runJavaScript(js);
} catch (_) {
// WebView not attached / navigation in progress — safe to drop; the
// caller has already scheduled its own timeout as a backstop.
}
}
}
/// Mounts the hidden engine WebView while rendering [child].
///
/// Place near the root of your app so the engine is always alive regardless of
/// which screen is active:
///
/// ```dart
/// MaterialApp(
/// home: FireblocksEngineHost(child: ExampleScreen()),
/// )
/// ```
class FireblocksEngineHost extends StatelessWidget {
final Widget child;
const FireblocksEngineHost({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Stack(
children: [
child,
// Hidden 1×1 — positioned off-screen to avoid intercepting touch events
// while staying in the tree so Flutter doesn't throttle / suspend it.
Positioned(
left: -1,
top: -1,
width: 1,
height: 1,
child: FireblocksHeadlessConnect.shared.widget,
),
],
);
}
}
fireblocks_headless_connect.dart. Prefer the buildable flutter-app/ over any older flutter-harness reference.
1. Add dependencies
pubspec.yaml
dependencies:
webview_flutter: 4.10.0 # hidden engine WebView
url_launcher: 6.3.1 # open wallet deeplinks
flutter_web_auth_2: 4.0.1 # visible fallback flow
app_links: ^6.0.0 # Phantom / deep-link returns
2. Register URL schemes
You need two hosts under your scheme: one for the visible flow callback (wallet-callback) and one for Phantom’s redirect (phantom-headless), plus LSApplicationQueriesSchemes on iOS for the wallet schemes you open.
Info.plist (iOS)
<key>CFBundleURLTypes</key>
<array><dict>
<key>CFBundleURLSchemes</key>
<array><string>myapp</string></array>
</dict></array>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>metamask</string><string>phantom</string>
<string>rainbow</string><string>trust</string>
</array>
AndroidManifest.xml
<!-- inside your <activity> block -->
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="wallet-callback" />
</intent-filter>
<intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="myapp" android:host="phantom-headless" />
</intent-filter>
3. Mount the engine and connect
Wrap your home screen withFireblocksEngineHost. It keeps the hidden WebViewWidget alive at all times. Prewarm at launch and connect on tap.
main.dart
MaterialApp(
home: FireblocksEngineHost(child: ExampleScreen()),
)
example_screen.dart
// prewarm at app start
FireblocksHeadlessConnect.shared.prewarm();
// receive the live wallet list
FireblocksHeadlessConnect.shared.onWallets = (wallets) {
setState(() => _wallets = wallets);
};
// connect on tap
final result = await FireblocksHeadlessConnect.shared.connect(
walletKey: 'metamask',
chain: 'evm',
);
switch (result) {
case ConnectSuccess(:final wallet):
setState(() => _connection = wallet);
case ConnectFallbackRequired():
final w = await FireblocksConnect.connect(
flowUrl: _flowUrl,
scheme: _scheme,
);
setState(() => _connection = w);
case ConnectFailure(:final code, :final message):
setState(() => _error = '[$code] $message');
}
app_links (getInitialLink + uriLinkStream) into FireblocksHeadlessConnect.shared.handleReturnUrl(uri). Do not rely on MaterialApp.onGenerateRoute for custom-scheme intents.
4. Sign a message
After a successful headless connect,await sign() with any string.
example_screen.dart
final result = await FireblocksHeadlessConnect.shared.sign(
message: 'Sign in to MyApp',
);
switch (result) {
case SignSuccess(:final value):
setState(() => _signature = value); // hex string
case SignFailure(:final error):
setState(() => _error = '[${error.code}] ${error.message}');
}
Signing is only available for wallets connected through the headless engine (
connectedHeadlessly == true). Wallets connected via the visible fallback flow do not hold an open session.5. Send a transaction (EVM)
sendTransaction() calls eth_sendTransaction: the wallet signs and broadcasts in one step. The result is an on-chain transaction hash. Prefer this over eth_signTransaction, which mobile wallets (including MetaMask) often reject.
The Flutter sample does not expose a sign-only signTransaction() helper. Use sendTransaction() for EVM sends, or call the engine’s signTx bridge yourself if you need sign-only.
example_screen.dart
final result = await FireblocksHeadlessConnect.shared.sendTransaction(
transaction:
'{"to":"${wallet.address}","value":"0x0","data":"0x","chainId":"0x1"}',
);
switch (result) {
case SendSuccess(:final txHash):
setState(() => _txHash = txHash); // already broadcast
case SendFailure(:final error):
setState(() => _error = '[${error.code}] ${error.message}');
}
chainId is required for send. The engine verifies it against the wallet’s active network and fails with chain_mismatch (or missing_chain_id) rather than silently using whatever network the wallet is on. Treat every send as final: confirm with the user before calling.6. Disconnect
ClearslocalStorage in the hidden WebView and reloads the engine so stale SDK state does not bleed into the next connect.
example_screen.dart
await FireblocksHeadlessConnect.shared.disconnect();
setState(() => _connection = null);
7. The bridge (for reference)
Flutter usesaddJavaScriptChannel('walletNative', …) which creates window.walletNative.postMessage(json). The channel name must match exactly.
bridge messages
// web → app (connect) request-scoped messages carry requestId
ready engine initialized
wallets { wallets: […] } the wallet menu (live)
deeplink { requestId, url } app opens the wallet
opening { requestId } wallet opening (Phantom)
connected { requestId, address, chain, … } success
fallback { requestId, reason } can't go headless → visible flow
error { requestId, code, message } failed
event { requestId?, event, sessionId, t } diagnostic timeline
// web → app (sign)
signed { requestId, signature } message signed (hex string)
signFailed { requestId, code, message } sign failed
signedTx { requestId, signedTransaction, chain } tx signed
signTxFailed { requestId, code, message } tx sign failed
// app → web
window.headlessConnect.connect({ requestId, walletKey, chain })
window.headlessConnect.cancel(requestId)
window.headlessConnect.handleReturnURL(url) // redirect wallets
window.headlessConnect.sign({ requestId, message })
window.headlessConnect.signTx({ requestId, transaction })
window.headlessConnect.sendTx({ requestId, transaction }) // EVM sign+broadcast
Common pitfalls
- Match the channel name exactly:
walletNative. A mismatch is invisible; the channel just never receives anything. - Use
app_linksfor Phantom returns, notonGenerateRoute. - Test on a physical device.
- Serve over HTTPS.