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';
import 'phantom_return.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` is where Phantom sends the user
/// back (`<scheme>://phantom-headless`) in either [phantomMode] — 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;
/// UI locale for the iframe app, sent as `?locale=<value>` — e.g. `"en_US"`,
/// `"es_LA"`; see the iframe app's README for the current allow-list.
/// `null`/empty leaves the page on its own default (`en_US`). Never changes
/// anything on the native side — it's forwarded as-is to the iframe app.
///
/// Set this BEFORE [prewarm]/[connect]: the URL is resolved when the hidden
/// WebView is first built, so a later change only takes effect on the next
/// engine reload.
String? locale;
/// UI theme for the iframe app, sent as `?theme=<value>` — `"light"` or
/// `"dark"`. `null`/empty leaves the page on its own default (`light`).
/// Never changes anything on the native side — it's forwarded as-is to
/// the iframe app.
///
/// Set this BEFORE [prewarm]/[connect]: same resolution timing as [locale].
String? theme;
/// How the iframe app connects Phantom, sent as `?phantomMode=<value>` —
/// `"in-app"` (Phantom's in-app browser over WalletConnect, the default) or
/// `"redirect"` (Phantom's encrypted deeplink redirect). `null`/empty
/// omits the param and the engine uses its own default. Forwarded as-is —
/// an unknown value falls back to `"in-app"` in the iframe app.
///
/// Set this BEFORE [prewarm]/[connect]: same resolution timing as [locale].
String? phantomMode;
/// The app's own https URL Phantom returns to, sent as
/// `?walletReturnUrl=<value>` and used as Phantom's `redirect_link` when
/// [phantomMode] is `"redirect"`. `null`/empty omits the param and the
/// engine falls back to `<returnScheme>://phantom-headless`. Forwarded
/// as-is.
///
/// Set this BEFORE [prewarm]/[connect]: same resolution timing as [locale].
String? walletReturnUrl;
/// [engineUrl] with [locale]/[theme]/[phantomMode]/[walletReturnUrl] applied.
String get _resolvedEngineUrl {
final base = Uri.parse(engineUrl);
return base.replace(queryParameters: {
...base.queryParameters,
if (locale != null && locale!.isNotEmpty) 'locale': locale!,
if (theme != null && theme!.isNotEmpty) 'theme': theme!,
if (phantomMode != null && phantomMode!.isNotEmpty) 'phantomMode': phantomMode!,
if (walletReturnUrl != null && walletReturnUrl!.isNotEmpty) 'walletReturnUrl': walletReturnUrl!,
}).toString();
}
static const _startupTimeout = Duration(seconds: 20);
static const _signTimeout = Duration(seconds: 60);
/// Longer than [_signTimeout]: a send can involve two sequential wallet
/// approvals (switch network, then send) rather than one, so 60s is too
/// tight a budget to fail loud on before the user's had a fair chance to
/// clear both prompts.
static const _sendTimeout = Duration(seconds: 120);
/// How long to wait for wallet approval AFTER a connect deeplink has
/// opened successfully. [_startupTimeout] is cancelled at that point (see
/// [_openDeeplink]) with nothing to replace it — so a wallet that never
/// answers (backgrounded relay session that never resumes, wallet-side
/// bug, user just closes the wallet app) used to leave [connect]'s
/// `Future` pending forever, no error, no way out but killing the app.
/// Generous on purpose: switching to a wallet app and back takes real
/// human time, and a WalletConnect session proposal is normally good for
/// 5 minutes anyway.
static const _connectApprovalTimeout = Duration(minutes: 3);
// 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 _signTxHandlers = <String, void Function(SignTxResult)>{};
final _sendTxHandlers = <String, void Function(SendResult)>{};
final _startupTimers = <String, Timer>{};
final _signTimers = <String, Timer>{};
final _signTxTimers = <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.
///
/// [chain] is 'evm', 'solana' or 'bitcoin'.
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, Solana,
/// or Bitcoin. Resolves once. [transaction] is an opaque JSON string (or,
/// for Solana, opaque base64); its shape depends on which chain the
/// connected wallet is on — this method itself is chain-agnostic and just
/// forwards it to the engine.
///
/// The wallet signs AND broadcasts in one step: `eth_sendTransaction` for
/// EVM (rather than `eth_signTransaction`, which isn't reliably implemented
/// by mobile wallets — MetaMask rejects it outright), broadcast-after-sign
/// for Solana, and the wallet's own `sendBitcoin` RPC for Bitcoin.
///
/// [transaction] format (EVM): 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, using the Dynamic SDK's network-switching support. On a
/// mismatch it asks the wallet to switch (adding the chain first via the
/// SDK's own network config if the wallet does not have it configured) and only
/// proceeds once the wallet confirms it actually moved, rather than
/// silently sending on the wrong chain. See `SendFailure.error.code`:
/// `chain_switch_unavailable` (wallet can't switch programmatically),
/// `chain_not_configured` (chain isn't in this project's network config),
/// `chain_switch_rejected` (wallet declined the switch/add prompt), or
/// `chain_mismatch` (wallet claimed success without actually switching).
///
/// [transaction] format (Solana): base64-encoded, pre-built
/// `VersionedTransaction` (or legacy `Transaction`) bytes — this app is
/// responsible for building it (a native SOL transfer vs. an SPL-token
/// transfer for USDC are both just "whatever instructions are already in
/// the bytes"); the engine only signs and submits. No chain-switch dance
/// for Solana — there's no wallet-side "active network" to verify, the
/// transaction already targets whichever cluster its recent blockhash
/// came from.
///
/// [transaction] format (Bitcoin): JSON string
/// `{"recipientAddress":"bc1…","amountSats":"12345"}` — `amountSats` is
/// the amount in satoshis as a decimal-digit string. The wallet's own
/// `sendBitcoin` RPC signs AND broadcasts; there is no chain-switch step
/// (Bitcoin has no equivalent of an EVM chainId to mismatch on).
///
/// On success, [SendSuccess.txHash] is the on-chain transaction hash (EVM),
/// signature (Solana, which IS the transaction's identifier), or
/// transaction ID (Bitcoin) — it has already been submitted, there is
/// nothing further to broadcast.
Future<SendResult> sendTransaction({required String transaction}) =>
_dispatchSend(
idPrefix: 'sendtx',
jsFunction: 'sendTx',
params: {'transaction': transaction},
unsupportedMessage:
'engine build predates sendTx — redeploy connect.dynamicauth.com',
);
/// Send a display-unit amount from the connected wallet — EVM or Solana.
/// Resolves once. The caller never builds a transaction or converts to
/// wei/lamports; the engine owns all validation, so the
/// [SendFailure.error] codes it surfaces include `invalid_amount`,
/// `missing_recipient`, `missing_from`, `invalid_token`,
/// `invalid_network_id`, `account_not_found`, `fallback_required`,
/// `unsupported_chain`, `unsupported_wallet`, `chain_not_configured`,
/// `chain_switch_unavailable`, `chain_switch_rejected`, `chain_mismatch`,
/// `invalid_param`, `method_unavailable`, `user_rejected`, `timeout` and
/// `unsupported_engine`.
///
/// - [from]: address of the connected account to send from (EVM addresses
/// match case-insensitively).
/// - [amount]: positive decimal string in display units, e.g. `"0.01"`.
/// - [recipient]: destination address.
/// - [networkId]: optional. EVM: decimal chainId, e.g. `"137"`. Solana:
/// `"mainnet-beta"` or `"devnet"`. Omitting it on EVM means the engine
/// performs no network check and sends on whatever network the wallet is
/// currently on.
/// - [token]: optional ERC-20 / SPL token; omit for the native asset.
///
/// v1 exclusions: Bitcoin returns `unsupported_chain`; Solana with Phantom
/// in redirect mode returns `unsupported_wallet`.
Future<SendResult> transferAmount({
required String from,
required String amount,
required String recipient,
String? networkId,
TransferToken? token,
}) =>
_dispatchSend(
idPrefix: 'transfer',
jsFunction: 'transferAmount',
params: {
'from': from,
'amount': amount,
'recipient': recipient,
'networkId': ?networkId,
if (token != null)
'token': {'address': token.address, 'decimals': token.decimals},
},
unsupportedMessage:
'engine build predates transferAmount — redeploy connect.dynamicauth.com',
);
/// [jsFunction] is spliced into the script, so it must be a fixed internal
/// constant; [params] only reaches JS through `json.encode`.
Future<SendResult> _dispatchSend({
required String idPrefix,
required String jsFunction,
required Map<String, Object?> params,
required String unsupportedMessage,
}) {
final requestId = _generateRequestId(idPrefix);
final completer = Completer<SendResult>();
_sendTxHandlers[requestId] = completer.complete;
_signTimers[requestId] = Timer(_sendTimeout, () {
_signTimers.remove(requestId);
_sendTxHandlers.remove(requestId)?.call(
const SendFailure(SignError(
code: 'timeout',
message: 'Wallet did not respond in time — if you approved it, the transaction '
'may still have been submitted; check the explorer before retrying.',
)),
);
});
final encodedParams = json.encode({'requestId': requestId, ...params});
// `sendTx` and `transferAmount` are newer additions to the engine than
// everything else this class calls — a deployment that predates them
// would otherwise leave this request silently unanswered until the
// 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.$jsFunction) {'
' window.headlessConnect.$jsFunction($encodedParams);'
'} else if (window.walletNative && window.walletNative.postMessage) {'
' window.walletNative.postMessage(JSON.stringify({'
' type: "sentTxFailed",'
' requestId: ${json.encode(requestId)},'
' code: "unsupported_engine",'
' message: ${json.encode(unsupportedMessage)}'
' }));'
'}',
);
return completer.future;
}
/// Sign a transaction with the currently-connected wallet without
/// broadcasting it. Resolves once.
///
/// [transaction] format (EVM): JSON string
/// `{"to":"0x…","value":"0x0","data":"0x","chainId":"0x1"}`.
///
/// [transaction] format (Solana): base64-encoded, pre-built
/// `VersionedTransaction` (or legacy `Transaction`) bytes.
///
/// [transaction] format (Bitcoin): JSON string
/// `{"unsignedPsbtBase64","allowedSighash","signature"}`. The result is a
/// base64 signed PSBT; the caller must finalize and broadcast it.
Future<SignTxResult> signTransaction({required String transaction}) {
final requestId = _generateRequestId('signtx');
final completer = Completer<SignTxResult>();
_signTxHandlers[requestId] = completer.complete;
_signTxTimers[requestId] = Timer(_signTimeout, () {
_signTxTimers.remove(requestId);
_signTxHandlers.remove(requestId)?.call(
const SignTxFailure('timeout', 'Wallet did not respond in time'),
);
});
final params =
json.encode({'requestId': requestId, 'transaction': transaction});
_safeRunJavaScript(
'if (window.headlessConnect && window.headlessConnect.signTx) {'
' window.headlessConnect.signTx($params);'
'} else if (window.walletNative && window.walletNative.postMessage) {'
' window.walletNative.postMessage(JSON.stringify({'
' type: "signTxFailed",'
' requestId: ${json.encode(requestId)},'
' code: "unsupported_engine",'
' message: "engine build predates signTx — redeploy connect.dynamicauth.com"'
' }));'
'}',
);
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 `<scheme>://phantom-headless` return URL, or the https link in
/// [walletReturnUrl] when set, from the app's deep-link handler into the
/// engine (which re-checks the https link itself) and decides what it means: in
/// `redirect` mode it carries Phantom's encrypted response and completes the
/// connect; in `in-app` mode it's the zero-payload "Return to app" tap and
/// the engine ignores it. 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) {
final isReturn = isPhantomReturn(uri, walletReturnUrl);
if (!isReturn) return false;
void deliver() {
_safeRunJavaScript(
'window.headlessConnect && window.headlessConnect.handleReturnURL(${json.encode(uri.toString())});',
);
}
// 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(_resolvedEngineUrl));
}
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);
// Which chains the engine is actually offering per wallet is
// otherwise invisible from the device, and it's the first thing you
// need when a wallet+chain combination misbehaves (a Phantom "EVM"
// entry that no connection path backed went unnoticed exactly
// because of this blind spot).
for (final w in _wallets) {
debugPrint('[FireblocksHeadlessConnect] wallet ${w.key}: '
'chains=${w.chains} mode=${w.mode}');
}
}
case 'deeplink':
// Engine produced a WalletConnect / MetaMask URI — open the wallet.
_openDeeplink(obj['requestId'] as String?, obj['url'] as String?);
case 'openWallet':
// A WalletConnect sign/send request is waiting — wake the wallet app
// so its approval prompt surfaces (the request itself already went
// over the relay; without this it sits invisibly until the user
// opens the wallet by hand). requestId here is a sign/send id, not a
// connect attempt — pass null so no connect bookkeeping is touched.
_openDeeplink(null, 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?;
final rawNetworkId = obj['networkId'] as String?;
final networkId = rawNetworkId == null || rawNetworkId.isEmpty
? null
: rawNetworkId;
// 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? ?? '',
network: obj['network'] as String?,
networkId: networkId,
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 'signedTx':
_finishSignTx(
obj['requestId'] as String?,
SignTxSuccess(
obj['signedTransaction'] as String? ?? '',
obj['chain'] as String? ?? '',
),
);
case 'signTxFailed':
_finishSignTx(
obj['requestId'] as String?,
SignTxFailure(
obj['code'] as String? ?? 'unknown',
obj['message'] as String? ?? '',
),
);
case 'sentTx':
_finishSendTx(
obj['requestId'] as String?,
SendSuccess(obj['txHash'] as String? ?? '', chain: obj['chain'] as String? ?? ''),
);
case 'sentTxFailed':
_finishSendTx(
obj['requestId'] as String?,
SendFailure(SignError(
code: obj['code'] as String? ?? 'unknown',
message: obj['message'] as String? ?? '',
)),
);
case 'event':
// The engine's diagnostic timeline (wallet_selected, deeplink_opened,
// fallback, connected, wallet_action_requested, error …). Logged rather
// than dropped: the resolved chain and the exact fallback reason only
// exist here, and without them a failed connect looks identical from
// the device whether it never found a path, opened the wrong app, or
// was rejected in the wallet. Swap the debugPrint for an analytics
// hook if you want these off-device.
debugPrint('[FireblocksHeadlessConnect] event ${obj['event']} '
'${obj['requestId'] ?? ''} ${obj['data'] ?? ''}');
}
}
/// Attempt to open a wallet deeplink produced by the engine. On success the
/// startup timer is REPLACED with the longer [_connectApprovalTimeout]
/// (the user is now away in their wallet app, approving) — not just
/// cancelled outright, or a wallet that never answers back would leave
/// [connect]'s `Future` pending forever (see that constant's doc comment).
/// 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) {
// requestId is null for a sign/send wake-up (see the 'openWallet' case
// above) — nothing to reschedule there, sign/send have their own
// timers already running.
if (requestId != null) {
_scheduleConnectApprovalTimeout(requestId);
} else {
_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 _finishSignTx(String? requestId, SignTxResult result) {
if (requestId == null) return;
_signTxTimers.remove(requestId)?.cancel();
final handler = _signTxHandlers.remove(requestId);
if (handler == null) {
debugPrint('[FireblocksHeadlessConnect] dropped signTx 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);
}
for (final timer in _signTxTimers.values) {
timer.cancel();
}
_signTxTimers.clear();
const signTxResult = SignTxFailure('disconnected', 'Disconnected');
final signTxHandlers =
Map<String, void Function(SignTxResult)>.of(_signTxHandlers);
_signTxHandlers.clear();
for (final handler in signTxHandlers.values) {
handler(signTxResult);
}
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();
}
/// Reuses [_startupTimers] (same map, same cancel-on-`_finishConnect` path)
/// with [_connectApprovalTimeout]'s longer budget — see that constant's
/// doc comment for why this exists at all.
void _scheduleConnectApprovalTimeout(String requestId) {
_startupTimers[requestId]?.cancel();
_startupTimers[requestId] = Timer(_connectApprovalTimeout, () {
_startupTimers.remove(requestId);
_finishConnect(
requestId,
const ConnectFailure(code: 'timeout', message: 'Wallet did not respond in time'),
);
});
}
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,
),
],
);
}
}