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 the
/headless.html engine route, 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 (below) and FireblocksConnectFlow.swift from the basic iOS guide (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": ["evm","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. Set environmentId before prewarm() to target a different Dynamic environment, and locale / theme the same way to override the engine’s UI locale and theme; all three are forwarded to the iframe app as query parameters.
Connect.swift
FireblocksHeadlessConnect.shared.environmentId = "b1e3aca9-0646-411a-b4ab-c31ce49935b3"
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). It consumes <scheme>://phantom-headless, the link Phantom opens to bring your app back after a connection (see Connect Phantom).
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?
/// The wallet's in-app-browser URL template (`{{encodedDappURI}}`), when
/// the wallet book carries one.
///
/// 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
/// `FireblocksWalletBrowserFlow`). The template says nothing about WHICH
/// chains that browser serves, so don't infer chain support from it.
public let inAppBrowser: String?
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)
}
public enum SignResult {
case success(signature: String)
case failure(code: String, message: String)
}
/// Result of `signTransaction`, which signs without broadcasting.
public enum SignTxResult {
case success(signedTransaction: String, chain: String)
case failure(code: String, message: String)
}
public enum SendResult {
/// `chain` is "evm", "solana", or "bitcoin" — `txHash` is the
/// on-chain transaction hash (EVM), signature (Solana, which IS the
/// transaction's identifier), or transaction ID (Bitcoin); either way
/// it's already submitted.
case success(txHash: String, chain: String)
case failure(code: String, message: String)
}
/// The no-UI engine page (headless.html). `returnScheme` is your app scheme:
/// Phantom's in-app browser returns to `<scheme>://phantom-headless`.
public var engineURL = URL(string: "https://connect.dynamicauth.com/headless.html?returnScheme=myapp")!
/// Dynamic environment ID for the engine page to use instead of the one it was
/// built with, sent as `?environmentId=<uuid>`. `nil`/empty leaves the page on
/// its own default, which is the pre-existing behavior.
///
/// Set this BEFORE `prewarm()`/`connect(...)`: the URL is resolved when the
/// hidden WKWebView is first created, so a later change only takes effect on
/// the next engine reload. Pass the same value to `FireblocksConnectFlow` so
/// the visible fallback flow lands on the same environment.
public var environmentId: String?
/// UI locale for the iframe app, sent as `?locale=<value>` — e.g. `"en_US"`,
/// `"es_LA"`; see the iframe app's README for the current allow-list.
/// `nil`/empty leaves the page on its own default (`en_US`). Never changes
/// anything on the native side — it's forwarded as-is to the iframe app.
///
/// Set this BEFORE `prewarm()`/`connect(...)`: same resolution timing as
/// `environmentId`.
public var locale: String?
/// UI theme for the iframe app, sent as `?theme=<value>` — `"light"` or
/// `"dark"`. `nil`/empty leaves the page on its own default (`light`).
/// Never changes anything on the native side — it's forwarded as-is to
/// the iframe app.
///
/// Set this BEFORE `prewarm()`/`connect(...)`: same resolution timing as
/// `environmentId`.
public var theme: String?
/// `engineURL` with `environmentId`/`locale`/`theme` applied. Only the
/// query changes, so the origin `isEngineOrigin` gates on is unaffected.
private var resolvedEngineURL: URL {
guard var components = URLComponents(url: engineURL, resolvingAgainstBaseURL: false)
else { return engineURL }
var items = components.queryItems ?? []
let overrides: [(String, String?)] = [
("environmentId", environmentId),
("locale", locale),
("theme", theme),
]
for (name, value) in overrides {
items.removeAll { $0.name == name }
if let value, !value.isEmpty {
items.append(URLQueryItem(name: name, value: value))
}
}
components.queryItems = items
return components.url ?? engineURL
}
/// 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
/// Requires the engine URL to point to a build that includes sign support.
public var signTimeout: TimeInterval = 60
/// Longer than `signTimeout`: a send can involve two sequential wallet
/// approvals (switch network, then send) rather than one, so 60s is too
/// tight a budget to fail loud on before the user's had a fair chance to
/// clear both prompts.
public var sendTimeout: TimeInterval = 120
private var webView: WKWebView?
private var ready = false
private var pendingReadyWork: [() -> Void] = []
private var handlers: [String: (Result) -> Void] = [:]
private var timers: [String: Timer] = [:]
// Sign/signTx/sendTx are independent of the connect attempt above (a wallet
// stays connected while the caller signs/sends any number of times), so
// they get their own handler/timer dictionaries rather than sharing
// `handlers`/`timers` — those two stay connect-only. Unlike `finish`
// (connect's completion path, which reloads the engine between attempts
// — see `resetEngine`'s doc comment), `finishSign`/`finishSendTx` below
// deliberately do NOT reload: that would tear down the very WalletConnect
// session a sign/send needs to still be alive for the next call.
private var signHandlers: [String: (SignResult) -> Void] = [:]
private var signTimers: [String: Timer] = [:]
private var signTxHandlers: [String: (SignTxResult) -> Void] = [:]
private var signTxTimers: [String: Timer] = [:]
private var sendTxHandlers: [String: (SendResult) -> Void] = [:]
private var sendTxTimers: [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()
}
}
/// Sign `message` with the currently-connected wallet. Resolves once via
/// `onResult`. Works for whatever chain the connected wallet is on (EVM
/// or Solana) — this method doesn't know or care which.
public func sign(message: String, onResult: @escaping (SignResult) -> Void) {
DispatchQueue.main.async {
let requestId = UUID().uuidString
self.signHandlers[requestId] = onResult
self.signTimers[requestId] = Timer.scheduledTimer(withTimeInterval: self.signTimeout, repeats: false) { [weak self] _ in
self?.finishSign(requestId, .failure(code: "timeout", message: "Wallet did not respond in time"))
}
guard
let data = try? JSONSerialization.data(withJSONObject: ["requestId": requestId, "message": message]),
let json = String(data: data, encoding: .utf8)
else { return }
self.webView?.evaluateJavaScript("window.headlessConnect && window.headlessConnect.sign(\(json));", completionHandler: nil)
}
}
/// Send a transaction with the currently-connected wallet — EVM, Solana,
/// or Bitcoin. Resolves once via `onResult`.
///
/// The wallet signs AND broadcasts in one step: `eth_sendTransaction` for
/// EVM, `signAndSendTransaction` for Solana (the wallet-adapter-standard
/// analog every Solana wallet implements).
///
/// `transaction` format depends on which chain the connected wallet is
/// on — this method doesn't know or care which, it's just an opaque
/// string the engine interprets:
/// - EVM: JSON string `{"to":"0x…","value":"0x0","data":"0x","chainId":"0x1"}`
/// — `chainId` is required; the engine verifies it against the
/// wallet's own active network before sending (see
/// `FireblocksSendFlow.send`'s docs for the same `SendResult.failure`
/// codes this can surface — `chain_switch_unavailable`,
/// `chain_not_configured`, `chain_switch_rejected`, `chain_mismatch`).
/// - Solana: base64-encoded, pre-built `VersionedTransaction` (or legacy
/// `Transaction`) bytes — the caller is responsible for building it (a
/// native SOL transfer vs. an SPL-token transfer for USDC are both
/// just "whatever instructions are already in the bytes"); the engine
/// only signs and submits.
/// - Bitcoin: JSON string `{"recipientAddress":"bc1…","amountSats":"12345"}`
/// — `amountSats` is the amount in satoshis as a decimal-digit string.
/// The wallet's own `sendBitcoin` RPC signs and broadcasts it.
public func sendTransaction(transaction: String, onResult: @escaping (SendResult) -> Void) {
DispatchQueue.main.async {
let requestId = UUID().uuidString
self.sendTxHandlers[requestId] = onResult
self.sendTxTimers[requestId] = Timer.scheduledTimer(withTimeInterval: self.sendTimeout, repeats: false) { [weak self] _ in
self?.finishSendTx(requestId, .failure(
code: "timeout",
message: "Wallet did not respond in time — if you approved it, the transaction "
+ "may still have been submitted; check the explorer before retrying."
))
}
guard
let data = try? JSONSerialization.data(withJSONObject: ["requestId": requestId, "transaction": transaction]),
let json = String(data: data, encoding: .utf8)
else { return }
// `sendTx` is a newer addition to the engine than everything else
// this class calls — a deployment that predates it would
// otherwise leave this request silently unanswered until the
// timeout, indistinguishable from a dead wallet. Self-report
// immediately instead, straight over the same `headless` message
// handler `ScriptMessageProxy` already listens on.
let js = """
if (window.headlessConnect && window.headlessConnect.sendTx) {
window.headlessConnect.sendTx(\(json));
} else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.headless) {
window.webkit.messageHandlers.headless.postMessage(JSON.stringify({
type: "sentTxFailed",
requestId: \(Self.jsString(requestId)),
code: "unsupported_engine",
message: "engine build predates sendTx — redeploy connect.dynamicauth.com"
}));
}
"""
self.webView?.evaluateJavaScript(js, completionHandler: nil)
}
}
/// Sign a transaction with the currently-connected wallet without
/// broadcasting it. Resolves once via `onResult`.
///
/// `transaction` format depends on which chain the connected wallet is on:
/// - EVM: JSON string `{"to":"0x…","value":"0x0","data":"0x","chainId":"0x1"}`
/// - Solana: base64-encoded, pre-built `VersionedTransaction` (or legacy
/// `Transaction`) bytes.
/// - Bitcoin: JSON string
/// `{"unsignedPsbtBase64","allowedSighash","signature"}`. The result is
/// a base64 signed PSBT; the caller must finalize and broadcast it.
public func signTransaction(transaction: String, onResult: @escaping (SignTxResult) -> Void) {
DispatchQueue.main.async {
let requestId = UUID().uuidString
self.signTxHandlers[requestId] = onResult
self.signTxTimers[requestId] = Timer.scheduledTimer(withTimeInterval: self.signTimeout, repeats: false) { [weak self] _ in
self?.finishSignTx(requestId, .failure(code: "timeout", message: "Wallet did not respond in time"))
}
guard
let data = try? JSONSerialization.data(withJSONObject: ["requestId": requestId, "transaction": transaction]),
let json = String(data: data, encoding: .utf8)
else { return }
let js = """
if (window.headlessConnect && window.headlessConnect.signTx) {
window.headlessConnect.signTx(\(json));
} else if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.headless) {
window.webkit.messageHandlers.headless.postMessage(JSON.stringify({
type: "signTxFailed",
requestId: \(Self.jsString(requestId)),
code: "unsupported_engine",
message: "engine build predates signTx — redeploy connect.dynamicauth.com"
}));
}
"""
self.webView?.evaluateJavaScript(js, completionHandler: nil)
}
}
// 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: resolvedEngineURL))
webView = wv
}
/// Called from the app's `onOpenURL`. Consumes `<scheme>://phantom-headless`,
/// the zero-payload "Return to app" tap from Phantom's in-app browser: the
/// OS has already brought the app forward, and the engine gets the
/// connection over the WalletConnect relay, so there's nothing to forward.
/// Returns `true` if it consumed the URL.
@discardableResult
public func handleReturnURL(_ url: URL) -> Bool {
url.host?.lowercased() == "phantom-headless"
}
// 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 "openWallet":
// A WalletConnect sign/send request is waiting — wake the wallet app
// so its approval prompt surfaces (the request already went over the
// relay; without this it sits invisibly until the user opens the
// wallet by hand). requestId here is a sign/send id, never a connect
// attempt — no connect bookkeeping (handlers/timers) applies.
guard let urlStr = obj["url"] as? String, let u = URL(string: urlStr) else { break }
UIApplication.shared.open(u, options: [:], 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 ?? "",
// Present only for session-cluster-bound wallets (Phantom) —
// see WalletConnection.network's docs.
network: obj["network"] 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 "signed":
finishSign(
obj["requestId"] as? String,
.success(signature: obj["signature"] as? String ?? "")
)
case "signFailed":
finishSign(
obj["requestId"] as? String,
.failure(code: obj["code"] as? String ?? "unknown", message: obj["message"] as? String ?? "")
)
case "signedTx":
finishSignTx(
obj["requestId"] as? String,
.success(
signedTransaction: obj["signedTransaction"] as? String ?? "",
chain: obj["chain"] as? String ?? ""
)
)
case "signTxFailed":
finishSignTx(
obj["requestId"] as? String,
.failure(code: obj["code"] as? String ?? "unknown", message: obj["message"] as? String ?? "")
)
case "sentTx":
finishSendTx(
obj["requestId"] as? String,
.success(txHash: obj["txHash"] as? String ?? "", chain: obj["chain"] as? String ?? "")
)
case "sentTxFailed":
finishSendTx(
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
}
// Unlike `finish` above, these deliberately do NOT call `resetEngine()` —
// that would tear down the WalletConnect/Dynamic session a sign/send just
// used, breaking the NEXT sign/send call for the same connected wallet.
// The session is only torn down by a fresh `connect()` (which resets the
// engine for its own new attempt) — same as the Kotlin/Dart engines.
private func finishSign(_ requestId: String?, _ result: SignResult) {
guard let requestId, let handler = signHandlers[requestId] else { return }
signHandlers[requestId] = nil
signTimers[requestId]?.invalidate()
signTimers[requestId] = nil
DispatchQueue.main.async { handler(result) }
}
private func finishSignTx(_ requestId: String?, _ result: SignTxResult) {
guard let requestId, let handler = signTxHandlers[requestId] else { return }
signTxHandlers[requestId] = nil
signTxTimers[requestId]?.invalidate()
signTxTimers[requestId] = nil
DispatchQueue.main.async { handler(result) }
}
private func finishSendTx(_ requestId: String?, _ result: SendResult) {
guard let requestId, let handler = sendTxHandlers[requestId] else { return }
sendTxHandlers[requestId] = nil
sendTxTimers[requestId]?.invalidate()
sendTxTimers[requestId] = nil
DispatchQueue.main.async { handler(result) }
}
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.shared.signTransaction(transaction: tx) { result in
switch result {
case .success(let signedTransaction, _):
// signedTransaction: 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.shared.signTransaction(transaction: b64) { result in
switch result {
case .success(let signedTransaction, _):
// signedTransaction: 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
// MARK: - Connect-API demo
/// Internal demo, NOT the recommended integration pattern (that's what
/// `ExampleView`'s doc comment used to describe before this replaced it — see
/// git history for the original headless-first / auto-fallback wallet picker).
///
/// Every wallet+chain pair the catalogue reports is listed three times, once
/// per section, and each section calls exactly one underlying API no matter
/// what that wallet/chain actually needs — so tapping a row shows you
/// unfiltered, in isolation, what that specific API does for that specific
/// wallet+chain:
///
/// 1. "Open Authenticate Webview" — `FireblocksConnectFlow.present(...)` with
/// no wallet preselected; the web app's own UI runs inside the system
/// `ASWebAuthenticationSession` sheet.
/// 2. "Connect wallet via Authenticate Webview" — same API, but with
/// `?wallet=&chain=` preselected so it jumps straight to one wallet+chain.
/// 3. "Connect wallet via Hidden Webview" — `FireblocksHeadlessConnect.shared.connect(walletKey:chain:)`,
/// the hidden no-UI WKWebView engine. A wallet/chain with no headless path
/// reports back `fallbackRequired` instead of connecting — shown inline
/// rather than auto-chaining into another flow, since the point here is to
/// see each API's raw answer.
struct WalletListView: View {
let hostedPageURL: URL
let scheme: String
var environmentId: String? = nil
let onConnected: (WalletConnection) -> Void
@State private var wallets: [HeadlessWallet] = []
@State private var connectingRowID: String?
@State private var rowOutcomes: [String: RowOutcome] = [:]
@State private var errorText: String?
// Wallet+chain combos where the headless engine is KNOWN to have no
// drivable path — Base Account's passkey/email connect needs a visible
// page. A `fallbackRequired` for one of these is the correct, expected
// answer; for anything else, it would mean something's actually broken.
private static let expectedFallback: Set<String> = ["baseaccount-evm"]
private static let bareRowID = "bare-auth-webview"
/// Same "featured" tier the pre-redesign picker showed by default — the
/// catalogue's own `featured` flag, not a locally curated list.
private var topWallets: [HeadlessWallet] {
wallets.filter { $0.featured == true }
}
/// The chains offered for `wallet` — mirrors the pre-redesign picker's
/// `pickerChains`: the engine's own `chains`, plus a synthetic `"evm"`
/// entry for a wallet whose EVM surface only exists inside its own
/// in-app browser (Phantom, on an engine older than its headless EVM
/// bridge) and so never appears in `chains` itself. See
/// the old `offersWalletBrowserEvm` doc comment (git history) for why
/// this is scoped to Phantom specifically rather than inferred from
/// `inAppBrowser` alone.
private func chains(for wallet: HeadlessWallet) -> [String] {
wallet.chains + (offersWalletBrowserEvm(wallet) ? ["evm"] : [])
}
private func offersWalletBrowserEvm(_ wallet: HeadlessWallet) -> Bool {
wallet.key.lowercased() == "phantom"
&& wallet.inAppBrowser != nil
&& !wallet.chains.contains("evm")
}
private struct RowOutcome {
let text: String
let isExpected: Bool
}
private struct WalletChainRow: Identifiable {
let id: String
let wallet: HeadlessWallet
let chain: String
var label: String { "\(wallet.name) — \(WalletListView.chainLabel(chain))" }
}
private enum Flow { case authWebview, hiddenWebview }
private var rows: [WalletChainRow] {
topWallets.flatMap { wallet in
chains(for: wallet).map { chain in
WalletChainRow(id: "\(wallet.key)-\(chain)", wallet: wallet, chain: chain)
}
}
}
var body: some View {
Group {
if wallets.isEmpty {
VStack {
Spacer()
ProgressView("Loading wallets…").font(.footnote)
Spacer()
}
} else {
List {
if let errorText {
Text(errorText).font(.footnote).foregroundStyle(.orange)
}
Section {
Button {
openBareAuthWebview()
} label: {
HStack {
Text("Open Authenticate Webview").foregroundStyle(Theme.ink)
Spacer()
if connectingRowID == Self.bareRowID { ProgressView() }
}
}
.disabled(connectingRowID != nil)
} header: {
card("Calls FireblocksConnectFlow.present(...) with no wallet preselected — "
+ "the web app's own UI runs inside the ASWebAuthenticationSession sheet.")
}
Section {
ForEach(rows) { row in rowButton(row, flow: .authWebview) }
} header: {
card("Calls FireblocksConnectFlow.present(...) with ?wallet=&chain= preselected — "
+ "same ASWebAuth sheet, but jumps straight to one wallet+chain.")
}
Section {
ForEach(rows) { row in rowButton(row, flow: .hiddenWebview) }
} header: {
card("Calls FireblocksHeadlessConnect.shared.connect(walletKey:chain:) — the hidden "
+ "WKWebView engine. Wallets/chains with no headless path report back "
+ "fallbackRequired instead of connecting.")
}
}
.listStyle(.plain)
}
}
.onAppear { FireblocksHeadlessConnect.shared.onWallets = { wallets = $0 } }
}
@ViewBuilder
private func card(_ text: String) -> some View {
Text(text)
.font(.caption)
.foregroundStyle(.secondary)
.textCase(nil)
}
@ViewBuilder
private func rowButton(_ row: WalletChainRow, flow: Flow) -> some View {
let id = rowID(flow, row)
Button {
connect(row, flow: flow, id: id)
} label: {
HStack(spacing: 12) {
WalletImageView(source: row.wallet.icon)
Text(row.label).font(.subheadline).foregroundStyle(Theme.ink)
Spacer()
trailing(for: id)
}
.contentShape(Rectangle())
}
.disabled(connectingRowID != nil)
}
@ViewBuilder
private func trailing(for id: String) -> some View {
if connectingRowID == id {
ProgressView()
} else if let outcome = rowOutcomes[id] {
Text(outcome.text)
.font(.caption2)
.foregroundStyle(outcome.isExpected ? Theme.green : .orange)
.multilineTextAlignment(.trailing)
.frame(maxWidth: 160, alignment: .trailing)
} else {
Image(systemName: "chevron.right").font(.footnote).foregroundStyle(.tertiary)
}
}
private func rowID(_ flow: Flow, _ row: WalletChainRow) -> String {
"\(flow)-\(row.id)"
}
// MARK: Actions
private func openBareAuthWebview() {
errorText = nil
connectingRowID = Self.bareRowID
FireblocksConnectFlow.present(hostedPageURL: hostedPageURL, scheme: scheme, environmentId: environmentId) { result in
connectingRowID = nil
switch result {
case .success(let connected): onConnected(connected)
case .failure(.cancelled): break
case .failure(let err): errorText = "Couldn't connect (\(err))."
}
}
}
private func connect(_ row: WalletChainRow, flow: Flow, id: String) {
errorText = nil
rowOutcomes[id] = nil
connectingRowID = id
switch flow {
case .authWebview:
guard let url = hostedURL(wallet: row.wallet.key, chain: row.chain) else {
connectingRowID = nil
errorText = "Couldn't build the hosted URL."
return
}
FireblocksConnectFlow.present(hostedPageURL: url, scheme: scheme, environmentId: environmentId) { result in
connectingRowID = nil
switch result {
case .success(let connected):
onConnected(connected)
case .failure(.cancelled):
break
case .failure(let err):
rowOutcomes[id] = RowOutcome(text: "Failed (\(err))", isExpected: false)
}
}
case .hiddenWebview:
FireblocksHeadlessConnect.shared.connect(walletKey: row.wallet.key, chain: row.chain) { result in
connectingRowID = nil
switch result {
case .success(let connected):
onConnected(connected)
case .fallbackRequired:
let expected = Self.expectedFallback.contains(row.id)
rowOutcomes[id] = RowOutcome(
text: expected ? "✅ fallbackRequired\n(expected)" : "⚠️ fallbackRequired\n(unexpected)",
isExpected: expected
)
case .failure(let code, _):
rowOutcomes[id] = RowOutcome(text: "Failed (\(code))", isExpected: false)
}
}
}
}
/// Same query-param contract the hosted page has always used for the
/// visible flow: `wallet`/`chain` preselect it, `redirect_uri` is this
/// app's callback scheme, `embedded=1` tells the page it's in a WebView
/// rather than a real browser tab.
private func hostedURL(wallet: String, chain: String?) -> URL? {
guard var comps = URLComponents(url: hostedPageURL, resolvingAgainstBaseURL: false) else { return nil }
var items = comps.queryItems ?? []
items.append(URLQueryItem(name: "wallet", value: wallet))
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)) }
if let environmentId, !environmentId.isEmpty {
items.append(URLQueryItem(name: "environmentId", value: environmentId))
}
comps.queryItems = items
return comps.url
}
private static func chainLabel(_ chain: String) -> String {
switch chain {
case "evm": return "EVM"
case "solana": return "SVM"
case "bitcoin": return "BTC"
default: return chain.uppercased()
}
}
}
6. Connect Phantom
Connect Phantom on EVM or Solana with the same call as any other wallet:connect(walletKey: "phantom", chain: "evm") or chain: "solana". Phantom has no WalletConnect entry in Dynamic’s wallet book, so the engine bridges through Phantom’s own in-app browser:
- The engine mints a WalletConnect pairing and sends a
deeplinkthat opens your hosted page inside Phantom. - The page approves the pairing with Phantom’s injected provider, and the engine receives the connection over the WalletConnect relay.
- Phantom’s “Return to app” button opens
<scheme>://phantom-headlesswith no payload.handleReturnURLconsumes it, and yourconnectcallback receives the result from the engine.
timeout if Phantom does not approve within 60 seconds.
7. Use a wallet’s own browser (Phantom on EVM)
Phantom injects its EVM provider (window.phantom.ethereum) only inside its own in-app browser. To sign or send with it, open your hosted page inside Phantom’s browser: connect there, then reopen the same browser for each sign or send.
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.View FireblocksWalletBrowserFlow.swift (copy-paste ready)
View FireblocksWalletBrowserFlow.swift (copy-paste ready)
FireblocksWalletBrowserFlow.swift
import Security
import UIKit
/// Drives the hosted visible flow **inside a wallet's own in-app browser**.
///
/// ## Why this exists (and why it isn't `FireblocksConnectFlow`)
///
/// `FireblocksConnectFlow` / `FireblocksSignFlow` / `FireblocksSendFlow` open
/// the hosted page in `ASWebAuthenticationSession` — a system browser. That
/// covers every wallet reachable by WalletConnect, a deeplink, or an SDK (Base
/// Account). It cannot cover a wallet whose only mobile surface for a given
/// chain is the provider it injects into its OWN browser, because that provider
/// isn't there in the system browser.
///
/// Phantom on EVM is exactly that case, and it's why "Phantom EVM" never
/// worked: no `phantom*` entry in Dynamic's wallet book has a `walletConnect`
/// block, and `phantomevm` carries no mobile deeplink at all — its only surface
/// is `window.phantom.ethereum`, injected inside Phantom's in-app browser (see
/// `classifyChain`'s doc comment in `src/redirect.ts`). So the page has to be
/// opened THERE, and the result handed back over this app's URL scheme.
///
/// ## Mechanics
///
/// 1. Build the hosted-page URL (`?wallet=…&chain=evm`, or an
/// `?intent=signMessage|sendTx` variant) with
/// `redirect_uri=<scheme>://<callbackHost>` and a fresh nonce.
/// 2. Wrap it in the wallet's in-app-browser template (`{{encodedDappURI}}`,
/// replaced everywhere — Phantom's uses it twice) and hand it to the OS.
/// The template comes from the engine's wallet catalogue
/// (`HeadlessWallet.inAppBrowser`), i.e. from Dynamic's wallet book — never
/// hard-coded here.
/// 3. The wallet opens its browser on the page; the user connects/signs with
/// the injected provider; the page redirects to `<scheme>://<callbackHost>`.
/// 4. That URL lands on the app's `.onOpenURL` — NOT on an
/// `ASWebAuthenticationSession` callback, since no session is involved.
/// Forward it to `handleCallbackURL`. The callback host differs from
/// `wallet-callback` on purpose, so a return from the wallet's app can never
/// be mistaken for (or steal) an in-flight system-browser flow's callback.
///
/// ## Caveats worth knowing before shipping this
///
/// - Step 2 hands an `https` universal link to `UIApplication.open`. iOS routes
/// it to the wallet app when the wallet claims that domain and is installed;
/// otherwise it opens Safari, where nothing is injected and the page
/// correctly reports that the wallet has no path for this chain. That message
/// in a browser that ISN'T the wallet means the hand-off went to the wrong
/// app, not that the wallet can't do it.
/// - Whether a wallet's browser honours a custom-scheme navigation is the
/// wallet's choice. The hosted page always ALSO renders a "Return to the app"
/// anchor for custom-scheme targets (a real tap is the reliable path where a
/// programmatic navigation is ignored), so the user has a way back either way.
/// - Nothing here is silent: the wallet app comes to the foreground with a web
/// page in it, plus the wallet's own approval prompt.
public enum FireblocksWalletBrowserFlow {
/// Host of the callback URL this flow listens for. Deliberately not
/// `wallet-callback` — see the type's docs.
public static let callbackHost = "wallet-browser"
/// How long to wait for the return URL before giving up. The user is in
/// another app for this whole window (open, connect, approve, come back),
/// so this is generous by design; it exists so an abandoned flow can't
/// leave a completion handler pending for the process lifetime.
public static let timeout: TimeInterval = 300
// MARK: Connect
/// Connect `walletKey` on `chain` inside the wallet's own browser.
///
/// - Parameter walletBrowserURL: the wallet's in-app-browser template, from
/// `HeadlessWallet.inAppBrowser`.
/// - Parameter locale: UI locale for the hosted page — e.g. `"en_US"`,
/// `"es_LA"`. `nil`/empty omits the param.
/// - Parameter theme: UI theme for the hosted page — `"light"` or
/// `"dark"`. `nil`/empty omits the param.
///
/// The resulting `WalletConnection` carries `walletBrowserURL` so later
/// sign/send calls can be routed back into the same browser — the account
/// lives with that injected provider and nowhere else.
public static func connect(
hostedPageURL: URL,
scheme: String,
walletBrowserURL: String,
walletKey: String,
chain: String = "evm",
environmentId: String? = nil,
locale: String? = nil,
theme: String? = nil,
completion: @escaping (Result<WalletConnection, FireblocksConnectError>) -> Void
) {
start(
hostedPageURL: hostedPageURL,
scheme: scheme,
walletBrowserURL: walletBrowserURL,
environmentId: environmentId,
locale: locale,
theme: theme,
params: [
URLQueryItem(name: "wallet", value: walletKey),
URLQueryItem(name: "chain", value: chain),
]
) { outcome in
switch outcome {
case .cancelled: completion(.failure(.cancelled))
case .couldNotOpen: completion(.failure(.couldNotStart))
case .invalidURL: completion(.failure(.invalidURL))
case .nonceMismatch: completion(.failure(.nonceMismatch))
case .values(let values):
guard let address = values["address"], !address.isEmpty else {
return completion(.failure(.malformedResult))
}
completion(.success(WalletConnection(
address: address,
chain: values["chain"] ?? chain,
walletName: values["walletName"] ?? "",
walletImage: values["walletImage"] ?? "",
connectedHeadlessly: false,
walletBrowserURL: walletBrowserURL,
walletKey: walletKey
)))
}
}
}
// MARK: Sign
/// Sign `message` with a wallet connected through `connect`.
///
/// `expectedAddress` is REQUIRED and checked by the page itself: it connects
/// again inside the wallet's browser before signing, and without this it
/// would sign with whatever account happens to connect.
///
/// - Parameter locale: UI locale for the hosted page — e.g. `"en_US"`,
/// `"es_LA"`. `nil`/empty omits the param.
/// - Parameter theme: UI theme for the hosted page — `"light"` or
/// `"dark"`. `nil`/empty omits the param.
public static func sign(
hostedPageURL: URL,
scheme: String,
walletBrowserURL: String,
walletKey: String,
message: String,
expectedAddress: String,
environmentId: String? = nil,
locale: String? = nil,
theme: String? = nil,
completion: @escaping (Result<SignedMessage, FireblocksSignError>) -> Void
) {
start(
hostedPageURL: hostedPageURL,
scheme: scheme,
walletBrowserURL: walletBrowserURL,
environmentId: environmentId,
locale: locale,
theme: theme,
params: [
URLQueryItem(name: "intent", value: "signMessage"),
URLQueryItem(name: "walletKey", value: walletKey),
URLQueryItem(name: "message", value: message),
URLQueryItem(name: "expectedAddress", value: expectedAddress),
]
) { outcome in
switch outcome {
case .cancelled: completion(.failure(.cancelled))
case .couldNotOpen: completion(.failure(.couldNotStart))
case .invalidURL: completion(.failure(.invalidURL))
case .nonceMismatch: completion(.failure(.nonceMismatch))
case .values(let values):
if values["error"] == "1" {
return completion(.failure(.failed(
code: values["code"] ?? "unknown",
message: values["message"] ?? ""
)))
}
guard let signature = values["signature"], !signature.isEmpty else {
return completion(.failure(.malformedResult))
}
completion(.success(SignedMessage(signature: signature)))
}
}
}
// MARK: Send
/// Send an EVM transaction with a wallet connected through `connect`.
/// `to` / `chainId` / `value` / `data` / `gasLimit` are `0x`-prefixed hex,
/// same contract as `FireblocksSendFlow.send`. The wallet signs AND
/// broadcasts: a success is already on-chain.
///
/// - Parameter locale: UI locale for the hosted page — e.g. `"en_US"`,
/// `"es_LA"`. `nil`/empty omits the param.
/// - Parameter theme: UI theme for the hosted page — `"light"` or
/// `"dark"`. `nil`/empty omits the param.
public static func send(
hostedPageURL: URL,
scheme: String,
walletBrowserURL: String,
walletKey: String,
to: String,
chainId: String,
expectedAddress: String,
value: String = "0x0",
data: String = "0x",
gasLimit: String? = nil,
environmentId: String? = nil,
locale: String? = nil,
theme: String? = nil,
completion: @escaping (Result<SentTransaction, FireblocksSendError>) -> Void
) {
var params = [
URLQueryItem(name: "intent", value: "sendTx"),
URLQueryItem(name: "walletKey", value: walletKey),
URLQueryItem(name: "to", value: to),
URLQueryItem(name: "value", value: value),
URLQueryItem(name: "data", value: data),
URLQueryItem(name: "chainId", value: chainId),
URLQueryItem(name: "expectedAddress", value: expectedAddress),
]
if let gasLimit, !gasLimit.isEmpty {
params.append(URLQueryItem(name: "gasLimit", value: gasLimit))
}
start(
hostedPageURL: hostedPageURL,
scheme: scheme,
walletBrowserURL: walletBrowserURL,
environmentId: environmentId,
locale: locale,
theme: theme,
params: params
) { outcome in
switch outcome {
case .cancelled: completion(.failure(.cancelled))
case .couldNotOpen: completion(.failure(.couldNotStart))
case .invalidURL: completion(.failure(.invalidURL))
case .nonceMismatch: completion(.failure(.nonceMismatch))
case .values(let values):
if values["error"] == "1" {
return completion(.failure(.failed(
code: values["code"] ?? "unknown",
message: values["message"] ?? ""
)))
}
guard let txHash = values["txHash"], !txHash.isEmpty else {
return completion(.failure(.malformedResult))
}
completion(.success(SentTransaction(txHash: txHash)))
}
}
}
// MARK: Out-of-band return
/// Feed an inbound URL to this flow. Returns `true` when the URL was this
/// flow's callback (consumed, whether or not a request was waiting), so
/// callers can chain handlers in `.onOpenURL`:
///
/// ```swift
/// if FireblocksHeadlessConnect.shared.handleReturnURL(url) { return }
/// if FireblocksWalletBrowserFlow.handleCallbackURL(url) { return }
/// ```
@discardableResult
public static func handleCallbackURL(_ url: URL) -> Bool {
guard url.host?.lowercased() == callbackHost else { return false }
guard let pending else { return true } // ours, but nothing waiting
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? []
let values = Dictionary(items.map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { first, _ in first })
// A stale callback (an old page re-opened in the wallet's browser) must
// not resolve the request that IS in flight: drop it and keep waiting,
// rather than failing the live request with a nonce mismatch.
guard values["nonce"] == pending.nonce else { return true }
finish(.values(values))
return true
}
/// Abandon the in-flight request (e.g. the user navigated away in this app).
public static func cancel() { finish(.cancelled) }
// MARK: Private
private enum Outcome {
case values([String: String])
case cancelled
case couldNotOpen
case invalidURL
case nonceMismatch
}
private struct Pending {
let nonce: String
let token: Int
let complete: (Outcome) -> Void
}
/// One request at a time: the callback carries no request id beyond its
/// nonce, and two overlapping flows in the same wallet browser would race
/// for the same return URL.
private static var pending: Pending?
private static var tokenSeed = 0
private static func start(
hostedPageURL: URL,
scheme: String,
walletBrowserURL: String,
environmentId: String?,
locale: String? = nil,
theme: String? = nil,
params: [URLQueryItem],
complete: @escaping (Outcome) -> Void
) {
finish(.cancelled) // supersede anything still waiting
let nonce = Self.makeNonce()
guard var comps = URLComponents(url: hostedPageURL, resolvingAgainstBaseURL: false) else {
return complete(.invalidURL)
}
var reserved = params.map(\.name) + ["redirect_uri", "nonce", "embedded"]
var ours = params + [
URLQueryItem(name: "redirect_uri", value: "\(scheme)://\(callbackHost)"),
URLQueryItem(name: "nonce", value: nonce),
// The page runs inside a wallet's WebView here — say so explicitly
// rather than leaving it to user-agent guessing (see `getEnvInfo` in
// `src/env.ts`): among other things this stops it offering a
// deeplink connector that would bounce out of that browser.
URLQueryItem(name: "embedded", value: "1"),
]
for (name, value) in [("environmentId", environmentId), ("locale", locale), ("theme", theme)] {
if let value, !value.isEmpty {
reserved.append(name)
ours.append(URLQueryItem(name: name, value: value))
}
}
comps.queryItems = (comps.queryItems ?? []).filter { !reserved.contains($0.name) } + ours
guard let pageURL = comps.url,
let encoded = pageURL.absoluteString.addingPercentEncoding(withAllowedCharacters: uriComponentAllowed)
else { return complete(.invalidURL) }
// Some templates (Phantom's) use the placeholder more than once — as
// both the browse target and the `ref` — so replace every occurrence.
guard let target = URL(string: walletBrowserURL.replacingOccurrences(
of: "{{encodedDappURI}}", with: encoded
)) else { return complete(.invalidURL) }
tokenSeed += 1
let token = tokenSeed
pending = Pending(nonce: nonce, token: token, complete: complete)
DispatchQueue.main.asyncAfter(deadline: .now() + timeout) {
// Only the request this timer was armed for.
if pending?.token == token { finish(.cancelled) }
}
DispatchQueue.main.async {
UIApplication.shared.open(target, options: [:]) { opened in
if !opened, pending?.token == token { finish(.couldNotOpen) }
}
}
}
private static func finish(_ outcome: Outcome) {
guard let inFlight = pending else { return }
pending = nil
DispatchQueue.main.async { inFlight.complete(outcome) }
}
/// Matches JavaScript's `encodeURIComponent` allow-list, so the template's
/// `{{encodedDappURI}}` is filled with exactly what the wallet expects.
private static let uriComponentAllowed = CharacterSet(
charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.!~*'()"
)
/// A cryptographically-random value correlating this launch with its return
/// (CSRF protection). Not a secret, but must be unguessable.
private static func makeNonce() -> String {
var bytes = [UInt8](repeating: 0, count: 16)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
return bytes.map { String(format: "%02x", $0) }.joined()
}
}
Carry the two extra fields
Add the template toHeadlessWallet and remember it on the connection, or sign and send fall back to the engine.
public struct HeadlessWallet: Decodable, Identifiable {
// …
/// The wallet's own in-app-browser template, or nil if it has none.
public let inAppBrowser: String?
}
public struct WalletConnection {
// …
/// Set when the connection was made inside the wallet's browser.
public let walletBrowserURL: String?
}
Forward the callback
Your existingCFBundleURLTypes entry already covers <scheme>://wallet-browser, so there is nothing to register per host. Hand the URL to the flow after the engine. No ASWebAuthenticationSession is involved in this return, so nothing else picks it up.
App.swift
.onOpenURL { url in
if FireblocksHeadlessConnect.shared.handleReturnURL(url) { return }
if FireblocksWalletBrowserFlow.handleCallbackURL(url) { return }
// your existing visible-flow return
}
WKWebView, catch the <scheme>://wallet-browser navigation there and pass it to the same method.
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 isphantomevm.injectedConfig.windowLocations: ["phantom.ethereum"], an EIP-1193 provider inside its browser.
WalletListView.swift
private func offersWalletBrowserEvm(_ wallet: HeadlessWallet) -> Bool {
wallet.key.lowercased() == "phantom"
&& wallet.inAppBrowser != nil
&& !wallet.chains.contains("evm")
}
private func chains(for wallet: HeadlessWallet) -> [String] {
wallet.chains + (offersWalletBrowserEvm(wallet) ? ["evm"] : [])
}
evm in Phantom’s chains and connects it headlessly (Connect Phantom), so the !wallet.chains.contains("evm") guard keeps the synthetic row from doubling it: it only appears for engines that predate the headless bridge, or if you want the row to route into the wallet-browser connect below instead, which is what signing needs. A headless Phantom connection is connect-only.
Connect, sign, and send
// connect: the synthetic picker value routes here
guard let template = wallet.inAppBrowser else { return }
FireblocksWalletBrowserFlow.connect(
hostedPageURL: hostedPageURL,
scheme: scheme,
walletBrowserURL: template,
walletKey: wallet.key
) { result in
switch result {
case .success(let connection): onConnected(connection)
case .failure(.cancelled): break
case .failure(let error): show(error)
}
}
// sign and send: reopen the SAME browser
if let browserURL = connection.walletBrowserURL, let key = connection.walletKey {
FireblocksWalletBrowserFlow.sign(
hostedPageURL: hostedPageURL,
scheme: scheme,
walletBrowserURL: browserURL,
walletKey: key,
message: "Hello from iOS",
expectedAddress: connection.address // the page refuses a mismatch
) { result in /* .success(SignedMessage) / .failure */ }
FireblocksWalletBrowserFlow.send(
hostedPageURL: hostedPageURL,
scheme: scheme,
walletBrowserURL: browserURL,
walletKey: key,
to: "0xRecipientAddress",
chainId: "0x1",
expectedAddress: connection.address,
value: "0x2386f26fc10000" // 0.01 ETH, hex wei
) { result in /* .success(SentTransaction) / .failure */ }
}
What travels on the URL
| Parameter | Value |
|---|---|
wallet and chain=evm | connect, or intent=signMessage / intent=sendTx with walletKey |
redirect_uri | <scheme>://wallet-browser |
nonce | random per attempt, verified on return, mismatches dropped |
embedded=1 | the page is inside a wallet web view, so it must not offer a connector that bounces out of it |
environmentId | optional Dynamic environment ID |
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-callbackis 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
httpsapp 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. 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
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.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.