This is an enterprise-only feature. Please contact us to enable.
No SDK in your app. Your app links no wallet SDK: no CocoaPods, no native crypto. It needs a hidden web view pointed at
headless.html, and your URL scheme. All WalletConnect / MetaMask / Phantom logic (and the wallet list itself) comes from that hidden view. When wallets or the SDK change, you redeploy the page; the app never changes.FireblocksHeadlessConnect.swift and FireblocksConnectFlow.swift (visible fallback and shared WalletConnection).
1. Get the wallet menu (no static file)
The engine derives the list live from the Dynamic catalog and pushes it to your app over the bridge (awallets message). No walletbook file to ship or keep in sync.
| Field | Type | Description |
|---|---|---|
key | string | Catalog key you pass back when the user taps it (e.g. metamask). |
name / icon | string | Display name and icon URL for the row. |
chains | ("evm" | "solana")[] | Which chains the wallet supports (drives a native chain picker). |
mode | "headless" | "fallback" | headless connects through the hidden view. fallback opens the visible flow (passkey/email wallets). |
featured | boolean | Show by default; the rest of the catalog rides along so search matches. |
wallets message (web to app)
{
"type": "wallets",
"wallets": [
{ "key": "metamask", "name": "MetaMask", "icon": "https://…",
"chains": ["evm","solana"], "mode": "headless", "featured": true },
{ "key": "phantom", "name": "Phantom", "icon": "https://…",
"chains": ["solana"], "mode": "headless", "featured": true }
]
}
2. Drop in FireblocksHeadlessConnect
One file owns a hiddenWKWebView pointed at headless.html, drives it over a message bridge, opens the wallet deeplink it returns, and calls you back. Pre-warm it at launch.
Connect.swift
FireblocksHeadlessConnect.shared.prewarm() // at launch
FireblocksHeadlessConnect.shared.connect(walletKey: "metamask", chain: "evm") { result in
switch result {
case .success(let wallet): // wallet.address, .chain, …
case .fallbackRequired: // open the visible flow for this wallet
case .failure(let code, _): // stable code, e.g. "user_rejected"
}
}
onOpenURL to FireblocksHeadlessConnect.shared.handleReturnURL($0). That is how redirect wallets (Phantom) hand their result back to the hidden view.
View FireblocksHeadlessConnect.swift (copy-paste ready)
View FireblocksHeadlessConnect.swift (copy-paste ready)
FireblocksHeadlessConnect.swift
import WebKit
import UIKit
// MARK: - Headless connect engine
/// Runs the hosted Fireblocks connect logic (the Dynamic SDK) inside a HIDDEN
/// WKWebView, so the app can render its own native wallet list and still keep
/// every bit of connection logic in the web layer.
///
/// For WalletConnect-protocol wallets (MetaMask, Rainbow, Trust, …) the pairing
/// is relay-based: the engine mints a URI, we open the wallet via deeplink, the
/// user approves, and the engine's `approval()` promise resolves over a
/// WebSocket — none of which needs a visible page. Wallets with no such path
/// (Phantom's redirect, Coinbase Smart Wallet passkey/email) come back as
/// `.fallbackRequired` so the caller opens the visible `FireblocksConnectFlow`.
///
/// ```swift
/// FireblocksHeadlessConnect.shared.prewarm() // at launch
/// FireblocksHeadlessConnect.shared.connect(walletKey: "rainbow", chain: "evm") { result in
/// switch result {
/// case .success(let wallet): // wallet.address, .chain
/// case .fallbackRequired(let why): // open the visible flow for this wallet
/// case .failure(let code, _): // stable code, e.g. "user_rejected"
/// }
/// }
/// ```
/// A wallet in the native list, delivered live by the engine (derived from the
/// Dynamic catalogue — no static file). `mode` is `"headless"` (connect silently
/// through the hidden view) or `"fallback"` (open the visible flow).
public struct HeadlessWallet: Decodable, Identifiable {
public let key: String
public let name: String
public let icon: String?
public let chains: [String]
public let mode: String
/// Shown by default; the rest of the catalogue rides along for search.
public let featured: Bool?
public var id: String { key }
public var isMultiChain: Bool { chains.count > 1 }
}
public final class FireblocksHeadlessConnect: NSObject {
public static let shared = FireblocksHeadlessConnect()
// Wallet universal-link hosts iOS won't hand to the wallet app from inside a
// WKWebView (only Phantom's redirect navigates the WebView today) — the nav
// delegate opens these externally. Self-contained so the module is drop-in.
private static let walletUniversalLinkHosts: Set<String> = [
"phantom.app", "phantom.com",
"link.metamask.io", "metamask.app.link",
"link.trustwallet.com", "rnbwapp.com", "rainbow.me",
"www.okx.com", "link.okx.com", "zerion.io",
]
public enum Result {
case success(WalletConnection)
case fallbackRequired(reason: String)
case failure(code: String, message: String)
}
/// The no-UI engine page (headless.html). `returnScheme` tells the engine to
/// point Phantom's redirect at your app scheme so it returns to the app.
public var engineURL = URL(string: "https://connections.dynamic.dev/headless.html?returnScheme=myapp")!
/// If the engine hasn't even produced a deeplink within this window, give up
/// and fall back to the visible flow. Cancelled once the deeplink arrives —
/// after that we wait indefinitely for the user to approve (or cancel).
public var startupTimeout: TimeInterval = 20
private var webView: WKWebView?
private var ready = false
private var pendingReadyWork: [() -> Void] = []
private var handlers: [String: (Result) -> Void] = [:]
private var timers: [String: Timer] = [:]
/// The wallet menu, pushed by the engine once it's ready (derived live from
/// the Dynamic catalogue — no static walletbook file). Set the callback to
/// receive it; the last value is replayed immediately if already delivered.
private var walletsList: [HeadlessWallet] = []
var onWallets: (([HeadlessWallet]) -> Void)? {
didSet { if !walletsList.isEmpty { onWallets?(walletsList) } }
}
// Keeps the app (and thus the hidden WebView's relay socket) alive for the
// ~30s the user spends approving in the wallet, so the session lands the
// instant they return instead of after a reconnect.
private var bgTask: UIBackgroundTaskIdentifier = .invalid
private override init() { super.init() }
// MARK: Public API
/// Build and load the hidden WebView ahead of time so the first connection
/// isn't slowed by SDK init + relay connect. Safe to call more than once.
public func prewarm() {
DispatchQueue.main.async { self.ensureWebView() }
}
public func connect(
walletKey: String,
chain: String?,
onResult: @escaping (Result) -> Void
) {
DispatchQueue.main.async {
self.ensureWebView()
// A previous attempt is still in flight (e.g. the user opened MetaMask,
// ignored the prompt, and is now trying another wallet). The SDK can
// hold a stuck pending connection that blocks the next mint — reset to
// a fresh engine so the new wallet gets a clean slate.
if !self.handlers.isEmpty { self.resetEngine() }
// UUID, not a sequential counter — a guessable/sequential ID lets any
// JS that ends up in this WebView forge a `connected` message for a
// request it never made (see the Critical bridge-trust finding).
let requestId = UUID().uuidString
self.handlers[requestId] = onResult
let work: () -> Void = { [weak self] in
guard let self else { return }
// Start the timer here, after the engine is ready, not at connect()
// time — so engine reload time (from a previous resetEngine()) doesn't
// eat into the 20s budget before the deeplink is even sent.
self.scheduleStartupTimeout(requestId)
self.drive(requestId: requestId, walletKey: walletKey, chain: chain)
}
if self.ready { work() } else { self.pendingReadyWork.append(work) }
}
}
// Reload the hidden WebView to abandon a stuck previous attempt. The reloaded
// page re-fires `ready` (flushing any queued connect) and re-pushes the list.
private func resetEngine() {
handlers.removeAll()
timers.values.forEach { $0.invalidate() }
timers.removeAll()
endBackgroundTask()
ready = false
pendingReadyWork.removeAll()
webView?.reload()
}
// The startup timer for a connect() only starts once the engine reaches
// `ready` (see the comment in `connect()`), so a `connect()` made — or
// queued — before that has nothing that will ever time it out. Normally
// `ready` arrives quickly; but if the engine's own page load never
// completes because the new deny-by-default navigation gate cancelled a
// redirect partway through it, `ready` never fires and those callers would
// otherwise hang forever. Called from the navigation-deny path below.
private func failPendingBeforeReady(code: String, message: String) {
guard !ready, !handlers.isEmpty else { return }
let pending = handlers
resetEngine()
for (_, handler) in pending {
DispatchQueue.main.async { handler(.failure(code: code, message: message)) }
}
}
/// Abort the in-flight attempt (e.g. the user backed out of the list).
public func cancel() {
DispatchQueue.main.async {
self.webView?.evaluateJavaScript("window.headlessConnect && window.headlessConnect.cancel('');", completionHandler: nil)
self.handlers.removeAll()
self.timers.values.forEach { $0.invalidate() }
self.timers.removeAll()
self.endBackgroundTask()
}
}
// MARK: WebView lifecycle
private func ensureWebView() {
guard webView == nil else { return }
let config = WKWebViewConfiguration()
// Ephemeral, per-launch — a connect-only flow needs nothing persisted,
// and the persistent store's slow session-resume is what makes repeat
// launches sluggish (same rationale as WebViewContainer).
config.websiteDataStore = .nonPersistent()
let ucc = WKUserContentController()
ucc.add(ScriptMessageProxy(self), name: "headless")
config.userContentController = ucc
let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
wv.isHidden = true
// The engine navigates to wallet deeplinks (e.g. Phantom's redirect); iOS
// won't open those from inside a WKWebView, so the delegate does.
wv.navigationDelegate = self
wv.uiDelegate = self
// A fully-detached WKWebView gets throttled/suspended by iOS. Keeping it
// in the window (hidden, 1×1) lets its JS + relay WebSocket keep running.
Self.keyWindow()?.addSubview(wv)
wv.load(URLRequest(url: engineURL))
webView = wv
}
/// Called from the app's `onOpenURL` when Phantom (or another redirect
/// wallet) returns to the app's custom scheme. Forwards the URL into the
/// hidden WebView so the engine can complete the connection. Returns `true`
/// if it consumed the URL.
@discardableResult
public func handleReturnURL(_ url: URL) -> Bool {
guard url.host?.lowercased() == "phantom-headless" else { return false }
let js = "window.headlessConnect && window.headlessConnect.handleReturnURL(\(Self.jsString(url.absoluteString)))"
DispatchQueue.main.async { self.webView?.evaluateJavaScript(js, completionHandler: nil) }
return true
}
// Encode a Swift string as a safe JS string literal (quotes + escaping).
private static func jsString(_ s: String) -> String {
guard
let data = try? JSONSerialization.data(withJSONObject: [s]),
let json = String(data: data, encoding: .utf8)
else { return "\"\"" }
return String(json.dropFirst().dropLast()) // ["..."] → "..."
}
private func drive(requestId: String, walletKey: String, chain: String?) {
var params: [String: String] = ["requestId": requestId, "walletKey": walletKey]
if let chain { params["chain"] = chain }
guard
let data = try? JSONSerialization.data(withJSONObject: params),
let json = String(data: data, encoding: .utf8)
else { return }
webView?.evaluateJavaScript("window.headlessConnect && window.headlessConnect.connect(\(json));", completionHandler: nil)
}
// MARK: Origin gating
// Shared by the bridge (below) and the navigation delegate: only the
// engine's own scheme+host+port may post a `connected` message or load
// top-level in this privileged WebView. Compared this way — not just
// host — so a same-host page served on a different scheme/port can't
// slip through either check.
fileprivate func isEngineOrigin(scheme: String?, host: String?, port: Int?) -> Bool {
let engineScheme = (engineURL.scheme ?? "https").lowercased()
let engineHost = (engineURL.host ?? "").lowercased()
guard !engineHost.isEmpty else { return false }
let enginePort = engineURL.port ?? Self.defaultPort(forScheme: engineScheme)
let candidateScheme = (scheme ?? "").lowercased()
let candidateHost = (host ?? "").lowercased()
let rawPort = port ?? 0
let candidatePort = rawPort == 0 ? Self.defaultPort(forScheme: candidateScheme) : rawPort
return candidateScheme == engineScheme && candidateHost == engineHost && candidatePort == enginePort
}
// WKSecurityOrigin/URL both report 0/nil for "no explicit port" — resolve
// that to the scheme's real default so https:x.com and https:x.com:443
// compare equal.
private static func defaultPort(forScheme scheme: String) -> Int {
switch scheme {
case "https": return 443
case "http": return 80
default: return -1
}
}
// MARK: Bridge
fileprivate func handleMessage(_ body: Any) {
guard
let str = body as? String,
let data = str.data(using: .utf8),
let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
let type = obj["type"] as? String
else { return }
switch type {
case "ready":
ready = true
let work = pendingReadyWork
pendingReadyWork = []
work.forEach { $0() }
case "wallets":
if let raw = obj["wallets"],
let data = try? JSONSerialization.data(withJSONObject: raw),
let list = try? JSONDecoder().decode([HeadlessWallet].self, from: data) {
walletsList = list
onWallets?(list)
}
case "deeplink":
// requestId must belong to the attempt actually in flight — `handlers`
// only ever holds the current attempt's ID (a new connect() resets any
// stale one), so this doubles as the drop-if-stale/forged check.
guard let requestId = obj["requestId"] as? String, handlers[requestId] != nil else { break }
guard let urlStr = obj["url"] as? String, let u = URL(string: urlStr) else {
cancelTimer(requestId)
beginBackgroundTask()
break
}
// Open the wallet deeplink. Cancel the startup timer only on success —
// if the app isn't installed, tell the engine immediately so it can
// fall back to the visible flow rather than hanging until timeout.
UIApplication.shared.open(u, options: [:]) { [weak self] ok in
DispatchQueue.main.async {
guard let self else { return }
if ok {
self.cancelTimer(requestId)
self.beginBackgroundTask()
} else {
let js = "window.headlessConnect && window.headlessConnect.onDeeplinkFailed && window.headlessConnect.onDeeplinkFailed(\(Self.jsString(requestId)))"
self.webView?.evaluateJavaScript(js, completionHandler: nil)
}
}
}
case "opening":
// The wallet is being opened via WebView navigation (Phantom). Same
// as deeplink: stop the fallback timer, keep alive, wait for the user.
// Same requestId-must-match-the-in-flight-attempt check as "deeplink".
guard let requestId = obj["requestId"] as? String, handlers[requestId] != nil else { break }
cancelTimer(requestId)
beginBackgroundTask()
case "connected":
// A missing/empty address is a malformed payload, not a "connected"
// wallet with a blank address — route it to failure instead of
// silently succeeding with "" (see Finding 19).
guard let address = obj["address"] as? String, !address.isEmpty else {
finish(
obj["requestId"] as? String,
.failure(code: "malformed_result", message: "connected message missing address")
)
break
}
finish(obj["requestId"] as? String, .success(WalletConnection(
address: address,
chain: obj["chain"] as? String ?? "",
walletName: obj["walletName"] as? String ?? "",
walletImage: obj["walletImage"] as? String ?? ""
)))
case "fallback":
finish(obj["requestId"] as? String, .fallbackRequired(reason: obj["reason"] as? String ?? ""))
case "error":
finish(
obj["requestId"] as? String,
.failure(code: obj["code"] as? String ?? "unknown", message: obj["message"] as? String ?? "")
)
case "event":
break // diagnostics timeline — hook up logging/analytics here if wanted
default:
break
}
}
private func finish(_ requestId: String?, _ result: Result) {
guard let requestId, let handler = handlers[requestId] else { return }
handlers[requestId] = nil
timers[requestId]?.invalidate()
timers[requestId] = nil
if handlers.isEmpty { endBackgroundTask() }
DispatchQueue.main.async { handler(result) }
// Reload the hidden WebView after every outcome so the Dynamic/WC session is
// clean for the next connect. A reload navigates back to headless.html (not a
// wallet URL), so the nav delegate won't open anything externally — unlike
// calling logout() from JS, which navigates to a wallet disconnect endpoint.
resetEngine()
}
private func scheduleStartupTimeout(_ requestId: String) {
let timer = Timer.scheduledTimer(withTimeInterval: startupTimeout, repeats: false) { [weak self] _ in
self?.finish(requestId, .fallbackRequired(reason: "headless startup timeout"))
}
timers[requestId] = timer
}
private func cancelTimer(_ requestId: String?) {
guard let requestId else { return }
timers[requestId]?.invalidate()
timers[requestId] = nil
}
private func beginBackgroundTask() {
endBackgroundTask()
bgTask = UIApplication.shared.beginBackgroundTask(withName: "headless-connect") { [weak self] in
self?.endBackgroundTask() // iOS is reclaiming the task — release it.
}
}
private func endBackgroundTask() {
guard bgTask != .invalid else { return }
UIApplication.shared.endBackgroundTask(bgTask)
bgTask = .invalid
}
private static func keyWindow() -> UIWindow? {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first { $0.isKeyWindow }
}
}
// MARK: - Navigation: open wallet deeplinks the WebView can't
// The engine (Phantom's redirect) navigates the WebView to wallet deeplinks and
// universal links. iOS won't hand those to the wallet app from inside a
// WKWebView, so intercept and open them externally — the same glue the visible
// WKWebView container uses. The engine's own page load (https) is allowed.
extension FireblocksHeadlessConnect: WKNavigationDelegate, WKUIDelegate {
public func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
guard let url = navigationAction.request.url else { return decisionHandler(.allow) }
let scheme = (url.scheme ?? "").lowercased()
if scheme != "http", scheme != "https", scheme != "about", scheme != "blob", scheme != "data" {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
return decisionHandler(.cancel)
}
if let host = url.host?.lowercased(),
Self.walletUniversalLinkHosts.contains(where: { host == $0 || host.hasSuffix(".\($0)") }) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
return decisionHandler(.cancel)
}
// Deny-by-default: only the engine's own origin may load top-level in
// this privileged WebView (the bridge is attached regardless of what
// page is showing). This also denies about:/blob:/data: — none of
// which the engine legitimately navigates to — closing the "any https
// page loads into the privileged WebView" gap from the Critical
// bridge-trust finding. Logged (not silently dropped) since a denial
// here means something unexpected tried to navigate this WebView.
//
// This delegate method fires for subframe navigation too, and this
// check isn't scoped to the main frame only — so a hostile subframe
// is denied here as well. That's belt-and-suspenders: the bridge's
// `message.frameInfo.isMainFrame` check (see ScriptMessageProxy) is
// what actually protects against a subframe posting bridge messages,
// independent of this navigation gate.
guard isEngineOrigin(scheme: scheme, host: url.host, port: url.port) else {
NSLog("FireblocksHeadlessConnect: denying navigation to non-engine origin: %@", url.absoluteString)
// See failPendingBeforeReady: if this denial happened before the
// engine ever reached `ready`, nothing else will ever fail out the
// attempt(s) waiting on it.
failPendingBeforeReady(
code: "navigation_denied",
message: "blocked a navigation to a non-engine origin before the engine became ready"
)
return decisionHandler(.cancel)
}
decisionHandler(.allow)
}
// Some SDKs open the wallet via window.open rather than a location change.
public func webView(
_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
if let url = navigationAction.request.url {
let scheme = (url.scheme ?? "").lowercased()
if scheme != "http", scheme != "https" {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
return nil
}
}
// MARK: - Weak message-handler proxy
// WKUserContentController retains its message handlers; a direct `add(self, …)`
// would create a retain cycle (webView → config → controller → self → webView).
// This weak proxy breaks it.
private final class ScriptMessageProxy: NSObject, WKScriptMessageHandler {
weak var target: FireblocksHeadlessConnect?
init(_ target: FireblocksHeadlessConnect) { self.target = target }
func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
// Only trust messages posted by the engine's own top-level frame. Without
// this, any JS that ends up running in this WebView — a compromised
// script, an XSS on the hosted page, a page reached via the navigation
// gate above having a gap — could forge a `connected` message and the
// native side would believe an attacker-chosen wallet address is
// connected. See the Critical bridge-trust finding.
guard let target else { return }
let origin = message.frameInfo.securityOrigin
guard message.frameInfo.isMainFrame,
target.isEngineOrigin(scheme: origin.protocol, host: origin.host, port: origin.port)
else { return }
target.handleMessage(message.body)
}
}
3. Sign a message
The hosted engine supportswindow.headlessConnect.sign. After a successful headless connect, call sign() with any string. The wallet app prompts the user to approve; the callback delivers a hex signature (EVM) or base58 (Solana).
SignView.swift
FireblocksHeadlessConnect.shared.sign(
message: "Sign in to MyApp · \(ISO8601DateFormatter().string(from: Date()))"
) { result in
switch result {
case .success(let signature):
// signature: hex string (EVM) or base58 (Solana)
case .failure(let code, let message):
// code: "user_rejected" | "timeout" | "unknown" | …
}
}
4. Sign a transaction
Pass a serialized transaction tosignTransaction() (engine: signTx). This only signs. It does not broadcast. Format and return value differ by chain.
SignView.swift (EVM)
// Minimal self-transfer to demonstrate signing; substitute your tx fields.
let tx = #"{"to":"\#(wallet.address)","value":"0x0","data":"0x"}"#
FireblocksHeadlessConnect.signTransaction(transaction: tx) { result in
switch result {
case .success(let signedTx):
// signedTx: RLP-encoded hex string (e.g. 0xf86c…)
// broadcast with eth_sendRawTransaction when ready
case .failure(let code, _): break
}
}
SignView.swift (Solana)
// txBytes: your serialized VersionedTransaction as Data
let b64 = txBytes.base64EncodedString()
FireblocksHeadlessConnect.signTransaction(transaction: b64) { result in
switch result {
case .success(let signedTx):
// signedTx: base64-encoded signed transaction bytes
case .failure(let code, _): break
}
}
5. Render the list (your UI)
On tap, route to the engine (mode: "headless") or the visible flow (mode: "fallback"). The engine also returns .fallbackRequired for anything it cannot do silently, so you fall back automatically. WalletListView is a sample you would swap for your own design.
View WalletListView.swift (sample list UI)
View WalletListView.swift (sample list UI)
WalletListView.swift
import SwiftUI
import WebKit
// MARK: - Native wallet list
/// The app-owned wallet picker. Renders the list natively and, on tap, drives
/// the headless engine (or the visible flow for fallback wallets). All the
/// connection logic — and the wallet list itself — comes from the web layer.
struct WalletListView: View {
/// Your hosted connect page (used only for the visible fallback flow).
let flowURL: URL
/// Your app's registered URL scheme (fallback flow's callback).
let scheme: String
let onConnected: (WalletConnection) -> Void
@State private var wallets: [HeadlessWallet] = []
@State private var connectingKey: String?
@State private var chainPickerFor: HeadlessWallet?
@State private var errorText: String?
@State private var search = ""
// WKWebView-based fallback for wallets whose inAppBrowser flow triggers an
// iOS "Open in App?" prompt inside ASWebAuthenticationSession.
@State private var showFallback = false
@State private var fallbackURL = URL(string: "about:blank")!
private var filtered: [HeadlessWallet] {
let q = search.trimmingCharacters(in: .whitespaces).lowercased()
// Default: just the featured wallets (like the web home). While
// searching: span the whole catalogue.
guard !q.isEmpty else { return wallets.filter { $0.featured == true } }
return wallets.filter { $0.name.lowercased().contains(q) || $0.key.lowercased().contains(q) }
}
var body: some View {
Group {
if let key = connectingKey, let wallet = wallets.first(where: { $0.key == key }) {
// Show a dedicated connecting screen while the engine mints /
// the wallet opens — otherwise the list would flash back between
// picking a chain and the wallet opening.
connectingView(wallet)
} else if let wallet = chainPickerFor {
chainPicker(wallet)
} else {
walletList
}
}
// The engine pushes the wallet list once it's ready (prewarmed at launch).
.onAppear { FireblocksHeadlessConnect.shared.onWallets = { wallets = $0 } }
.fullScreenCover(isPresented: $showFallback, onDismiss: { connectingKey = nil }) {
FallbackWebSheet(url: fallbackURL, scheme: scheme) { result in
showFallback = false
connectingKey = nil
switch result {
case .success(let connected): onConnected(connected)
case .failure(.cancelled): break
case .failure(let err): errorText = "Couldn't connect (\(err))."
}
}
}
}
@ViewBuilder
private func connectingView(_ wallet: HeadlessWallet) -> some View {
VStack(spacing: 16) {
Spacer()
WalletImageView(source: wallet.icon)
Text("Opening \(wallet.name)…").font(.headline).foregroundStyle(Theme.ink)
Text("Approve the connection in \(wallet.name), then you'll come back here.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
ProgressView().padding(.top, 4)
Spacer()
Button("Cancel") {
FireblocksHeadlessConnect.shared.cancel()
connectingKey = nil
}
.font(.footnote)
.tint(Theme.blue)
}
.padding(24)
}
// MARK: Wallet list
private var walletList: some View {
VStack(spacing: 10) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("Search for your wallet", text: $search)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
if !search.isEmpty {
Button { search = "" } label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.tertiary)
}
}
}
.padding(10)
.background(Color(hex: 0xF0F2F5))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
if let errorText {
Text(errorText).font(.footnote).foregroundStyle(.orange)
}
if wallets.isEmpty {
Spacer()
ProgressView("Loading wallets…").font(.footnote)
Spacer()
} else {
List {
ForEach(filtered) { wallet in
Button { tap(wallet) } label: { row(wallet) }
.disabled(connectingKey != nil)
}
}
.listStyle(.plain)
}
}
}
// MARK: Chain picker (multi-chain wallet) — a screen, like the web flow
@ViewBuilder
private func chainPicker(_ wallet: HeadlessWallet) -> some View {
VStack(spacing: 18) {
HStack {
Button {
chainPickerFor = nil
} label: {
Label("Back", systemImage: "chevron.left").font(.callout)
}
.tint(Theme.blue)
Spacer()
}
VStack(spacing: 10) {
WalletImageView(source: wallet.icon)
Text("Choose a chain").font(.title3.bold()).foregroundStyle(Theme.ink)
Text("\(wallet.name) works on more than one chain. Pick where you'd like to connect.")
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
VStack(spacing: 10) {
ForEach(wallet.chains, id: \.self) { chain in
Button { start(wallet, chain: chain) } label: { chainTile(chain) }
.disabled(connectingKey != nil)
}
}
Spacer()
}
.padding(.top, 8)
}
@ViewBuilder
private func chainTile(_ chain: String) -> some View {
HStack(spacing: 12) {
ZStack {
RoundedRectangle(cornerRadius: 9, style: .continuous)
.fill(chain == "solana" ? Color(hex: 0x9945FF) : Theme.blue)
Image(systemName: chain == "solana" ? "bolt.fill" : "diamond.fill")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.white)
}
.frame(width: 34, height: 34)
VStack(alignment: .leading, spacing: 2) {
Text(chainLabel(chain)).font(.headline).foregroundStyle(Theme.ink)
Text(chainSubtitle(chain)).font(.footnote).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right").font(.footnote).foregroundStyle(.tertiary)
}
.padding(14)
.frame(maxWidth: .infinity)
.background(Color(hex: 0xF9FAFB))
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
.contentShape(Rectangle())
}
// MARK: Rows
@ViewBuilder
private func row(_ wallet: HeadlessWallet) -> some View {
HStack(spacing: 12) {
WalletImageView(source: wallet.icon)
Text(wallet.name).font(.headline).foregroundStyle(Theme.ink)
Spacer()
if connectingKey == wallet.key {
ProgressView()
} else {
Image(systemName: "chevron.right").font(.footnote).foregroundStyle(.tertiary)
}
}
.padding(.vertical, 4)
.contentShape(Rectangle())
}
// MARK: Actions
private func tap(_ wallet: HeadlessWallet) {
errorText = nil
if wallet.isMultiChain {
chainPickerFor = wallet // let the user pick evm vs solana
} else {
start(wallet, chain: wallet.chains.first)
}
}
private func start(_ wallet: HeadlessWallet, chain: String?) {
chainPickerFor = nil
if wallet.mode == "fallback" {
openFallback(wallet, chain: chain)
return
}
connectingKey = wallet.key
FireblocksHeadlessConnect.shared.connect(walletKey: wallet.key, chain: chain) { result in
connectingKey = nil
switch result {
case .success(let connected):
onConnected(connected)
case .fallbackRequired:
openFallback(wallet, chain: chain)
case .failure(let code, _):
errorText = "Couldn't connect (\(code))."
}
}
}
// The visible flow, deep-linked straight to this wallet via `?wallet=` so it
// Opens the visible connect page for wallets that can't go headless.
//
// Passkey wallets (e.g. baseaccount / Coinbase Smart Wallet) need
// ASWebAuthenticationSession: it runs in a privileged browser context that has
// access to the system passkey APIs. A plain WKWebView doesn't get those
// entitlements, so the Face ID / passkey prompt never appears.
//
// Other fallback wallets use a WKWebView sheet whose nav delegate can
// intercept wallet Universal Links and open them directly — avoiding the
// "Open in App?" system prompt that ASWebAuth triggers.
private static let passkeyWallets: Set<String> = ["baseaccount"]
private func openFallback(_ wallet: HeadlessWallet, chain: String?) {
guard var comps = URLComponents(url: flowURL, resolvingAgainstBaseURL: false) else { return }
var items = comps.queryItems ?? []
items.append(URLQueryItem(name: "wallet", value: wallet.key))
items.append(URLQueryItem(name: "redirect_uri", value: "\(scheme)://wallet-callback"))
items.append(URLQueryItem(name: "embedded", value: "1"))
if let chain { items.append(URLQueryItem(name: "chain", value: chain)) }
comps.queryItems = items
guard let url = comps.url else { return }
connectingKey = wallet.key
if Self.passkeyWallets.contains(wallet.key) {
FireblocksConnectFlow.present(flowURL: url, scheme: scheme) { result in
connectingKey = nil
switch result {
case .success(let connected): onConnected(connected)
case .failure(.cancelled): break
case .failure(let err): errorText = "Couldn't connect (\(err))."
}
}
} else {
fallbackURL = url
showFallback = true
}
}
private func chainLabel(_ chain: String) -> String {
chain == "solana" ? "Solana" : "Ethereum & EVM"
}
private func chainSubtitle(_ chain: String) -> String {
chain == "solana" ? "Solana network" : "Polygon, Base, Arbitrum & more"
}
}
// MARK: - WKWebView fallback sheet
private struct FallbackWebSheet: View {
let url: URL
let scheme: String
let onResult: (Result<WalletConnection, FireblocksConnectError>) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
FallbackWebContainer(url: url, scheme: scheme, onResult: onResult)
.ignoresSafeArea(edges: .bottom)
.navigationTitle("Connect")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
onResult(.failure(.cancelled))
}
}
}
}
}
}
private struct FallbackWebContainer: UIViewRepresentable {
let url: URL
let scheme: String
let onResult: (Result<WalletConnection, FireblocksConnectError>) -> Void
func makeCoordinator() -> Coordinator { Coordinator(scheme: scheme, onResult: onResult) }
func makeUIView(context: Context) -> WKWebView {
let config = WKWebViewConfiguration()
config.websiteDataStore = .nonPersistent()
let wv = WKWebView(frame: .zero, configuration: config)
wv.navigationDelegate = context.coordinator
wv.uiDelegate = context.coordinator
wv.load(URLRequest(url: url))
return wv
}
func updateUIView(_ webView: WKWebView, context: Context) {}
static func dismantleUIView(_ webView: WKWebView, coordinator: Coordinator) {
webView.stopLoading()
webView.navigationDelegate = nil
webView.uiDelegate = nil
webView.loadHTMLString("", baseURL: nil)
}
final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
let scheme: String
let onResult: (Result<WalletConnection, FireblocksConnectError>) -> Void
private var finished = false
init(scheme: String, onResult: @escaping (Result<WalletConnection, FireblocksConnectError>) -> Void) {
self.scheme = scheme
self.onResult = onResult
}
func webView(_ webView: WKWebView, decidePolicyFor action: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
guard let url = action.request.url else { return decisionHandler(.allow) }
let s = (url.scheme ?? "").lowercased()
// Callback return — parse result and deliver.
if s == scheme.lowercased() {
decisionHandler(.cancel)
guard !finished else { return }
finished = true
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
let dict = Dictionary(items.map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { a, _ in a })
guard let address = dict["address"], !address.isEmpty else {
DispatchQueue.main.async { self.onResult(.failure(.malformedResult)) }
return
}
DispatchQueue.main.async {
self.onResult(.success(WalletConnection(
address: address,
chain: dict["chain"] ?? "",
walletName: dict["walletName"] ?? "",
walletImage: dict["walletImage"] ?? ""
)))
}
return
}
// Custom schemes (metamask://, phantom://, wc:, …) — open in wallet app.
if s != "http", s != "https", s != "about", s != "blob", s != "data" {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
decisionHandler(.cancel)
return
}
// Let all https URLs load in the WKWebView. Fallback-mode wallets
// (e.g. Base account) rely on an in-page flow: the connect page stays
// loaded here and the final redirect arrives as myapp:// above.
// Opening Universal Links externally would break that return path.
decisionHandler(.allow)
}
// target=_blank links — load in same frame or open custom schemes.
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
for action: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
if let url = action.request.url {
let s = (url.scheme ?? "").lowercased()
if s == "http" || s == "https" { webView.load(action.request) }
else { UIApplication.shared.open(url, options: [:], completionHandler: nil) }
}
return nil
}
}
}
6. The bridge (for reference)
You do not write the bridge. It is what flows between the hidden view and the harness files. Handy when debugging.bridge messages
// web → app (connect) request-scoped messages carry requestId
ready engine initialized
wallets { wallets: […] } the wallet menu (live)
deeplink { requestId, url } app opens the wallet
opening { requestId } wallet opening (Phantom)
connected { requestId, address, chain, … } success
fallback { requestId, reason } can't go headless → visible flow
error { requestId, code, message } failed
event { requestId?, event, sessionId, t } diagnostic timeline
// web → app (sign)
signed { requestId, signature } message signed (hex string)
signFailed { requestId, code, message } sign failed
signedTx { requestId, signedTransaction, chain } tx signed
signTxFailed { requestId, code, message } tx sign failed
// app → web
window.headlessConnect.connect({ requestId, walletKey, chain })
window.headlessConnect.cancel(requestId)
window.headlessConnect.handleReturnURL(url) // redirect wallets
window.headlessConnect.sign({ requestId, message })
window.headlessConnect.signTx({ requestId, transaction })
requestId as a fresh UUID per connect and drops any message whose id does not match the one still in flight, so a stale or forged reply cannot complete a newer request. It also drops anything posted from an origin other than the engine URL, and rejects a connected message without a non-empty address as malformed_result.
Common pitfalls
- Keep the hidden web view in the hierarchy. A fully detached
WKWebViewgets suspended by iOS and its relay socket stalls. Keep it 1×1 and hidden. - Test on a physical device. Wallets do not run in the Simulator.
- Serve over HTTPS. The flow mints WalletConnect URIs via WebCrypto, which needs a secure context.
- Native deeplinks are faster. Universal links round-trip through the wallet’s link server first; the hosted page prefers native schemes when embedded.