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

# Hosted wallet connector on iOS (basic)

> Present the hosted connect page in an ASWebAuthenticationSession and read the wallet address on your custom URL scheme.

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

On iOS the [hosted connect page](/docs/connections/overview) contract is the same as the web, hosted inside a web view. The result comes back on a custom URL scheme instead of an `https` callback.

The whole integration is one small Swift file, `FireblocksConnectFlow.swift`. Copy it, call it, read the result.

For a fully native wallet list with message and transaction signing, see [iOS headless](/docs/connections/ios-headless).

<Tip>
  Present with `ASWebAuthenticationSession`: it is built for "open web, return via a callback scheme," runs ephemerally (no consent prompt, no stale-session lag), and needs no navigation glue. Use a `WKWebView` only if you need the flow embedded in custom UI.
</Tip>

<AccordionGroup>
  <Accordion title="View the full FireblocksConnectFlow.swift (copy-paste ready)">
    ````swift FireblocksConnectFlow.swift theme={"system"}
    import AuthenticationServices
    import Security
    import UIKit

    // MARK: - Result

    /// A wallet the user connected through the hosted Fireblocks flow.
    public struct WalletConnection {
        /// The connected wallet's public address.
        public let address: String
        /// Chain family: `"evm"`, `"solana"`, or `"bitcoin"`.
        public let chain: String
        /// Display name, e.g. `"MetaMask"`.
        public let walletName: String
        /// Icon URL — often an SVG-sprite URL, so render it with a WebKit-backed
        /// `<img>` rather than `UIImage`.
        public let walletImage: String
        /// `true` for wallets connected through the hidden `FireblocksHeadlessConnect`
        /// WebView; `false` for the visible `FireblocksConnectFlow` / WKWebView
        /// fallback flow. Callers use this to pick which sign/send path applies —
        /// see `FireblocksHeadlessConnect.sign`/`.sendTransaction` vs.
        /// `FireblocksSignFlow`/`FireblocksSendFlow`. Defaults to `true` since most
        /// existing call sites are headless; the fallback constructors below pass
        /// `false` explicitly.
        public let connectedHeadlessly: Bool
        /// The Solana network this wallet SESSION is bound to (`"mainnet-beta"` |
        /// `"devnet"`), when the wallet's protocol binds one at connect time
        /// (Phantom's redirect protocol does). The session decides what the
        /// wallet will sign for, so send UIs must follow it — the engine rejects
        /// a send on the other network. `nil` for wallets with no such binding
        /// (their send UI may offer a network choice freely).
        public let network: String?
        /// Set when this connection was made inside the WALLET's own in-app browser
        /// (`FireblocksWalletBrowserFlow`) — the in-app-browser template it was
        /// opened with. The account belongs to the provider injected in that
        /// browser and exists nowhere else, so sign/send have to be re-opened
        /// there too: pass this back to `FireblocksWalletBrowserFlow.sign` /
        /// `.send`. `nil` for every other path (headless engine, system-browser
        /// visible flow).
        public let walletBrowserURL: String?
        /// The catalogue key that produced this connection, e.g. `"phantom"`.
        /// Not part of the engine's wire protocol (its `connected` message carries
        /// no wallet key) — set by the caller that made the connect call, so a
        /// later sign/send can rebuild the hosted-page URL. Required by
        /// `FireblocksWalletBrowserFlow`'s sign/send, which re-open the page in the
        /// wallet's browser. `nil` when unknown.
        public let walletKey: String?

        public init(
            address: String,
            chain: String,
            walletName: String,
            walletImage: String,
            connectedHeadlessly: Bool = true,
            network: String? = nil,
            walletBrowserURL: String? = nil,
            walletKey: String? = nil
        ) {
            self.address = address
            self.chain = chain
            self.walletName = walletName
            self.walletImage = walletImage
            self.connectedHeadlessly = connectedHeadlessly
            self.network = network
            self.walletBrowserURL = walletBrowserURL
            self.walletKey = walletKey
        }
    }

    /// Why a connection attempt ended without a `WalletConnection`.
    public enum FireblocksConnectError: Error {
        case invalidURL
        /// The user dismissed the sheet.
        case cancelled
        /// The returned nonce didn't match the one we sent — rejected (possible CSRF).
        case nonceMismatch
        /// No address, or the callback couldn't be parsed.
        case malformedResult
        case couldNotStart
    }

    // MARK: - Flow

    /// Connect a self-custodial wallet through a hosted Fireblocks page — no SDK.
    ///
    /// ```swift
    /// FireblocksConnectFlow.present(
    ///     hostedPageURL: URL(string: "https://connect.example.com/")!,
    ///     scheme: "myapp"                     // your Info.plist URL scheme
    /// ) { result in
    ///     switch result {
    ///     case .success(let wallet): print(wallet.address, wallet.chain)
    ///     case .failure(let error):  print(error)
    ///     }
    /// }
    /// ```
    ///
    /// The page opens in `ASWebAuthenticationSession` — Apple's API for "open web,
    /// return via a callback scheme." It runs ephemerally, so there's no consent
    /// prompt and no session left to resume on the next run. This type appends
    /// `redirect_uri`, a random `nonce`, and `embedded=1` to your URL, verifies the
    /// returned nonce, and calls `completion` on the main thread.
    public final class FireblocksConnectFlow: NSObject {

        /// Present the flow. The instance keeps itself alive until it finishes, so
        /// the caller doesn't need to retain the return value.
        ///
        /// - Parameter environmentId: Dynamic environment ID for the hosted page to
        ///   use instead of its own default, sent as `?environmentId=<uuid>`.
        ///   `nil`/empty omits the param entirely.
        /// - Parameter locale: UI locale for the hosted page — e.g. `"en_US"`,
        ///   `"es_LA"`. `nil`/empty omits the param.
        /// - Parameter theme: UI theme for the hosted page — `"light"` or
        ///   `"dark"`. `nil`/empty omits the param.
        @discardableResult
        public static func present(
            hostedPageURL: URL,
            scheme: String,
            environmentId: String? = nil,
            locale: String? = nil,
            theme: String? = nil,
            completion: @escaping (Result<WalletConnection, FireblocksConnectError>) -> Void
        ) -> FireblocksConnectFlow {
            let flow = FireblocksConnectFlow(
                scheme: scheme,
                environmentId: environmentId,
                locale: locale,
                theme: theme,
                completion: completion
            )
            flow.start(hostedPageURL: hostedPageURL)
            return flow
        }

        // MARK: Private

        // fileprivate (not private): FireblocksSendFlow below reuses these two —
        // same callback host convention, same nonce-generation code, rather than
        // duplicating either.
        fileprivate static let callbackHost = "wallet-callback"

        // The host Phantom's in-app-browser bridge return button targets (see
        // src/redirect.ts's RETURN_HOST) — a zero-payload "bring the app
        // forward" tap, NOT a real result. Deliberately distinct from
        // callbackHost: this session is often still open and waiting on this
        // same connection's OWN WalletConnect approval over the relay, so
        // handleCallbackURL must recognize this host and leave the session alone
        // rather than cancelling it, which would race that delivery.
        fileprivate static let returnHost = "wallet-return"

        private let scheme: String
        private let environmentId: String?
        private let locale: String?
        private let theme: String?
        private let completion: (Result<WalletConnection, FireblocksConnectError>) -> Void
        private let nonce = FireblocksConnectFlow.makeNonce()
        private var session: ASWebAuthenticationSession?
        private var selfReference: FireblocksConnectFlow?
        private var didFinish = false

        /// The flow currently on screen, so an app-level `open(URL:)` can complete it.
        private static weak var active: FireblocksConnectFlow?

        private init(
            scheme: String,
            environmentId: String?,
            locale: String? = nil,
            theme: String? = nil,
            completion: @escaping (Result<WalletConnection, FireblocksConnectError>) -> Void
        ) {
            self.scheme = scheme
            self.environmentId = environmentId
            self.locale = locale
            self.theme = theme
            self.completion = completion
            super.init()
            selfReference = self
        }

        private func start(hostedPageURL: URL) {
            guard let url = buildURL(from: hostedPageURL) else { return finish(.failure(.invalidURL)) }
            FireblocksConnectFlow.active = self

            let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { [weak self] callback, error in
                self?.handle(callback: callback, error: error)
            }
            session.presentationContextProvider = self
            // NOT ephemeral — live-tested regression (Flutter build, same
            // ASWebAuthenticationSession API underneath): with this set to
            // true, a passkey-based wallet (Base Account / Coinbase Smart
            // Wallet) never got a working session on iOS at all. Apple's
            // ASWebAuthenticationSession docs document ephemeral sessions as
            // NOT sharing the persistent Safari/iCloud Keychain passkey store
            // the way a normal session does — matches exactly, since Base
            // Account's connect step is a passkey ceremony start to finish.
            // Cost: a one-time "<app> wants to use <domain> to sign in" system
            // consent dialog per domain instead of a silent session.
            session.prefersEphemeralWebBrowserSession = false
            self.session = session

            if !session.start() { finish(.failure(.couldNotStart)) }
        }

        /// Append our contract params, replacing any the caller already set.
        private func buildURL(from hostedPageURL: URL) -> URL? {
            guard var components = URLComponents(url: hostedPageURL, resolvingAgainstBaseURL: false) else { return nil }
            var reserved = ["redirect_uri", "nonce", "embedded"]
            var ours = [
                URLQueryItem(name: "redirect_uri", value: "\(scheme)://\(Self.callbackHost)"),
                URLQueryItem(name: "nonce", value: nonce),
                URLQueryItem(name: "embedded", value: "1"),
            ]
            // `environmentId`/`locale`/`theme` are only taken over when we were
            // actually given one — otherwise a caller that already put it on
            // `hostedPageURL` (as the wallet-list fallback does) would have it
            // stripped here instead of preserved.
            for (name, value) in [("environmentId", environmentId), ("locale", locale), ("theme", theme)] {
                if let value, !value.isEmpty {
                    reserved.append(name)
                    ours.append(URLQueryItem(name: name, value: value))
                }
            }
            components.queryItems =
                (components.queryItems ?? []).filter { !reserved.contains($0.name) } + ours
            return components.url
        }

        private func handle(callback: URL?, error: Error?) {
            if let error {
                let cancelled = (error as NSError).code == ASWebAuthenticationSessionError.canceledLogin.rawValue
                return finish(.failure(cancelled ? .cancelled : .couldNotStart))
            }
            guard let callback,
                  let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems
            else { return finish(.failure(.malformedResult)) }

            let values = Dictionary(items.map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { first, _ in first })
            guard values["nonce"] == nonce else { return finish(.failure(.nonceMismatch)) }
            guard let address = values["address"], !address.isEmpty else { return finish(.failure(.malformedResult)) }

            finish(.success(WalletConnection(
                address: address,
                chain: values["chain"] ?? "",
                walletName: values["walletName"] ?? "",
                walletImage: values["walletImage"] ?? "",
                connectedHeadlessly: false
            )))
        }

        private func finish(_ result: Result<WalletConnection, FireblocksConnectError>) {
            DispatchQueue.main.async {
                guard !self.didFinish else { return } // ignore a second completion
                self.didFinish = true
                if FireblocksConnectFlow.active === self { FireblocksConnectFlow.active = nil }
                self.completion(result)
                self.selfReference = nil // release
            }
        }

        /// A cryptographically-random value that correlates this launch with its
        /// return (CSRF protection). Not a secret, but must be unguessable.
        fileprivate static func makeNonce() -> String {
            var bytes = [UInt8](repeating: 0, count: 16)
            _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
            return bytes.map { String(format: "%02x", $0) }.joined()
        }

        // MARK: Out-of-band return

        /// Complete an in-flight flow from an app-level `open(URL:)`.
        ///
        /// Most wallets return *inside* the ASWebAuth session, so you don't need
        /// this. But some (e.g. Phantom) finish in their own in-app browser and hand
        /// the result back via the URL scheme — that lands on your App's
        /// `.onOpenURL`, not the session callback. Forward it here so those complete
        /// and the sheet dismisses. Returns `true` if it consumed the URL.
        @discardableResult
        public static func handleCallbackURL(_ url: URL) -> Bool {
            guard let flow = active,
                  url.scheme?.lowercased() == flow.scheme.lowercased()
            else { return false }
            if url.host?.lowercased() == returnHost {
                return true // zero-payload return tap — app is already foregrounded; leave the session running
            }
            flow.handle(callback: url, error: nil) // deliver result (guards double-finish)
            flow.session?.cancel()                 // dismiss the still-open sheet
            return true
        }
    }

    // MARK: - Presentation anchor

    extension FireblocksConnectFlow: ASWebAuthenticationPresentationContextProviding {
        public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
            UIApplication.shared.connectedScenes
                .compactMap { $0 as? UIWindowScene }
                .flatMap(\.windows)
                .first { $0.isKeyWindow } ?? ASPresentationAnchor()
        }
    }

    // MARK: - Send

    /// The result of a successful `FireblocksSendFlow.send` call.
    public struct SentTransaction {
        /// The on-chain transaction hash — already submitted, not a raw signed tx.
        public let txHash: String
    }

    /// Why a send attempt ended without a `SentTransaction`.
    public enum FireblocksSendError: Error {
        case invalidURL
        /// The user dismissed the sheet.
        case cancelled
        case nonceMismatch
        case malformedResult
        case couldNotStart
        /// The flow itself reported a failure (e.g. the connected address didn't
        /// match `expectedAddress`, a chain mismatch, or the wallet/network
        /// rejected the send). `code` is a stable machine code; `message` is
        /// human-readable.
        case failed(code: String, message: String)
    }

    /// Send an EVM transaction with a wallet that can ONLY connect through the
    /// visible flow — Base Account / Coinbase Smart Wallet today, and by
    /// extension any other installed/SDK EVM wallet, since the mechanism is
    /// generic. `FireblocksHeadlessConnect` can't do this for these wallets:
    /// their signer opens a real system-browser popup for EVERY signing call,
    /// not just the first connect, and the headless engine's hidden WKWebView
    /// can't reliably host that popup — so, like `FireblocksConnectFlow` above,
    /// this opens `ASWebAuthenticationSession` rather than driving a hidden
    /// engine.
    ///
    /// ```swift
    /// FireblocksSendFlow.send(
    ///     hostedPageURL: URL(string: "https://connect.example.com/")!,
    ///     scheme: "myapp",
    ///     walletKey: "baseaccount",
    ///     to: "0x…",
    ///     chainId: "0x2105",             // Base mainnet
    ///     expectedAddress: wallet.address // from an earlier FireblocksConnectFlow result
    /// ) { result in
    ///     switch result {
    ///     case .success(let tx):    print(tx.txHash)
    ///     case .failure(let error): print(error)
    ///     }
    /// }
    /// ```
    ///
    /// This is NEVER silent: expect the sheet to open and at least one wallet
    /// approval popup, even for a wallet already connected via
    /// `FireblocksConnectFlow` earlier — Base Account's signer requires a fresh
    /// popup for every signing operation, by design. The page itself asks for
    /// two separate taps ("Connect", then "Confirm & send") before returning
    /// here — not one auto-driven pass.
    public final class FireblocksSendFlow: NSObject {

        /// - Parameters:
        ///   - hostedPageURL: Your hosted Fireblocks connect page URL — same one you
        ///     pass to `FireblocksConnectFlow.present`, e.g.
        ///     `https://connect.example.com/`.
        ///   - scheme: Your app's registered URL scheme (must match your
        ///     Info.plist — see `FireblocksConnectFlow.present`'s docs).
        ///   - walletKey: Catalogue key for the wallet to use, e.g. "baseaccount".
        ///   - to, chainId: Required, `0x`-prefixed hex.
        ///   - value, data, gasLimit: Optional, `0x`-prefixed hex (`value`
        ///     defaults to `"0x0"`, `data` to `"0x"`).
        ///   - expectedAddress: REQUIRED — the address you already know this
        ///     wallet connected as (e.g. an earlier `FireblocksConnectFlow`
        ///     result's `WalletConnection.address`). Without this the page would
        ///     send from whatever account happens to connect — a
        ///     wrong-account-send risk for any wallet capable of holding more
        ///     than one account. The page itself refuses to send on a mismatch;
        ///     this parameter is what it checks against, not a hint.
        ///   - environmentId: Dynamic environment ID for the hosted page to use
        ///     instead of its own default. `nil`/empty omits the param entirely.
        ///   - locale: UI locale for the hosted page — e.g. `"en_US"`, `"es_LA"`.
        ///     `nil`/empty omits the param.
        ///   - theme: UI theme for the hosted page — `"light"` or `"dark"`.
        ///     `nil`/empty omits the param.
        @discardableResult
        public static func send(
            hostedPageURL: URL,
            scheme: String,
            walletKey: String,
            to: String,
            chainId: String,
            expectedAddress: String,
            value: String = "0x0",
            data: String = "0x",
            gasLimit: String? = nil,
            environmentId: String? = nil,
            locale: String? = nil,
            theme: String? = nil,
            completion: @escaping (Result<SentTransaction, FireblocksSendError>) -> Void
        ) -> FireblocksSendFlow {
            let flow = FireblocksSendFlow(completion: completion)
            flow.start(
                hostedPageURL: hostedPageURL,
                scheme: scheme,
                walletKey: walletKey,
                to: to,
                value: value,
                data: data,
                chainId: chainId,
                gasLimit: gasLimit,
                expectedAddress: expectedAddress,
                environmentId: environmentId,
                locale: locale,
                theme: theme
            )
            return flow
        }

        // MARK: Private

        private let completion: (Result<SentTransaction, FireblocksSendError>) -> Void
        private let nonce = FireblocksConnectFlow.makeNonce()
        private var scheme = ""
        private var session: ASWebAuthenticationSession?
        private var selfReference: FireblocksSendFlow?
        private var didFinish = false

        private init(completion: @escaping (Result<SentTransaction, FireblocksSendError>) -> Void) {
            self.completion = completion
            super.init()
            selfReference = self
        }

        private func start(
            hostedPageURL: URL,
            scheme: String,
            walletKey: String,
            to: String,
            value: String,
            data: String,
            chainId: String,
            gasLimit: String?,
            expectedAddress: String,
            environmentId: String?,
            locale: String? = nil,
            theme: String? = nil
        ) {
            self.scheme = scheme
            guard var components = URLComponents(url: hostedPageURL, resolvingAgainstBaseURL: false) else {
                return finish(.failure(.invalidURL))
            }
            var items = components.queryItems ?? []
            items.append(contentsOf: [
                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),
                URLQueryItem(name: "redirect_uri", value: "\(scheme)://\(FireblocksConnectFlow.callbackHost)"),
                URLQueryItem(name: "nonce", value: nonce),
            ])
            if let gasLimit, !gasLimit.isEmpty {
                items.append(URLQueryItem(name: "gasLimit", value: gasLimit))
            }
            for (name, value) in [("environmentId", environmentId), ("locale", locale), ("theme", theme)] {
                if let value, !value.isEmpty {
                    items.append(URLQueryItem(name: name, value: value))
                }
            }
            components.queryItems = items
            guard let url = components.url else { return finish(.failure(.invalidURL)) }

            FireblocksSendFlow.active = self

            let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { [weak self] callback, error in
                self?.handle(callback: callback, error: error)
            }
            session.presentationContextProvider = self
            // See FireblocksConnectFlow.start's comment on this same option —
            // not ephemeral, for the same passkey-session reason.
            session.prefersEphemeralWebBrowserSession = false
            self.session = session

            if !session.start() { finish(.failure(.couldNotStart)) }
        }

        /// The flow currently on screen, so an app-level `open(URL:)` can
        /// complete it — mirrors `FireblocksConnectFlow.active` exactly, same
        /// reasoning (see `handleCallbackURL` below and
        /// `FireblocksConnectFlow.handleCallbackURL`'s docs for why this path
        /// exists — not expected to be needed for Base Account specifically,
        /// since its signing popup returns within the ASWebAuth session, but
        /// this isn't Base-Account-specific code, and a future wallet driven
        /// through this same flow might behave like Phantom does on the connect
        /// side).
        private static weak var active: FireblocksSendFlow?

        private func handle(callback: URL?, error: Error?) {
            if let error {
                let cancelled = (error as NSError).code == ASWebAuthenticationSessionError.canceledLogin.rawValue
                return finish(.failure(cancelled ? .cancelled : .couldNotStart))
            }
            guard let callback,
                  let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems
            else { return finish(.failure(.malformedResult)) }

            let values = Dictionary(items.map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { first, _ in first })
            guard values["nonce"] == nonce else { return finish(.failure(.nonceMismatch)) }

            if values["error"] == "1" {
                return finish(.failure(.failed(code: values["code"] ?? "unknown", message: values["message"] ?? "")))
            }
            guard let txHash = values["txHash"], !txHash.isEmpty else { return finish(.failure(.malformedResult)) }
            finish(.success(SentTransaction(txHash: txHash)))
        }

        private func finish(_ result: Result<SentTransaction, FireblocksSendError>) {
            DispatchQueue.main.async {
                guard !self.didFinish else { return } // ignore a second completion
                self.didFinish = true
                if FireblocksSendFlow.active === self { FireblocksSendFlow.active = nil }
                self.completion(result)
                self.selfReference = nil // release
            }
        }

        // MARK: Out-of-band return

        /// Complete an in-flight send from an app-level `open(URL:)`. Mirrors
        /// `FireblocksConnectFlow.handleCallbackURL` exactly — see its docs for
        /// why this path exists (some wallets finish outside the ASWebAuth
        /// session and hand the result back via the URL scheme instead, which
        /// lands on your app's `.onOpenURL`, not the session callback). Forward
        /// it here so those complete and the sheet dismisses. Returns `true` if
        /// it consumed the URL.
        @discardableResult
        public static func handleCallbackURL(_ url: URL) -> Bool {
            guard let flow = active,
                  url.scheme?.lowercased() == flow.scheme.lowercased()
            else { return false }
            flow.handle(callback: url, error: nil) // deliver result (guards double-finish)
            flow.session?.cancel()                 // dismiss the still-open sheet
            return true
        }
    }

    extension FireblocksSendFlow: ASWebAuthenticationPresentationContextProviding {
        public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
            UIApplication.shared.connectedScenes
                .compactMap { $0 as? UIWindowScene }
                .flatMap(\.windows)
                .first { $0.isKeyWindow } ?? ASPresentationAnchor()
        }
    }

    // MARK: - Sign

    /// The result of a successful `FireblocksSignFlow.sign` call.
    public struct SignedMessage {
        /// Hex signature string.
        public let signature: String
    }

    /// Why a sign attempt ended without a `SignedMessage`.
    public enum FireblocksSignError: Error {
        case invalidURL
        /// The user dismissed the sheet.
        case cancelled
        case nonceMismatch
        case malformedResult
        case couldNotStart
        /// The flow itself reported a failure (e.g. the connected address didn't
        /// match `expectedAddress`, or the wallet rejected the sign request).
        /// `code` is a stable machine code; `message` is human-readable.
        case failed(code: String, message: String)
    }

    /// Sign a message with a wallet that can ONLY connect through the visible
    /// flow — Base Account / Coinbase Smart Wallet today, and by extension any
    /// other installed/SDK EVM wallet. Same reasoning as `FireblocksSendFlow`
    /// above for why this needs its own flow rather than going through
    /// `FireblocksHeadlessConnect`: `@base-org/account`'s signer opens a real
    /// system-browser popup for `personal_sign` too, not just
    /// `eth_sendTransaction` — there's no "sign is cheaper than send" shortcut
    /// here.
    ///
    /// ```swift
    /// FireblocksSignFlow.sign(
    ///     hostedPageURL: URL(string: "https://connect.example.com/")!,
    ///     scheme: "myapp",
    ///     walletKey: "baseaccount",
    ///     message: "Sign in to My App",
    ///     expectedAddress: wallet.address // from an earlier FireblocksConnectFlow result
    /// ) { result in
    ///     switch result {
    ///     case .success(let signed):  print(signed.signature)
    ///     case .failure(let error):   print(error)
    ///     }
    /// }
    /// ```
    ///
    /// This is NEVER silent: expect the sheet to open and at least one wallet
    /// approval popup, even for a wallet already connected via
    /// `FireblocksConnectFlow` earlier. The page itself asks for two separate
    /// taps ("Connect", then "Confirm & sign") before returning here — not one
    /// auto-driven pass.
    public final class FireblocksSignFlow: NSObject {

        /// - Parameters:
        ///   - hostedPageURL: Your hosted Fireblocks connect page URL — same one you
        ///     pass to `FireblocksConnectFlow.present`, e.g.
        ///     `https://connect.example.com/`.
        ///   - scheme: Your app's registered URL scheme (must match your
        ///     Info.plist — see `FireblocksConnectFlow.present`'s docs).
        ///   - walletKey: Catalogue key for the wallet to use, e.g. "baseaccount".
        ///   - message: The message to sign, as plain text.
        ///   - expectedAddress: REQUIRED — the address you already know this
        ///     wallet connected as (e.g. an earlier `FireblocksConnectFlow`
        ///     result's `WalletConnection.address`). Without this the page would
        ///     sign with whatever account happens to connect. The page itself
        ///     refuses to sign on a mismatch; this parameter is what it checks
        ///     against, not a hint.
        ///   - environmentId: Dynamic environment ID for the hosted page to use
        ///     instead of its own default. `nil`/empty omits the param entirely.
        ///   - locale: UI locale for the hosted page — e.g. `"en_US"`, `"es_LA"`.
        ///     `nil`/empty omits the param.
        ///   - theme: UI theme for the hosted page — `"light"` or `"dark"`.
        ///     `nil`/empty omits the param.
        @discardableResult
        public static func sign(
            hostedPageURL: URL,
            scheme: String,
            walletKey: String,
            message: String,
            expectedAddress: String,
            environmentId: String? = nil,
            locale: String? = nil,
            theme: String? = nil,
            completion: @escaping (Result<SignedMessage, FireblocksSignError>) -> Void
        ) -> FireblocksSignFlow {
            let flow = FireblocksSignFlow(completion: completion)
            flow.start(
                hostedPageURL: hostedPageURL,
                scheme: scheme,
                walletKey: walletKey,
                message: message,
                expectedAddress: expectedAddress,
                environmentId: environmentId,
                locale: locale,
                theme: theme
            )
            return flow
        }

        // MARK: Private

        private let completion: (Result<SignedMessage, FireblocksSignError>) -> Void
        private let nonce = FireblocksConnectFlow.makeNonce()
        private var scheme = ""
        private var session: ASWebAuthenticationSession?
        private var selfReference: FireblocksSignFlow?
        private var didFinish = false

        private init(completion: @escaping (Result<SignedMessage, FireblocksSignError>) -> Void) {
            self.completion = completion
            super.init()
            selfReference = self
        }

        private func start(
            hostedPageURL: URL,
            scheme: String,
            walletKey: String,
            message: String,
            expectedAddress: String,
            environmentId: String?,
            locale: String? = nil,
            theme: String? = nil
        ) {
            self.scheme = scheme
            guard var components = URLComponents(url: hostedPageURL, resolvingAgainstBaseURL: false) else {
                return finish(.failure(.invalidURL))
            }
            var items = components.queryItems ?? []
            items.append(contentsOf: [
                URLQueryItem(name: "intent", value: "signMessage"),
                URLQueryItem(name: "walletKey", value: walletKey),
                URLQueryItem(name: "message", value: message),
                URLQueryItem(name: "expectedAddress", value: expectedAddress),
                URLQueryItem(name: "redirect_uri", value: "\(scheme)://\(FireblocksConnectFlow.callbackHost)"),
                URLQueryItem(name: "nonce", value: nonce),
            ])
            for (name, value) in [("environmentId", environmentId), ("locale", locale), ("theme", theme)] {
                if let value, !value.isEmpty {
                    items.append(URLQueryItem(name: name, value: value))
                }
            }
            components.queryItems = items
            guard let url = components.url else { return finish(.failure(.invalidURL)) }

            FireblocksSignFlow.active = self

            let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { [weak self] callback, error in
                self?.handle(callback: callback, error: error)
            }
            session.presentationContextProvider = self
            // See FireblocksConnectFlow.start's comment on this same option —
            // not ephemeral, for the same passkey-session reason.
            session.prefersEphemeralWebBrowserSession = false
            self.session = session

            if !session.start() { finish(.failure(.couldNotStart)) }
        }

        /// The flow currently on screen — mirrors `FireblocksSendFlow.active`
        /// exactly, same reasoning.
        private static weak var active: FireblocksSignFlow?

        private func handle(callback: URL?, error: Error?) {
            if let error {
                let cancelled = (error as NSError).code == ASWebAuthenticationSessionError.canceledLogin.rawValue
                return finish(.failure(cancelled ? .cancelled : .couldNotStart))
            }
            guard let callback,
                  let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems
            else { return finish(.failure(.malformedResult)) }

            let values = Dictionary(items.map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { first, _ in first })
            guard values["nonce"] == nonce else { return finish(.failure(.nonceMismatch)) }

            if values["error"] == "1" {
                return finish(.failure(.failed(code: values["code"] ?? "unknown", message: values["message"] ?? "")))
            }
            guard let signature = values["signature"], !signature.isEmpty else { return finish(.failure(.malformedResult)) }
            finish(.success(SignedMessage(signature: signature)))
        }

        private func finish(_ result: Result<SignedMessage, FireblocksSignError>) {
            DispatchQueue.main.async {
                guard !self.didFinish else { return } // ignore a second completion
                self.didFinish = true
                if FireblocksSignFlow.active === self { FireblocksSignFlow.active = nil }
                self.completion(result)
                self.selfReference = nil // release
            }
        }

        // MARK: Out-of-band return

        /// Complete an in-flight sign from an app-level `open(URL:)`. Mirrors
        /// `FireblocksSendFlow.handleCallbackURL` exactly.
        @discardableResult
        public static func handleCallbackURL(_ url: URL) -> Bool {
            guard let flow = active,
                  url.scheme?.lowercased() == flow.scheme.lowercased()
            else { return false }
            flow.handle(callback: url, error: nil) // deliver result (guards double-finish)
            flow.session?.cancel()                 // dismiss the still-open sheet
            return true
        }
    }

    extension FireblocksSignFlow: ASWebAuthenticationPresentationContextProviding {
        public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
            UIApplication.shared.connectedScenes
                .compactMap { $0 as? UIWindowScene }
                .flatMap(\.windows)
                .first { $0.isKeyWindow } ?? ASPresentationAnchor()
        }
    }

    // MARK: - Fund from exchange

    /// The result of a successful `FireblocksFundFromExchangeFlow.fund` call.
    public struct ExchangeTransfer {
        /// The EXCHANGE's transfer id — not an on-chain hash. The exchange
        /// broadcasts on its own schedule, so there may be no hash yet at all;
        /// poll the exchange (or the destination address) for settlement.
        public let transferId: String
        /// Which exchange the funds came from, e.g. `"coinbase"`.
        public let exchange: String
        /// Amount and currency the exchange accepted, echoed back — these can
        /// differ from what was requested (e.g. rounded to the currency's precision).
        public let amount: String
        public let currency: String
        /// The exchange's own status string when it reports one (e.g. `"pending"`).
        public let status: String?
    }

    /// Why a fund-from-exchange attempt ended without an `ExchangeTransfer`.
    public enum FireblocksFundError: Error {
        case invalidURL
        /// The user dismissed the sheet.
        case cancelled
        case nonceMismatch
        case malformedResult
        case couldNotStart
        /// The flow itself reported a failure — the exchange isn't enabled for this
        /// environment, no balance is available, the exchange refused the transfer,
        /// … `code` is a stable machine code; `message` is human-readable.
        case failed(code: String, message: String)
    }

    /// Fund an address from the user's exchange account — Coinbase today.
    ///
    /// This is NOT a wallet operation and needs no connected wallet. The hosted
    /// page signs the user in to the EXCHANGE over OAuth, reads their balances,
    /// and the exchange itself signs and broadcasts the withdrawal. There is no
    /// headless equivalent either: OAuth has nowhere to run inside
    /// `FireblocksHeadlessConnect`'s hidden WKWebView (a popup has no surface, and
    /// a cross-origin redirect is refused by its navigation delegate), which is
    /// why this lives here in the ASWebAuthenticationSession flow, like
    /// `FireblocksSendFlow` does.
    ///
    /// ```swift
    /// FireblocksFundFromExchangeFlow.fund(
    ///     hostedPageURL: URL(string: "https://connect.example.com/")!,
    ///     scheme: "myapp",
    ///     to: wallet.address,          // where the funds land
    ///     currency: "USDC",
    ///     amount: "10"
    /// ) { result in
    ///     switch result {
    ///     case .success(let transfer): print(transfer.transferId)
    ///     case .failure(let error):    print(error)
    ///     }
    /// }
    /// ```
    ///
    /// NEVER silent: expect the sheet to open, an exchange sign-in, and — for an
    /// account with two-step verification on, or a transfer whose jurisdictions
    /// require recipient details — one or two further prompts on the page.
    public final class FireblocksFundFromExchangeFlow: NSObject {

        /// - Parameters:
        ///   - to: Destination address. Deliberately unvalidated: an exchange
        ///     withdrawal can target any chain the exchange supports (EVM `0x…`, a
        ///     Solana base58 key, a BTC address…). The exchange decides whether it
        ///     matches `currency`/`network`, and refuses the transfer if not.
        ///   - exchange: Catalogue key; only `"coinbase"` is wired today.
        ///   - currency: Token symbol to debit (e.g. `"USDC"`). When it matches
        ///     exactly one balance, the page skips its balance picker.
        ///   - amount: Decimal string prefill. The user still confirms it on the
        ///     page, which refuses more than the available balance.
        ///   - network: Network slug passed through to the exchange (e.g. `"base"`).
        ///     Get this right — the exchange, not this app, decides which chain the
        ///     funds land on.
        @discardableResult
        public static func fund(
            hostedPageURL: URL,
            scheme: String,
            to: String,
            exchange: String = "coinbase",
            currency: String? = nil,
            amount: String? = nil,
            network: String? = nil,
            environmentId: String? = nil,
            locale: String? = nil,
            theme: String? = nil,
            completion: @escaping (Result<ExchangeTransfer, FireblocksFundError>) -> Void
        ) -> FireblocksFundFromExchangeFlow {
            let flow = FireblocksFundFromExchangeFlow(completion: completion)
            flow.start(
                hostedPageURL: hostedPageURL,
                scheme: scheme,
                to: to,
                exchange: exchange,
                currency: currency,
                amount: amount,
                network: network,
                environmentId: environmentId,
                locale: locale,
                theme: theme
            )
            return flow
        }

        // MARK: Private

        private let completion: (Result<ExchangeTransfer, FireblocksFundError>) -> Void
        private let nonce = FireblocksConnectFlow.makeNonce()
        private var scheme = ""
        private var exchange = "coinbase"
        private var session: ASWebAuthenticationSession?
        private var selfReference: FireblocksFundFromExchangeFlow?
        private var didFinish = false

        private init(completion: @escaping (Result<ExchangeTransfer, FireblocksFundError>) -> Void) {
            self.completion = completion
            super.init()
            selfReference = self
        }

        private func start(
            hostedPageURL: URL,
            scheme: String,
            to: String,
            exchange: String,
            currency: String?,
            amount: String?,
            network: String?,
            environmentId: String?,
            locale: String? = nil,
            theme: String? = nil
        ) {
            self.scheme = scheme
            self.exchange = exchange
            guard var components = URLComponents(url: hostedPageURL, resolvingAgainstBaseURL: false) else {
                return finish(.failure(.invalidURL))
            }
            var items = components.queryItems ?? []
            items.append(contentsOf: [
                URLQueryItem(name: "intent", value: "fundFromExchange"),
                URLQueryItem(name: "exchange", value: exchange),
                URLQueryItem(name: "to", value: to),
                URLQueryItem(name: "redirect_uri", value: "\(scheme)://\(FireblocksConnectFlow.callbackHost)"),
                URLQueryItem(name: "nonce", value: nonce),
            ])
            if let currency, !currency.isEmpty { items.append(URLQueryItem(name: "currency", value: currency)) }
            if let amount, !amount.isEmpty { items.append(URLQueryItem(name: "amount", value: amount)) }
            if let network, !network.isEmpty { items.append(URLQueryItem(name: "network", value: network)) }
            for (name, value) in [("environmentId", environmentId), ("locale", locale), ("theme", theme)] {
                if let value, !value.isEmpty {
                    items.append(URLQueryItem(name: name, value: value))
                }
            }
            components.queryItems = items
            guard let url = components.url else { return finish(.failure(.invalidURL)) }

            FireblocksFundFromExchangeFlow.active = self

            let session = ASWebAuthenticationSession(url: url, callbackURLScheme: scheme) { [weak self] callback, error in
                self?.handle(callback: callback, error: error)
            }
            session.presentationContextProvider = self
            // See FireblocksConnectFlow.start's comment on this same option — not
            // ephemeral. It matters here for its own reason too: the exchange's
            // OAuth screen is far friendlier to a user already signed in to that
            // exchange in this browser session.
            session.prefersEphemeralWebBrowserSession = false
            self.session = session

            if !session.start() { finish(.failure(.couldNotStart)) }
        }

        /// The flow currently on screen — mirrors `FireblocksSendFlow.active`, same
        /// reasoning (an out-of-band return that lands on the app's `open(URL:)`).
        private static weak var active: FireblocksFundFromExchangeFlow?

        private func handle(callback: URL?, error: Error?) {
            if let error {
                let cancelled = (error as NSError).code == ASWebAuthenticationSessionError.canceledLogin.rawValue
                return finish(.failure(cancelled ? .cancelled : .couldNotStart))
            }
            guard let callback,
                  let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems
            else { return finish(.failure(.malformedResult)) }

            let values = Dictionary(items.map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { first, _ in first })
            guard values["nonce"] == nonce else { return finish(.failure(.nonceMismatch)) }

            if values["error"] == "1" {
                return finish(.failure(.failed(code: values["code"] ?? "unknown", message: values["message"] ?? "")))
            }
            guard let transferId = values["transferId"], !transferId.isEmpty else {
                return finish(.failure(.malformedResult))
            }
            finish(.success(ExchangeTransfer(
                transferId: transferId,
                exchange: values["exchange"] ?? exchange,
                amount: values["amount"] ?? "",
                currency: values["currency"] ?? "",
                status: values["status"]
            )))
        }

        private func finish(_ result: Result<ExchangeTransfer, FireblocksFundError>) {
            DispatchQueue.main.async {
                guard !self.didFinish else { return } // ignore a second completion
                self.didFinish = true
                if FireblocksFundFromExchangeFlow.active === self { FireblocksFundFromExchangeFlow.active = nil }
                self.completion(result)
                self.selfReference = nil // release
            }
        }

        // MARK: Out-of-band return

        /// Complete an in-flight fund from an app-level `open(URL:)`. Mirrors
        /// `FireblocksSendFlow.handleCallbackURL` exactly.
        @discardableResult
        public static func handleCallbackURL(_ url: URL) -> Bool {
            guard let flow = active,
                  url.scheme?.lowercased() == flow.scheme.lowercased()
            else { return false }
            flow.handle(callback: url, error: nil) // deliver result (guards double-finish)
            flow.session?.cancel()                 // dismiss the still-open sheet
            return true
        }
    }

    extension FireblocksFundFromExchangeFlow: ASWebAuthenticationPresentationContextProviding {
        public func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
            UIApplication.shared.connectedScenes
                .compactMap { $0 as? UIWindowScene }
                .flatMap(\.windows)
                .first { $0.isKeyWindow } ?? ASPresentationAnchor()
        }
    }
    ````
  </Accordion>
</AccordionGroup>

## 1. Register your URL scheme

Add your app's custom scheme to `Info.plist`. It only has to match the scheme you pass to the flow.

```xml Info.plist theme={"system"}
<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array><string>myapp</string></array>
  </dict>
</array>
```

## 2. Present the flow

Call `FireblocksConnectFlow.present`. It appends `redirect_uri`, a random `nonce`, and `embedded=1` to the URL, opens the session, verifies the returned nonce, and hands you a typed result. Pass `environmentId` to target a different Dynamic environment, and `locale` / `theme` to override the hosted page's UI locale and theme.

```swift ConnectButton.swift theme={"system"}
FireblocksConnectFlow.present(
    hostedPageURL: URL(string: "https://connect.dynamicauth.com/")!,
    scheme: "myapp",
    environmentId: "b1e3aca9-0646-411a-b4ab-c31ce49935b3"
) { result in
    switch result {
    case .success(let wallet):
        // wallet.address, wallet.chain,
        // wallet.walletName, wallet.walletImage
    case .failure(.cancelled):
        break
    case .failure(let error):
        // .nonceMismatch / .malformedResult / …
    }
}
```

Forward your app's `onOpenURL` to `FireblocksConnectFlow.handleCallbackURL($0)`. Most wallets return inside the session. Phantom connects inside its own in-app browser, and its "Return to app" button opens `<scheme>://wallet-return` without any connection data. `handleCallbackURL` consumes that URL and leaves the session open, because the result still arrives inside the session over WalletConnect.

```swift App.swift theme={"system"}
WindowGroup {
    ContentView()
        .onOpenURL { FireblocksConnectFlow.handleCallbackURL($0) }
}
```

## 3. Use the result

`WalletConnection` carries the same fields as the web callback (the nonce is already verified for you):

| Field | Type | Description |
| - | - | - |
| `address` | `String` | The connected wallet's public address. |
| `chain` | `"evm" \| "solana"` | Which chain family the address belongs to. |
| `walletName` | `String` | Display name of the wallet (e.g. `MetaMask`). |
| `walletImage` | `String` | Icon URL. Usually an SVG-sprite URL, so render it with a WebKit-backed `<img>` rather than `UIImage`. |

<Note>
  The `embedded=1` flag tells the page it is inside a native container, so it opens wallets via their native scheme and avoids redirect protocols that would escape to Safari. Real wallet round-trips require a physical device. Wallets do not run in the Simulator.
</Note>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.