guides
Guide

Safe Smart Accounts

Magic Staff · June 7, 2024
Safe Smart Accounts

Safe smart accounts provide a secure and flexible way to manage digital assets and interactions on the blockchain. Unlike externally-owned accounts (EOAs), Safe accounts offer enhanced security and flexibility, making digital ownership more accessible and secure.

Using Safe's account abstraction tooling, you can create and manage these Safe accounts tied to a Magic user. This guide demonstrates how to integrate Safe smart accounts into a simple Magic project using relay-kit package. We'll focus on a project connected to the Ethereum Sepolia test network, but you can adapt it for any network supported by both Magic and Safe. The code snippets provided are based on a Next.js web app but can be easily adapted for any JavaScript framework.

#Project prerequisites

To follow along with this guide, you’ll need the following:

  1. A Magic Publishable API Key
  2. A Pimlico API key and sponsorship policy
  3. A web client

You can get your Publishable API Key from your Magic Dashboard.

You can get your Pimlico API key from the Pimlico dashboard. You will need to enable the bundler methods and verifying paymasters when prompted to create the API key. Along with the API key, you will need to create a sponsor policy for the gas payments. Select your policy options such as name, start date and end date. For more information, refer to the official Pimlico sponsor policy documentation.

We’ll use the make-magic CLI tool to bootstrap a Next.js app with Magic authentication already baked into the client. You’re welcome to use your own client, but this tutorial and its accompanying code snippets assume the output of the make-magic CLI as the starting point. At the time of writing we are using make-magic version 4.13.0.

The make-magic CLI tool is an easy way to bootstrap new projects with Magic. Install the CLI by running npm install -g [email protected].

To generate your application, simply run the command below in the shell of your choice. Be sure to replace <YOUR_PUBLISHABLE_API_KEY> with the Publishable API Key from your Magic Dashboard.

Bash
01npx make-magic \\
02    --template nextjs-dedicated-wallet \\
03    --network ethereum-sepolia \\
04    --login-methods EmailOTP \\
05    --publishable-api-key <YOUR_PUBLISHABLE_API_KEY>

This will bootstrap the starting point of the tutorial for you. In the scaffolded project, be sure to add your Magic Publishable API Key and Pimlico API key to the .env as NEXT_PUBLIC_MAGIC_API_KEY and NEXT_PUBLIC_PIMLICO_API_KEY, respectively.

.env
01# Publishable API Key found in the Magic Dashboard
02NEXT_PUBLIC_MAGIC_API_KEY=pk_live_1234567890
03
04# The RPC URL for the blockchain network
05NEXT_PUBLIC_BLOCKCHAIN_NETWORK=ethereum-sepolia
06
07# API Key found in the Pimlico Dashboard
08NEXT_PUBLIC_PIMLICO_API_KEY=<PIMLICO_API_KEY>

#Install additional dependencies

In addition to the packages included in the scaffold produced by the make-magic CLI, you’ll need to install @safe-global/relay-kit for Safe account functionality, viem for EVM-related types and transaction convenience methods along with permissionless to handle smart account signers.

Run the following command to install the required dependencies:

NPM
Yarn
01npm i @safe-global/relay-kit permissionless viem

#Update Magic provider

We need a way to retrieve the wallet clients for Magic and Safe.

First we will need to import a few dependencies from viem for creating the clients, then we add the context type and context declarations. Then we create and export a publicClient that we can import into our other components whenever we want to interact with the Safe smart account:

Typescript
01const publicClient = createPublicClient({
02  transport: http(getNetworkUrl()),
03  chain: sepolia,
04});

In src/components/magic/MagicProvider.tsx, add the following code:

Typescript
01import { getChainId, getNetworkUrl } from '@/utils/network';
02import { OAuthExtension } from '@magic-ext/oauth';
03import { Magic as MagicBase } from 'magic-sdk';
04import { ReactNode, createContext, useContext, useEffect, useMemo, useState } from 'react';
05const { Web3 } = require('web3');
06import { createPublicClient, http, PublicClient } from 'viem';
07import { sepolia } from 'viem/chains';
08
09export type Magic = MagicBase<OAuthExtension[]>;
10
11type MagicContextType = {
12  magic: Magic | null;
13  web3: typeof Web3 | null;
14  publicClient: PublicClient | null;
15};
16
17const MagicContext = createContext<MagicContextType>({
18  magic: null,
19  web3: null,
20  publicClient: null,
21});
22
23export const useMagic = () => useContext(MagicContext);
24
25const MagicProvider = ({ children }: { children: ReactNode }) => {
26  const [magic, setMagic] = useState<Magic | null>(null);
27  const [web3, setWeb3] = useState<typeof Web3 | null>(null);
28  const [publicClient, setPublicClient] = useState<PublicClient | null>(null);
29
30  useEffect(() => {
31    if (process.env.NEXT_PUBLIC_MAGIC_API_KEY) {
32      const magic = new MagicBase(process.env.NEXT_PUBLIC_MAGIC_API_KEY as string, {
33        network: {
34          rpcUrl: getNetworkUrl(),
35          chainId: getChainId(),
36        },
37        extensions: [new OAuthExtension()],
38      });
39
40      setMagic(magic);
41      setWeb3(new Web3((magic as any).rpcProvider));
42      const publicClient = createPublicClient({
43        transport: http(getNetworkUrl()),
44        chain: sepolia,
45      });
46      setPublicClient(publicClient);
47    }
48  }, []);
49
50  const value = useMemo(() => {
51    return {
52      magic,
53      web3,
54      publicClient
55    };
56  }, [magic, web3, publicClient]);
57
58  return <MagicContext.Provider value={value}>{children}</MagicContext.Provider>;
59};
60
61export default MagicProvider;

#Initialize Safe

To connect our Magic user to a Safe smart account, we first need to create the Safe provider so we can expose a smart account client to the rest of the application. You can also initialize the Safe instance with an optional bundler or paymaster. In this guide we will be implementing both of these features.

The bundler aggregates multiple transactions into a single batch. This process is particularly useful for improving efficiency and reducing the number of on-chain transactions. Bundlers are commonly used in scenarios where several actions need to be executed in a sequence or simultaneously.

A paymaster is a contract that sponsors gas fees or allows users to pay gas fees using alternative methods, such as specific ERC-20 tokens. Paymasters are great for enhancing accessibility and flexibility in decentralized applications by abstracting away the need for users to hold native tokens (e.g., ETH) for transaction fees.

The code below defines a custom hook named useSafeProvider. This hook connects the logged-in Magic user to a Safe smart account. It leverages the authenticated Magic user information to obtain a provider and create a safe smart account configured with specific parameters.

Once the Safe smart account is set up, we configure it with the necessary bundler and paymaster options using Pimlico for interacting with the smart account on the Sepolia testnet. Finally, we store this smart account client in a state variable, making it available for other parts of the application to use for smart contract interactions.

Inside of src/components, create a directory named safe. Create a file inside this directory named useSafeProvider.tsx. Add the following code:

Typescript
01import { useEffect, useCallback, useState } from "react";
02import { useMagic } from "../magic/MagicProvider";
03import { providerToSmartAccountSigner } from "permissionless";
04import { Safe4337Pack } from "@safe-global/relay-kit";
05
06export const useSafeProvider = () => {
07  const { magic, publicClient } = useMagic();
08  const [smartClient, setSmartClient] =
09    useState<Safe4337Pack>();
10  const connectToSmartContractAccount = useCallback(async () => {
11    if (!magic || !publicClient) return;
12
13    const pimlicoKey = `https://api.pimlico.io/v2/sepolia/rpc?apikey=${process.env.NEXT_PUBLIC_PIMLICO_API_KEY}`
14    const magicProvider = await magic.wallet.getProvider();
15    const userInfo = await magic.user.getInfo();
16    const smartAccountSigner =
17      await providerToSmartAccountSigner(magicProvider);
18
19    const safe4337Pack = await Safe4337Pack.init({
20      provider: magicProvider,
21      signer: smartAccountSigner.publicKey,
22      bundlerUrl: pimlicoKey, 
23      paymasterOptions: {
24        isSponsored: true,
25        paymasterUrl: pimlicoKey,
26        paymasterAddress: '0x0000000000325602a77416A16136FDafd04b299f', // Sepolia paymaster address
27        paymasterTokenAddress: '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238' // Sepolia token address
28      },
29      options: {
30        owners: [userInfo.publicAddress ?? ""],
31        threshold: 1
32      },
33    })
34
35    setSmartClient(safe4337Pack);
36  }, [magic, publicClient]);
37
38  useEffect(() => {
39    if (magic?.user.isLoggedIn) {
40      connectToSmartContractAccount();
41    }
42  }, [magic?.user.isLoggedIn, connectToSmartContractAccount]);
43
44  return {
45    smartClient,
46  };
47};

#Update UI components

With the project successfully creating and connecting users’ smart accounts with Safe, we can now update the UI to display the smart account address, balance, and enable sending transactions from the smart account. These changes take place in the UserInfoCard and we also need to create a new component for sending transactions from your Safe smart account.

#Update UserInfoCard

We need to update the UserInfoCard to display both the Magic wallet address and balance, along with the same information from our Safe account.

The Magic wallet address comes from localStorage and the Safe address can be retrieved by calling await smartClient.protocolKit.getAddress().

To get the balance of the Magic account, we can use the getBalance function by calling publicClient?.getBalance and passing in the address for the Magic account. For the smart account balance, we can utilize the functions provided by the smartClient functions by calling await smartClient?.protocolKit.getBalance().

Then we can update the UI so that it will display both the Magic and Safe smart account addresses:

Typescript
01<div className="flex flex-col gap-2">
02    <div className="code">
03      Magic Wallet:{" "}
04      {magicAddress?.length == 0 ? "Fetching address.." : magicAddress}
05    </div>
06    <div className="code">
07      Safe Smart Account:{" "}
08      {safeAddress?.length == 0 ? "Fetching address.." : safeAddress}
09    </div>
10</div>

Along with the addresses, we'll want to display the smart account balance too. Here is how that works:

Typescript
01<div className="flex flex-col gap-2">
02    <div className="code">
03      Magic Balance: {magicBalance.substring(0, 7)} {getNetworkToken()}
04    </div>
05    <div className="code">
06      Safe Smart Account Balance: {safeBalance.substring(0, 7)}{" "}
07      {getNetworkToken()}
08    </div>
09</div>

Open src/components/magic/cards/UserInfoCard.tsx and add the following code:

Typescript
01import { useCallback, useEffect, useMemo, useState } from 'react';
02import Divider from '@/components/ui/Divider';
03import { LoginProps } from '@/utils/types';
04import { logout } from '@/utils/common';
05import { useMagic } from '../MagicProvider';
06import Card from '@/components/ui/Card';
07import CardHeader from '@/components/ui/CardHeader';
08import CardLabel from '@/components/ui/CardLabel';
09import Spinner from '@/components/ui/Spinner';
10import { getNetworkName, getNetworkToken } from '@/utils/network';
11import { useSafeProvider } from '@/components/safe/useSafeProvider';
12import { formatEther } from 'viem';
13
14const UserInfo = ({ token, setToken }: LoginProps) => {
15  const { magic, web3, publicClient } = useMagic();
16  const { smartClient } = useSafeProvider();
17  const [copied, setCopied] = useState('Copy');
18  const [isRefreshing, setIsRefreshing] = useState(false);
19  const [magicBalance, setMagicBalance] = useState<string>("...");
20  const [safeBalance, setSafeBalance] = useState<string>("...");
21  const [safeAddress, setSafeAddress] = useState<string | undefined>("");
22  const [magicAddress] = useState(localStorage.getItem("user"));
23
24  const [publicAddress] = useState(localStorage.getItem('user'));
25
26  const getBalance = useCallback(async () => {
27    if (magicAddress && publicClient) {
28      const magicBalance = await publicClient?.getBalance({
29        address: magicAddress as `0x${string}`,
30      });
31      if (magicBalance == BigInt(0)) {
32        setMagicBalance("0");
33      } else {
34        setMagicBalance(formatEther(magicBalance));
35      }
36    }
37    if (safeAddress && smartClient) {
38      const safeBalance = await smartClient?.protocolKit.getBalance();
39      if (safeBalance == BigInt(0)) {
40        setSafeBalance("0");
41      } else {
42        setSafeBalance(formatEther(safeBalance));
43      }
44    }
45  }, [safeAddress, magicAddress, publicClient]);
46
47  const getSmartContractAccount = useCallback(async () => {
48    if (smartClient) {
49      const address = await smartClient.protocolKit.getAddress();
50      setSafeAddress(address);
51    }
52  }, [smartClient]);
53
54  useEffect(() => {
55    getSmartContractAccount();
56  }, [getSmartContractAccount]);
57
58  const refresh = useCallback(async () => {
59    setIsRefreshing(true);
60    await getBalance();
61    setTimeout(() => {
62      setIsRefreshing(false);
63    }, 500);
64  }, [getBalance]);
65
66  useEffect(() => {
67    if (web3) {
68      refresh();
69    }
70  }, [web3, refresh]);
71
72  useEffect(() => {
73    setMagicBalance("...");
74    setSafeBalance("...");
75  }, [magic]);
76
77  const disconnect = useCallback(async () => {
78    if (magic) {
79      await logout(setToken, magic);
80    }
81  }, [magic, setToken]);
82
83  const copy = useCallback(() => {
84    if (publicAddress && copied === 'Copy') {
85      setCopied('Copied!');
86      navigator.clipboard.writeText(publicAddress);
87      setTimeout(() => {
88        setCopied('Copy');
89      }, 1000);
90    }
91  }, [copied, publicAddress]);
92
93  return (
94    <Card>
95      <CardHeader id="Wallet">Wallet</CardHeader>
96      <CardLabel
97        leftHeader="Status"
98        rightAction={<div onClick={disconnect}>Disconnect</div>}
99        isDisconnect
100      />
101      <div className="flex-row">
102        <div className="green-dot" />
103        <div className="connected">Connected to {getNetworkName()}</div>
104      </div>
105      <Divider />
106      <CardLabel
107        leftHeader="Addresses"
108        rightAction={
109          !magicAddress ? <Spinner /> : <div onClick={copy}>{copied}</div>
110        }
111      />
112      <div className="flex flex-col gap-2">
113        <div className="code">
114          Magic Wallet:{" "}
115          {magicAddress?.length == 0 ? "Fetching address.." : magicAddress}
116        </div>
117        <div className="code">
118          Safe Smart Account:{" "}
119          {safeAddress?.length == 0 ? "Fetching address.." : safeAddress}
120        </div>
121      </div>
122      <Divider />
123      <CardLabel
124        leftHeader="Balance"
125        rightAction={
126          isRefreshing ? (
127            <div className="loading-container">
128              <Spinner />
129            </div>
130          ) : (
131            <div onClick={refresh}>Refresh</div>
132          )
133        }
134      />
135      <div className="flex flex-col gap-2">
136        <div className="code">
137          Magic Balance: {magicBalance.substring(0, 7)} {getNetworkToken()}
138        </div>
139        <div className="code">
140          Safe Smart Account Balance: {safeBalance.substring(0, 7)}{" "}
141          {getNetworkToken()}
142        </div>
143      </div>
144    </Card>
145  );
146};
147
148export default UserInfo;

#Add SendSafeTransaction card

Now that we have a Magic account and a smart account linked to it, wouldn’t it be nice to have a card for each one to send a transaction from their respective accounts? Lets create a separate card for the smart account.

First, we need to create the transaction with the relevant data. This involves specifying the recipient address, the amount to be sent, and any data associated with the transaction.

Next, we use the smartClient to create a Safe transaction (createTransaction) from the prepared transaction data. Once the transaction is created, we sign the transaction to authorize it using signSafeOperation.

Finally, we execute the signed transaction using the smartClient. This will return a userOperationHash that we can use to check the status of the transaction. We then poll for the transaction receipt, waiting for the transaction to be mined and confirmed.

Inside src/components/magic/cards, create a file named SendSafeTransactionCard.tsx. We will be adding the functionality to send a transaction from your Safe account.

Paste the following code into the SendSafeTransactionCard file:

Typescript
01import React, { useCallback, useEffect, useState } from "react";
02import Divider from "@/components/ui/Divider";
03import FormButton from "@/components/ui/FormButton";
04import FormInput from "@/components/ui/FormInput";
05import ErrorText from "@/components/ui/ErrorText";
06import Card from "@/components/ui/Card";
07import CardHeader from "@/components/ui/CardHeader";
08import { getFaucetUrl, getNetworkToken } from "@/utils/network";
09import Image from "next/image";
10import Link from "public/link.svg";
11import { useSafeProvider } from "@/components/safe/useSafeProvider";
12import { isAddress, parseEther } from "viem";
13import showToast from "@/utils/showToast";
14
15const SendSafeTransaction = () => {
16  const { smartClient } = useSafeProvider();
17  const [toAddress, setToAddress] = useState("");
18  const [amount, setAmount] = useState("");
19  const [disabled, setDisabled] = useState(!toAddress || !amount);
20  const [hash, setHash] = useState<any>("");
21  const [toAddressError, setToAddressError] = useState(false);
22  const [amountError, setAmountError] = useState(false);
23
24  useEffect(() => {
25    setDisabled(!toAddress || !amount);
26    setAmountError(false);
27    setToAddressError(false);
28  }, [amount, toAddress]);
29
30  const sendTransaction = useCallback(async () => {
31    if (!smartClient) return;
32
33    if (!isAddress(toAddress)) {
34      return setToAddressError(true);
35    }
36    if (isNaN(Number(amount))) {
37      return setAmountError(true);
38    }
39    setDisabled(true);
40
41    const transaction = {
42      to: toAddress,
43      value: parseEther(amount).toString(),
44      data: '0x'
45    };
46
47    const transactions = [transaction];
48
49    const safeOperation = await smartClient.createTransaction({ transactions })
50    const signedSafeOperation = await smartClient.signSafeOperation(safeOperation);
51    const userOperationHash = await smartClient.executeTransaction({
52      executable: signedSafeOperation
53    });
54
55    let userOperationReceipt = null;
56
57    while (!userOperationReceipt) {
58      await new Promise((resolve) => setTimeout(resolve, 2000));
59      userOperationReceipt = await smartClient.getUserOperationReceipt(userOperationHash);
60    }
61
62    console.log('Transaction successful:', userOperationReceipt);
63    if (userOperationReceipt) {
64      setToAddress("");
65      setAmount("");
66      console.log("Transaction hash:", userOperationReceipt);
67      showToast({
68        message: "Transaction Successful.",
69        type: "success",
70      });
71      setHash(userOperationReceipt);
72      console.log("UserOp Transaction receipt:", userOperationReceipt);
73    }
74    setDisabled(false);
75  }, [smartClient, amount, toAddress]);
76
77  return (
78    <Card>
79      <CardHeader id="send-transaction">Send Safe Transaction</CardHeader>
80      {getFaucetUrl() && (
81        <div>
82          <a href={getFaucetUrl()} target="_blank" rel="noreferrer">
83            <FormButton onClick={() => null} disabled={false}>
84              Get Test {getNetworkToken()}
85              <Image src={Link} alt="link-icon" className="ml-[3px]" />
86            </FormButton>
87          </a>
88          <Divider />
89        </div>
90      )}
91      <FormInput
92        value={toAddress}
93        onChange={(e: any) => setToAddress(e.target.value)}
94        placeholder="Receiving Address"
95      />
96      {toAddressError ? <ErrorText>Invalid address</ErrorText> : null}
97      <FormInput
98        value={amount}
99        onChange={(e: any) => setAmount(e.target.value)}
100        placeholder={`Amount (${getNetworkToken()})`}
101      />
102      {amountError ? (
103        <ErrorText className="error">Invalid amount</ErrorText>
104      ) : null}
105      <FormButton
106        onClick={sendTransaction}
107        disabled={!toAddress || !amount || disabled}
108      >
109        Send Transaction
110      </FormButton>
111    </Card>
112  );
113};
114
115export default SendSafeTransaction;

#Update Dashboard.tsx

Now we’ll have to update the dashboard to display both transaction cards. In src/components/magic/Dashboard.tsx, import the SendSafeTransaction card we just created and replace the <SendTransaction /> component inside the child element with the following:

Typescript
01<SendTransaction />
02<Spacer size={10} />
03<SendSafeTransaction />

Now there will be two transaction components displayed where the user can transfer funds from either account to another Ethereum account! Once you’ve transferred to or from your Safe smart account, you can check your transaction history by pasting the address into Sepolia Etherscan!

#Next steps

You now know how to integrate Magic with a Safe smart account, including the following features:

  1. Simple authentication with Email OTP
  2. Automatic smart account creation for first-time users
  3. Ability to have Magic users interact with their smart accounts
  4. Transfer funds from your smart account

Feel free to take a look at our final code solution. Take a look at the Safe docs for more information on what is possible with Magic and smart accounts.

Let's make some magic!