The following steps are for developers to implement account recovery using ether-email-auth. Note that this is the workflow for custom smart contracts -- if your wallet is 7579 compatible, you can simply install our 7579 module directly.
First, install foundry by running the following command:
curl -L https://foundry.paradigm.xyz | bash
Then, install the specific version of foundry by running the following command: Note: The latest version of foundry fails some tests.
foundryup -v nightly-cafc2606a2187a42b236df4aa65f4e8cdfcea970
git clone https://github.com/zkemail/ether-email-auth.git
At the moment, please use the feat/docs-updates branch.
Move to the packages/contracts
directory and run the following command.
yarn install
Also, build the contract by running the following command.
yarn build
As an example, we show how to implement the email-based account recovery into a simple wallet contract in SimpleWallet.sol. First, copy-paste SimpleWallet.sol to [the expected filepath of SimpleWallet.sol in the reader's working directory].
This implementation inherits OwnableUpgradeable.
This function is implemented to change the owner of this wallet. When the account recovery is executed, the recovery controller calls this function. Regarding the recovery controller, see the following section.
function changeOwner(address newOwner) public {
require(
msg.sender == owner() || msg.sender == recoveryController,
"only owner or recovery controller"
);
_transferOwnership(newOwner);
}
Implement the following implementation of RecoveryController. RecoveryController.sol
The Recovery Controller inherits from EmailAccountRecovery and is used to define custom guardians and email templates. The implementation address of EmailAuth is set in the Recovery Controller, and the actual email template verification is performed by EmailAuth.
The Controller account must inherit EmailAccountRecovery.sol.
contract RecoveryController is OwnableUpgradeable, EmailAccountRecovery {
Implement the status of the Guardian to execute Account Recovery.
Each address is initialized to NONE. If the guardian requests to accept the account recovery, the status is set to REQUESTED. If the guardian accepts the account recovery, the status is set to ACCEPTED.
enum GuardianStatus {
NONE,
REQUESTED,
ACCEPTED
}
Implement the following mapping.
mapping(address => bool) public isRecovering; // Whether the account address is being recovered
mapping(address => address) public newSignerCandidateOfAccount; // The new signer candidate of the account address
mapping(address => GuardianStatus) public guardians; // The status of the guardian of the account address
mapping(address => uint) public timelockPeriodOfAccount; // The timelock period of the account address
mapping(address => uint) public currentTimelockOfAccount; // The current timelock of the account address
Define the subject of the email when the guardian requests.
function acceptanceSubjectTemplates()
public
pure
override
returns (string[][] memory)
{
string[][] memory templates = new string[][](1);
templates[0] = new string[](5);
templates[0][0] = "Accept";
templates[0][1] = "guardian";
templates[0][2] = "request";
templates[0][3] = "for";
templates[0][4] = "{ethAddr}";
return templates;
}
Define the subject of the email when the recovery is executed.
function recoverySubjectTemplates()
public
pure
override
returns (string[][] memory)
{
string[][] memory templates = new string[][](1);
templates[0] = new string[](8);
templates[0][0] = "Set";
templates[0][1] = "the";
templates[0][2] = "new";
templates[0][3] = "signer";
templates[0][4] = "of";
templates[0][5] = "{ethAddr}";
templates[0][6] = "to";
templates[0][7] = "{ethAddr}";
return templates;
}
Implement a method to return the account address to be recovered from AcceptanceSubject. The account address to be recovered is stored in templates[0][4]
in the implementation of acceptanceSubjectTemplates
. In this subject template, the variable {ethAddr}
is first defined in templates[0][4]
. Therefore, subjectParams[0]
contains this value.
function extractRecoveredAccountFromAcceptanceSubject(
bytes[] memory subjectParams,
uint templateIdx
) public pure override returns (address) {
require(templateIdx == 0, "invalid template index");
require(subjectParams.length == 1, "invalid subject params");
return abi.decode(subjectParams[0], (address));
}
Implement a method to return the account address to be recovered from RecoverySubject. The account address to be recovered is stored in templates[0][6]
in the implementation of recoverySubjectTemplates
. This is the first element of subjectParams
, so return subjectParams[0]
.
function extractRecoveredAccountFromRecoverySubject(
bytes[] memory subjectParams,
uint templateIdx
) public pure override returns (address) {
require(templateIdx == 0, "invalid template index");
require(subjectParams.length == 2, "invalid subject params");
return abi.decode(subjectParams[0], (address));
}
Implement a method to accept the guardian. If AcceptanceSubject is used, the account address to be recovered is stored in subjectParams[0]
. isRecovering
is defined in the contract, it's a mapping that stores whether the account address is being recovered. At this stage, this address must not be in isRecovering
. Next, check if the guardian is in the REQUESTED
status. Finally, change the status of the guardian to ACCEPTED
.
function acceptGuardian(
address guardian,
uint templateIdx,
bytes[] memory subjectParams,
bytes32
) internal override {
address account = abi.decode(subjectParams[0], (address));
require(!isRecovering[account], "recovery in progress");
require(guardian != address(0), "invalid guardian");
require(
guardians[guardian] == GuardianStatus.REQUESTED,
"guardian status must be REQUESTED"
);
require(templateIdx == 0, "invalid template index");
require(subjectParams.length == 1, "invalid subject params");
guardians[guardian] = GuardianStatus.ACCEPTED;
}
Implement a method to execute recovery. RecoverySubjectTemplate has already been defined in the implementation of recoverySubjectTemplates
. When this RecoverySubjectTemplate is used, the new signer is stored in subjectParams[1]
. subjectParams[0]
is the account address to be recovered. Check if this address is not in isRecovering
. Next, check if the guardian is in the ACCEPTED
status. Finally, set isRecovering
to true and update newSignerCandidateOfAccount
and currentTimelockOfAccount
.
function processRecovery(
address guardian,
uint templateIdx,
bytes[] memory subjectParams,
bytes32
) internal override {
address account = abi.decode(subjectParams[0], (address));
require(!isRecovering[account], "recovery in progress");
require(guardian != address(0), "invalid guardian");
require(
guardians[guardian] == GuardianStatus.ACCEPTED,
"guardian status must be ACCEPTED"
);
require(templateIdx == 0, "invalid template index");
require(subjectParams.length == 2, "invalid subject params");
address newSignerInEmail = abi.decode(subjectParams[1], (address));
require(newSignerInEmail != address(0), "invalid new signer");
isRecovering[account] = true;
newSignerCandidateOfAccount[account] = newSignerInEmail;
currentTimelockOfAccount[account] =
block.timestamp +
timelockPeriodOfAccount[account];
}
Implement a method to complete recovery. Check if the given account address account is being recovered and the timelock for account is not expired. Finally, set isRecovering
to false and update newSignerCandidateOfAccount
and currentTimelockOfAccount
. This change only changes the state of the account in the Recovery Controller. Then, call SimpleWallet.changeOwner
to change the actual owner to the new signer.
function completeRecovery(
address account,
bytes memory recoveryCalldata
) public override {
require(account != address(0), "invalid account");
require(isRecovering[account], "recovery not in progress");
require(
currentTimelockOfAccount[account] <= block.timestamp,
"timelock not expired"
);
address newSigner = newSignerCandidateOfAccount[account];
isRecovering[account] = false;
currentTimelockOfAccount[account] = 0;
newSignerCandidateOfAccount[account] = address(0);
SimpleWallet(payable(account)).changeOwner(newSigner);
}
First, set the environment variables. You should set the following environment variables to .env Your PRIVATE_KEY
needs some gas fees to deploy.
cp .env.example .env
Then, set the following environment variables to .env
PRIVATE_KEY= # Your private key with 0x prefix
ETHERSCAN_API_KEY= # Your Basescan API key
Then Open DeployRecoveryController.s.sol and change the following variables. Because this script are using create2 feature, you'll deploy the contracts with the same address if you use the same salt.
bytes32 salt = "YOUR_SALT";
After that, deploy the contract by running the following command.
source .env
forge script script/DeployRecoveryController.s.sol:Deploy --rpc-url $SEPOLIA_RPC_URL --chain-id $CHAIN_ID --etherscan-api-key $ETHERSCAN_API_KEY --broadcast --verify -vvvv
That's all for the contracts side.
Developers can use the relayers and probers we have provided. To send a request, see the API endpoints on the following page.