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

# Hosted wallet connector on Flutter (headless)

> Render your own native wallet list with a hidden WebViewWidget engine. Connect, sign messages, send EVM, Solana or Bitcoin transactions, and sign PSBTs. 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 the `/headless.html` engine route 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);

      /// 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] besides the real chains ('evm', 'solana') accepts the synthetic
      /// picker value 'solana-testmode' (offered by the engine for Phantom):
      /// connects on Solana but binds the wallet session to devnet, for a
      /// Phantom app that's in Testnet Mode. Phantom validates every subsequent
      /// sign request against the session's network and rejects a mismatch
      /// outright — and there's no way to query which mode the app is in, so
      /// the user has to say.
      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}) {
        final requestId = _generateRequestId('sendtx');
        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 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 connect.dynamicauth.com"'
          '  }));'
          '}',
        );
        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 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);
              // 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?;
            // 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?,
                      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,
            ),
          ],
        );
      }
    }

    ````
  </Accordion>
</AccordionGroup>

<AccordionGroup>
  <Accordion title="View models.dart (copy-paste ready)">
    ```dart models.dart theme={"system"}
    /// A wallet entry in the native list, delivered live by the engine.
    /// [mode] is `"headless"` (silent connection) or `"fallback"` (visible flow needed).
    class HeadlessWallet {
      final String key;
      final String name;
      final String icon;
      final List<String> chains;
      final String mode;
      final bool featured;

      const HeadlessWallet({
        required this.key,
        required this.name,
        required this.icon,
        required this.chains,
        required this.mode,
        required this.featured,
      });

      factory HeadlessWallet.fromJson(Map<String, dynamic> json) => HeadlessWallet(
            key: json['key'] as String? ?? '',
            name: json['name'] as String? ?? '',
            icon: json['icon'] as String? ?? '',
            chains: (json['chains'] as List<dynamic>?)?.cast<String>() ?? const [],
            mode: json['mode'] as String? ?? 'fallback',
            featured: json['featured'] as bool? ?? false,
          );

      bool get isMultiChain => chains.length > 1;
      bool get isHeadless => mode == 'headless';
    }

    /// A successfully connected wallet account.
    class WalletConnection {
      final String address;
      final String chain;
      final String walletName;
      final String walletImage;

      /// `true` for wallets connected through the hidden WebView engine;
      /// `false` for the visible ASWebAuth / Chrome Custom Tabs fallback flow.
      final bool connectedHeadlessly;

      /// The [HeadlessWallet.key] that produced this connection, e.g. `"metamask"`.
      ///
      /// NOT part of the engine's wire protocol (the `connected` bridge message
      /// carries no wallet key) — set locally by the caller that made the
      /// `connect()` call, so the UI can offer a one-tap "Reconnect" to the same
      /// wallet on a later app launch. `null` when unknown.
      final String? walletKey;

      const WalletConnection({
        required this.address,
        required this.chain,
        required this.walletName,
        required this.walletImage,
        this.connectedHeadlessly = true,
        this.walletKey,
      });

      WalletConnection copyWith({String? walletKey}) => WalletConnection(
            address: address,
            chain: chain,
            walletName: walletName,
            walletImage: walletImage,
            connectedHeadlessly: connectedHeadlessly,
            walletKey: walletKey ?? this.walletKey,
          );
    }

    /// Describes a failed sign operation.
    class SignError {
      final String code;
      final String message;

      const SignError({required this.code, required this.message});

      @override
      String toString() => '[$code] $message';
    }

    // ── Connect result ───────────────────────────────────────────────────────────

    sealed class ConnectResult {
      const ConnectResult();
    }

    final class ConnectSuccess extends ConnectResult {
      final WalletConnection wallet;
      const ConnectSuccess(this.wallet);
    }

    /// The wallet can't go headless; caller should open [FireblocksConnect.connect].
    final class ConnectFallbackRequired extends ConnectResult {
      final String reason;
      const ConnectFallbackRequired(this.reason);
    }

    final class ConnectFailure extends ConnectResult {
      final String code;
      final String message;
      const ConnectFailure({required this.code, required this.message});
    }

    // ── Sign result ──────────────────────────────────────────────────────────────

    /// Result of [FireblocksHeadlessConnect.sign].
    sealed class SignResult {
      const SignResult();
    }

    final class SignSuccess extends SignResult {
      /// Signed message: hex signature string.
      /// Signed message (Bitcoin): base64. Signed tx (EVM): RLP-encoded hex.
      /// Signed tx (Solana): base64.
      final String value;
      const SignSuccess(this.value);
    }

    final class SignFailure extends SignResult {
      final SignError error;
      const SignFailure(this.error);
    }

    // ── Sign transaction result ──────────────────────────────────────────────────

    /// Result of [FireblocksHeadlessConnect.signTransaction]. This operation signs
    /// without broadcasting; a Bitcoin success contains a signed PSBT that the
    /// caller finalizes and broadcasts.
    sealed class SignTxResult {
      const SignTxResult();
    }

    final class SignTxSuccess extends SignTxResult {
      /// Signed transaction: RLP-encoded hex (EVM), base64 (Solana), or a base64
      /// signed PSBT (Bitcoin).
      final String signedTransaction;

      /// "evm", "solana", or "bitcoin".
      final String chain;

      const SignTxSuccess(this.signedTransaction, this.chain);
    }

    final class SignTxFailure extends SignTxResult {
      final String code;
      final String message;

      const SignTxFailure(this.code, this.message);
    }

    // ── Send result ──────────────────────────────────────────────────────────────

    /// Result of [FireblocksHeadlessConnect.sendTransaction] (headless, EVM,
    /// Solana, or Bitcoin). Unlike [SignResult] (sign-only, nothing broadcast),
    /// this wallet call signs AND broadcasts the transaction; a [SendSuccess]
    /// means it's already on-chain.
    sealed class SendResult {
      const SendResult();
    }

    final class SendSuccess extends SendResult {
      /// The on-chain transaction hash (EVM), signature (Solana, which IS the
      /// transaction's identifier), or transaction ID (Bitcoin) — already
      /// submitted, not a raw signed tx.
      final String txHash;

      /// "evm", "solana", or "bitcoin". Empty for the visible-flow path, which is
      /// EVM-only and doesn't echo a chain back.
      final String chain;

      const SendSuccess(this.txHash, {this.chain = ''});
    }

    final class SendFailure extends SendResult {
      final SignError error;
      const SendFailure(this.error);
    }

    ```
  </Accordion>

  <Accordion title="View app_config.dart (copy-paste ready)">
    ````dart app_config.dart theme={"system"}
    /// Central configuration for the app's URL scheme and hosted engine URLs.
    ///
    /// Kept in one place (rather than duplicated string literals across
    /// `main.dart` / `example_screen.dart` / native manifests) because the
    /// scheme in particular must match exactly in three other places:
    ///
    ///  - `ios/Runner/Info.plist`               → `CFBundleURLSchemes`
    ///  - `android/app/src/main/AndroidManifest.xml` → `<data android:scheme="…">`
    ///  - the hosted engine's `returnScheme` query param (baked into [engineUrl])
    ///
    /// Override any of these at build/run time without touching source:
    /// ```bash
    /// flutter run \
    ///   --dart-define=WALLET_SCHEME=myapp \
    ///   --dart-define=ENGINE_BASE_URL=https://connect.dynamicauth.com/headless.html \
    ///   --dart-define=FLOW_URL=https://connect.dynamicauth.com/ \
    ///   --dart-define=DYNAMIC_ENVIRONMENT_ID=b1e3aca9-0646-411a-b4ab-c31ce49935b3
    /// ```
    abstract final class AppConfig {
      /// The app's registered custom URL scheme. Must be a valid RFC 3986 scheme:
      /// lowercase letters/digits/`+`/`-`/`.` only, starting with a letter — no
      /// underscores (required by `flutter_web_auth_2`'s `callbackUrlScheme`).
      static const scheme = String.fromEnvironment('WALLET_SCHEME', defaultValue: 'fbapp');

      static const _engineBaseUrl = String.fromEnvironment(
        'ENGINE_BASE_URL',
        defaultValue: 'https://connect.dynamicauth.com/headless.html',
      );

      /// The visible, hosted connect page used by the ASWebAuth / Chrome Custom
      /// Tabs fallback flow ([FireblocksConnect]).
      static const flowUrl = String.fromEnvironment(
        'FLOW_URL',
        defaultValue: 'https://connect.dynamicauth.com/',
      );

      /// Which Dynamic environment the hosted pages should use, sent as
      /// `?environmentId=<uuid>` on both [engineUrl] and the visible flow URL.
      ///
      /// Empty (the default) means "don't send the param", and the hosted page falls
      /// back to the environment it was built with. The page ignores anything that
      /// isn't a UUID.
      static const environmentId = String.fromEnvironment('DYNAMIC_ENVIRONMENT_ID');

      /// The no-UI engine page loaded into the hidden WebView. `returnScheme`
      /// tells Phantom (and other redirect-protocol wallets) where to send the
      /// user back after they approve.
      static String get engineUrl => withEnvironmentId(
            Uri.parse(_engineBaseUrl).replace(queryParameters: {
              ...Uri.parse(_engineBaseUrl).queryParameters,
              'returnScheme': scheme,
            }).toString(),
          );

      /// Add `environmentId` to [url] when one is configured, preserving any params
      /// already on it. A no-op when [environmentId] is empty.
      static String withEnvironmentId(String url) {
        if (environmentId.isEmpty) return url;
        final base = Uri.parse(url);
        return base.replace(queryParameters: {
          ...base.queryParameters,
          'environmentId': environmentId,
        }).toString();
      }
    }
    ````
  </Accordion>
</AccordionGroup>

## 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>
  <string>xverse</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', // 'evm', 'solana' or 'bitcoin'
);
switch (result) {
  case ConnectSuccess(:final wallet):
    setState(() => _connection = wallet);
  case ConnectFallbackRequired():
    final w = await FireblocksConnect.connect(
      flowUrl: _flowUrl,
      scheme: _scheme,
      environmentId: AppConfig.environmentId,
    );
    setState(() => _connection = w);
  case ConnectFailure(:final code, :final message):
    setState(() => _error = '[$code] $message');
}
```

A connection is chain-specific: one wallet gives you an EVM address **or** a Solana address **or** a Bitcoin address, never several at once. Read the chains a wallet offers from `HeadlessWallet.chains` and pass one of them as `chain`. For Bitcoin, see [Bitcoin wallets](#6-bitcoin-wallets).

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 for EVM, base64 for Solana and Bitcoin
  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 transaction argument is a JSON string whose shape depends on the connected wallet's chain: EVM transaction fields here, a Bitcoin send request for a Bitcoin wallet (see [Send bitcoin](#send-bitcoin)). `signTransaction()` is the sign-only sibling, used mainly for [Bitcoin PSBTs](#sign-a-psbt).

```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. Bitcoin wallets

Bitcoin reuses the harness above: the same `connect()`, `sign()`, `sendTransaction()` and `signTransaction()` calls. What changes is the `chain` you connect with, and the JSON you hand to send and sign.

<Note>
  **What you need:** an engine deployment whose `/headless.html` build registers the Bitcoin extension, plus Xverse or Phantom installed on the device. Bitcoin runs on mainnet here, so every send moves real funds.
</Note>

### Connect a Bitcoin wallet

Two routes reach a Bitcoin address, and the wallet list decides between them per wallet:

1. **Headless session (Xverse, Phantom).** The engine opens a [WalletConnect bip122](https://docs.reown.com/advanced/multichain/rpc-reference/bitcoin-rpc) session and deep-links the wallet. You get a full session: message signing, sends, and PSBT signing all work.
2. **Wallet browser (address only).** Several Bitcoin wallets inject a provider **only** inside their own in-app browser and offer no relay path at all. The engine flags those in the `wallets` bridge message with `inAppBrowserChain: 'bitcoin'`, and the sample offers them as a separate picker entry that runs the visible flow inside that browser. It returns an address and nothing else: there is no session behind it, so signing and sending are unavailable.

```dart example_screen.dart theme={"system"}
final result = await FireblocksHeadlessConnect.shared.connect(
  walletKey: 'xverse',
  chain: 'bitcoin',
);
switch (result) {
  case ConnectSuccess(:final wallet):
    setState(() => _connection = wallet); // wallet.chain == 'bitcoin'
  case ConnectFallbackRequired():
    // no relay path for this wallet: open the visible flow, or the wallet
    // browser route below
  case ConnectFailure(:final code, :final message):
    setState(() => _error = '[$code] $message');
}
```

The engine adds the headless `bitcoin` option itself for the wallets it has a verified bip122 deep link for (Xverse and Phantom today), so `HeadlessWallet.chains` already contains `'bitcoin'` for them. The wallet browser route is the one your list has to offer, gated on the flag the engine sets:

```dart wallet_list.dart theme={"system"}
/// Synthetic picker value, never sent to the engine: the Bitcoin route that
/// runs inside the wallet's own in-app browser.
const _walletBrowserBitcoinChain = 'bitcoin-wallet-browser';

/// Offer it only when the engine named the chain for us, and the wallet has
/// no headless Bitcoin path already (Xverse has one, so it never shows here).
bool _offersWalletBrowserBitcoin(HeadlessWallet wallet) =>
    wallet.inAppBrowserUrl != null &&
    wallet.inAppBrowserChain == 'bitcoin' &&
    !wallet.chains.contains('bitcoin');
```

Wire this helper into the picker and tap handling shown in [section 7](#7-connect-phantom-on-evm-its-own-browser).

<Note>
  `inAppBrowserChain` is set only when the wallet's catalogue entry names a single unambiguous chain for its browser (Xverse: one injected config, `chain: "btc"`). It is `null` for a wallet like Phantom whose template is overloaded, which is why Phantom's EVM browser route stays hand-picked in [section 7](#7-connect-phantom-on-evm-its-own-browser). Never infer a chain from `inAppBrowserUrl` alone.
</Note>

Once the deep link opens, `connect()` waits up to **3 minutes** for the user to approve in the wallet, then fails with `ConnectFailure(code: 'timeout')`. Switching apps, unlocking a wallet and reading an approval screen takes real time, and a WalletConnect proposal stays valid for about five minutes.

<Warning>
  iOS only opens a custom scheme your app has declared. Add `xverse` to `LSApplicationQueriesSchemes` (see [section 2](#2-register-url-schemes)) or the deep link silently fails to open and the connect falls back.
</Warning>

### Sign a message with a Bitcoin wallet

`sign()` is unchanged. The engine routes the request over the bip122 session, and the wallet returns a **base64** signature rather than EVM's hex string.

```dart example_screen.dart theme={"system"}
final result = await FireblocksHeadlessConnect.shared.sign(
  message: 'Sign in to MyApp',
);
```

Before each Bitcoin request the engine deep-links the bare wallet scheme (`xverse://`) to bring the wallet to the foreground. The request itself travels over the relay either way, but most wallets only render the approval sheet while they are open.

### Send bitcoin

`sendTransaction()` takes a JSON send request instead of EVM transaction fields. The wallet signs **and** broadcasts, so `SendSuccess.txHash` is a transaction id that is already in the mempool.

```dart example_screen.dart theme={"system"}
final result = await FireblocksHeadlessConnect.shared.sendTransaction(
  transaction: json.encode({
    'recipientAddress': 'bc1qexampleaddress…',
    'amountSats': '12345',
  }),
);
switch (result) {
  case SendSuccess(:final txHash):
    setState(() => _txId = txHash); // https://mempool.space/tx/<txHash>
  case SendFailure(:final error):
    setState(() => _error = '[${error.code}] ${error.message}');
}
```

| Field              | Required | Description                                                                                               |
| ------------------ | -------- | --------------------------------------------------------------------------------------------------------- |
| `recipientAddress` | Yes      | Destination address. The wallet validates it and shows its own confirmation screen before it signs.       |
| `amountSats`       | Yes      | Amount in satoshis, as a decimal-digit **string** (JSON has no big integers). Must be a positive integer. |

No `chainId` applies, and there is no network-switch step: unlike EVM, one Bitcoin wallet session speaks one network.

<Warning>
  A Bitcoin send is final and mainnet. Confirm with the user in your own UI before you call, the way the sample does with an alert dialog, and rehearse with a few thousand satoshis rather than a full balance.
</Warning>

### Sign a PSBT

`signTransaction()` signs without broadcasting. For Bitcoin it takes a [PSBT](https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki) and returns the signed PSBT in base64. Finalizing and broadcasting are yours: the engine never submits it.

```dart example_screen.dart theme={"system"}
final result = await FireblocksHeadlessConnect.shared.signTransaction(
  transaction: json.encode({
    'unsignedPsbtBase64': psbtBase64,
    'allowedSighash': [1], // 1 = SIGHASH_ALL
    'signature': [
      {
        'address': connection.address, // the connected wallet's own address
        'signingIndexes': [0],
      },
    ],
  }),
);
switch (result) {
  case SignTxSuccess(:final signedTransaction):
    setState(() => _signedPsbt = signedTransaction); // base64 PSBT
  case SignTxFailure(:final code, :final message):
    setState(() => _error = '[$code] $message');
}
```

| Field                | Required | Description                                                                                                                                                                                                                                                 |
| -------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `unsignedPsbtBase64` | Yes      | The PSBT to sign, base64.                                                                                                                                                                                                                                   |
| `allowedSighash`     | Yes      | Sighash flags the wallet may sign with. `[1]` is `SIGHASH_ALL`. Only the code that built the PSBT knows which flags it used, so the engine never defaults it. Wallets reached over WalletConnect take it as a per-input hint instead of a hard requirement. |
| `signature`          | Yes      | Which inputs belong to the wallet: one entry per address, each with non-empty `signingIndexes`.                                                                                                                                                             |

<Warning>
  `signature` is not optional bookkeeping. Wallets derive the inputs to sign straight from it and sign **nothing** when it is empty, which returns a "signed" PSBT the wallet never touched. The engine parses no PSBTs of its own, so it rejects the request instead: every entry must name the connected wallet's own address (its payment or ordinals address) with at least one index.
</Warning>

### Bitcoin error codes

`SignTxFailure.code` and `SendFailure.error.code` carry these on the Bitcoin paths:

| Code                        | Where               | Meaning                                                                                          |
| --------------------------- | ------------------- | ------------------------------------------------------------------------------------------------ |
| `missing_recipient`         | send                | `recipientAddress` absent.                                                                       |
| `invalid_amount`            | send                | `amountSats` is not a positive integer string.                                                   |
| `missing_psbt`              | sign                | `unsignedPsbtBase64` absent.                                                                     |
| `missing_allowed_sighash`   | sign                | `allowedSighash` absent, on a wallet the engine drives through the Dynamic SDK.                  |
| `missing_signature_indexes` | sign                | `signature` names no owned address with a non-empty `signingIndexes`.                            |
| `invalid_psbt`              | sign                | The wallet or SDK rejected the PSBT itself.                                                      |
| `transaction_required`      | sign, send          | The request reached the wallet with no transaction attached.                                     |
| `broadcast_failed`          | send                | Signed, but the network refused the broadcast (fees, conflicting spend).                         |
| `unsupported_chain`         | sign, send          | The connected wallet is not EVM, Solana, or Bitcoin.                                             |
| `no_wallet`                 | sign, send          | No live headless session. A wallet browser connection is address-only, so it lands here.         |
| `timeout`                   | connect, sign, send | The wallet never answered: 3 minutes for a connect approval, 60 seconds to sign, 120 for a send. |

## 7. Connect Phantom on EVM (its own browser)

Phantom injects an EVM provider (`window.phantom.ethereum`) only inside its own in-app browser. It has no WalletConnect entry in Dynamic's wallet book and no EVM deeplink, so the hidden engine cannot drive it and the visible flow has no provider to talk to. The route that works is to open your hosted page **inside Phantom's browser** and take the result back over your URL scheme.

Each operation is one round trip: Phantom comes to the foreground with your page in it, the user approves, and the result arrives on `<scheme>://wallet-browser`.

<Note>
  The engine reports each wallet's in-app-browser template in the `wallets` bridge message as `inAppBrowser`. The template contains `{{encodedDappURI}}`, and you replace **every** occurrence (Phantom's uses it twice). A template is not a chain: it only means the wallet can open a URL in its own browser, so you decide per wallet which chains that browser serves.
</Note>

<AccordionGroup>
  <Accordion title="View fireblocks_wallet_browser.dart (copy-paste ready)">
    ````dart fireblocks_wallet_browser.dart theme={"system"}
    import 'dart:async';
    import 'dart:math';

    import 'package:flutter/foundation.dart';
    import 'package:url_launcher/url_launcher.dart';

    import 'fireblocks_connect.dart' show FireblocksConnectCancelled, FireblocksConnectError;
    import 'models.dart';

    /// Drives the hosted visible flow **inside a wallet's own in-app browser**.
    ///
    /// ## Why this exists (and why it isn't [FireblocksConnect])
    ///
    /// [FireblocksConnect] opens the hosted page in the SYSTEM browser
    /// (`flutter_web_auth_2` → Chrome Custom Tabs / ASWebAuthenticationSession).
    /// That works for any wallet reachable by WalletConnect, a deeplink, or an SDK
    /// (Base Account). It cannot work for a wallet whose only mobile surface for a
    /// given chain is the provider it injects into its OWN browser — because that
    /// provider simply isn't there in the system browser.
    ///
    /// Phantom on EVM is exactly that case, and it's why "Phantom EVM" never
    /// worked here: no `phantom*` wallet-book entry has a WalletConnect block, and
    /// `phantomevm` carries no mobile deeplink at all — its only surface is
    /// `window.phantom.ethereum`, injected inside Phantom's in-app browser (see
    /// `classifyChain`'s doc comment in `src/redirect.ts`). So the page has to be
    /// opened THERE, and the result handed back to this app over its custom
    /// scheme.
    ///
    /// ## Mechanics
    ///
    /// 1. Build the hosted-page URL (`?wallet=…&chain=evm`, or an
    ///    `?intent=signMessage|sendTx` variant) with
    ///    `redirect_uri=<scheme>://[callbackHost]` and a fresh nonce.
    /// 2. Wrap it in the wallet's in-app-browser template (`{{encodedDappURI}}`,
    ///    replaced everywhere — Phantom's uses it twice) and hand it to the OS.
    ///    The template comes from the engine's wallet catalogue
    ///    ([HeadlessWallet.inAppBrowserUrl]), i.e. from Dynamic's wallet book —
    ///    never hard-coded here.
    /// 3. The wallet opens its browser on the page, the user connects/signs with
    ///    the injected provider, and the page redirects to
    ///    `<scheme>://[callbackHost]?…`.
    /// 4. That link arrives through `app_links` (NOT through `flutter_web_auth_2`,
    ///    which only intercepts callbacks inside its own session — this is why the
    ///    callback host must differ from the visible flow's `wallet-callback`, and
    ///    why [handleReturnUrl] has to be wired up in `main.dart`).
    ///
    /// ## Caveats worth knowing before you ship this
    ///
    /// - The hand-off in step 2 is an `https` universal/app link. It reaches the
    ///   wallet only if the OS routes that domain to the wallet app: iOS honours
    ///   universal links from `UIApplication.open`, but on Android an unverified
    ///   app link can land in Chrome instead — where the wallet injects nothing
    ///   and the page correctly reports that the wallet has no path for this
    ///   chain. "No connection path" seen in a browser that ISN'T the wallet means
    ///   the hand-off went to the wrong app, not that the wallet can't do it.
    /// - Whether a wallet's browser honours a custom-scheme navigation is the
    ///   wallet's choice, not ours. The hosted page always ALSO renders a "Return
    ///   to the app" anchor for custom-scheme targets (a real tap is the reliable
    ///   path where a programmatic navigation is ignored), so the user has a way
    ///   back either way.
    /// - Nothing here is silent: expect the wallet app to come to the foreground
    ///   with a web page in it, plus the wallet's own approval prompt.
    abstract final class FireblocksWalletBrowser {
      /// Host of the callback URL this flow listens for — deliberately NOT
      /// `wallet-callback`, which `flutter_web_auth_2`'s `CallbackActivity` claims
      /// on Android for the system-browser flow. A link arriving from the wallet's
      /// app has no `flutter_web_auth_2` session to belong to, so it must be
      /// delivered as a plain app link and routed here instead.
      static const callbackHost = 'wallet-browser';

      /// How long to wait for the return link before giving up. The user is in
      /// another app for this whole window (connect, approve, come back), so this
      /// is generous by design; it exists so an abandoned flow can't leave a
      /// [Future] pending for the process lifetime.
      static const timeout = Duration(minutes: 5);

      static _Pending? _pending;

      /// Connect [walletKey] on [chain] inside the wallet's own browser.
      ///
      /// [walletBrowserUrl] — the wallet's in-app-browser template, from
      /// [HeadlessWallet.inAppBrowserUrl].
      /// [flowUrl] / [scheme] / [environmentId] — as [FireblocksConnect.connect].
      ///
      /// The returned [WalletConnection] carries [WalletConnection.walletBrowserUrl]
      /// so later sign/send calls can be routed back into the same browser — the
      /// connection lives with that injected provider and nowhere else.
      static Future<WalletConnection> connect({
        required String flowUrl,
        required String scheme,
        required String walletBrowserUrl,
        required String walletKey,
        String chain = 'evm',
        String environmentId = '',
      }) async {
        final callback = await _run(
          flowUrl: flowUrl,
          scheme: scheme,
          walletBrowserUrl: walletBrowserUrl,
          environmentId: environmentId,
          params: {'wallet': walletKey, 'chain': chain},
        );
        final address = callback.uri.queryParameters['address'] ?? '';
        if (address.isEmpty) {
          throw const FireblocksConnectError('No address in callback URL');
        }
        return WalletConnection(
          address: address,
          chain: callback.uri.queryParameters['chain'] ?? chain,
          walletName: callback.uri.queryParameters['walletName'] ?? '',
          walletImage: callback.uri.queryParameters['walletImage'] ?? '',
          connectedHeadlessly: false,
          walletKey: walletKey,
          walletBrowserUrl: walletBrowserUrl,
        );
      }

      /// Sign [message] with a wallet connected through [connect].
      ///
      /// [expectedAddress] is REQUIRED and checked by the page itself: the page
      /// connects again inside the wallet's browser before signing, and without
      /// this it would sign with whatever account happens to connect.
      static Future<SignResult> signMessage({
        required String flowUrl,
        required String scheme,
        required String walletBrowserUrl,
        required String walletKey,
        required String message,
        required String expectedAddress,
        String environmentId = '',
      }) async {
        final callback = await _run(
          flowUrl: flowUrl,
          scheme: scheme,
          walletBrowserUrl: walletBrowserUrl,
          environmentId: environmentId,
          params: {
            'intent': 'signMessage',
            'walletKey': walletKey,
            'message': message,
            'expectedAddress': expectedAddress,
          },
        );
        if (callback.uri.queryParameters['error'] == '1') {
          return SignFailure(SignError(
            code: callback.uri.queryParameters['code'] ?? 'unknown',
            message: callback.uri.queryParameters['message'] ?? '',
          ));
        }
        final signature = callback.uri.queryParameters['signature'] ?? '';
        if (signature.isEmpty) {
          throw const FireblocksConnectError('No signature in callback URL');
        }
        return SignSuccess(signature);
      }

      /// Send an EVM transaction with a wallet connected through [connect].
      ///
      /// [to] / [chainId] / [value] / [data] / [gasLimit] are `0x`-prefixed hex,
      /// same contract as [FireblocksConnect.sendTransaction]. The wallet signs
      /// AND broadcasts: a [SendSuccess] is already on-chain.
      static Future<SendResult> sendTransaction({
        required String flowUrl,
        required String scheme,
        required String walletBrowserUrl,
        required String walletKey,
        required String to,
        required String chainId,
        required String expectedAddress,
        String value = '0x0',
        String data = '0x',
        String? gasLimit,
        String environmentId = '',
      }) async {
        final callback = await _run(
          flowUrl: flowUrl,
          scheme: scheme,
          walletBrowserUrl: walletBrowserUrl,
          environmentId: environmentId,
          params: {
            'intent': 'sendTx',
            'walletKey': walletKey,
            'to': to,
            'value': value,
            'data': data,
            'chainId': chainId,
            if (gasLimit != null) 'gasLimit': gasLimit,
            'expectedAddress': expectedAddress,
          },
        );
        if (callback.uri.queryParameters['error'] == '1') {
          return SendFailure(SignError(
            code: callback.uri.queryParameters['code'] ?? 'unknown',
            message: callback.uri.queryParameters['message'] ?? '',
          ));
        }
        final txHash = callback.uri.queryParameters['txHash'] ?? '';
        if (txHash.isEmpty) {
          throw const FireblocksConnectError('No txHash in callback URL');
        }
        return SendSuccess(txHash, chain: 'evm');
      }

      /// Feed an inbound deep link to this flow. Returns `true` when the URL was
      /// this flow's callback (consumed, whether or not a request was waiting for
      /// it), so callers can chain handlers:
      ///
      /// ```dart
      /// if (FireblocksHeadlessConnect.shared.handleReturnUrl(uri)) return;
      /// FireblocksWalletBrowser.handleReturnUrl(uri);
      /// ```
      static bool handleReturnUrl(Uri uri) {
        if (uri.host.toLowerCase() != callbackHost) return false;
        final pending = _pending;
        if (pending == null) {
          debugPrint('[FireblocksWalletBrowser] callback with no request in flight: $uri');
          return true;
        }
        // Nonce mismatch is NOT completed as a failure: a stale callback (the
        // user re-opened an old page in the wallet's browser) must not resolve
        // the request that IS in flight. Drop it and keep waiting.
        if (uri.queryParameters['nonce'] != pending.nonce) {
          debugPrint('[FireblocksWalletBrowser] dropped callback with mismatched nonce');
          return true;
        }
        _pending = null;
        pending.timer.cancel();
        if (!pending.completer.isCompleted) {
          pending.completer.complete(_Callback(uri));
        }
        return true;
      }

      /// Abandon the in-flight request (e.g. the user navigated away in this app).
      /// Its [Future] completes with [FireblocksConnectCancelled].
      static void cancel() => _abandon(const FireblocksConnectCancelled());

      // MARK: – Private

      static Future<_Callback> _run({
        required String flowUrl,
        required String scheme,
        required String walletBrowserUrl,
        required String environmentId,
        required Map<String, String> params,
      }) async {
        // One request at a time — the callback carries no request id beyond its
        // nonce, and two overlapping flows in the same wallet browser would race
        // for the same return link.
        _abandon(const FireblocksConnectCancelled());

        final nonce = _randomHex(16);
        final base = Uri.parse(flowUrl);
        final pageUrl = base.replace(queryParameters: {
          ...base.queryParameters,
          ...params,
          'redirect_uri': '$scheme://$callbackHost',
          'nonce': nonce,
          // The page runs inside a wallet's WebView here — tell it so explicitly
          // rather than leaving it to user-agent guessing (see `getEnvInfo` in
          // `src/env.ts`): among other things this stops it offering a deeplink
          // connector that would try to bounce out of that browser.
          'embedded': '1',
          // Tells the page it's ALREADY inside walletKey's own browser (see
          // App.tsx's openedInsideWalletBrowser doc comment) — so if the
          // provider still isn't detected, it shows a clear message on THIS
          // load instead of re-offering the same in-app-browser link (which,
          // tapped from inside that browser, is a dead loop).
          'walletBrowser': '1',
          if (environmentId.isNotEmpty) 'environmentId': environmentId,
        }).toString();

        // Some templates (Phantom's) use the placeholder more than once — as both
        // the browse target and the `ref` — so replace every occurrence.
        final target = Uri.tryParse(
          walletBrowserUrl.replaceAll('{{encodedDappURI}}', Uri.encodeComponent(pageUrl)),
        );
        if (target == null) {
          throw FireblocksConnectError('Unusable in-app-browser URL: $walletBrowserUrl');
        }

        final completer = Completer<_Callback>();
        final timer = Timer(timeout, () => _abandon(const FireblocksConnectCancelled()));
        _pending = _Pending(nonce: nonce, completer: completer, timer: timer);

        var opened = false;
        try {
          opened = await launchUrl(target, mode: LaunchMode.externalApplication);
        } catch (_) {
          opened = false;
        }
        if (!opened) {
          _abandon(FireblocksConnectError(
            'Could not open the wallet app for $target — is it installed?',
          ));
        }
        return completer.future;
      }

      /// Fail/cancel the in-flight request, if any, with [error].
      static void _abandon(Exception error) {
        final pending = _pending;
        if (pending == null) return;
        _pending = null;
        pending.timer.cancel();
        if (!pending.completer.isCompleted) {
          pending.completer.completeError(error);
        }
      }

      static String _randomHex(int bytes) {
        final rng = Random.secure();
        return List.generate(bytes, (_) => rng.nextInt(256))
            .map((b) => b.toRadixString(16).padLeft(2, '0'))
            .join();
      }
    }

    class _Pending {
      final String nonce;
      final Completer<_Callback> completer;
      final Timer timer;
      const _Pending({required this.nonce, required this.completer, required this.timer});
    }

    class _Callback {
      final Uri uri;
      const _Callback(this.uri);
    }

    ````
  </Accordion>
</AccordionGroup>

### Carry the two extra fields

`HeadlessWallet` needs the template, and `WalletConnection` needs to remember it. Add both to your models, and keep `walletBrowserUrl` in `copyWith`, or sign and send fall back to the engine. `inAppBrowserChain` marks a wallet's in-app browser as Bitcoin-only, as described in section 6.

```dart models.dart theme={"system"}
class HeadlessWallet {
  /// The wallet's own in-app-browser template, or `null` if it has none.
  final String? inAppBrowserUrl;

  /// The chain [inAppBrowserUrl] is for, when the engine can identify one.
  final String? inAppBrowserChain;

  // …parsed from the bridge message:
  // inAppBrowserUrl: json['inAppBrowser'] as String?,
  // inAppBrowserChain: json['inAppBrowserChain'] as String?,
}

class WalletConnection {
  /// Set when the connection was made inside the wallet's browser.
  final String? walletBrowserUrl;
}
```

### Register the callback host

Add a third host to your scheme, next to `wallet-callback` and `phantom-headless`. On iOS your existing `CFBundleURLSchemes` entry already covers it.

```xml AndroidManifest.xml theme={"system"}
<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-browser" />
</intent-filter>
```

Route the link in your `app_links` listener, for both the cold start link and the stream. `flutter_web_auth_2` never picks this up, because there is no session it belongs to.

```dart main.dart theme={"system"}
void _route(Uri uri) {
  if (FireblocksHeadlessConnect.shared.handleReturnUrl(uri)) return;
  FireblocksWalletBrowser.handleReturnUrl(uri);
}
```

### Offer the option only for Phantom

Do not derive EVM support from the presence of a template. Phantom's template comes from its Sui wallet-book entry, so a template on its own says nothing about EVM. The evidence for Phantom specifically is `phantomevm.injectedConfig.windowLocations: ["phantom.ethereum"]`, an EIP-1193 provider inside its browser.

```dart wallet_list.dart theme={"system"}
const _walletBrowserEvmChain = 'evm-wallet-browser';

bool _offersWalletBrowserEvm(HeadlessWallet wallet) =>
    wallet.key.toLowerCase() == 'phantom' &&
    wallet.inAppBrowserUrl != null &&
    !wallet.chains.contains('evm');

List<String> _pickerChains(HeadlessWallet wallet) => [
      ...wallet.chains,
      if (_offersWalletBrowserEvm(wallet)) _walletBrowserEvmChain,
      if (_offersWalletBrowserBitcoin(wallet)) _walletBrowserBitcoinChain,
    ];
```

Show it as a normal chain row ("Ethereum & EVM", with "Opens in the wallet's own browser" underneath). The synthetic value stays in your UI and is never sent to the page.

When the user picks a synthetic browser route, branch before the headless connect:

```dart wallet_list.dart theme={"system"}
if (chain == _walletBrowserEvmChain) {
  _openInWalletBrowser(tapped, chain: 'evm');
} else if (chain == _walletBrowserBitcoinChain) {
  _openInWalletBrowser(tapped, chain: 'bitcoin');
}
```

### Connect, sign, and send

```dart example_screen.dart theme={"system"}
// connect: the synthetic picker value routes here
final connection = await FireblocksWalletBrowser.connect(
  flowUrl: flowUrl,
  scheme: scheme,
  walletBrowserUrl: wallet.inAppBrowserUrl!,
  walletKey: wallet.key,
);

// sign and send: reopen the SAME browser
final template = connection.walletBrowserUrl;
if (template != null) {
  final signed = await FireblocksWalletBrowser.signMessage(
    flowUrl: flowUrl,
    scheme: scheme,
    walletBrowserUrl: template,
    walletKey: connection.walletKey!,
    message: 'Hello from Flutter',
    expectedAddress: connection.address,  // the page refuses a mismatch
  );

  final sent = await FireblocksWalletBrowser.sendTransaction(
    flowUrl: flowUrl,
    scheme: scheme,
    walletBrowserUrl: template,
    walletKey: connection.walletKey!,
    to: '0xRecipientAddress',
    chainId: '0x1',
    value: '0x2386f26fc10000',      // 0.01 ETH, hex wei
    expectedAddress: connection.address,
  );
}
```

### What travels on the URL

| Parameter                | Value                                                                                         |
| ------------------------ | --------------------------------------------------------------------------------------------- |
| `wallet` and `chain=evm` | connect, or `intent=signMessage` / `intent=sendTx` with `walletKey`                           |
| `redirect_uri`           | `<scheme>://wallet-browser`                                                                   |
| `nonce`                  | random per attempt, verified on return, mismatches dropped                                    |
| `embedded=1`             | the page is inside a wallet web view, so it must not offer a connector that bounces out of it |
| `environmentId`          | optional Dynamic environment ID                                                               |

The callback carries `address` and `chain` (connect), `signature` (sign), or `txHash` (send), or `error=1&code=&message=`, always with the `nonce` echoed back. A send is already broadcast when the hash arrives. One request is in flight at a time, a new one supersedes the previous, and an abandoned one times out after five minutes.

### Phantom pitfalls

* **Keep the template on the connection.** Sign and send must reopen the same browser, because the account exists nowhere else. Losing the stored template sends the request to the engine, which reports no wallet connected.
* **Use a separate callback host.** `wallet-callback` is claimed by the visible flow, so a link arriving from Phantom would be dropped or complete an unrelated request.
* **Watch where the template lands on Android.** The template is an `https` app link and reaches Phantom only if its app links are verified. Otherwise Android can hand it to Chrome, where nothing is injected and the page correctly reports no EVM path. That message in a browser that is not Phantom means the hand-off went to the wrong app.
* **Offer the return anchor.** The page renders a "Return to the app" link for browsers that ignore a programmatic redirect.

## 8. 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);
```

## 9. 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
openWallet { requestId?, url }             wakes the wallet for a pending request
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 }  EVM, Solana, or Bitcoin tx signed; Bitcoin returns a signed PSBT
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 }) // EVM, Solana, or Bitcoin sign-only; Bitcoin returns a signed PSBT
window.headlessConnect.sendTx({ requestId, transaction }) // EVM, Solana, or Bitcoin 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.**
