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:
- A Magic Publishable API Key
- A Pimlico API key and sponsorship policy
- 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.
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.
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:
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:
01const publicClient = createPublicClient({
02 transport: http(getNetworkUrl()),
03 chain: sepolia,
04});
In src/components/magic/MagicProvider.tsx
, add the following code:
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:
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:
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:
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:
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:
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:
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:
- Simple authentication with Email OTP
- Automatic smart account creation for first-time users
- Ability to have Magic users interact with their smart accounts
- 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.