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": ["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.
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). 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://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.
///
/// 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?
/// `engineURL` with `environmentId` applied. Only the query changes, so the
/// origin `isEngineOrigin` gates on is unaffected.
private var resolvedEngineURL: URL {
guard let environmentId, !environmentId.isEmpty,
var components = URLComponents(url: engineURL, resolvingAgainstBaseURL: false)
else { return engineURL }
components.queryItems =
(components.queryItems ?? []).filter { $0.name != "environmentId" }
+ [URLQueryItem(name: "environmentId", value: environmentId)]
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
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: resolvedEngineURL))
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 (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. Connect Phantom on EVM (its own browser)
Phantom injects an EVM provider (window.phantom.ethereum) only inside its own in-app browser. It has no WalletConnect entry in Dynamic’s wallet book and no EVM deeplink, so the hidden engine cannot drive it and the visible flow has no provider to talk to. The route that works is to open your hosted page inside Phantom’s browser and take the result back over your URL scheme.
Each operation is one round trip: Phantom comes to the foreground with your page in it, the user approves, and the result arrives on <scheme>://wallet-browser.
The engine reports each wallet’s in-app-browser template in the
wallets bridge message as inAppBrowser. The template contains {{encodedDappURI}}, and you replace every occurrence (Phantom’s uses it twice). A template is not a chain: it only means the wallet can open a URL in its own browser, so you decide per wallet which chains that browser serves.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: 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`.
///
/// 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,
completion: @escaping (Result<WalletConnection, FireblocksConnectError>) -> Void
) {
start(
hostedPageURL: hostedPageURL,
scheme: scheme,
walletBrowserURL: walletBrowserURL,
environmentId: environmentId,
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.
public static func sign(
hostedPageURL: URL,
scheme: String,
walletBrowserURL: String,
walletKey: String,
message: String,
expectedAddress: String,
environmentId: String? = nil,
completion: @escaping (Result<SignedMessage, FireblocksSignError>) -> Void
) {
start(
hostedPageURL: hostedPageURL,
scheme: scheme,
walletBrowserURL: walletBrowserURL,
environmentId: environmentId,
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.
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,
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,
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?,
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"),
]
if let environmentId, !environmentId.isEmpty {
reserved.append("environmentId")
ours.append(URLQueryItem(name: "environmentId", value: environmentId))
}
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 static let walletBrowserEvmChain = "evm-wallet-browser"
private func offersWalletBrowserEvm(_ wallet: HeadlessWallet) -> Bool {
wallet.key.lowercased() == "phantom"
&& wallet.inAppBrowser != nil
&& !wallet.chains.contains("evm")
}
private func pickerChains(_ wallet: HeadlessWallet) -> [String] {
wallet.chains + (offersWalletBrowserEvm(wallet) ? [Self.walletBrowserEvmChain] : [])
}
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.
7. 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.