Smart contract deep dive: Building smart wallets for individuals & teams
A deep-dive into thirdweb’s smart contracts for EIP-4337 smart wallets.
You’re about to take a deep-dive into thirdweb’s smart contracts for ERC-4337 smart wallets! If you’re unfamiliar with “ERC-4337” or “Account Abstraction”, read this explainer blog to catch up.
Now let’s get into it.
A look at the smart wallets landscape
There’s a wide set of reasons why you’d want to use a smart wallet.
As an individual, you may want to use a smart wallet for capabilities like account recovery, multi-signer wallets, etc. — all the goods you don’t get using an EOA wallet like Metamask. (Read more about smart wallets vs EOA wallets)
As a developer, you may want to issue smart wallets to your web3 app’s users to create a better user experience — an ‘invisible wallet’ experience, executing transactions in batches, gasless transactions, and so on.
The bottom line is that there is a slew of advantages in using a smart wallet; a number of capabilities already exist today, but many innovative smart wallet features are still actively being built.
At thirdweb, we set out to build smart wallet contracts that make sense to deploy and use today, and are prepared for future smart wallet innovations.
ERC-4337 smart wallet contracts
An ‘account’ contract is the smart contract behind a smart wallet. An ‘account factory’ contract creates ‘account’ contracts. We’ve developed 3 sets of ‘account factory’ + ‘account’ contracts.
Summary
Basic smart wallet capabilities
All of the account contracts (Simple, Dynamic and Managed) share the same basic capabilities. We’ll list the ones worth calling out:
- Accounts are ERC-4337 compatible, which means that among other things, they implement the
validateUserOp
function:
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.12;
struct UserOperation {
address sender;
uint256 nonce;
bytes initCode;
bytes callData;
uint256 callGasLimit;
uint256 verificationGasLimit;
uint256 preVerificationGas;
uint256 maxFeePerGas;
uint256 maxPriorityFeePerGas;
bytes paymasterAndData;
bytes signature;
}
interface IAccount {
function validateUserOp(
UserOperation calldata userOp,
bytes32 userOpHash,
uint256 missingAccountFunds
) external returns (uint256 validationData);
}
- Accounts expose an
execute
function to execute a single call, and anexecuteBatch
function to execute multiple calls in one transaction.
/// @notice Executes a transaction (called directly from an admin, or by entryPoint)
function execute(
address _target,
uint256 _value,
bytes calldata _calldata
) external;
/// @notice Executes a sequence transaction (called directly from an admin, or by entryPoint)
function executeBatch(
address[] calldata _target,
uint256[] calldata _value,
bytes[] calldata _calldata
) external;
- Accounts support EIP-1271 contract signatures, and implements
isValidSignature
:
function isValidSignature(bytes32 _hash, bytes memory _signature)
external
view
returns (bytes4 magicValue);
- Accounts have
addDeposit
andwithdrawDepositTo
functions to manage the accounts native token balance in the respective ERC-4337 EntryPoint smart contract. - Accounts implement
onERC721Received
andonERC1155Received
functions which let them accept and own NFTs. And of course, accounts can own ERC-20 and native tokens as well. - Finally, all accounts share the same permission model. Account contracts expose an
isValidSigner
helper function that checks whether a given signer is eligible to use the account with a given UserOp.
/// @notice Returns whether a signer is authorized to perform transactions using the wallet.
function isValidSigner(address _signer, UserOperation calldata _userOp) public view virtual returns (bool)
Permission model for smart wallets.
All of the account contracts [Simple, Dynamic and Managed] share the same permission model. In this section, we’ll describe this permission model in detail.
Types of actors
An account recognizes only two types of actors:
[1] Admins
Admins have unrestricted access to the account; call any functions on the contract, use the contract without going through the ERC-4337 infrastructure (bundlers, EntryPoint, etc.), withdraw the account’s native token balance, and so on.
[2] Non-admins with permissions
We’ll refer to ‘non-admins with permissions’ as just “signers”.
Signers must go through ERC-4337 infrastructure (bundlers, EntryPoint, etc.) to use an account to execute transactions. Signers can use an account under certain restrictions.
Permissions for signers
Signers use an account under the following restrictions:
struct SignerPermissions {
address signer;
address[] approvedTargets;
uint256 nativeTokenLimitPerTransaction;
uint128 startTimestamp;
uint128 endTimestamp;
}
Each individual signer has their own permissions to use the account. Admins set the permissions for signers.
Essentially, a signer can use the account contract to call specific wallets, transfer at most a specific native token amount out of account, and only use the account in a specific time window.
Security note:
A signer cannot have ‘catch-all’ permissions where they can call any target contract, or transfer any amount of native tokens. This is an important security consideration, since it is common (unfortunately) for private keys to get lost or leaked. In this way, the account encourages usage through purposeful signer keys.
Even though an account may have ‘recovery’, having your admin keys leaked means whoever has your admin keys has unrestricted control over the smart wallet.
Assigning permissions to a signer
Admins set the permissions for signers. An admins sets the permissions for a given signer by calling the setPermissionsForSigner
function:
struct SignerPermissionRequest {
address signer;
address[] approvedTargets;
uint256 nativeTokenLimitPerTransaction;
uint128 permissionStartTimestamp;
uint128 permissionEndTimestamp;
uint128 reqValidityStartTimestamp;
uint128 reqValidityEndTimestamp;
bytes32 uid;
}
/// @notice Sets the permissions for a given signer.
function setPermissionsForSigner(SignerPermissionRequest calldata req, bytes calldata signature) external;
The function uses EIP-712 typed data signatures. That means an admin must fill and sign the SignerPermissionRequest
payload and pass the payload and produced signature to the function.
This method of using signatures is a UX consideration. Even though an admin can bypass ERC-4337 infrastructure i.e. bundler and use their smart wallet directly, it is highly likely that they’re using their smart wallet through a bundler.
When using a smart wallet through a bundler, the value of msg.sender
inside the setPermissionsForSigner
function is the EntryPoint contract, and not the admin using the smart wallet. And so, a permission check cannot be performed on the caller sensibly. By making the setPermissionsForSigner
function signature-based, an admin can set permissions for a signer without breaking the UX of using a bundler.
Assigning admin permissions
Existing admins on the account can add new admins, remove existing admins or renounce their own admin status.
This is done by simply calling the setAdmin
function:
/// @notice Adds / removes an account as an admin.
function setAdmin(address account, bool isAdmin) external;
This function is deliberately left as non-signature based. Assigning signer permissions is a critical user action, and we enforce an admin to consciously, directly call the setAdmin
function.
Permission model for account factory contracts
All account factory contracts [Simply, Dynamic and Managed] use role-based access control.
All three account factory contracts have the DEFAULT_ADMIN_ROLE
, and only a holder of this role is eligible to set the contract metadata of the account factory (i.e. name, description, image and any other metadata to associate with the contract).
There is no permissioned action on the Simple and Dynamic account factory contracts.
The Managed account factory contract, however, has an additional role EXTENSION_ROLE
. Only a holder of this role can perform upgrades to the children account contracts created by the managed account factory.
Upgradeable account smart contracts
The Simple account contract Account
is non-upgradeable.
However, the DynamicAccount
and ManagedAccount
contracts are upgradeable contracts.
Dynamic contracts: a primer
The dynamic and managed account contracts both use the dynamic contract pattern. Here’s a quick primer:
An “upgradeable contract” is an implementation contract + a proxy contract.
The job of a proxy contract is to forward any calls it receives to the implementation contract via delegateCall
. As a shorthand — a proxy contract stores state, and always asks an implementation contract how to mutate its state (upon receiving a call). You can learn about this pattern in detail by reading this post.
The dynamic contract pattern introduces a Router
smart contract.
This router contract is a proxy, but instead of always delegateCall-ing the same implementation contract, a router delegateCalls particular implementation contracts (a.k.a “Extensions”) for the particular function calls it receives:
A router stores a map from function selectors → to the implementation contract where the given function is implemented. “Upgrading a contract” now simply means updating what implementation contract a given function, or functions are mapped to.
How do upgrades work with Dynamic account contracts?
The DynamicAccount
account smart contract is written in the dynamic contract pattern and inherits the router contract mentioned previously.
And so, the dynamic account contract essentially works like the diagram shown about for Router
. Each individual dynamic account contract (created by a parent account factory, or deployed as a standalone) is upgradeable. Upgrades can be performed by an admin of the account via the following API:
struct ExtensionMetadata {
string name;
string metadataURI;
address implementation;
}
struct ExtensionFunction {
bytes4 functionSelector;
string functionSignature;
}
struct Extension {
ExtensionMetadata metadata;
ExtensionFunction[] functions;
}
/// @dev Adds a new extension to the router.
function addExtension(Extension memory extension) external;
/// @dev Updates an existing extension in the router, or overrides a default extension.
function updateExtension(Extension memory extension) external;
/// @dev Removes an existing extension from the router.
function removeExtension(Extension memory extension) external;
Ultimately, this API is responsible for updating the map of function selectors → to extension contracts stored in the account contract.
How do upgrades work with Managed account contracts?
Like the dynamic account contract, the ManagedAccount
account contract is also written in the dynamic contract pattern.
The main difference between these two types of account contracts is that each individual dynamic account stores its own map of function selectors → to extension contracts, whereas all managed account contracts listen into this same map stored by their parent ManagedAccountFactory
factory contracts.
This is why managed accounts are called “managed”. An admin of the managed account factory contracts is responsible for managing the capabilities of the factory’s children managed accounts.
When an admin of a managed account factory updates the function selector → extension map in the factory contract (through), this upgrade is instantly applied to all of the factory’s children account contracts.
Test drive: issue smart wallets to your app’s users.
At the time of writing, our smart wallet contracts have been audited and are currently in beta access. This means you can test out an end-to-end testnet integration of smart wallets into your app right now.
Note: sign-up for early mainnet access for smart wallets by filling this form.
Deploy an account factory contract for yourself (Simple, Dynamic or Managed):
Head to the Accounts
tab where you can create an account contract for yourself. Under the hood, we’re calling the createAccount
function on your account factory contract.
/// @notice Deploys a new Account for admin.
function createAccount(address _admin, bytes calldata _data) external returns (address);
Go to your account contract’s dashboard. You can view your smart wallet’s token and NFT balances in the Account
tab, as well as all the wallet’s signers (and their permissions) in the Account Permissions
tab.
Now’s the time to actually use your smart wallet to perform some transactions!
First, grab yourself an API key at https://thirdweb.com/dashboard/settings/api-keys
Back to code — connect to your smart wallet:
import { LocalWallet, SmartWallet } from "@thirdweb-dev/wallets";
import { Goerli } from "@thirdweb-dev/chains";
// First, connect the personal wallet, which can be any wallet (metamask, walletconnect, etc.)
// Here we're just generating a new local wallet which can be saved later
const personalWallet = new LocalWallet();
await personalWallet.generate();
// Setup the Smart Wallet configuration
const config: SmartWalletConfig = {
chain: Goerli, // the chain where your smart wallet will be or is deployed
factoryAddress: "{{factory_address}}", // your own deployed account factory address
clientId: "YOUR_CLIENT_ID", // Use client id if using on the client side, get it from dashboard settings
secretKey: "YOUR_SECRET_KEY", // Use secret key if using on a node script or server, get it from dashboard settings
gasless: true, // enable or disable gasless transactions
};
// Then, connect the Smart wallet
const wallet = new SmartWallet(config);
await wallet.connect({
personalWallet,
});
And now, take this wallet
object and use the SDK like you normally would:
import { LocalWallet, SmartWallet } from "@thirdweb-dev/wallets";
import { Goerli } from "@thirdweb-dev/chains";
import { ThirdwebSDK } from "@thirdweb-dev/sdk";
// .. previous snippet
const wallet = new SmartWallet(config);
await wallet.connect({
personalWallet,
});
const sdk = ThirdwebSDK.fromWallet(wallet, Goerli, {
clientId: "YOUR_CLIENT_ID", // Use client id if using on the client side, get it from dashboard settings
secretKey: "YOUR_SECRET_KEY", // Use secret key if using on a node script or server, get it from dashboard settings
});
// Under the hood, the SDK will route your call through a bundler
// all transactions will be executed as the Smart Wallet
// deploy a new NFT collection contract
const nftContractAddress = await sdk.deployer.deployNFTCollection({
name: "My first NFT Contract",
primary_sale_recipient: await wallet.getAddress(),
});
// mint an NFT on the deployed NFT collection
const nftContract = await sdk.getContract(nftContractAddress);
const tx = await nftContract.erc721.mint({
name: "My first NFT",
});
You can find more details about using thirdweb’s smart wallets on the portal:
Smart contracts bounty program
Thanks for joining me in this deep dive; hope you found it interesting.
As always, we have a live gas optimization and security vulnerabilities bounty program. Take a look at our smart wallet contracts — break them, improve them and make some money 😄