This is an enterprise-only feature. Please contact us to enable.
ASWebAuthenticationSession on iOS.
The basic integration is fireblocks_connect.dart. For a fully native wallet list with signing (and optional send), see Flutter headless.
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.View fireblocks_connect.dart (copy-paste ready)
View fireblocks_connect.dart (copy-paste ready)
fireblocks_connect.dart
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();
}
}
View models.dart (copy-paste ready)
View models.dart (copy-paste ready)
models.dart
/// 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
pubspec.yaml
dependencies:
flutter_web_auth_2: 4.0.1
url_launcher: 6.3.1
2. Register your URL scheme
For iOS, add toios/Runner/Info.plist.
Info.plist
<key>CFBundleURLTypes</key>
<array><dict>
<key>CFBundleURLSchemes</key>
<array><string>myapp</string></array>
</dict></array>
<activity> in AndroidManifest.xml.
AndroidManifest.xml
<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>
<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.plistso the URL does not push a route. If your app routes deep links itself, ignore thewallet-returnhost there. - Android. Catch the host in its own activity that only finishes. It must not reach
MainActivity, because resuming the Flutter app makesflutter_web_auth_2cancel the pendingauthenticate().
Info.plist
<key>FlutterDeepLinkingEnabled</key>
<false/>
AndroidManifest.xml
<!-- 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>
WalletReturnActivity.kt
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.
connect_button.dart
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
}
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.
Real wallet round-trips require a physical device.