> ## Documentation Index
> Fetch the complete documentation index at: https://www.dynamic.xyz/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Connections on Flutter (headless)

> Render your own native wallet list with a hidden WebViewWidget engine. Connect, sign messages, or send EVM transactions. No wallet SDK in your app.

<Note>
  This is an enterprise-only feature. Please [contact us](https://www.dynamic.xyz/book-a-call) to enable.
</Note>

The same headless architecture as iOS and Android: render your own native list and drive a **hidden** `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](/docs/connections/flutter) is the recommended default.

<Note>
  **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.
</Note>

<AccordionGroup>
  <Accordion title="View fireblocks_headless_connect.dart (copy-paste ready)">
    ````dart fireblocks_headless_connect.dart theme={"system"}
    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,
            ),
          ],
        );
      }
    }
    ````
  </Accordion>
</AccordionGroup>

Source in the sample app: [`fireblocks_headless_connect.dart`](https://github.com/dynamic-labs-oss/iframe-fb/blob/main/flutter-app/lib/src/fireblocks_headless_connect.dart). Prefer the buildable [`flutter-app/`](https://github.com/dynamic-labs-oss/iframe-fb/blob/main/flutter-app) over any older `flutter-harness` reference.

## 1. Add dependencies

```yaml pubspec.yaml theme={"system"}
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.

```xml Info.plist (iOS) theme={"system"}
<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>
```

```xml AndroidManifest.xml theme={"system"}
<!-- 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 with `FireblocksEngineHost`. It keeps the hidden `WebViewWidget` alive at all times. Prewarm at launch and connect on tap.

```dart main.dart theme={"system"}
MaterialApp(
  home: FireblocksEngineHost(child: ExampleScreen()),
)
```

```dart example_screen.dart theme={"system"}
// 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');
}
```

Forward deep-links from Phantom with `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.

```dart example_screen.dart theme={"system"}
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}');
}
```

<Note>
  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.
</Note>

## 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.

```dart example_screen.dart theme={"system"}
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}');
}
```

<Note>
  `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.
</Note>

## 6. Disconnect

Clears `localStorage` in the hidden `WebView` and reloads the engine so stale SDK state does not bleed into the next connect.

```dart example_screen.dart theme={"system"}
await FireblocksHeadlessConnect.shared.disconnect();
setState(() => _connection = null);
```

## 7. The bridge (for reference)

Flutter uses `addJavaScriptChannel('walletNative', …)` which creates `window.walletNative.postMessage(json)`. The channel name must match exactly.

```text bridge messages theme={"system"}
// 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_links` for Phantom returns**, not `onGenerateRoute`.
* **Test on a physical device.**
* **Serve over HTTPS.**
