This is an enterprise-only feature. Please contact us to enable.
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.
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.View the full FireblocksConnectFlow.swift (copy-paste ready)
View the full FireblocksConnectFlow.swift (copy-paste ready)
FireblocksConnectFlow.swift
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()
}
}
1. Register your URL scheme
Add your app’s custom scheme toInfo.plist. It only has to match the scheme you pass to the flow.
Info.plist
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array><string>myapp</string></array>
</dict>
</array>
2. Present the flow
CallFireblocksConnectFlow.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.
ConnectButton.swift
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 / …
}
}
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.
App.swift
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. |
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.