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

# Hosted wallet connector on Flutter (basic)

> Present the hosted connect page with flutter_web_auth_2 and read the wallet address on your custom URL scheme.

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

On Flutter the [hosted connect page](https://docs.dynamic.xyz/docs/connections/overview.md) contract is the same as every other platform, using Chrome Custom Tabs on Android and `ASWebAuthenticationSession` on iOS.

The basic integration is `fireblocks_connect.dart`. For a fully native wallet list with signing (and optional send), see [Flutter headless](https://docs.dynamic.xyz/docs/connections/flutter-headless.md).

> [!TIP]
>
> `flutter_web_auth_2` wraps both platform auth-session APIs in one call. It is the Flutter equivalent of iOS's `ASWebAuthenticationSession` and Android's Chrome Custom Tabs.

> [accordion] View fireblocks\_connect.dart (copy-paste ready)
>
> ````dart fireblocks_connect.dart theme={"system"}
> import 'dart:convert';
> import 'dart:math';
>
> import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
>
> import 'models.dart';
>
> /// User cancelled or dismissed the system browser without completing the flow.
> class FireblocksConnectCancelled implements Exception {
>   const FireblocksConnectCancelled();
>   @override
>   String toString() => 'FireblocksConnectCancelled';
> }
>
> /// Malformed callback URL or nonce mismatch.
> class FireblocksConnectError implements Exception {
>   final String message;
>   const FireblocksConnectError(this.message);
>   @override
>   String toString() => 'FireblocksConnectError: $message';
> }
>
> /// Opens the visible wallet-select flow in a system browser
> /// (Chrome Custom Tabs on Android, ASWebAuthenticationSession on iOS).
> ///
> /// Use this when [FireblocksHeadlessConnect] calls back with
> /// [ConnectFallbackRequired], or as a standalone connect path.
> ///
> /// A host that renders the page in its own WebView instead builds the URL with
> /// [hostedUri], intercepts the `<scheme>://wallet-callback` navigation, and
> /// verifies it with [parseCallback].
> ///
> /// ```dart
> /// try {
> ///   final wallet = await FireblocksConnect.connect(
> ///     hostedPageUrl: 'https://connect.dynamicauth.com/',
> ///     scheme: 'fbapp',
> ///   );
> ///   // wallet.address, wallet.chain, …
> /// } on FireblocksConnectCancelled {
> ///   // user dismissed
> /// } on FireblocksConnectError catch (e) {
> ///   print(e);
> /// }
> /// ```
> abstract final class FireblocksConnect {
>   /// Open the hosted connect page and wait for the wallet-callback redirect.
>   ///
>   /// [hostedPageUrl] — your hosted Fireblocks connect page URL.
>   /// [scheme]  — your app's registered URL scheme (must match Info.plist,
>   ///             AndroidManifest.xml, and the `flutter_web_auth_2`
>   ///             `CallbackActivity` intent-filter).
>   /// [environmentId] — optional Dynamic environment ID for the hosted page to
>   ///             use instead of its built-in default. Omit (or pass empty) to
>   ///             leave the page on its own default.
>   /// [locale] — UI locale for the hosted page, e.g. `'en_US'`, `'es_LA'`. Omit
>   ///             (or pass empty) to leave the page on its own default.
>   /// [theme] — UI theme for the hosted page, `'light'` or `'dark'`. Omit (or
>   ///             pass empty) to leave the page on its own default.
>   /// [phantomMode] — how the hosted page connects Phantom, `'in-app'` or
>   ///             `'redirect'`. Omit (or pass empty) to leave the page on
>   ///             its own default.
>   static Future<WalletConnection> connect({
>     required String hostedPageUrl,
>     required String scheme,
>     String environmentId = '',
>     String locale = '',
>     String theme = '',
>     String phantomMode = '',
>   }) async {
>     final nonce = newNonce();
>     final uri = hostedUri(
>       hostedPageUrl: hostedPageUrl,
>       scheme: scheme,
>       nonce: nonce,
>       environmentId: environmentId,
>       locale: locale,
>       theme: theme,
>       phantomMode: phantomMode,
>     );
>
>     final String callbackUrl;
>     try {
>       callbackUrl = await FlutterWebAuth2.authenticate(
>         url: uri.toString(),
>         callbackUrlScheme: scheme,
>         // NOT ephemeral (iOS-only flag — flutter_web_auth_2's Android side
>         // doesn't read it at all, so this has no effect there). Live-tested
>         // regression: with preferEphemeral: true, a passkey-based wallet
>         // (Base Account / Coinbase Smart Wallet) never got a working
>         // session on iOS at all. Apple's ASWebAuthenticationSession docs
>         // document ephemeral sessions as NOT sharing the persistent
>         // Safari/iCloud Keychain passkey store the way a normal session
>         // does — this matches exactly. The cost: the user sees a one-time
>         // "<app> wants to use <domain> to sign in" system consent dialog
>         // per domain instead of a silent session. Applied to all three
>         // calls in this class (connect/sendTransaction/signMessage) for
>         // consistency — they all drive the same wallets through the same
>         // session mechanism.
>         options: const FlutterWebAuth2Options(preferEphemeral: false),
>       );
>     } on Exception catch (e) {
>       final msg = e.toString().toLowerCase();
>       if (msg.contains('cancel') || msg.contains('dismiss') || msg.contains('usercancel')) {
>         throw const FireblocksConnectCancelled();
>       }
>       rethrow;
>     }
>
>     return parseCallback(callbackUrl, expectedNonce: nonce);
>   }
>
>   /// The hosted page URL with the contract params appended: `redirect_uri`,
>   /// [nonce], `embedded=1`, and whichever of the optional ones are non-empty.
>   /// For a host that renders the page itself instead of calling [connect].
>   static Uri hostedUri({
>     required String hostedPageUrl,
>     required String scheme,
>     required String nonce,
>     String environmentId = '',
>     String locale = '',
>     String theme = '',
>     String phantomMode = '',
>   }) {
>     final base = Uri.parse(hostedPageUrl);
>     return base.replace(queryParameters: {
>       ...base.queryParameters,
>       'redirect_uri': '$scheme://wallet-callback',
>       'nonce': nonce,
>       'embedded': '1',
>       if (environmentId.isNotEmpty) 'environmentId': environmentId,
>       if (locale.isNotEmpty) 'locale': locale,
>       if (theme.isNotEmpty) 'theme': theme,
>       if (phantomMode.isNotEmpty) 'phantomMode': phantomMode,
>     });
>   }
>
>   /// A fresh random value correlating one launch with its return.
>   static String newNonce() => _randomHex(16);
>
>   /// Verifies the `<scheme>://wallet-callback` URL the page redirected to and
>   /// reads the connected wallet from it. Throws [FireblocksConnectError] on a
>   /// nonce mismatch or a missing address.
>   static WalletConnection parseCallback(String callbackUrl, {required String expectedNonce}) {
>     final uri = Uri.parse(callbackUrl);
>     if (uri.queryParameters['nonce'] != expectedNonce) {
>       throw const FireblocksConnectError('Nonce mismatch — possible replay attack');
>     }
>     final address = uri.queryParameters['address'] ?? '';
>     if (address.isEmpty) {
>       throw const FireblocksConnectError('No address in callback URL');
>     }
>     final networkId = uri.queryParameters['networkId'];
>     return WalletConnection(
>       address: address,
>       chain: uri.queryParameters['chain'] ?? '',
>       walletName: uri.queryParameters['walletName'] ?? '',
>       walletImage: uri.queryParameters['walletImage'] ?? '',
>       networkId: networkId == null || networkId.isEmpty ? null : networkId,
>       connectedHeadlessly: false,
>     );
>   }
>
>   /// Send an EVM transaction with a wallet that can ONLY connect through
>   /// this visible flow — Base Account / Coinbase Smart Wallet today, and by
>   /// extension any other installed/SDK EVM wallet, since the mechanism is
>   /// generic. [FireblocksHeadlessConnect.sendTransaction] can't do this for
>   /// these wallets: @base-org/account's signer opens a real system-browser
>   /// popup for EVERY signing call, not just the first connect, and the
>   /// headless engine runs in a hidden WebView that can't reliably host that
>   /// popup — so this, like [connect] above, opens the system browser
>   /// (`flutter_web_auth_2`) rather than driving a hidden engine.
>   ///
>   /// This is NEVER silent: expect the system browser to open and at least
>   /// one wallet approval popup, even for a wallet you already connected via
>   /// [connect] earlier — Base Account's signer requires a fresh popup for
>   /// every signing operation, by design. The page itself asks for two
>   /// separate taps ("Connect", then "Confirm & send") before returning here.
>   ///
>   /// [hostedPageUrl] / [scheme] / [environmentId] / [locale] / [theme] /
>   ///   [phantomMode] — same as [connect].
>   /// [walletKey] — the catalogue key for the wallet to use, e.g.
>   ///   `'baseaccount'` (confirm the exact key with `?debug` on the hosted
>   ///   page's normal connect view if unsure).
>   /// [to] / [chainId] — required, `0x`-prefixed hex.
>   /// [value] / [data] / [gasLimit] — optional, `0x`-prefixed hex (`value`
>   ///   defaults to `'0x0'`, `data` to `'0x'`).
>   /// [expectedAddress] — REQUIRED, `0x`-prefixed hex: the address you
>   ///   already know this wallet connected as (e.g. from an earlier
>   ///   [connect] call's [WalletConnection.address]). Without this the page
>   ///   would send from whatever account happens to connect — a
>   ///   wrong-account-send risk for any wallet capable of holding more than
>   ///   one account. The page itself refuses to send on a mismatch; this
>   ///   parameter is what it checks against, not a hint.
>   static Future<SendResult> sendTransaction({
>     required String hostedPageUrl,
>     required String scheme,
>     required String walletKey,
>     required String to,
>     required String chainId,
>     required String expectedAddress,
>     String value = '0x0',
>     String data = '0x',
>     String? gasLimit,
>     String environmentId = '',
>     String locale = '',
>     String theme = '',
>     String phantomMode = '',
>   }) async {
>     final nonce = _randomHex(16);
>     final base = Uri.parse(hostedPageUrl);
>     final uri = base.replace(queryParameters: {
>       ...base.queryParameters,
>       'intent': 'sendTx',
>       'walletKey': walletKey,
>       'to': to,
>       'value': value,
>       'data': data,
>       'chainId': chainId,
>       if (gasLimit != null) 'gasLimit': gasLimit,
>       'expectedAddress': expectedAddress,
>       'redirect_uri': '$scheme://wallet-callback',
>       'nonce': nonce,
>       if (environmentId.isNotEmpty) 'environmentId': environmentId,
>       if (locale.isNotEmpty) 'locale': locale,
>       if (theme.isNotEmpty) 'theme': theme,
>       if (phantomMode.isNotEmpty) 'phantomMode': phantomMode,
>     });
>
>     final String callbackUrl;
>     try {
>       callbackUrl = await FlutterWebAuth2.authenticate(
>         url: uri.toString(),
>         callbackUrlScheme: scheme,
>         // See connect()'s comment on this same option — not ephemeral, for
>         // the same passkey-session reason, applied consistently here too.
>         options: const FlutterWebAuth2Options(preferEphemeral: false),
>       );
>     } on Exception catch (e) {
>       final msg = e.toString().toLowerCase();
>       if (msg.contains('cancel') || msg.contains('dismiss') || msg.contains('usercancel')) {
>         throw const FireblocksConnectCancelled();
>       }
>       rethrow;
>     }
>
>     return _parseSendCallback(callbackUrl, expectedNonce: nonce);
>   }
>
>   static SendResult _parseSendCallback(String callbackUrl, {required String expectedNonce}) {
>     final uri = Uri.parse(callbackUrl);
>     if (uri.queryParameters['nonce'] != expectedNonce) {
>       throw const FireblocksConnectError('Nonce mismatch — possible replay attack');
>     }
>     if (uri.queryParameters['error'] == '1') {
>       return SendFailure(SignError(
>         code: uri.queryParameters['code'] ?? 'unknown',
>         message: uri.queryParameters['message'] ?? '',
>       ));
>     }
>     final txHash = uri.queryParameters['txHash'] ?? '';
>     if (txHash.isEmpty) {
>       throw const FireblocksConnectError('No txHash in callback URL');
>     }
>     return SendSuccess(txHash);
>   }
>
>   /// Sign a message with a wallet that can ONLY connect through this
>   /// visible flow — Base Account / Coinbase Smart Wallet today, and by
>   /// extension any other installed/SDK EVM wallet. Same reasoning as
>   /// [sendTransaction] above for why this needs its own flow rather than
>   /// going through [FireblocksHeadlessConnect.sign]: @base-org/account's
>   /// signer opens a real system-browser popup for `personal_sign` too, not
>   /// just `eth_sendTransaction` — there's no "sign is cheaper than send"
>   /// shortcut here.
>   ///
>   /// This is NEVER silent: expect the system browser to open and at least
>   /// one wallet approval popup, even for a wallet you already connected via
>   /// [connect] earlier. The page itself asks for two separate taps
>   /// ("Connect", then "Confirm & sign") before returning here.
>   ///
>   /// [hostedPageUrl] / [scheme] / [environmentId] / [locale] / [theme] /
>   ///   [phantomMode] — same as [connect].
>   /// [walletKey] — the catalogue key for the wallet to use, e.g.
>   ///   `'baseaccount'`.
>   /// [message] — the message to sign, as plain text.
>   /// [expectedAddress] — REQUIRED, `0x`-prefixed hex: the address you
>   ///   already know this wallet connected as. Without this the page would
>   ///   sign with whatever account happens to connect. The page itself
>   ///   refuses to sign on a mismatch; this parameter is what it checks
>   ///   against, not a hint.
>   static Future<SignResult> signMessage({
>     required String hostedPageUrl,
>     required String scheme,
>     required String walletKey,
>     required String message,
>     required String expectedAddress,
>     String environmentId = '',
>     String locale = '',
>     String theme = '',
>     String phantomMode = '',
>   }) async {
>     final nonce = _randomHex(16);
>     final base = Uri.parse(hostedPageUrl);
>     final uri = base.replace(queryParameters: {
>       ...base.queryParameters,
>       'intent': 'signMessage',
>       'walletKey': walletKey,
>       'message': message,
>       'expectedAddress': expectedAddress,
>       'redirect_uri': '$scheme://wallet-callback',
>       'nonce': nonce,
>       if (environmentId.isNotEmpty) 'environmentId': environmentId,
>       if (locale.isNotEmpty) 'locale': locale,
>       if (theme.isNotEmpty) 'theme': theme,
>       if (phantomMode.isNotEmpty) 'phantomMode': phantomMode,
>     });
>
>     final String callbackUrl;
>     try {
>       callbackUrl = await FlutterWebAuth2.authenticate(
>         url: uri.toString(),
>         callbackUrlScheme: scheme,
>         // See connect()'s comment on this same option — not ephemeral, for
>         // the same passkey-session reason, applied consistently here too.
>         options: const FlutterWebAuth2Options(preferEphemeral: false),
>       );
>     } on Exception catch (e) {
>       final msg = e.toString().toLowerCase();
>       if (msg.contains('cancel') || msg.contains('dismiss') || msg.contains('usercancel')) {
>         throw const FireblocksConnectCancelled();
>       }
>       rethrow;
>     }
>
>     return _parseSignCallback(callbackUrl, expectedNonce: nonce);
>   }
>
>   static SignResult _parseSignCallback(String callbackUrl, {required String expectedNonce}) {
>     final uri = Uri.parse(callbackUrl);
>     if (uri.queryParameters['nonce'] != expectedNonce) {
>       throw const FireblocksConnectError('Nonce mismatch — possible replay attack');
>     }
>     if (uri.queryParameters['error'] == '1') {
>       return SignFailure(SignError(
>         code: uri.queryParameters['code'] ?? 'unknown',
>         message: uri.queryParameters['message'] ?? '',
>       ));
>     }
>     final signature = uri.queryParameters['signature'] ?? '';
>     if (signature.isEmpty) {
>       throw const FireblocksConnectError('No signature in callback URL');
>     }
>     return SignSuccess(signature);
>   }
>
>   /// Fund [to] from the user's exchange account — Coinbase today.
>   ///
>   /// This is NOT a wallet operation and needs no connected wallet. The hosted
>   /// page signs the user in to the EXCHANGE over OAuth, reads their balances,
>   /// and the exchange itself signs and broadcasts the withdrawal. So there is
>   /// no headless path for it either: OAuth has nowhere to run inside the
>   /// hidden engine WebView (a popup has no surface, and a cross-origin
>   /// redirect is refused by its navigation delegate), which is why this lives
>   /// here, in the system-browser flow, like [sendTransaction] does.
>   ///
>   /// NEVER silent: expect the browser to open, an exchange sign-in, and — for
>   /// an account with two-step verification on, or a transfer whose
>   /// jurisdictions require recipient details — one or two further prompts on
>   /// the page before it returns.
>   ///
>   /// [to] — destination address. Deliberately unvalidated here: an exchange
>   ///   withdrawal can target any chain the exchange supports, so this may be
>   ///   an EVM `0x…`, a Solana base58 key, a BTC address… The exchange is the
>   ///   authority on whether it matches [currency]/[network], and it refuses
>   ///   the transfer if not.
>   /// [exchange] — catalogue key; only `'coinbase'` is wired today.
>   /// [currency] — token symbol to debit (e.g. `'USDC'`). When it matches
>   ///   exactly one balance, the page skips its balance picker.
>   /// [amount] — decimal string prefill (e.g. `'12.5'`). The user still
>   ///   confirms it on the page; the page refuses more than the available
>   ///   balance.
>   /// [network] — network slug passed through to the exchange (e.g. `'base'`).
>   ///   Get this right: it's the exchange, not this app, that decides which
>   ///   chain the funds land on.
>   /// [hostedPageUrl] / [scheme] / [environmentId] / [locale] / [theme] /
>   ///   [phantomMode] — same as [connect].
>   static Future<ExchangeTransferResult> fundFromExchange({
>     required String hostedPageUrl,
>     required String scheme,
>     required String to,
>     String exchange = 'coinbase',
>     String? currency,
>     String? amount,
>     String? network,
>     String environmentId = '',
>     String locale = '',
>     String theme = '',
>     String phantomMode = '',
>   }) async {
>     final nonce = _randomHex(16);
>     final base = Uri.parse(hostedPageUrl);
>     final uri = base.replace(queryParameters: {
>       ...base.queryParameters,
>       'intent': 'fundFromExchange',
>       'exchange': exchange,
>       'to': to,
>       if (currency != null && currency.isNotEmpty) 'currency': currency,
>       if (amount != null && amount.isNotEmpty) 'amount': amount,
>       if (network != null && network.isNotEmpty) 'network': network,
>       'redirect_uri': '$scheme://wallet-callback',
>       'nonce': nonce,
>       if (environmentId.isNotEmpty) 'environmentId': environmentId,
>       if (locale.isNotEmpty) 'locale': locale,
>       if (theme.isNotEmpty) 'theme': theme,
>       if (phantomMode.isNotEmpty) 'phantomMode': phantomMode,
>     });
>
>     final String callbackUrl;
>     try {
>       callbackUrl = await FlutterWebAuth2.authenticate(
>         url: uri.toString(),
>         callbackUrlScheme: scheme,
>         // See connect()'s comment on this same option — not ephemeral. It
>         // matters here for its own reason too: the exchange's OAuth screen is
>         // far friendlier to a user who is already signed in to that exchange
>         // in this browser session.
>         options: const FlutterWebAuth2Options(preferEphemeral: false),
>       );
>     } on Exception catch (e) {
>       final msg = e.toString().toLowerCase();
>       if (msg.contains('cancel') || msg.contains('dismiss') || msg.contains('usercancel')) {
>         throw const FireblocksConnectCancelled();
>       }
>       rethrow;
>     }
>
>     return _parseExchangeCallback(callbackUrl, expectedNonce: nonce, exchange: exchange);
>   }
>
>   static ExchangeTransferResult _parseExchangeCallback(
>     String callbackUrl, {
>     required String expectedNonce,
>     required String exchange,
>   }) {
>     final uri = Uri.parse(callbackUrl);
>     if (uri.queryParameters['nonce'] != expectedNonce) {
>       throw const FireblocksConnectError('Nonce mismatch — possible replay attack');
>     }
>     if (uri.queryParameters['error'] == '1') {
>       return ExchangeTransferFailure(SignError(
>         code: uri.queryParameters['code'] ?? 'unknown',
>         message: uri.queryParameters['message'] ?? '',
>       ));
>     }
>     final transferId = uri.queryParameters['transferId'] ?? '';
>     if (transferId.isEmpty) {
>       throw const FireblocksConnectError('No transferId in callback URL');
>     }
>     return ExchangeTransferSuccess(
>       transferId: transferId,
>       exchange: uri.queryParameters['exchange'] ?? exchange,
>       amount: uri.queryParameters['amount'] ?? '',
>       currency: uri.queryParameters['currency'] ?? '',
>       status: uri.queryParameters['status'],
>     );
>   }
>
>   /// Read the user's balances at an exchange — Coinbase today. Read-only:
>   /// [FundFromExchangeIntent]'s `mode=balances` (same page as
>   /// [fundFromExchange], see its doc comment for why this needs the system
>   /// browser and can't be headless) links the exchange over OAuth if it
>   /// isn't linked yet, reads the balances, and returns immediately — no
>   /// amount screen, no MFA, no travel-rule prompt, and nothing is ever moved.
>   ///
>   /// [exchange] — catalogue key; only `'coinbase'` is wired today.
>   /// [hostedPageUrl] / [scheme] / [environmentId] / [locale] / [theme] /
>   ///   [phantomMode] — same as [connect].
>   static Future<ExchangeBalancesResult> getExchangeBalances({
>     required String hostedPageUrl,
>     required String scheme,
>     String exchange = 'coinbase',
>     String environmentId = '',
>     String locale = '',
>     String theme = '',
>     String phantomMode = '',
>   }) async {
>     final nonce = _randomHex(16);
>     final base = Uri.parse(hostedPageUrl);
>     final uri = base.replace(queryParameters: {
>       ...base.queryParameters,
>       'intent': 'fundFromExchange',
>       'mode': 'balances',
>       'exchange': exchange,
>       'redirect_uri': '$scheme://wallet-callback',
>       'nonce': nonce,
>       if (environmentId.isNotEmpty) 'environmentId': environmentId,
>       if (locale.isNotEmpty) 'locale': locale,
>       if (theme.isNotEmpty) 'theme': theme,
>       if (phantomMode.isNotEmpty) 'phantomMode': phantomMode,
>     });
>
>     final String callbackUrl;
>     try {
>       callbackUrl = await FlutterWebAuth2.authenticate(
>         url: uri.toString(),
>         callbackUrlScheme: scheme,
>         // Same reasoning as fundFromExchange's identical option: a user
>         // already signed in to the exchange in this browser session skips
>         // straight past its OAuth screen.
>         options: const FlutterWebAuth2Options(preferEphemeral: false),
>       );
>     } on Exception catch (e) {
>       final msg = e.toString().toLowerCase();
>       if (msg.contains('cancel') || msg.contains('dismiss') || msg.contains('usercancel')) {
>         throw const FireblocksConnectCancelled();
>       }
>       rethrow;
>     }
>
>     return _parseExchangeBalancesCallback(callbackUrl, expectedNonce: nonce, exchange: exchange);
>   }
>
>   static ExchangeBalancesResult _parseExchangeBalancesCallback(
>     String callbackUrl, {
>     required String expectedNonce,
>     required String exchange,
>   }) {
>     final uri = Uri.parse(callbackUrl);
>     if (uri.queryParameters['nonce'] != expectedNonce) {
>       throw const FireblocksConnectError('Nonce mismatch — possible replay attack');
>     }
>     if (uri.queryParameters['error'] == '1') {
>       return ExchangeBalancesFailure(SignError(
>         code: uri.queryParameters['code'] ?? 'unknown',
>         message: uri.queryParameters['message'] ?? '',
>       ));
>     }
>     final accountsJson = uri.queryParameters['accounts'];
>     if (accountsJson == null) {
>       throw const FireblocksConnectError('No accounts in callback URL');
>     }
>     // Rethrow as the documented FireblocksConnectError — jsonDecode/cast
>     // failures would otherwise leak their own exception types instead.
>     try {
>       final decoded = jsonDecode(accountsJson);
>       if (decoded is! List) throw const FormatException('accounts is not a list');
>       return ExchangeBalancesSuccess(
>         exchange: uri.queryParameters['exchange'] ?? exchange,
>         accounts: decoded
>             .map((e) => ExchangeBalance.fromJson(e as Map<String, dynamic>))
>             .toList(),
>       );
>     } catch (_) {
>       throw const FireblocksConnectError('Malformed accounts payload in callback URL');
>     }
>   }
>
>   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();
>   }
> }
> ````

> [accordion] 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;
>
>   /// The wallet's in-app-browser URL template (`{{encodedDappURI}}`), when the
>   /// wallet book carries one — `null` otherwise.
>   ///
>   /// This is NOT one of [chains]: it means "this wallet can open a URL in its
>   /// own browser", where it injects its providers. Some wallets are only
>   /// reachable that way for some chains (Phantom on EVM — see
>   /// [FireblocksWalletBrowser]). The template itself says nothing about WHICH
>   /// chains that browser serves — see [inAppBrowserChain] for the one case
>   /// where the engine can say so trustworthily.
>   final String? inAppBrowserUrl;
>
>   /// The chain [inAppBrowserUrl] is for — set by the engine (`headless.ts`)
>   /// ONLY when the wallet-book entry has a single unambiguous chain for it
>   /// (e.g. Xverse: exactly one `injectedConfig` entry, `chain: "btc"`).
>   /// `null` for a wallet like Phantom, whose template is overloaded (really
>   /// its Sui entry, not EVM) — that case still needs the hand-verified route
>   /// in [FireblocksWalletBrowser] rather than trusting this field.
>   final String? inAppBrowserChain;
>
>   const HeadlessWallet({
>     required this.key,
>     required this.name,
>     required this.icon,
>     required this.chains,
>     required this.mode,
>     required this.featured,
>     this.inAppBrowserUrl,
>     this.inAppBrowserChain,
>   });
>
>   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,
>         inAppBrowserUrl: json['inAppBrowser'] as String?,
>         inAppBrowserChain: json['inAppBrowserChain'] as String?,
>       );
>
>   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;
>
>   /// The Solana network this wallet SESSION is bound to ('mainnet-beta' |
>   /// 'devnet'), when the wallet's protocol binds one at connect time
>   /// (Phantom's redirect protocol does). The session decides what the wallet
>   /// will sign for, so send UIs must follow it — the engine rejects a send
>   /// on the other network. `null` for wallets with no such binding (their
>   /// send UI may offer a network choice freely).
>   final String? network;
>
>   /// CAIP-2 chain id (e.g. `eip155:1`) of the connected network or manually
>   /// chosen network, on visible and headless connects; `null` when the wallet
>   /// did not report one. Unrelated to
>   /// [network], which is the Phantom Solana session cluster.
>   final String? networkId;
>
>   /// Set when this connection was made inside the WALLET's own in-app browser
>   /// ([FireblocksWalletBrowser]) — the in-app-browser URL template it was
>   /// opened with. The connection belongs to the provider injected in that
>   /// browser and exists nowhere else, so sign/send have to be re-opened there
>   /// too: pass this back to [FireblocksWalletBrowser.signMessage] /
>   /// [FireblocksWalletBrowser.sendTransaction]. `null` for every other path
>   /// (headless engine, system-browser visible flow).
>   final String? walletBrowserUrl;
>
>   const WalletConnection({
>     required this.address,
>     required this.chain,
>     required this.walletName,
>     required this.walletImage,
>     this.connectedHeadlessly = true,
>     this.walletKey,
>     this.network,
>     this.networkId,
>     this.walletBrowserUrl,
>   });
>
>   WalletConnection copyWith({String? walletKey}) => WalletConnection(
>         address: address,
>         chain: chain,
>         walletName: walletName,
>         walletImage: walletImage,
>         connectedHeadlessly: connectedHeadlessly,
>         walletKey: walletKey ?? this.walletKey,
>         network: network,
>         networkId: networkId,
>         walletBrowserUrl: walletBrowserUrl,
>       );
> }
>
> /// 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] (headless) OR
> /// [FireblocksConnect.signMessage] (visible flow — Base Account / Coinbase
> /// Smart Wallet, or any other wallet that can only connect that way; see
> /// that method's docs for why it can't go through the headless engine).
> sealed class SignResult {
>   const SignResult();
> }
>
> final class SignSuccess extends SignResult {
>   /// Signed message: hex signature string.
>   /// 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) OR [FireblocksConnect.sendTransaction] (visible flow —
> /// Base Account / Coinbase Smart Wallet, or any other wallet that can only
> /// connect that way; EVM only — see that method's docs for why it can't go
> /// through the headless engine). 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);
> }
>
> /// ERC-20 / SPL token for [FireblocksHeadlessConnect.transferAmount]: contract
> /// (or mint) [address] and the token's [decimals].
> class TransferToken {
>   final String address;
>   final int decimals;
>
>   const TransferToken({required this.address, required this.decimals});
> }
>
> // ── Exchange transfer result ─────────────────────────────────────────────────
>
> /// Result of [FireblocksConnect.fundFromExchange] — funds sent from the user's
> /// exchange account to an address.
> ///
> /// No wallet is involved: the user authenticates with the EXCHANGE, and the
> /// exchange signs and broadcasts the withdrawal itself. That's also why this
> /// isn't a [SendResult] — there is no transaction hash to return, only the
> /// exchange's own transfer id (see [ExchangeTransferSuccess.transferId]).
> sealed class ExchangeTransferResult {
>   const ExchangeTransferResult();
> }
>
> final class ExchangeTransferSuccess extends ExchangeTransferResult {
>   /// The EXCHANGE's transfer id — not an on-chain hash. The exchange
>   /// broadcasts on its own schedule, so at this point there may be no hash at
>   /// all yet; poll the exchange (or the destination address) for settlement.
>   final String transferId;
>
>   /// Which exchange the funds came from, e.g. `"coinbase"`.
>   final String exchange;
>
>   /// Amount and currency the exchange accepted, echoed back — it can differ
>   /// from what was requested (e.g. rounded to the currency's precision).
>   final String amount;
>   final String currency;
>
>   /// The exchange's own status string when it reports one (e.g. `"pending"`).
>   final String? status;
>
>   const ExchangeTransferSuccess({
>     required this.transferId,
>     required this.exchange,
>     required this.amount,
>     required this.currency,
>     this.status,
>   });
> }
>
> final class ExchangeTransferFailure extends ExchangeTransferResult {
>   final SignError error;
>   const ExchangeTransferFailure(this.error);
> }
>
> // ── Exchange balances result ─────────────────────────────────────────────────
>
> /// One balance from [FireblocksConnect.getExchangeBalances] — an
> /// account/currency pair, since a single exchange account can hold several
> /// currencies and each is reported separately.
> ///
> /// Deliberately carries no account id: this travels inside a callback URL
> /// (a custom-scheme one on Android can land in system navigation/Intent
> /// logs), and nothing on this read-only path ever needs to address a
> /// specific account again — see the matching comment on the web side's
> /// `ExchangeBalance` in `src/redirect.ts`.
> final class ExchangeBalance {
>   final String accountName;
>   final String currency;
>   final double balance;
>
>   /// What's actually free to move — [balance] minus anything held/pending.
>   /// Show this one if you're only showing a single number.
>   final double available;
>
>   const ExchangeBalance({
>     required this.accountName,
>     required this.currency,
>     required this.balance,
>     required this.available,
>   });
>
>   factory ExchangeBalance.fromJson(Map<String, dynamic> json) => ExchangeBalance(
>         accountName: json['accountName'] as String,
>         currency: json['currency'] as String,
>         balance: (json['balance'] as num).toDouble(),
>         available: (json['available'] as num).toDouble(),
>       );
> }
>
> /// Result of [FireblocksConnect.getExchangeBalances] — the read-only sibling
> /// of [ExchangeTransferResult]: same OAuth link step, but it never moves
> /// money. Nothing here needs a destination address, an amount, MFA, or
> /// travel-rule details — those only exist once a transfer is actually being
> /// attempted.
> sealed class ExchangeBalancesResult {
>   const ExchangeBalancesResult();
> }
>
> final class ExchangeBalancesSuccess extends ExchangeBalancesResult {
>   /// Which exchange these balances came from, e.g. `"coinbase"`.
>   final String exchange;
>   final List<ExchangeBalance> accounts;
>
>   const ExchangeBalancesSuccess({required this.exchange, required this.accounts});
> }
>
> final class ExchangeBalancesFailure extends ExchangeBalancesResult {
>   final SignError error;
>   const ExchangeBalancesFailure(this.error);
> }
> ```

## 1. Add dependencies

```yaml pubspec.yaml theme={"system"}
dependencies:
  flutter_web_auth_2: 4.0.1
  url_launcher: 6.3.1
```

## 2. Register your URL scheme

For iOS, add to `ios/Runner/Info.plist`.

```xml Info.plist theme={"system"}
<key>CFBundleURLTypes</key>
<array><dict>
  <key>CFBundleURLSchemes</key>
  <array><string>myapp</string></array>
</dict></array>
```

For Android, add inside your `<activity>` in `AndroidManifest.xml`.

```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-callback" />
</intent-filter>
```

Phantom connects inside its own in-app browser, and its "Return to app" button opens `<scheme>://wallet-return` without any connection data while the system browser is still waiting for the result. Your app must accept that URL without acting on it:

* **iOS.** Turn off Flutter's deep linking in `Info.plist` so the URL does not push a route. If your app routes deep links itself, ignore the `wallet-return` host there.
* **Android.** Catch the host in its own activity that only finishes. It must not reach `MainActivity`, because resuming the Flutter app makes `flutter_web_auth_2` cancel the pending `authenticate()`.

```xml Info.plist theme={"system"}
<key>FlutterDeepLinkingEnabled</key>
<false/>
```

```xml AndroidManifest.xml theme={"system"}
<!-- inside <application>, next to your <activity> block -->
<activity
    android:name=".WalletReturnActivity"
    android:exported="true"
    android:taskAffinity="${applicationId}.walletreturn"
    android:launchMode="singleTop"
    android:excludeFromRecents="true"
    android:theme="@android:style/Theme.Translucent.NoTitleBar">
    <intent-filter>
        <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-return" />
    </intent-filter>
</activity>
```

```kotlin WalletReturnActivity.kt theme={"system"}
package com.example.myapp // your app's package, next to MainActivity

import android.app.Activity
import android.os.Bundle

/**
 * Receives `myapp://wallet-return`, the zero-payload "Return to app" tap from
 * Phantom's in-app-browser bridge, and does nothing but finish. The open
 * Custom Tab finishes the flow itself via `myapp://wallet-callback` once the
 * WalletConnect approval lands.
 */
class WalletReturnActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        finish()
    }
}
```

## 3. Connect and use the result

`networkId` is the CAIP-2 id of the network the wallet is on. It is not `network`, the Phantom Solana session cluster. See [Network fields](https://docs.dynamic.xyz/docs/connections/overview.md#network-fields).

```dart connect_button.dart theme={"system"}
import 'package:flutter_app/src/fireblocks_connect.dart';

try {
  final wallet = await FireblocksConnect.connect(
    hostedPageUrl: 'https://connect.dynamicauth.com/',
    scheme: 'myapp',
    environmentId: 'b1e3aca9-0646-411a-b4ab-c31ce49935b3',
  );
  // wallet.address, wallet.chain,
  // wallet.walletName, wallet.walletImage
} on FireblocksConnectCancelled {
  // user dismissed
} on FireblocksConnectError catch (e) {
  // e.message: nonce mismatch or malformed callback
}
```

Pass `locale` and `theme` (plain strings, e.g. `'es_LA'`, `'dark'`) the same way to override the hosted page's UI locale and theme.

`walletImage` is usually an SVG-sprite URL. Render it in a `WebViewWidget` rather than `Image.network`.

> [!NOTE]
>
> Real wallet round-trips require a physical device.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.