> ## Documentation Index
> Fetch the complete documentation index at: https://www.dynamic.xyz/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Connections on iOS (headless)

> Render your own native wallet list with a hidden WKWebView engine. Connect, sign messages, and sign transactions. No wallet SDK in your app.

<Note>
  This is an enterprise-only feature. Please [contact us](https://www.dynamic.xyz/book-a-call) to enable.
</Note>

Want your app to render its **own native wallet list** with no web UI? Keep connection logic in the web layer and drive it from a **hidden** web view. The [basic iOS flow](/docs/connections/ios) is the recommended default; use headless when you need a fully native front end. After connecting, you can also sign messages and transactions over the bridge.

<Note>
  **No SDK in your app.** Your app links no wallet SDK: no CocoaPods, no native crypto. It needs a hidden web view pointed at `headless.html`, and your URL scheme. All WalletConnect / MetaMask / Phantom logic (and the wallet list itself) comes from that hidden view. When wallets or the SDK change, you redeploy the page; the app never changes.
</Note>

Copy two files: [`FireblocksHeadlessConnect.swift`](https://github.com/dynamic-labs-oss/iframe-fb/blob/main/ios-harness/Sources/FireblocksHeadlessConnect.swift) and [`FireblocksConnectFlow.swift`](https://github.com/dynamic-labs-oss/iframe-fb/blob/main/ios-harness/Sources/FireblocksConnectFlow.swift) (visible fallback and shared `WalletConnection`).

## 1. Get the wallet menu (no static file)

The engine derives the list live from the Dynamic catalog and pushes it to your app over the bridge (a `wallets` 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](/docs/connections/ios) (passkey/email wallets). |
| `featured`      | `boolean`                  | Show by default; the rest of the catalog rides along so search matches.                                                     |

```json wallets message (web to app) theme={"system"}
{
  "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 hidden `WKWebView` 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.

```swift Connect.swift theme={"system"}
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"
    }
}
```

Forward your app's `onOpenURL` to `FireblocksHeadlessConnect.shared.handleReturnURL($0)`. That is how redirect wallets (Phantom) hand their result back to the hidden view.

<AccordionGroup>
  <Accordion title="View FireblocksHeadlessConnect.swift (copy-paste ready)">
    ````swift FireblocksHeadlessConnect.swift theme={"system"}
    import WebKit
    import UIKit

    // MARK: - Headless connect engine

    /// Runs the hosted Fireblocks connect logic (the Dynamic SDK) inside a HIDDEN
    /// WKWebView, so the app can render its own native wallet list and still keep
    /// every bit of connection logic in the web layer.
    ///
    /// For WalletConnect-protocol wallets (MetaMask, Rainbow, Trust, …) the pairing
    /// is relay-based: the engine mints a URI, we open the wallet via deeplink, the
    /// user approves, and the engine's `approval()` promise resolves over a
    /// WebSocket — none of which needs a visible page. Wallets with no such path
    /// (Phantom's redirect, Coinbase Smart Wallet passkey/email) come back as
    /// `.fallbackRequired` so the caller opens the visible `FireblocksConnectFlow`.
    ///
    /// ```swift
    /// FireblocksHeadlessConnect.shared.prewarm() // at launch
    /// FireblocksHeadlessConnect.shared.connect(walletKey: "rainbow", chain: "evm") { result in
    /// switch result {
    /// case .success(let wallet): // wallet.address, .chain
    /// case .fallbackRequired(let why): // open the visible flow for this wallet
    /// case .failure(let code, _): // stable code, e.g. "user_rejected"
    /// }
    /// }
    /// ```
    /// A wallet in the native list, delivered live by the engine (derived from the
    /// Dynamic catalogue — no static file). `mode` is `"headless"` (connect silently
    /// through the hidden view) or `"fallback"` (open the visible flow).
    public struct HeadlessWallet: Decodable, Identifiable {
        public let key: String
        public let name: String
        public let icon: String?
        public let chains: [String]
        public let mode: String
        /// Shown by default; the rest of the catalogue rides along for search.
        public let featured: Bool?
        public var id: String { key }
        public var isMultiChain: Bool { chains.count > 1 }
    }

    public final class FireblocksHeadlessConnect: NSObject {

        public static let shared = FireblocksHeadlessConnect()

        // Wallet universal-link hosts iOS won't hand to the wallet app from inside a
        // WKWebView (only Phantom's redirect navigates the WebView today) — the nav
        // delegate opens these externally. Self-contained so the module is drop-in.
        private static let walletUniversalLinkHosts: Set<String> = [
            "phantom.app", "phantom.com",
            "link.metamask.io", "metamask.app.link",
            "link.trustwallet.com", "rnbwapp.com", "rainbow.me",
            "www.okx.com", "link.okx.com", "zerion.io",
        ]

        public enum Result {
            case success(WalletConnection)
            case fallbackRequired(reason: String)
            case failure(code: String, message: String)
        }

        /// The no-UI engine page (headless.html). `returnScheme` tells the engine to
        /// point Phantom's redirect at your app scheme so it returns to the app.
        public var engineURL = URL(string: "https://connections.dynamic.dev/headless.html?returnScheme=myapp")!

        /// If the engine hasn't even produced a deeplink within this window, give up
        /// and fall back to the visible flow. Cancelled once the deeplink arrives —
        /// after that we wait indefinitely for the user to approve (or cancel).
        public var startupTimeout: TimeInterval = 20

        private var webView: WKWebView?
        private var ready = false
        private var pendingReadyWork: [() -> Void] = []
        private var handlers: [String: (Result) -> Void] = [:]
        private var timers: [String: Timer] = [:]

        /// The wallet menu, pushed by the engine once it's ready (derived live from
        /// the Dynamic catalogue — no static walletbook file). Set the callback to
        /// receive it; the last value is replayed immediately if already delivered.
        private var walletsList: [HeadlessWallet] = []
        var onWallets: (([HeadlessWallet]) -> Void)? {
            didSet { if !walletsList.isEmpty { onWallets?(walletsList) } }
        }
        // Keeps the app (and thus the hidden WebView's relay socket) alive for the
        // ~30s the user spends approving in the wallet, so the session lands the
        // instant they return instead of after a reconnect.
        private var bgTask: UIBackgroundTaskIdentifier = .invalid

        private override init() { super.init() }

        // MARK: Public API

        /// Build and load the hidden WebView ahead of time so the first connection
        /// isn't slowed by SDK init + relay connect. Safe to call more than once.
        public func prewarm() {
            DispatchQueue.main.async { self.ensureWebView() }
        }

        public func connect(
            walletKey: String,
            chain: String?,
            onResult: @escaping (Result) -> Void
        ) {
            DispatchQueue.main.async {
                self.ensureWebView()
                // A previous attempt is still in flight (e.g. the user opened MetaMask,
                // ignored the prompt, and is now trying another wallet). The SDK can
                // hold a stuck pending connection that blocks the next mint — reset to
                // a fresh engine so the new wallet gets a clean slate.
                if !self.handlers.isEmpty { self.resetEngine() }
                // UUID, not a sequential counter — a guessable/sequential ID lets any
                // JS that ends up in this WebView forge a `connected` message for a
                // request it never made (see the Critical bridge-trust finding).
                let requestId = UUID().uuidString
                self.handlers[requestId] = onResult
                let work: () -> Void = { [weak self] in
                    guard let self else { return }
                    // Start the timer here, after the engine is ready, not at connect()
                    // time — so engine reload time (from a previous resetEngine()) doesn't
                    // eat into the 20s budget before the deeplink is even sent.
                    self.scheduleStartupTimeout(requestId)
                    self.drive(requestId: requestId, walletKey: walletKey, chain: chain)
                }
                if self.ready { work() } else { self.pendingReadyWork.append(work) }
            }
        }

        // Reload the hidden WebView to abandon a stuck previous attempt. The reloaded
        // page re-fires `ready` (flushing any queued connect) and re-pushes the list.
        private func resetEngine() {
            handlers.removeAll()
            timers.values.forEach { $0.invalidate() }
            timers.removeAll()
            endBackgroundTask()
            ready = false
            pendingReadyWork.removeAll()
            webView?.reload()
        }

        // The startup timer for a connect() only starts once the engine reaches
        // `ready` (see the comment in `connect()`), so a `connect()` made — or
        // queued — before that has nothing that will ever time it out. Normally
        // `ready` arrives quickly; but if the engine's own page load never
        // completes because the new deny-by-default navigation gate cancelled a
        // redirect partway through it, `ready` never fires and those callers would
        // otherwise hang forever. Called from the navigation-deny path below.
        private func failPendingBeforeReady(code: String, message: String) {
            guard !ready, !handlers.isEmpty else { return }
            let pending = handlers
            resetEngine()
            for (_, handler) in pending {
                DispatchQueue.main.async { handler(.failure(code: code, message: message)) }
            }
        }

        /// Abort the in-flight attempt (e.g. the user backed out of the list).
        public func cancel() {
            DispatchQueue.main.async {
                self.webView?.evaluateJavaScript("window.headlessConnect && window.headlessConnect.cancel('');", completionHandler: nil)
                self.handlers.removeAll()
                self.timers.values.forEach { $0.invalidate() }
                self.timers.removeAll()
                self.endBackgroundTask()
            }
        }

        // MARK: WebView lifecycle

        private func ensureWebView() {
            guard webView == nil else { return }
            let config = WKWebViewConfiguration()
            // Ephemeral, per-launch — a connect-only flow needs nothing persisted,
            // and the persistent store's slow session-resume is what makes repeat
            // launches sluggish (same rationale as WebViewContainer).
            config.websiteDataStore = .nonPersistent()
            let ucc = WKUserContentController()
            ucc.add(ScriptMessageProxy(self), name: "headless")
            config.userContentController = ucc

            let wv = WKWebView(frame: CGRect(x: 0, y: 0, width: 1, height: 1), configuration: config)
            wv.isHidden = true
            // The engine navigates to wallet deeplinks (e.g. Phantom's redirect); iOS
            // won't open those from inside a WKWebView, so the delegate does.
            wv.navigationDelegate = self
            wv.uiDelegate = self
            // A fully-detached WKWebView gets throttled/suspended by iOS. Keeping it
            // in the window (hidden, 1×1) lets its JS + relay WebSocket keep running.
            Self.keyWindow()?.addSubview(wv)
            wv.load(URLRequest(url: engineURL))
            webView = wv
        }

        /// Called from the app's `onOpenURL` when Phantom (or another redirect
        /// wallet) returns to the app's custom scheme. Forwards the URL into the
        /// hidden WebView so the engine can complete the connection. Returns `true`
        /// if it consumed the URL.
        @discardableResult
        public func handleReturnURL(_ url: URL) -> Bool {
            guard url.host?.lowercased() == "phantom-headless" else { return false }
            let js = "window.headlessConnect && window.headlessConnect.handleReturnURL(\(Self.jsString(url.absoluteString)))"
            DispatchQueue.main.async { self.webView?.evaluateJavaScript(js, completionHandler: nil) }
            return true
        }

        // Encode a Swift string as a safe JS string literal (quotes + escaping).
        private static func jsString(_ s: String) -> String {
            guard
                let data = try? JSONSerialization.data(withJSONObject: [s]),
                let json = String(data: data, encoding: .utf8)
            else { return "\"\"" }
            return String(json.dropFirst().dropLast()) // ["..."] → "..."
        }

        private func drive(requestId: String, walletKey: String, chain: String?) {
            var params: [String: String] = ["requestId": requestId, "walletKey": walletKey]
            if let chain { params["chain"] = chain }
            guard
                let data = try? JSONSerialization.data(withJSONObject: params),
                let json = String(data: data, encoding: .utf8)
            else { return }
            webView?.evaluateJavaScript("window.headlessConnect && window.headlessConnect.connect(\(json));", completionHandler: nil)
        }

        // MARK: Origin gating

        // Shared by the bridge (below) and the navigation delegate: only the
        // engine's own scheme+host+port may post a `connected` message or load
        // top-level in this privileged WebView. Compared this way — not just
        // host — so a same-host page served on a different scheme/port can't
        // slip through either check.
        fileprivate func isEngineOrigin(scheme: String?, host: String?, port: Int?) -> Bool {
            let engineScheme = (engineURL.scheme ?? "https").lowercased()
            let engineHost = (engineURL.host ?? "").lowercased()
            guard !engineHost.isEmpty else { return false }
            let enginePort = engineURL.port ?? Self.defaultPort(forScheme: engineScheme)

            let candidateScheme = (scheme ?? "").lowercased()
            let candidateHost = (host ?? "").lowercased()
            let rawPort = port ?? 0
            let candidatePort = rawPort == 0 ? Self.defaultPort(forScheme: candidateScheme) : rawPort

            return candidateScheme == engineScheme && candidateHost == engineHost && candidatePort == enginePort
        }

        // WKSecurityOrigin/URL both report 0/nil for "no explicit port" — resolve
        // that to the scheme's real default so https:x.com and https:x.com:443
        // compare equal.
        private static func defaultPort(forScheme scheme: String) -> Int {
            switch scheme {
            case "https": return 443
            case "http": return 80
            default: return -1
            }
        }

        // MARK: Bridge

        fileprivate func handleMessage(_ body: Any) {
            guard
                let str = body as? String,
                let data = str.data(using: .utf8),
                let obj = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any],
                let type = obj["type"] as? String
            else { return }

            switch type {
            case "ready":
                ready = true
                let work = pendingReadyWork
                pendingReadyWork = []
                work.forEach { $0() }

            case "wallets":
                if let raw = obj["wallets"],
                   let data = try? JSONSerialization.data(withJSONObject: raw),
                   let list = try? JSONDecoder().decode([HeadlessWallet].self, from: data) {
                    walletsList = list
                    onWallets?(list)
                }

            case "deeplink":
                // requestId must belong to the attempt actually in flight — `handlers`
                // only ever holds the current attempt's ID (a new connect() resets any
                // stale one), so this doubles as the drop-if-stale/forged check.
                guard let requestId = obj["requestId"] as? String, handlers[requestId] != nil else { break }
                guard let urlStr = obj["url"] as? String, let u = URL(string: urlStr) else {
                    cancelTimer(requestId)
                    beginBackgroundTask()
                    break
                }
                // Open the wallet deeplink. Cancel the startup timer only on success —
                // if the app isn't installed, tell the engine immediately so it can
                // fall back to the visible flow rather than hanging until timeout.
                UIApplication.shared.open(u, options: [:]) { [weak self] ok in
                    DispatchQueue.main.async {
                        guard let self else { return }
                        if ok {
                            self.cancelTimer(requestId)
                            self.beginBackgroundTask()
                        } else {
                            let js = "window.headlessConnect && window.headlessConnect.onDeeplinkFailed && window.headlessConnect.onDeeplinkFailed(\(Self.jsString(requestId)))"
                            self.webView?.evaluateJavaScript(js, completionHandler: nil)
                        }
                    }
                }

            case "opening":
                // The wallet is being opened via WebView navigation (Phantom). Same
                // as deeplink: stop the fallback timer, keep alive, wait for the user.
                // Same requestId-must-match-the-in-flight-attempt check as "deeplink".
                guard let requestId = obj["requestId"] as? String, handlers[requestId] != nil else { break }
                cancelTimer(requestId)
                beginBackgroundTask()

            case "connected":
                // A missing/empty address is a malformed payload, not a "connected"
                // wallet with a blank address — route it to failure instead of
                // silently succeeding with "" (see Finding 19).
                guard let address = obj["address"] as? String, !address.isEmpty else {
                    finish(
                        obj["requestId"] as? String,
                        .failure(code: "malformed_result", message: "connected message missing address")
                    )
                    break
                }
                finish(obj["requestId"] as? String, .success(WalletConnection(
                    address: address,
                    chain: obj["chain"] as? String ?? "",
                    walletName: obj["walletName"] as? String ?? "",
                    walletImage: obj["walletImage"] as? String ?? ""
                )))

            case "fallback":
                finish(obj["requestId"] as? String, .fallbackRequired(reason: obj["reason"] as? String ?? ""))

            case "error":
                finish(
                    obj["requestId"] as? String,
                    .failure(code: obj["code"] as? String ?? "unknown", message: obj["message"] as? String ?? "")
                )

            case "event":
                break // diagnostics timeline — hook up logging/analytics here if wanted

            default:
                break
            }
        }

        private func finish(_ requestId: String?, _ result: Result) {
            guard let requestId, let handler = handlers[requestId] else { return }
            handlers[requestId] = nil
            timers[requestId]?.invalidate()
            timers[requestId] = nil
            if handlers.isEmpty { endBackgroundTask() }
            DispatchQueue.main.async { handler(result) }
            // Reload the hidden WebView after every outcome so the Dynamic/WC session is
            // clean for the next connect. A reload navigates back to headless.html (not a
            // wallet URL), so the nav delegate won't open anything externally — unlike
            // calling logout() from JS, which navigates to a wallet disconnect endpoint.
            resetEngine()
        }

        private func scheduleStartupTimeout(_ requestId: String) {
            let timer = Timer.scheduledTimer(withTimeInterval: startupTimeout, repeats: false) { [weak self] _ in
                self?.finish(requestId, .fallbackRequired(reason: "headless startup timeout"))
            }
            timers[requestId] = timer
        }

        private func cancelTimer(_ requestId: String?) {
            guard let requestId else { return }
            timers[requestId]?.invalidate()
            timers[requestId] = nil
        }

        private func beginBackgroundTask() {
            endBackgroundTask()
            bgTask = UIApplication.shared.beginBackgroundTask(withName: "headless-connect") { [weak self] in
                self?.endBackgroundTask() // iOS is reclaiming the task — release it.
            }
        }

        private func endBackgroundTask() {
            guard bgTask != .invalid else { return }
            UIApplication.shared.endBackgroundTask(bgTask)
            bgTask = .invalid
        }

        private static func keyWindow() -> UIWindow? {
            UIApplication.shared.connectedScenes
                .compactMap { $0 as? UIWindowScene }
                .flatMap { $0.windows }
                .first { $0.isKeyWindow }
        }
    }

    // MARK: - Navigation: open wallet deeplinks the WebView can't

    // The engine (Phantom's redirect) navigates the WebView to wallet deeplinks and
    // universal links. iOS won't hand those to the wallet app from inside a
    // WKWebView, so intercept and open them externally — the same glue the visible
    // WKWebView container uses. The engine's own page load (https) is allowed.
    extension FireblocksHeadlessConnect: WKNavigationDelegate, WKUIDelegate {
        public func webView(
            _ webView: WKWebView,
            decidePolicyFor navigationAction: WKNavigationAction,
            decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
        ) {
            guard let url = navigationAction.request.url else { return decisionHandler(.allow) }
            let scheme = (url.scheme ?? "").lowercased()
            if scheme != "http", scheme != "https", scheme != "about", scheme != "blob", scheme != "data" {
                UIApplication.shared.open(url, options: [:], completionHandler: nil)
                return decisionHandler(.cancel)
            }
            if let host = url.host?.lowercased(),
               Self.walletUniversalLinkHosts.contains(where: { host == $0 || host.hasSuffix(".\($0)") }) {
                UIApplication.shared.open(url, options: [:], completionHandler: nil)
                return decisionHandler(.cancel)
            }
            // Deny-by-default: only the engine's own origin may load top-level in
            // this privileged WebView (the bridge is attached regardless of what
            // page is showing). This also denies about:/blob:/data: — none of
            // which the engine legitimately navigates to — closing the "any https
            // page loads into the privileged WebView" gap from the Critical
            // bridge-trust finding. Logged (not silently dropped) since a denial
            // here means something unexpected tried to navigate this WebView.
            //
            // This delegate method fires for subframe navigation too, and this
            // check isn't scoped to the main frame only — so a hostile subframe
            // is denied here as well. That's belt-and-suspenders: the bridge's
            // `message.frameInfo.isMainFrame` check (see ScriptMessageProxy) is
            // what actually protects against a subframe posting bridge messages,
            // independent of this navigation gate.
            guard isEngineOrigin(scheme: scheme, host: url.host, port: url.port) else {
                NSLog("FireblocksHeadlessConnect: denying navigation to non-engine origin: %@", url.absoluteString)
                // See failPendingBeforeReady: if this denial happened before the
                // engine ever reached `ready`, nothing else will ever fail out the
                // attempt(s) waiting on it.
                failPendingBeforeReady(
                    code: "navigation_denied",
                    message: "blocked a navigation to a non-engine origin before the engine became ready"
                )
                return decisionHandler(.cancel)
            }
            decisionHandler(.allow)
        }

        // Some SDKs open the wallet via window.open rather than a location change.
        public func webView(
            _ webView: WKWebView,
            createWebViewWith configuration: WKWebViewConfiguration,
            for navigationAction: WKNavigationAction,
            windowFeatures: WKWindowFeatures
        ) -> WKWebView? {
            if let url = navigationAction.request.url {
                let scheme = (url.scheme ?? "").lowercased()
                if scheme != "http", scheme != "https" {
                    UIApplication.shared.open(url, options: [:], completionHandler: nil)
                }
            }
            return nil
        }
    }

    // MARK: - Weak message-handler proxy

    // WKUserContentController retains its message handlers; a direct `add(self, …)`
    // would create a retain cycle (webView → config → controller → self → webView).
    // This weak proxy breaks it.
    private final class ScriptMessageProxy: NSObject, WKScriptMessageHandler {
        weak var target: FireblocksHeadlessConnect?
        init(_ target: FireblocksHeadlessConnect) { self.target = target }
        func userContentController(_ controller: WKUserContentController, didReceive message: WKScriptMessage) {
            // Only trust messages posted by the engine's own top-level frame. Without
            // this, any JS that ends up running in this WebView — a compromised
            // script, an XSS on the hosted page, a page reached via the navigation
            // gate above having a gap — could forge a `connected` message and the
            // native side would believe an attacker-chosen wallet address is
            // connected. See the Critical bridge-trust finding.
            guard let target else { return }
            let origin = message.frameInfo.securityOrigin
            guard message.frameInfo.isMainFrame,
                  target.isEngineOrigin(scheme: origin.protocol, host: origin.host, port: origin.port)
            else { return }
            target.handleMessage(message.body)
        }
    }
    ````
  </Accordion>
</AccordionGroup>

## 3. Sign a message

The hosted engine supports `window.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).

```swift SignView.swift theme={"system"}
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" | …
    }
}
```

Signing is only available for wallets connected through the headless engine. Wallets that completed the visible fallback flow do not hold an open session.

## 4. Sign a transaction

Pass a serialized transaction to `signTransaction()` (engine: `signTx`). This only signs. It does not broadcast. Format and return value differ by chain.

```swift SignView.swift (EVM) theme={"system"}
// 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
    }
}
```

```swift SignView.swift (Solana) theme={"system"}
// 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.

<AccordionGroup>
  <Accordion title="View WalletListView.swift (sample list UI)">
    ```swift WalletListView.swift theme={"system"}
    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
            }
        }
    }
    ```
  </Accordion>
</AccordionGroup>

## 6. The bridge (for reference)

You do not write the bridge. It is what flows between the hidden view and the harness files. Handy when debugging.

```text bridge messages theme={"system"}
// 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 })
```

The component mints `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 `WKWebView` gets 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.
