> ## 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.

# 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.

On iOS the [hosted connect page](https://www.dynamic.xyz/docs/connections/overview.md) 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](https://www.dynamic.xyz/docs/connections/ios-headless.md).

> [!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.

> [accordion] 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?
>     /// CAIP-2 chain id of the connected network or manually chosen network
>     /// (e.g. `"eip155:1"`), passed through untouched, on visible and headless
>     /// connects. `nil` when the wallet did not report one; an empty value counts
>     /// as absent. Unrelated to `network`,
>     /// which is the Phantom session cluster.
>     public let networkId: String?
>
>     public init(
>         address: String,
>         chain: String,
>         walletName: String,
>         walletImage: String,
>         connectedHeadlessly: Bool = true,
>         network: String? = nil,
>         walletBrowserURL: String? = nil,
>         walletKey: String? = nil,
>         networkId: 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
>         self.networkId = networkId.flatMap { $0.isEmpty ? nil : $0 }
>     }
> }
>
> /// 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.
> ///
> /// A host that renders the page itself (an embedded `WKWebView`) skips the
> /// session and reuses the same contract: build the URL with `makeHostedURL`,
> /// intercept the navigation to `<scheme>://wallet-callback`, and verify it with
> /// `parseCallback`.
> 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.
>     /// - Parameter phantomMode: how the hosted page connects Phantom —
>     ///   `"in-app"` or `"redirect"`. `nil`/empty omits the
>     ///   param and the page uses its own default (`in-app`).
>     @discardableResult
>     public static func present(
>         hostedPageURL: URL,
>         scheme: String,
>         environmentId: String? = nil,
>         locale: String? = nil,
>         theme: String? = nil,
>         phantomMode: String? = nil,
>         completion: @escaping (Result<WalletConnection, FireblocksConnectError>) -> Void
>     ) -> FireblocksConnectFlow {
>         let flow = FireblocksConnectFlow(
>             scheme: scheme,
>             environmentId: environmentId,
>             locale: locale,
>             theme: theme,
>             phantomMode: phantomMode,
>             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 phantomMode: 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,
>         phantomMode: String? = nil,
>         completion: @escaping (Result<WalletConnection, FireblocksConnectError>) -> Void
>     ) {
>         self.scheme = scheme
>         self.environmentId = environmentId
>         self.locale = locale
>         self.theme = theme
>         self.phantomMode = phantomMode
>         self.completion = completion
>         super.init()
>         selfReference = self
>     }
>
>     private func start(hostedPageURL: URL) {
>         guard let url = Self.makeHostedURL(
>             hostedPageURL: hostedPageURL, scheme: scheme, nonce: nonce,
>             environmentId: environmentId, locale: locale, theme: theme, phantomMode: phantomMode
>         ) 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.
>     ///
>     /// `environmentId`/`locale`/`theme`/`phantomMode` are only taken over when
>     /// 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.
>     public static func makeHostedURL(
>         hostedPageURL: URL,
>         scheme: String,
>         nonce: String,
>         environmentId: String? = nil,
>         locale: String? = nil,
>         theme: String? = nil,
>         phantomMode: String? = nil
>     ) -> 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)://\(callbackHost)"),
>             URLQueryItem(name: "nonce", value: nonce),
>             URLQueryItem(name: "embedded", value: "1"),
>         ]
>         for (name, value) in [
>             ("environmentId", environmentId), ("locale", locale), ("theme", theme), ("phantomMode", phantomMode),
>         ] {
>             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
>     }
>
>     /// Verify and decode the `<scheme>://wallet-callback` URL the page returned.
>     public static func parseCallback(_ callback: URL, nonce: String) -> Result<WalletConnection, FireblocksConnectError> {
>         guard let items = URLComponents(url: callback, resolvingAgainstBaseURL: false)?.queryItems
>         else { return .failure(.malformedResult) }
>
>         let values = Dictionary(items.map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { first, _ in first })
>         guard values["nonce"] == nonce else { return .failure(.nonceMismatch) }
>         guard let address = values["address"], !address.isEmpty else { return .failure(.malformedResult) }
>
>         return .success(WalletConnection(
>             address: address,
>             chain: values["chain"] ?? "",
>             walletName: values["walletName"] ?? "",
>             walletImage: values["walletImage"] ?? "",
>             connectedHeadlessly: false,
>             networkId: values["networkId"]
>         ))
>     }
>
>     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 else { return finish(.failure(.malformedResult)) }
>         finish(Self.parseCallback(callback, nonce: nonce))
>     }
>
>     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.
>     public 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()
>     }
> }
> ````

## 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). `networkId` is the CAIP-2 id of the network the wallet is on. It is not `network`, the Phantom Solana session cluster. See [Network fields](https://www.dynamic.xyz/docs/connections/overview.md#network-fields).

| Field | Type | Description |
| - | - | - |
| `address` | `String` | The connected wallet's public address. |
| `chain` | `"evm" \| "solana" \| "bitcoin"` | 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`. |
| `networkId` | `String?` | The [CAIP-2](https://www.dynamic.xyz/docs/connections/caip-2.md) id of the connected or manually chosen network (e.g. `eip155:1`); `nil` when the wallet did not report one. |

> [!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.


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