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.
///
/// ```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.
static Future<WalletConnection> connect({
required String hostedPageUrl,
required String scheme,
String environmentId = '',
String locale = '',
String theme = '',
}) async {
final nonce = _randomHex(16);
final base = Uri.parse(hostedPageUrl);
final uri = 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,
});
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);
}
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');
}
return WalletConnection(
address: address,
chain: uri.queryParameters['chain'] ?? '',
walletName: uri.queryParameters['walletName'] ?? '',
walletImage: uri.queryParameters['walletImage'] ?? '',
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] —
/// 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 = '',
}) 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,
});
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] —
/// 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 = '',
}) 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,
});
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] —
/// 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 = '',
}) 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,
});
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] —
/// same as [connect].
static Future<ExchangeBalancesResult> getExchangeBalances({
required String hostedPageUrl,
required String scheme,
String exchange = 'coinbase',
String environmentId = '',
String locale = '',
String theme = '',
}) 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,
});
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;
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 ──────────────────────────────────────────────────────────────
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);
}
// ── Send result ──────────────────────────────────────────────────────────────
/// Result of [FireblocksHeadlessConnect.sendTransaction] (EVM, Solana, or
/// Bitcoin) or [FireblocksConnect.sendTransaction] (visible flow, EVM only).
/// 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), 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);
}
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
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.