Wallets

Key Features

Modular Wallets are built on smart accounts that extend functionality through modules. This allows developers to tailor specific use cases and enable key features such as:

Explore these key features in more detail below.

Circle Smart Accounts allow users to set up passkeys and utilize biometrics verification for signing, which provides a seamless Web3 experience. Smart Accounts are built on top of Viem and adhere to the ERC-6900 and ERC-4337 (v0.7) standards. Advanced use cases are supported through modules.

// 1. register or login with a passkey
const passkeyTransport = toPasskeyTransport(clientUrl, clientKey)
const credential = await toWebAuthnCredential({
  transport: passkeyTransport,
  mode: WebAuthnMode.Register, //or WebAuthnMode.Login if login
  username: 'my-passkey',
})

// 2. create a public client
const modularTransport = toModularTransport(
  clientUrl + '/polygonAmoy',
  clientKey,
)
const client = createPublicClient({
  chain: polygonAmoy,
  transport: modularTransport,
})

// 3. create a circle smart account
const smartAccount = await toCircleSmartAccount({
  client,
  owner: toWebAuthnAccount({
    credential,
  }),
})

// 4. create a bundler client
const bundlerClient = createBundlerClient({
  smartAccount,
  chain: polygonAmoy,
  transport: modularTransport,
})

The sample code below demonstrates how to send a transaction, also known as a User Operation (userOp) in the context of Account Abstraction (AA), to the Bundler.

// 5. send a user operation
const userOpHash = await bundlerClient.sendUserOperation({
  calls: [
    {
      to: '0x...abc',
      value: parseEther('1'),
    },
  ],
})

// 6. wait for transaction receipt
const { receipt } = await bundlerClient.waitForUserOperationReceipt({
  userOpHash,
})

Gasless transactions, in the context of Account Abstraction (AA), allow users to perform blockchain operations without needing to hold native tokens for gas fees. Circle provides the Gas Station service, enabling developers to sponsor network fees on behalf of users. This feature addresses the common challenge of requiring native tokens for onchain activities, simplifying blockchain interactions for end users by eliminating the need to manage token balances.

import { http, parseEther } from 'viem'
import {
  createBundlerClient,
  createPaymasterClient,
} from 'viem/account-abstraction'
import { polygonAmoy } from 'viem/chains'
import { toModularTransport } from '@circle-fin/modular-wallets-core'

const clientUrl = 'your-client-url'
const clientKey = 'your-client-key'

// 1. Create modular transport from client url and clientKey
const modularTransport = toModularTransport(clientUrl, clientKey)

// 2. Create a bundler client
const bundlerClient = createBundlerClient({
  chain: polygonAmoy,
  transport: modularTransport,
})

// 3. Specify `paymaster: true` to sponsor gas fees
const userOpHash = await bundlerClient.sendUserOperation({
  smartAccount, // Assume `smartAccount` is an instance of `toCircleSmartAccount`
  calls: [
    {
      to: '0x1234567890123456789012345678901234567890',
      value: parseEther('0.1'),
    },
  ],
  paymaster: true,
})

Modular Wallets support sending multiple transactions into a single call. This approach simplifies the user experience and improves gas efficiency. For instance, users can approve multiple transfers or swaps with a single signature.

See the sample code below for sending batch transactions.

import { parseEther } from 'viem'
import { createBundlerClient } from 'viem/account-abstraction'
import { polygonAmoy } from 'viem/chains'
import { toModularTransport } from '@circle-fin/modular-wallets-core'

const clientUrl = 'your-client-url'
const clientKey = 'your-client-key'

// 1. Create modular transport from client url and clientKey
const modularTransport = toModularTransport(clientUrl, clientKey)

// 2. Create a bundler client
const bundlerClient = createBundlerClient({
  chain: polygonAmoy,
  transport: modularTransport,
})

// 3. Send batch transactions in user operation using the bundler client
const userOpHash = await bundlerClient.sendUserOperation({
  smartAccount, // Assume `smartAccount` is an instance of `toCircleSmartAccount`
  calls: [
    {
      to: '0x1234567890123456789012345678901234567890',
      value: parseEther('1'),
    },
    {
      to: '0x9876543210987654321098765432109876543210',
      value: parseEther('2'),
    },
  ],
})

Traditional Externally Owned Accounts (EOA) process transactions sequentially using a linear nonce (e.g., 1, 2, 3). This requires each transaction to complete before the next begins.

In contrast, smart accounts support 2D nonces, which consist of:

  • Nonce key
  • Nonce value

This two-dimensional approach enables parallel transaction execution for independent operations.

A user wants to execute three trades:

  1. Swap 100 USDT for DAI (transaction 1)
  2. Swap 100 DAI for USDC (transaction 2)
  3. Swap 1 WETH for USDC (transaction 3)

Transactions 1 and 2 are dependent and must execute sequentially. Transaction 3, however, is independent and can execute in parallel with the first two.

To send parallel transactions through userOps, use "nonce keys" to compute a nonce. See sample code below.

// Assume `userOps` is an array of user operations to be sent
// and `smartAccount` is an instance of `toCircleSmartAccount` to handle signing

const signedUserOps: UserOperation[] = [];

// Sequentially sign each userOp and store the result
userOps.forEach(async (userOp, index) => {
    try {
        const nonce = await smartAccount.getNonce({
            // Use the nonceKey to compute nonce
            key: BigInt(userOp.nonce ?? index + 1),
        });
        const signature = await smartAccount.signUserOperation({ ...userOp, nonce });
        signedUserOps.push({ ...userOp, nonce, signature });
    } catch (error) {
        // Handle the error (e.g., skip, retry)
        console.error("Error signing user operation:", error);
    }
});

// Send all signed user operations in parallel
await Promise.all(
    signedUserOps.map(async (userOp) => {
        try {
            return await bundlerClient.sendUserOperation({
                smartAccount,
                ...userOp,
            });
        } catch (error) {
            // Handle the error (e.g., retry)
            console.error("Error sending user operation:", error);
        }
    })
);

Circle Smart Accounts enable signing and verifying various data types:

  • Message. Signs a message using a passkey, generating an Ethereum-specific EIP-191 signature.
  • Typed Data. Signs typed data using a passkey, generating an Ethereum-specific EIP-712 signature.

See the sample code below for signing and verifying messages.

import { createPublicClient } from 'viem'
import { toWebAuthnAccount } from 'viem/account-abstraction'
import { polygonAmoy } from 'viem/chains'
import {
  toCircleSmartAccount,
  toModularTransport,
  toPasskeyTransport,
  toWebAuthnCredential,
  WebAuthnMode,
} from '@circle-fin/modular-wallets-core'

const clientUrl = 'your-client-url'
const clientKey = 'your-client-key'

// 1. Register or login with a passkey
const credential = await toWebAuthnCredential({
  transport: toPasskeyTransport(clientUrl, clientKey),
  mode: WebAuthnMode.Register, // or WebAuthnMode.Login
  username: 'my-passkey',
})

// 2. Create public client
const client = createPublicClient({
  chain: polygonAmoy,
  transport: toModularTransport(clientUrl, clientKey),
})

// 3. Create Circle Smart Account
const smartAccount = await toCircleSmartAccount({
  client,
  owner: toWebAuthnAccount({ credential }),
})

// 4. Sign message with Circle Smart Account
const message = 'TestSignMessage'
const signature = await smartAccount.signMessage(message)

// 5. Verify the signature
const isValid = await client.verifyMessage({
  address: smartAccount.address,
  message,
  signature,
})

The Modular Wallets SDK can also function as a provider, enabling third-party wallet SDKs to connect to Circle's services. The following example demonstrates an EIP-1193 provider implementation for the Web SDK.

JavaScript
import Web3 from "web3"

import { polygonAmoy } from "viem/chains"
import {
  createClient,
  createPublicClient,
  http,
  type Hex,
} from "viem"
import {
  type P256Credential,
  type SmartAccount,
  WebAuthnAccount,
  createBundlerClient,
  toWebAuthnAccount,
} from "viem/account-abstraction"

import {
  EIP1193Provider,
  WebAuthnMode,
  toCircleSmartAccount,
  toModularTransport,
  toPasskeyTransport,
  toWebAuthnCredential,
} from "w3s-web-core-sdk"

/* ========== Replace these with your values ========== */
const clientKey = "YOUR_CLIENT_KEY"
const clientUrl = "YOUR_CLIENT_URL"
const infuraUrl = "YOUR_INFURA_ENDPOINT_URL" // E.g. "https://polygon-mumbai.infura.io/v3/xxx"

/* ========== Creating Circle Passkey Transport ========== */
const passkeyTransport = toPasskeyTransport(clientUrl, clientKey)
const modularTransport = toModularTransport(`${clientUrl}/polygonAmoy`, clientKey)

/* ========== Creating client ========== */
const client = createClient({
  chain: polygonAmoy,
  transport: modularTransport,
})

let web3            // Web3 instance
let account         // Circle Smart Account
let credential      // Passkey credential

/**
 * Initialize the Web3 instance with Circle Smart Account.
 */
export async function init() {
  try {
    credential = JSON.parse(localStorage.getItem("credential") || "null")
    if (!credential) {
      console.log("No credential found in localStorage. Please register or login first.")
      return
    }

    // Create Circle Smart Account
    account = await toCircleSmartAccount({
      client,
      owner: toWebAuthnAccount({ credential }) as WebAuthnAccount,
    })

    // Create PublicClient and BundlerClient
    const publicClientInstance = createPublicClient({
      chain: polygonAmoy,
      transport: http(infuraUrl),
    })
    const bundlerClientInstance = createBundlerClient({
      account,
      chain: polygonAmoy,
      transport: modularTransport,
    })

    // Transform to EIP1193 provider
    const provider = new EIP1193Provider(bundlerClientInstance, publicClientInstance)

    // Init Web3
    web3 = new Web3(provider)

    console.log("Initialized successfully!")
    console.log("Smart Account Address:", account.address)
  } catch (err) {
    console.error("Init Error:", err)
  }
}

/**
 * Register a new account with Passkey (WebAuthn) and store it in localStorage.
 * @param {string | undefined} username - The username.
 */
export async function register(username) {
  try {
    const result = await toWebAuthnCredential({
      transport: passkeyTransport,
      mode: WebAuthnMode.Register,
      username,
    })

    localStorage.setItem("credential", JSON.stringify(result))
    console.log("Register success:", result)
    console.log("Call `init()` to initialize your account.")
  } catch (err) {
    console.error("Register fail:", err)
  }
}

/**
 * Login with Passkey (WebAuthn) and store it in localStorage.
 */
export async function login() {
  try {
    const result = await toWebAuthnCredential({
      transport: passkeyTransport,
      mode: WebAuthnMode.Login,
    })

    localStorage.setItem("credential", JSON.stringify(result))
    console.log("Login success:", result)
    console.log("Call `init()` to initialize your account.")
  } catch (err) {
    console.error("Login fail:", err)
  }
}

/* ========== Case 1: Request accounts ========== */
export async function requestAccounts() {
  try {
    if (!web3) {
      throw new Error("web3 not initialized. Please call `init()` first.")
    }
    const accounts = await web3.eth.getAccounts()
    console.log("Accounts:", accounts)
    return accounts
  } catch (err) {
    console.error("Request Accounts Error:", err)
  }
}

/* ========== Case 2: Personal sign ========== */
export async function personalSign() {
  try {
    if (!web3) {
      throw new Error("web3 not initialized. Please call `init()` first.")
    }
    const accounts = await web3.eth.getAccounts()

    const signature = await web3.eth.personal.sign(
      "Hello World",
      accounts[0],
      "passphrase"
    )

    console.log("Personal Sign Result:", signature)
    return signature
  } catch (err) {
    console.error("Personal Sign Error:", err)
  }
}

/* ========== Case 3: Sign typed data (EIP-712 / v4) ========== */
export async function signTypedData() {
  try {
    if (!web3) {
      throw new Error("web3 not initialized. Please call `init()` first.")
    }
    const accounts = await web3.eth.getAccounts()
    const from = accounts[0]

    // Prepare the typed Data
    const domain = {
      name: "MyDApp",
      version: "1.0",
      chainId: 80002,
      verifyingContract: "0x1111111111111111111111111111111111111111",
    }
    const message = {
      content: "Hello from typed data!",
      sender: from,
      timestamp: Math.floor(Date.now() / 1000),
    }
    const dataToSign = {
      domain,
      message,
      primaryType: "Message",
      types: {
        EIP712Domain: [
          { name: "name", type: "string" },
          { name: "version", type: "string" },
          { name: "chainId", type: "uint256" },
          { name: "verifyingContract", type: "address" },
        ],
        Message: [
          { name: "content", type: "string" },
          { name: "sender", type: "address" },
          { name: "timestamp", type: "uint256" },
        ],
      },
    }

    const signature = await web3.eth.signTypedData(from, dataToSign)

    console.log("Signature", signature)

    return signature
  } catch (err) {
    console.error("Sign Typed Data Error:", err)
  }
}

/* ========== Case 4: Sign transaction (sendTransaction) ========== */
export async function sendTransaction(to, value) {
  try {
    if (!web3) {
      throw new Error("web3 not initialized. Please call `init()` first.")
    }
    if (!to || !value) {
      throw new Error("Please provide `to` address and `value` in ETH.")
    }

    const accounts = await web3.eth.getAccounts()
    const suggestedGasPrice = ((await web3.eth.getGasPrice()) * 11n) / 10n

    const txResult = await web3.eth.sendTransaction({
      from: accounts[0],
      to,
      value: web3.utils.toWei(value, "ether"),
      gas: 53638, // Estimated gas limit for a simple transaction
      gasPrice: suggestedGasPrice,
    })

    console.log("Transaction:", txResult)
    return txResult.transactionHash
  } catch (err) {
    console.error("SendTx Error:", err)
  }
}

register("your-name")

// Or you have registered already:
login()

init()

// Request accounts
requestAccounts()

// Personal sign
personalSign()

// sign typed data
signTypedData()

// Send transaction
sendTransaction("0xRecipientAddress", "0.01")
Did this page help you?
© 2023-2025 Circle Technology Services, LLC. All rights reserved.