Skip to main content
The CDP Swift SDK is an embedded-wallets solution for iOS and macOS applications. It provides end-user authentication, account creation, signing, swaps, and transaction broadcasting through the Coinbase Developer Platform. The SDK ships a single library target — CDPCore — exposing an actor-based, async/await API.

Quickstart

This guide will help you get started with CDPCore. You’ll learn how to install the package, initialize the SDK, and make your first API call.

Requirements

  • Swift 5.9+ (Xcode 15+)
  • iOS 16+ / macOS 13+

Installation

Add the SDK to your Swift package dependencies:
// Package.swift
dependencies: [
    .package(url: "https://github.com/coinbase/cdp-swift", from: "0.1.0"),
]
Then add the product to your target:
.target(
    name: "YourApp",
    dependencies: [
        .product(name: "CDPCore", package: "cdp-swift"),
    ]
)

Gather your CDP Project information

  1. Sign in or create an account on the CDP Portal
  2. On your dashboard, select a project from the dropdown at the top, and copy the Project ID

Initialize the SDK

Before calling any methods in the SDK, you must first create a WalletsClient and call start(). WalletsClient is an actor — every public method is async. Calling start() restores any persisted session and registers the default Apple platform services (Keychain, crypto, OAuth).
import CDPCore
import SwiftUI

@main
struct MyApp: App {
    @StateObject private var appState = AppState()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(appState)
                .task { await appState.initializeSDK() }
                .onOpenURL { url in
                    Task { await appState.handleOpenURL(url) }
                }
        }
    }
}

@MainActor
final class AppState: ObservableObject {
    @Published var client: WalletsClient?
    @Published var user: User?

    func initializeSDK() async {
        let c = try? WalletsClient(config: CDPCoreConfig(projectId: "your-project-id"))
        await c?.start()
        client = c

        await c?.onAuthStateChange { [weak self] user in
            Task { @MainActor in self?.user = user }
        }
    }

    func handleOpenURL(_ url: URL) async {
        try? await client?.handleOAuthCode(url: url)
    }
}
For OAuth redirects, forward incoming URLs to handleOAuthCode(url:) via .onOpenURL (see OAuth).

Configuration

CDPCoreConfig controls SDK behaviour. Only projectId is required.
let config = CDPCoreConfig(
    projectId: "your-project-id",
    customAuth: nil,                  // BYO identity provider — see Custom Authentication
    useMock: false,                   // true → MockWalletsAPIClient for previews / offline
    debugging: false,                 // verbose logging
    basePath: nil,                    // override CDP API base URL
    ethereum: EthereumConfig(
        createOnLogin: .smart,        // .smart | .eoa | nil
        enableSpendPermissions: true
    ),
    solana: SolanaConfig(createOnLogin: true),
    callbackURLScheme: "myapp"        // OAuth deep-link scheme (defaults to bundleIdentifier)
)
For custom platform services (alternative storage, crypto, OAuth), call PlatformRegistry.shared.setPlatformServices(...) before start().

Account configuration

You can configure the SDK to create different types of accounts for new users. Smart Account configuration:
let config = CDPCoreConfig(
    projectId: "your-project-id",
    ethereum: EthereumConfig(createOnLogin: .smart) // Creates Smart Accounts instead of EOAs
)
When ethereum.createOnLogin is set to .smart, the SDK will:
  1. Automatically create an EOA (Externally Owned Account) as the owner
  2. Create a Smart Account owned by that EOA
  3. Make both accounts available on the user object
Solana account configuration:
let config = CDPCoreConfig(
    projectId: "your-project-id",
    solana: SolanaConfig(createOnLogin: true) // Creates Solana accounts
)
Deferred account creation: Omit createOnLogin entirely to prevent automatic account creation and instead create accounts manually when needed (see Create Accounts Manually).
let config = CDPCoreConfig(
    projectId: "your-project-id"
    // No ethereum or solana createOnLogin configuration
)

Sign In a User

The SDK supports five sign-in flows: Email OTP, SMS OTP, OAuth (Google/Apple/Telegram/…), Sign-In With Ethereum (SIWE), and developer-issued JWT (see Custom Authentication).

Email OTP

let flow = try await client.signInWithEmail(SignInWithEmailOptions(email: "user@example.com"))
let verified = try await client.verifyEmailOTP(
    VerifyEmailOTPOptions(flowId: flow.flowId, otp: "123456")
)
print("Signed in as \(verified.user.userId)")

SMS OTP

let flow = try await client.signInWithSms(SignInWithSmsOptions(phoneNumber: "+14155552671"))
let verified = try await client.verifySmsOTP(
    VerifySmsOTPOptions(flowId: flow.flowId, otp: "123456")
)

OAuth

signInWithOAuth returns a flow ID and opens the provider’s auth page. The provider redirects back to your app via the URL scheme configured in CDPCoreConfig.callbackURLScheme; forward that URL to handleOAuthCode (see Initialize the SDK).
let flowId = try await client.signInWithOAuth(providerType: .google)
// Redirect arrives via .onOpenURL → handleOAuthCode(url:) completes the flow.
Supported providers via OAuth2ProviderType: .google, .apple, .telegram, plus other configured providers. For manual code exchange (no deep link):
let result = try await client.verifyOAuth(
    VerifyOAuthOptions(flowId: flowId, code: code, providerType: .google)
)
Observe in-progress OAuth state:
await client.onOAuthStateChange { state in
    // state?.status: .pending | .success | .error
}

Sign-In With Ethereum (SIWE)

let challenge = try await client.signInWithSiwe(SignInWithSiweOptions(...))
// Sign challenge.message with the user's wallet, then:
let result = try await client.verifySiweSignature(
    VerifySiweSignatureOptions(flowId: challenge.flowId, signature: signature)
)
Once a user is authenticated, you can link additional auth methods to their account. This allows users to sign in using multiple methods (email, SMS, OAuth providers) with the same embedded wallet.
let flowId = try await client.linkEmail("alt@example.com")
_ = try await client.verifyEmailOTP(VerifyEmailOTPOptions(flowId: flowId, otp: "123456"))

let smsFlowId = try await client.linkSms("+14155552671")
let oauthFlowId = try await client.linkOAuth(providerType: .google)
let appleFlowId = try await client.linkApple()
let googleFlowId = try await client.linkGoogle()
let telegramFlowId = try await client.linkTelegram()

Sign In with Custom Authentication

If you’re using a third-party identity provider (Auth0, Firebase, AWS Cognito, or any OIDC-compliant provider), you can authenticate users with JWTs from your provider.

Prerequisites

Before using custom authentication:
  1. Configure your identity provider in the CDP Portal:
    • Navigate to Embedded Wallet Configuration
    • Click on the Custom auth tab
    • Add your JWKS endpoint URL (e.g., https://your-domain.auth0.com/.well-known/jwks.json)
    • Configure your JWT issuer and audience
  2. Provide a customAuth closure when initializing the SDK. The closure returns a fresh JWT from your identity provider, and the SDK invokes it automatically whenever a fresh bearer token is needed.
let config = CDPCoreConfig(
    projectId: "your-project-id",
    customAuth: CustomAuth { try await myIdentityProvider.currentJwt() }
)
let client = try WalletsClient(config: config)
await client.start()

Authenticate a User

Once configured, call authenticateWithJWT() to authenticate the user:
let result = try await client.authenticateWithJWT()
print("Is new user: \(result.isNewUser)")

// The user is now signed in and wallets are created based on your config
let user = await client.getCurrentUser()
print("EVM Address:", user?.evmAccountObjects?.first?.address ?? "none")

How it works

  1. Your user signs in to your identity provider (Auth0, Firebase, Cognito, etc.)
  2. You call authenticateWithJWT(), which internally invokes your customAuth closure
  3. The SDK sends the JWT to CDP’s backend, which validates it against your configured JWKS
  4. If valid, the user is authenticated and wallets are auto-created based on your configuration
  5. The customAuth closure is called automatically whenever the SDK needs a fresh token

View User Information

Once the end user has signed in, you can read their session and account information.
let user = await client.getCurrentUser()                   // User?
let signedIn = await client.isSignedIn()                   // Bool
let token = try await client.getAccessToken()              // String?
let expiry = await client.getAccessTokenExpiration()       // Int? (epoch seconds)

await client.onAuthStateChange { user in
    // Called on sign-in / sign-out / token refresh.
}
Existing accounts are exposed on the User:
let user = await client.getCurrentUser()
user?.evmAccountObjects        // [EndUserEvmAccount]?
user?.evmSmartAccountObjects   // [EndUserEvmSmartAccount]?
user?.solanaAccountObjects     // [EndUserSolanaAccount]?
To end a session:
try await client.signOut()                                  // clears session
await client.resetSession()                                 // nuclear: Keychain + cookies + state

Multi-Factor Authentication

Sensitive actions (signing, sending, spend permissions, delegation) automatically gate on MFA when enabled for the project. You must register an MFA listener via MFAState — without one, those actions throw CDPCoreError.mfa(.listenerRequired, _). The SDK supports two MFA methods:
  • TOTP (Time-based One-Time Password): Users enroll using authenticator apps like Google Authenticator or Authy
  • SMS: Users receive verification codes via text message to their phone number
Important: Users must be authenticated (signed in) before they can enroll in MFA or perform MFA verification.

Check MFA configuration

let config = try await client.getMfaConfig()        // MfaConfigState?

MFA enrollment flow

The enrollment flow consists of two steps. For TOTP, enrollment returns an authUrl and secret to provision the authenticator app.
let enroll = try await client.initiateMfaEnrollment(
    InitiateMfaEnrollmentOptions(mfaMethod: .totp)
)
switch enroll {
case .totp(let authUrl, let secret): break   // provision authenticator app
case .sms(let success): break
}

try await client.submitMfaEnrollment(
    SubmitMfaEnrollmentOptions(code: "123456", mfaMethod: .totp)
)

MFA verification flow

When performing sensitive operations that require MFA verification, use the verification flow:
let flowId = try await client.initiateMfaVerification(
    InitiateMfaVerificationOptions(mfaMethod: .totp)
)
try await client.submitMfaVerification(
    SubmitMfaVerificationOptions(code: "123456", mfaMethod: .totp)
)
await client.cancelMfaVerification()

Create Accounts Manually

If you configured your SDK without createOnLogin, you can manually create accounts for authenticated users when needed. This gives you full control over when accounts are created. All three methods accept an optional idempotencyKey: String.
let eoa = try await client.createEvmEoaAccount()                 // EndUserEvmAccount
let smart = try await client.createEvmSmartAccount()             // EndUserEvmSmartAccount (requires an EOA owner)
let solana = try await client.createSolanaAccount()              // EndUserSolanaAccount

Sign Messages and Typed Data

End users can sign EVM messages, hashes, and typed data to generate signatures for various onchain applications. All signing operations are MFA-gated when the project enables MFA — see Multi-Factor Authentication.

EVM message / hash

let msg = try await client.signEvmMessage(
    SignEvmMessageOptions(evmAccount: address, message: "Hello, CDP")
)

let hash = try await client.signEvmHash(
    SignEvmHashOptions(evmAccount: address, hash: "0xabc…")
)

EVM transaction (EIP-1559)

let tx = EvmTransaction(to: "0x…", value: "1000000000000000")
let signed = try await client.signEvmTransaction(
    SignEvmTransactionOptions(evmAccount: address, transaction: tx)
)
// signed.signedTransaction is RLP-encoded with the 0x02 EIP-1559 prefix.

EVM typed data (EIP-712)

let typedData = EIP712TypedData(
    domain: EIP712Domain(name: "MyDapp", version: "1", chainId: 84532,
                         verifyingContract: "0x…"),
    types: ["EIP712Domain": [...], "Message": [["name": "content", "type": "string"]]],
    primaryType: "Message",
    message: ["content": AnyCodable("Hello")]
)
let result = try await client.signEvmTypedData(
    SignEvmTypedDataOptions(evmAccount: address, typedData: typedData)
)

Solana message / transaction

Solana payloads are passed through as base64.
let messageBase64 = Data("Hello".utf8).base64EncodedString()
let sig = try await client.signSolanaMessage(
    SignSolanaMessageOptions(solanaAccount: address, message: messageBase64)
)

let signed = try await client.signSolanaTransaction(
    SignSolanaTransactionOptions(solanaAccount: address, transaction: base64Tx)
)

Send an EVM Transaction

We support signing and sending an EVM transaction in a single call. Network enums are available via SendEvmTransactionNetwork, SendEvmUsdcNetwork, EvmUserOperationNetwork, SendSolanaTransactionNetwork, and SendSolanaUsdcNetwork.
let tx = EvmTransaction(to: "0x…", value: "1000000000000000")
let res = try await client.sendEvmTransaction(
    SendEvmTransactionOptions(
        evmAccount: address,
        network: .baseSepolia,
        transaction: tx
    )
)
// res.transactionHash
USDC helpers (auto-encodes ERC-20 transfer):
try await client.sendEvmUsdc(SendEvmUsdcOptions(
    evmAccount: address,
    to: recipient,
    amount: "1.5",                     // decimal string, e.g. "1.5" USDC
    network: .baseSepolia
))

Smart Account Operations

Smart Accounts provide advanced account abstraction features, including user operations and paymaster support.

Send user operations

let call = EvmCall(to: contract, value: "0", data: callData)
let opRes = try await client.sendUserOperation(
    SendUserOperationOptions(
        evmSmartAccount: smartAccountAddress,
        network: .baseSepolia,
        calls: [call],
        useCdpPaymaster: true
    )
)
let hash: Hex = opRes.userOperationHash

Get user operation status

let status = try await client.getUserOperation(
    GetUserOperationOptions(
        userOperationHash: hash,
        evmSmartAccount: smartAccountAddress,
        network: .baseSepolia
    )
)
// status.status.rawValue, status.transactionHash
Smart-account USDC convenience:
try await client.sendEvmSmartAccountUsdc(SendEvmSmartAccountUsdcOptions(
    evmSmartAccount: smartAccountAddress,
    to: recipient,
    amount: "1.5",
    network: .baseSepolia,
    useCdpPaymaster: true
))

Send a Solana Transaction

let res = try await client.sendSolanaTransaction(
    SendSolanaTransactionOptions(
        solanaAccount: address,
        network: .solanaDevnet,
        transaction: base64Tx,
        useCdpSponsor: false
    )
)
// res.transactionSignature

try await client.sendSolanaUsdc(SendSolanaUsdcOptions(
    solanaAccount: address,
    to: recipient,
    amount: "1.5",
    network: .solanaDevnet
))

Swaps

let price = try await client.getSwapPrice(GetSwapPriceOptions(
    fromToken: usdc, toToken: weth, fromAmount: "1000000",
    account: nil,                       // auto-resolve taker (prefers smart account over EOA)
    network: .base, slippageBps: 100
))
guard price.liquidityAvailable else { return }
print("Min out: \(price.minToAmount ?? "?")")

let result = try await client.executeSwap(ExecuteSwapOptions(
    fromToken: usdc, toToken: weth, fromAmount: "1000000",
    account: takerAddress, network: .base, slippageBps: 100
))
switch result {
case .eoaResult(let txHash): print("EOA tx: \(txHash)")
case .smartAccountResult(let opHash): print("UserOp: \(opHash)")
}
useCdpPaymaster and paymasterUrl are mutually exclusive — passing both throws CDPCoreError.inputValidation.

Spend Permissions

Spend permissions allow Smart Accounts to delegate spending authority to other accounts within specified limits and time periods. This requires EthereumConfig(enableSpendPermissions: true) and an EVM smart account.
let created = try await client.createSpendPermission(CreateSpendPermissionOptions(
    evmSmartAccount: smartAccount,
    network: "base-sepolia",
    spender: spenderAddress,
    token: "eth",                       // "eth" or ERC-20 address
    allowance: "1000000000000000",      // wei
    periodInDays: 7,
    end: Int(Date().addingTimeInterval(86400 * 30).timeIntervalSince1970)
))
// created.userOpHash, created.status

let list = try await client.listSpendPermissions(
    ListSpendPermissionsOptions(evmSmartAccount: smartAccount)
)
for permission in list.spendPermissions {
    print(permission.permissionHash, permission.revoked)
}

let revoked = try await client.revokeSpendPermission(RevokeSpendPermissionOptions(
    evmSmartAccount: smartAccount,
    network: permission.network,
    permissionHash: permission.permissionHash
))

Delegation

Developer-key delegation lets your backend perform certain actions on behalf of the user.
let info = try await client.getDelegation()         // DelegationInfo?

try await client.createDelegation(
    CreateDelegationOptions(expiresAt: Date().addingTimeInterval(86400))
)

try await client.revokeDelegation()
Address-scoped variants are available: getDelegationForAddress, createDelegationForAddress, and revokeDelegationForAddress.

EIP-7702

Delegate an EOA to a smart-account implementation contract.
let opId = try await client.createEvmEip7702Delegation(
    CreateEvmEip7702DelegationOptions(address: eoa, network: "base-sepolia")
)

let success = try await client.waitForEvmEip7702Delegation(
    WaitForEvmEip7702DelegationOptions(delegationOperationId: opId)
)

Error Handling

All SDK errors are cases of CDPCoreError:
CaseMeaning
.notInitialized(String)start() not called
.notSignedIn(String)Operation requires an authenticated user
.alreadySignedIn(String)Sign-in attempted while already authenticated
.accountNotFound(String)The current user does not own the supplied address
.inputValidation(String)Invalid argument (address format, missing field, conflicting options)
.validation(String)Server-side validation error
.mfa(MfaErrorCode, String).superseded / .cancelled / .listenerRequired
.swap(SwapErrorCode, String).insufficientLiquidity / .insufficientAllowance / .insufficientBalance / .transactionSimulationFailed
.customAuth(String)BYO-auth misconfiguration or JWT failure
.api(statusCode:errorType:message:correlationId:)API returned an error
.network(String)Transport-level failure
.internal(String)Unexpected internal state
Inspect specific cases with pattern matching:
do {
    _ = try await client.getSwapPrice(options)
} catch let CDPCoreError.swap(code, message) where code == .insufficientLiquidity {
    print("No liquidity: \(message)")
} catch {
    print(error.localizedDescription)
}

Testing Your Integration

Set useMock: true to swap in MockWalletsAPIClient, which returns deterministic responses without making network calls — ideal for SwiftUI previews and unit tests.
let client = try WalletsClient(
    config: CDPCoreConfig(projectId: "test", useMock: true)
)
await client.start()
For richer fake responses (programmable per-call), implement the WalletsAPIClient protocol yourself and inject it via the apiClient: parameter on WalletsClient.init.