Skip to main content
This is an enterprise-only feature. Please contact us to enable.
The same headless architecture as iOS and Android: render your own native list and drive a hidden WebViewWidget that runs the Dynamic SDK and returns results (including message and transaction signatures) over a JS bridge. No wallet SDK in your Flutter app. The basic Flutter flow is the recommended default.
No SDK in your app. Your app links no wallet SDK: no CocoaPods, no native crypto, no Gradle dep. It needs a hidden WebViewWidget pointed at the /headless.html engine route and your URL scheme. All WalletConnect / MetaMask / Phantom logic (and the wallet list itself) comes from that hosted view. Redeploy the page to update wallets; the app never changes.
fireblocks_headless_connect.dart
models.dart
app_config.dart

1. Add dependencies

pubspec.yaml

2. Register URL schemes

You need two hosts under your scheme: one for the visible flow callback (wallet-callback) and one for Phantom’s redirect (phantom-headless), plus LSApplicationQueriesSchemes on iOS for the wallet schemes you open.
Info.plist (iOS)
AndroidManifest.xml

3. Mount the engine and connect

Wrap your home screen with FireblocksEngineHost. It keeps the hidden WebViewWidget alive at all times. Prewarm at launch and connect on tap.
main.dart
example_screen.dart
A connection is chain-specific: one wallet gives you an EVM address or a Solana address or a Bitcoin address, never several at once. Read the chains a wallet offers from HeadlessWallet.chains and pass one of them as chain. For Bitcoin, see Bitcoin wallets. Forward deep-links from Phantom with app_links (getInitialLink + uriLinkStream) into FireblocksHeadlessConnect.shared.handleReturnUrl(uri). Do not rely on MaterialApp.onGenerateRoute for custom-scheme intents.

4. Sign a message

After a successful headless connect, await sign() with any string.
example_screen.dart
Signing is only available for wallets connected through the headless engine (connectedHeadlessly == true). Wallets connected via the visible fallback flow do not hold an open session.

5. Send a transaction (EVM)

sendTransaction() calls eth_sendTransaction: the wallet signs and broadcasts in one step. The result is an on-chain transaction hash. Prefer this over eth_signTransaction, which mobile wallets (including MetaMask) often reject. The transaction argument is a JSON string whose shape depends on the connected wallet’s chain: EVM transaction fields here, a Bitcoin send request for a Bitcoin wallet (see Send bitcoin). signTransaction() is the sign-only sibling, used mainly for Bitcoin PSBTs.
example_screen.dart
chainId is required for send. The engine verifies it against the wallet’s active network and fails with chain_mismatch (or missing_chain_id) rather than silently using whatever network the wallet is on. Treat every send as final: confirm with the user before calling.

6. Bitcoin wallets

Bitcoin reuses the harness above: the same connect(), sign(), sendTransaction() and signTransaction() calls. What changes is the chain you connect with, and the JSON you hand to send and sign.
What you need: an engine deployment whose /headless.html build registers the Bitcoin extension, plus Xverse or Phantom installed on the device. Bitcoin runs on mainnet here, so every send moves real funds.

Connect a Bitcoin wallet

Two routes reach a Bitcoin address, and the wallet list decides between them per wallet:
  1. Headless session (Xverse, Phantom). The engine opens a WalletConnect bip122 session and deep-links the wallet. You get a full session: message signing, sends, and PSBT signing all work.
  2. Wallet browser (address only). Several Bitcoin wallets inject a provider only inside their own in-app browser and offer no relay path at all. The engine flags those in the wallets bridge message with inAppBrowserChain: 'bitcoin', and the sample offers them as a separate picker entry that runs the visible flow inside that browser. It returns an address and nothing else: there is no session behind it, so signing and sending are unavailable.
example_screen.dart
The engine adds the headless bitcoin option itself for the wallets it has a verified bip122 deep link for (Xverse and Phantom today), so HeadlessWallet.chains already contains 'bitcoin' for them. The wallet browser route is the one your list has to offer, gated on the flag the engine sets:
wallet_list.dart
Wire this helper into the picker and tap handling shown in section 7.
inAppBrowserChain is set only when the wallet’s catalogue entry names a single unambiguous chain for its browser (Xverse: one injected config, chain: "btc"). It is null for a wallet like Phantom whose template is overloaded, which is why Phantom’s EVM browser route stays hand-picked in section 7. Never infer a chain from inAppBrowserUrl alone.
Once the deep link opens, connect() waits up to 3 minutes for the user to approve in the wallet, then fails with ConnectFailure(code: 'timeout'). Switching apps, unlocking a wallet and reading an approval screen takes real time, and a WalletConnect proposal stays valid for about five minutes.
iOS only opens a custom scheme your app has declared. Add xverse to LSApplicationQueriesSchemes (see section 2) or the deep link silently fails to open and the connect falls back.

Sign a message with a Bitcoin wallet

sign() is unchanged. The engine routes the request over the bip122 session, and the wallet returns a base64 signature rather than EVM’s hex string.
example_screen.dart
Before each Bitcoin request the engine deep-links the bare wallet scheme (xverse://) to bring the wallet to the foreground. The request itself travels over the relay either way, but most wallets only render the approval sheet while they are open.

Send bitcoin

sendTransaction() takes a JSON send request instead of EVM transaction fields. The wallet signs and broadcasts, so SendSuccess.txHash is a transaction id that is already in the mempool.
example_screen.dart
No chainId applies, and there is no network-switch step: unlike EVM, one Bitcoin wallet session speaks one network.
A Bitcoin send is final and mainnet. Confirm with the user in your own UI before you call, the way the sample does with an alert dialog, and rehearse with a few thousand satoshis rather than a full balance.

Sign a PSBT

signTransaction() signs without broadcasting. For Bitcoin it takes a PSBT and returns the signed PSBT in base64. Finalizing and broadcasting are yours: the engine never submits it.
example_screen.dart
signature is not optional bookkeeping. Wallets derive the inputs to sign straight from it and sign nothing when it is empty, which returns a “signed” PSBT the wallet never touched. The engine parses no PSBTs of its own, so it rejects the request instead: every entry must name the connected wallet’s own address (its payment or ordinals address) with at least one index.

Bitcoin error codes

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

7. Connect Phantom on EVM (its own browser)

Phantom injects an EVM provider (window.phantom.ethereum) only inside its own in-app browser. It has no WalletConnect entry in Dynamic’s wallet book and no EVM deeplink, so the hidden engine cannot drive it and the visible flow has no provider to talk to. The route that works is to open your hosted page inside Phantom’s browser and take the result back over your URL scheme. Each operation is one round trip: Phantom comes to the foreground with your page in it, the user approves, and the result arrives on <scheme>://wallet-browser.
The engine reports each wallet’s in-app-browser template in the wallets bridge message as inAppBrowser. The template contains {{encodedDappURI}}, and you replace every occurrence (Phantom’s uses it twice). A template is not a chain: it only means the wallet can open a URL in its own browser, so you decide per wallet which chains that browser serves.
fireblocks_wallet_browser.dart

Carry the two extra fields

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

Register the callback host

Add a third host to your scheme, next to wallet-callback and phantom-headless. On iOS your existing CFBundleURLSchemes entry already covers it.
AndroidManifest.xml
Route the link in your app_links listener, for both the cold start link and the stream. flutter_web_auth_2 never picks this up, because there is no session it belongs to.
main.dart

Offer the option only for Phantom

Do not derive EVM support from the presence of a template. Phantom’s template comes from its Sui wallet-book entry, so a template on its own says nothing about EVM. The evidence for Phantom specifically is phantomevm.injectedConfig.windowLocations: ["phantom.ethereum"], an EIP-1193 provider inside its browser.
wallet_list.dart
Show it as a normal chain row (“Ethereum & EVM”, with “Opens in the wallet’s own browser” underneath). The synthetic value stays in your UI and is never sent to the page. When the user picks a synthetic browser route, branch before the headless connect:
wallet_list.dart

Connect, sign, and send

example_screen.dart

What travels on the URL

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

Phantom pitfalls

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

8. Disconnect

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

9. The bridge (for reference)

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

Common pitfalls

  • Match the channel name exactly: walletNative. A mismatch is invisible; the channel just never receives anything.
  • Use app_links for Phantom returns, not onGenerateRoute.
  • Test on a physical device.
  • Serve over HTTPS.
Last modified on September 21, 2026