The first iteration of account abstraction from Dynamic uses ZeroDev and embedded wallets. This guide will walk you through setting up ZeroDev and Dynamic so that you can sponsor transactions. While we’re using Base Sepolia for this guide, you can use any network that has implemented the Ethereum Petra upgrade.

Prerequisite

This feature only works with Embedded Wallets (MPC). Before you start, enable Dynamic-powered Embedded Wallets and Email (or social) login. Follow this guide to setup Embedded Wallets.

Quick Start

Want to jump right in? Check out our Dynamic Gasless Starter - a complete Next.js starter showcasing gasless transactions using Dynamic’s embedded TSS-MPC wallets with account abstraction. The starter includes email-based wallet creation, gas sponsorship, and token minting without users needing to pay gas fees.

ZeroDev setup

1

ZeroDev Account

You must use the ZeroDev V1 dashboard to configure your project.
Sign up for a free account at https://dashboard-v1.zerodev.app/ and create a project, configure your project name and network (we’ll use Base Sepolia for this example, but you can choose any supported network) and copy your new ZeroDev project ID.
Copy ZeroDev Project ID
In the Gas Policies tab, click on the button labeled “New” in the Project Policies section
Gas Policy Page
Select “Amount” as the Type, “0.1” as the value and “Day” as the interval. This is saying that we will sponsor up to 0.1 ETH total per day.Alternatively, adding a blank policy is also valid — you just need to have one policy for the integration to work.
Set up a gas sponsorship rule
For details on all available policy types and options, see ZeroDev’s Gas Policies documentation.

Dashboard setup

1

Enable ZeroDev in the Dashboard

In Smart Wallets & AA, enable ZeroDev and configure your project.
  • Paste your ZeroDev Project ID under Project IDs and Chains. Use “Add another chain” to support multiple chains.
  • Choose the AA type:
    • Default (7702) — embedded wallet delegates to a smart account at the same address. See our 7702 guide and chains with 7702 support.
    • Legacy (4337) — smart account deployed at a different address.
For this example, you do not need to configure a custom bundler or paymaster. We’ll use the default settings.
Advanced: You can specify a bundler/paymaster RPC. See specifying a bundler/paymaster RPC for more details.
Enable ZeroDev and configure Project IDs and settings
2

Choose who gets a Smart Contract Wallet (SCW)

On the same configuration page, choose which users get a smart wallet:
  • New users only — Issue smart wallets only for users created after this setting is enabled.
  • All users — Issue smart wallets for all users. Existing users will receive a smart wallet the next time they log in.
Who gets a smart wallet


Set up the SDK

Install and configure connectors

For this guide, we’ll be using React and TypeScript, but this can easily be adapted to other frameworks. If you don’t already have an app created, check out our Quickstart guide or Create dynamic app. Install the Account Abstraction package:
Shell
npm install @dynamic-labs/ethereum-aa
Then add the ZeroDevSmartWalletConnectors to your existing walletConnectors array in the DynamicContextProvider:
To use ZeroDev v5.2, use Dynamic SDK version ^2.0.5. For newer versions, use the latest compatible Dynamic SDK.
  import { DynamicContextProvider, DynamicWidget } from "@dynamic-labs/sdk-react-core";
  import { EthereumWalletConnectors } from "@dynamic-labs/ethereum";
  import { ZeroDevSmartWalletConnectors } from "@dynamic-labs/ethereum-aa";

  const App = () => (
    <DynamicContextProvider
      settings={{
        environmentId: "YOUR_ENVIRONMENT_ID",
        walletConnectors: [
          EthereumWalletConnectors,
          ZeroDevSmartWalletConnectors
        ],
      }}
    >
      <DynamicWidget />
    </DynamicContextProvider>
  )

  export default App;
Make sure to grab your Dynamic environment id from the Dynamic Dashboard under Developer > SDK & API Keys, and replace it in the environmentID setting.

Send a transaction (no difference from EOAs)

The code to send a transaction is the same as a normal EVM wallet. The returned hash is a user operation hash, not a transaction hash.
if (!primaryWallet || !isEthereumWallet(primaryWallet)) {
  throw new Error("Wallet not connected or not EVM compatible");
}
const walletClient = await primaryWallet.getWalletClient();

const hash = await walletClient.sendTransaction({
  to: primaryWallet.address as `0x${string}`,
  value: BigInt(0),
});
One important thing to note is that the hash returned is a user operation hash, not a transaction hash. To wait for the user operation to complete, use the Kernel client:
const connector = primaryWallet.connector;
if (!connector || !isZeroDevConnector(connector)) {
  throw new Error("Connector is not a ZeroDev connector");
}
const kernelClient = connector.getAccountAbstractionProvider();
if (!kernelClient) throw new Error("Kernel client not found");

const transaction = await kernelClient.waitForUserOperationReceipt({
  hash,
});
console.log("Transaction:", transaction);

Complete EIP-7702 Example


import { useState } from 'react';
import { EthereumWalletConnectors } from '@dynamic-labs/ethereum';
import { DynamicContextProvider, DynamicWidget, useDynamicContext, isEthereumWallet } from '@dynamic-labs/sdk-react-core';
import { ZeroDevSmartWalletConnectors, isZeroDevConnector } from '@dynamic-labs/ethereum-aa';

function App() {
  return (
    <DynamicContextProvider
      settings={{
        environmentId: 'YOUR_ENVIRONMENT_ID',
        walletConnectors: [
          EthereumWalletConnectors,
          ZeroDevSmartWalletConnectors
        ]
      }}
    >
      <DynamicWidget />
      <SendTransaction />
    </DynamicContextProvider>
  )
}

function SendTransaction() {
  const { primaryWallet } = useDynamicContext();

  const [error, setError] = useState("");
  const [txHash, setTxHash] = useState("");
  const [isSendingTransaction, setIsSendingTransaction] = useState(false);

  if (!primaryWallet) {
    return null;
  }

  const handleSendTransaction = async () => {
    try {
      setIsSendingTransaction(true);
      setError("");

      if (!primaryWallet || !isEthereumWallet(primaryWallet)) {
        throw new Error("Wallet not connected or not EVM compatible");
      }

      const walletClient = await primaryWallet.getWalletClient();

      // Send transaction (same code as EOAs)
      const hash = await walletClient.sendTransaction({
        to: primaryWallet.address as `0x${string}`,
        value: BigInt(0),
      });

      // Wait for user operation to complete
      const connector = primaryWallet.connector;
      if (!connector || !isZeroDevConnector(connector)) {
        throw new Error("Connector is not a ZeroDev connector");
      }
      const kernelClient = connector.getAccountAbstractionProvider();
      if (!kernelClient) throw new Error("Kernel client not found");

      const transaction = await kernelClient.waitForUserOperationReceipt({
        hash,
      });

      setTxHash(transaction.receipt.transactionHash);
    } catch (err: unknown) {
      setError(
        err instanceof Error ? err.message : "Error sending transaction"
      );
    } finally {
      setIsSendingTransaction(false);
    }
  };

  return (
    <>
      <div className="grid gap-12">
        {primaryWallet && (
          <div className="grid gap-4">
            <button
              onClick={handleSendTransaction}
              disabled={!primaryWallet || isSendingTransaction}
              className="w-full"
            >
              {isSendingTransaction ? "Sending..." : "Send Transaction"}
            </button>

            {txHash && (
              <div className="p-6 bg-gray-50 rounded-lg mt-6">
                Transaction Hash:
                <a
                  href={`https://sepolia.basescan.org/tx/${txHash}`}
                  target="_blank"
                  rel="noopener noreferrer"
                  className="block bg-gray-100 p-3 rounded hover:bg-gray-200 transition-colors text-blue-600 underline flex items-center gap-2"
                >
                  {`${txHash.slice(0, 6)}...${txHash.slice(-4)}`}
                  <span className="text-gray-500 text-sm">
                    (View on Explorer)
                  </span>
                </a>
              </div>
            )}
          </div>
        )}

        {error && <p className="text-red-500 mt-6">Error: {error}</p>}
      </div>
    </>
  );
}

export default App

Testing your integration

Run your app, and if you copied our snippet from earlier, you should see this basic page: Basic CRA Landing Click Connect your wallet, enter your email and hit Continue. After pasting in your OTP you’ll be fully logged in! Next, we’re going to send a transaction. To do that, we will need some of the network’s native token. Grab your wallet address by clicking on the Dynamic Widget, then click on the three dots next to your address and hit “Copy wallet address”. If you’re using Base Sepolia as in this guide, you can paste your address into the Base Sepolia Faucet which will deposit some free ETH into your account. After doing so, if you refresh your app, you should see your balance update
Optionally, set up fiat onramp by following our guide here: https://docs.dynamic.xyz/fiat-onboarding/banxa
Send Transaction Now, send yourself some tokens by clicking on the Send button in the Dynamic Widget. Enter 0.01 as the amount, and an address of your choosing as the recipient, then hit Send now. You will see a screen like the following. Notice that there is no gas estimate field, because this transaction will be sponsored! Confirm Send Transaction Hit confirm, sign for the transaction with your passkey. Congratulations, you just sent a gas-sponsored transaction! If you take your smart wallet address and paste it into the block explorer for your network (for Base Sepolia, that’s the Base Sepolia Scan), you will see your smart wallet and the transaction you just sent. Base Sepolia Explorer

Advanced Configuration

Now that you’ve completed the initial setup and sent your first transaction, you can utilize the full functionality of ZeroDev inside Dynamic - everything from session keys to gas policies. Learn more in the ZeroDev Docs.

Bundled transactions

For advanced use cases like bundling multiple transactions into a single user operation, you can interact directly with the kernel client.
const connector = primaryWallet?.connector;

const kernelClient = connector.getAccountAbstractionProvider({
  withSponsorship: true,
});

const userOpHash = await kernelClient.sendUserOperation({
  callData: await kernelClient.account.encodeCalls([
    {
      data: "0x",
      to: zeroAddress,
      value: BigInt(0),
    },
    {
      data: "0x",
      to: zeroAddress,
      value: BigInt(0),
    },
  ]),
});
Note that there is a delay between loading the page and the ZeroDev kernel client being available. To ensure that the kernel client is available, please await one of the following methods: getAddress(), getConnectedAccounts() or getNetwork() before calling getAccountAbstractionProvider().

Specifying a bundler/paymaster RPC

Use ZeroDevSmartWalletConnectorsWithConfig and pass in values for bundlerRpc and paymasterRpc:
import { ZeroDevSmartWalletConnectorsWithConfig } from "@dynamic-labs/ethereum-aa";

<DynamicContextProvider
  settings={{
    environmentId: "YOUR_ENV_ID",
    walletConnectors: [
      ZeroDevSmartWalletConnectorsWithConfig({
        bundlerRpc: "CUSTOM_BUNDLER_RPC",
        paymasterRpc: "CUSTOM_PAYMASTER_RPC",
      }),
    ],
  }}
>
  {/* ... your app */}
</DynamicContextProvider>;
For more info, see: Pimlico Paymaster documentation

Specifying a bundler

To specify a bundler, use ZeroDevSmartWalletConnectorsWithConfig and pass in a value for bundlerProvider:
import { ZeroDevSmartWalletConnectorsWithConfig } from "@dynamic-labs/ethereum-aa";

<DynamicContextProvider
  settings={{
    environmentId: "YOUR_ENV_ID",
    walletConnectors: [
      ZeroDevSmartWalletConnectorsWithConfig({ bundlerProvider: "STACKUP" }),
    ],
  }}
>
  {/* ... your app */}
</DynamicContextProvider>;
For more info, see: https://docs.zerodev.app/meta-infra/rpcs#bundler—paymaster-rpcs

Retrieving the Kernel Client

import { isZeroDevConnector } from '@dynamic-labs/ethereum-aa';

const App = () => {
  const { primaryWallet } = useDynamicContext();

  useEffect(() => {
    const { connector } = primaryWallet;

    const getKernelClient = async () => {
      if (!isZeroDevConnector(connector)) {
        return;
      }

      // ensure that the kernel client has been loaded successfully
      await connector.getNetwork();

      const params = {
        // if you have gas sponsorship enabled, set `withSponsorship` to `true`, else omit
        withSponsorship: true
      };
      const kernelClient = connector.getAccountAbstractionProvider(params);
    }
  ...
}

Using with Viem & Ethers

You can use viem or ethers with account abstraction to sign messages or send sponsored transaction with no extra configuration, it also works with our wagmi integration.

Going Further

Once you’ve tested things out and want to deploy to a live network, you will need to do the following:
  1. Add your credit card to ZeroDev under Account Settings > Billing
  2. Create a new ZeroDev project and select a live network
  3. Copy your new ZeroDev project id and paste it into your Dynamic Dashboard a. We recommend using your Dynamic Sandbox environment for testing your testnet setup, and using your Dynamic Live environment for production.

Restricting Access to your ZeroDev Project

In order to restrict access to your ZeroDev project id to allow only dynamic to use it you can add dynamic’s static IP address’s to your projects IP allowlist. Dynamic’s IP addresses:
  • 52.204.85.87
  • 54.145.74.8
  • 107.20.170.238
  • 52.206.26.56
  • 3.232.2.67
  • 44.213.187.169
ZeroDev Access Control

Examples

[Legacy 4337] Get smart wallet address vs signer address

For Legacy (4337) implementations, the wallet connector will return your smart wallet address, that address will be used in the Dynamic UI and is the main address you will interact with. But you can fetch the signer address by using the wallet connector’s eoaConnector property and then fetching the address there.
This example only applies to Legacy (4337) implementations. With 7702, the smart wallet and signer addresses are the same.
import { useEffect, useState } from "react";
import {
  useDynamicContext,
  DynamicContextProvider,
  DynamicWidget,
} from "@dynamic-labs/sdk-react-core";
import {
  isZeroDevConnector,
  ZeroDevSmartWalletConnectors,
} from "@dynamic-labs/ethereum-aa";
import { EthereumWalletConnectors } from "@dynamic-labs/ethereum";

const SignerAddress = () => {
  const { primaryWallet } = useDynamicContext();
  const [signerAddress, setSignerAddress] = useState("");

  useEffect(() => {
    if (!primaryWallet) {
      return;
    }

    const {
      connector,
       address, // This is your smart wallet address
    } = primaryWallet;

    if (!isZeroDevConnector(connector)) {
      return;
    }

    const signerConnector = connector.eoaConnector;

    if (!signerConnector) {
      return;
    }

    const getAddress = async () => {
      const address = await signerConnector.getAddress();

      if (!address) {
        return;
      }

      setSignerAddress(address);
    };
    getAddress();
  }, [primaryWallet]);

  return <span>My Signer address: {signerAddress}</span>;
};

const App = () => (
  <DynamicContextProvider
    settings={{
      environmentId: "YOUR_ENVIRONMENT_ID",
      walletConnectors: [
        EthereumWalletConnectors,
        ZeroDevSmartWalletConnectors,
      ],
    }}
  >
    <DynamicWidget />

    <SignerAddress />
  </DynamicContextProvider>
);

export default App;
For more information about ZeroDev’s AA features, go to ZeroDev’s documentation

FAQ