Creating an SCW (Smart Contract Wallet) from scratch can be challenging, especially when working on a tight deadline for a hackathon and every minute counts. This is where Trampoline comes in - a lightweight framework designed to simplify the process of building custom wallets. With Trampoline, you can create a smart contract wallet quickly and easily.
Trampoline provides all the essential functionalities you’d expect in a Chrome extension SCW, such as injecting the web3 provider into the dapp, supporting web3 provider RPC calls, and supporting ERC-4337. This means that you can focus on building custom wallet features instead of worrying about re-implementing standard boilerplate code.
In this blog post, we'll walk you through using Trampoline to build your own custom SCW in a hackathon.
We've broken the post into two parts:
Setting up Trampoline: We'll show you how to clone the repository and set up the necessary URLs and dependencies.
Building a demo project: We'll guide you through creating a demo project using Trampoline. Our demo project will be an SCW that requires two signatures for each tx.
To get started with Trampoline, you'll need to clone the repository from
Before we dive into creating our custom smart contract wallet, let's make sure that we can get the default wallet working. The default configuration in the Trampoline repository is set up for the Sepolia chain, but you'll need to configure a bundler and provider URL to complete the setup.
By default, the repository is configured with Candide’s bundler and Infura's provider. If you hit a rate limit on Candide’s bundler, you can switch to using Stackup's bundler instead. However, currently, only Candide supports Sepolia.
The final configuration may look like this:
{
"enablePasswordEncryption": false,
"showTransactionConfirmationScreen": false,
"factory_address": "0x63B05f624Ce478D724aB4390D92d3cdF4e731f1a",
"network": {
"chainID": "11155111",
"family": "EVM",
"name": "Sepolia",
"provider": "https://sepolia.infura.io/v3/bdabe9d2f9244005af0f566398e648da",
"entryPointAddress": "0x0576a174D229E3cFA37253523E645A78A0C91B57",
"bundler": "https://sepolia.voltaire.candidewallet.com/rpc",
"baseAsset": {
"symbol": "ETH",
"name": "ETH",
"decimals": 18,
"image": "https://ethereum.org/static/6b935ac0e6194247347855dc3d328e83/6ed5f/eth-diamond-black.webp"
}
}
}
Once you've added both URLs, run the following commands to install all the necessary dependencies:
yarn
To start the project, run:
yarn start
Once the build has been successfully completed, you can load your extension in Chrome by following these steps:
Go to chrome://extensions/
Enable Developer mode
Click on Load unpacked extension
Select the build
folder.
With these steps completed, you're ready to start building your custom smart contract wallet using Trampoline.
Today we'll build a demo project to illustrate how to create a smart contract wallet using Trampoline. Our SCW will require two different signatures on each transaction to consider it valid. Only then it will relay it to the blockchain. One signature will be made from a private key that will be generated and stored locally inside the Trampoline extension, and the other signature will come from Rainbow Wallet.
We've broken this project down into four main portions:
Contracts
Onboarding UI
Account API
Transaction UI
With these steps, you'll have a working smart contract wallet with two-step authentication that you can use to send transactions on the blockchain. Let's dive into each of these portions in more detail.
For the purpose of this demo project, we'll be creating a smart contract wallet that requires two signatures to send any transaction to the blockchain. One private key will be generated and stored inside the Trampoline Chrome extension, and we'll use the Rainbow Wallet for storing the other private key.
To get started, create a folder contracts
in the project's root directory. Trampoline has already configured the project with hardhat
and hardhat-deploy
, so once we're done writing the contracts, we can make changes in deploy/deploy.ts
to deploy our newly written smart contract wallet and factory.
For this demo, we've already written the TwoOwnerAccount
contract. You can check out the code on GitHub.
You can also copy and paste the code from here:
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.12;
import '@account-abstraction/contracts/samples/SimpleAccount.sol';
contract TwoOwnerAccount is SimpleAccount {
using ECDSA for bytes32;
address public ownerOne;
address public ownerTwo;
constructor(IEntryPoint anEntryPoint) SimpleAccount(anEntryPoint) {}
function initialize(
address _ownerOne,
address _ownerTwo
) public virtual initializer {
super._initialize(address(0));
ownerOne = _ownerOne;
ownerTwo = _ownerTwo;
}
function _validateSignature(
UserOperation calldata userOp,
bytes32 userOpHash
) internal view override returns (uint256 validationData) {
(userOp, userOpHash);
bytes32 hash = userOpHash.toEthSignedMessageHash();
(bytes memory signatureOne, bytes memory signatureTwo) = abi.decode(
userOp.signature,
(bytes, bytes)
);
address recoveryOne = hash.recover(signatureOne);
address recoveryTwo = hash.recover(signatureTwo);
bool ownerOneCheck = ownerOne == recoveryOne;
bool ownerTwoCheck = ownerTwo == recoveryTwo;
if (ownerOneCheck && ownerTwoCheck) return 0;
return SIG_VALIDATION_FAILED;
}
function encodeSignature(
bytes memory signatureOne,
bytes memory signatureTwo
) public pure returns (bytes memory) {
return (abi.encode(signatureOne, signatureTwo));
}
}
We've already written the TwoOwnerAccountFactory
contract as well, using this we will be able to deploy TwoOwnerAccount
. You can copy and paste the code from here:
// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.12;
import '@openzeppelin/contracts/utils/Create2.sol';
import '@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol';
import './TwoOwnerAccount.sol';
contract TwoOwnerAccountFactory {
TwoOwnerAccount public immutable accountImplementation;
constructor(IEntryPoint _entryPoint) {
accountImplementation = new TwoOwnerAccount(_entryPoint);
}
/**
* create an account, and return its address.
* returns the address even if the account is already deployed.
* Note that during UserOperation execution, this method is called only if the account is not deployed.
* This method returns an existing account address so that entryPoint.getSenderAddress() would work even after account creation
*/
function createAccount(
address _ownerOne,
address _ownerTwo,
uint256 salt
) public returns (TwoOwnerAccount ret) {
address addr = getAddress(_ownerOne, _ownerTwo, salt);
uint256 codeSize = addr.code.length;
if (codeSize > 0) {
return TwoOwnerAccount(payable(addr));
}
ret = TwoOwnerAccount(
payable(
new ERC1967Proxy{salt: bytes32(salt)}(
address(accountImplementation),
abi.encodeCall(
TwoOwnerAccount.initialize,
(_ownerOne, _ownerTwo)
)
)
)
);
}
/**
* calculate the counterfactual address of this account as it would be returned by createAccount()
*/
function getAddress(
address _ownerOne,
address _ownerTwo,
uint256 salt
) public view returns (address) {
return
Create2.computeAddress(
bytes32(salt),
keccak256(
abi.encodePacked(
type(ERC1967Proxy).creationCode,
abi.encode(
address(accountImplementation),
abi.encodeCall(
TwoOwnerAccount.initialize,
(_ownerOne, _ownerTwo)
)
)
)
)
);
}
}
As you can see, our TwoOwnerAccountFactory
has a function called createAccount
, which takes in the two owners and deploys our smart contract wallet.
We've also created the TwoOwnerAccount
contract to serve as our smart contract wallet. This contract extends the SimpleAccount
contract and overrides the _validateSignature
and initialize
functions. If you have a more complex smart contract wallet, you can extend the BaseAccount
contract instead from @account-abstraction/contracts
.
Now let’s write our deploy script to deploy the TwoOwnerAccountFactory
, You can check the difference in the deploy script in the following image with the link to the relevant commit in its caption:
Here, you can see that we are also utilizing the entryPointAddress
that has already been configured inside the src/exconfig
file.
Once we are done with this, we can test and deploy our contracts using the following command:
MNEMONIC_FILE=~/.secret/testnet-mnemonic.txt INFURA_ID=<infura_id> npx hardhat deploy --network sepolia
The first account derived from the mnemonic will be used to deploy the SCW to the selected network. That's it for writing contracts! Now that we've completed this step, we can move on to creating the onboarding UI.
In this section, we will create the onboarding UI to connect Rainbow wallet with our SCW. First, we need to generate a private key that we will store in the Chrome extension's secure vault and assign it as ownerOne
in our contract. Then, we will use Rainbow Wallet's public key as ownerTwo
.
Open the file src/pages/Account/components/onboarding/onboarding.tsx
and copy-paste the following code:
import {
Button,
CardActions,
CardContent,
CircularProgress,
FormControl,
FormGroup,
InputLabel,
OutlinedInput,
Typography,
} from '@mui/material';
import { Stack } from '@mui/system';
import React, { useCallback, useEffect, useState } from 'react';
import { useAccount, useConnect } from 'wagmi';
import { OnboardingComponent, OnboardingComponentProps } from '../types';
const Onboarding: OnboardingComponent = ({
accountName,
onOnboardingComplete,
}: OnboardingComponentProps) => {
const { connect, connectors, error, isLoading, pendingConnector } =
useConnect();
const { address, isConnected } = useAccount();
useEffect(() => {
if (isConnected) {
onOnboardingComplete({
address,
});
}
}, [isConnected, address, onOnboardingComplete]);
return (
<>
<CardContent>
<Typography variant="h3" gutterBottom>
Add 2FA Device
</Typography>
<Typography variant="body1" color="text.secondary">
All your transactions must be signed by your mobile wallet and this
chrome extension to prevent fraudulant transactions.
<br />
</Typography>
</CardContent>
<CardActions sx={{ pl: 4, pr: 4, width: '100%' }}>
<Stack spacing={2} sx={{ width: '100%' }}>
{connectors.map((connector) => (
<Button
size="large"
variant="contained"
disabled={!connector.ready}
key={connector.id}
onClick={() => connect({ connector })}
>
{connector.name}
{!connector.ready && ' (unsupported)'}
{isLoading &&
connector.id === pendingConnector?.id &&
' (connecting)'}
</Button>
))}
{error && <Typography>{error.message}</Typography>}
</Stack>
</CardActions>
</>
);
};
export default Onboarding;
The above code can be found at commit 70b9fd4.
In this code, we are using wagmi
js to connect to an external provider. wagmi
is already pre-configured in the Chrome extension and can be used in any of the account's components. If you want to change wagmi
’s configuration, you can do so by modifying the configuration in the file src/pages/App/app.tsx
.
The OnboardingComponent
has a prop passed to it called onOnboardingComplete
. We must call this function once our onboarding is complete. We can pass it any context, and this context will be available to us in the account-api
. In this example, we are passing the address of the connected Rainbow wallet. We will use this address as ownerTwo
of our SCW in the account-api
.
In addition to connecting to Rainbow Wallet in onboarding.tsx
, we also need to generate a new private key to be used as owner two for our account. We will be doing this in the account-api
section, which we will cover next. The private key generated will be stored in the secure vault of the Trampoline extension and assigned as ownerOne
in our SCW contract. By completing this onboarding process, the user will be able to use their Rainbow Wallet and the locally generated private key to sign transactions on our SCW.
Account API is the underlying SCW API which will be used to create userOperations
& signing the userOperations
. Account API can be found at the location src/pages/Account/account-api/account-api.ts
. You can check out the complete code of this file at commit 6fe793.
We will discuss the changes that we have made in account-api.tsx
here one by one: After completing the onboarding process, the account-api
instance is created by calling the onOnboardingComplete
function and passing the context we received from the OnboardingComponent
. The context is passed into the account-api
constructor in the params
object's context
key and can be accessed using params.context
.
Let’s check out the changes in the constructor where we utilise this params.context
Let’s first focus on ownerTwo
, to set ownerTwo
value we use the context address and check if there is any previously serialized state using params.deserializeState?.ownerTwo
. This step is important because account-api
can be re-initialized when the extension is reloaded, the browser is closed and opened again, or when Chrome removes the background script from memory to save memory usage. In such cases, we can restore the previous state using deserializeState
. The deserializeState
contains the value that you returned from the serialize
function, which is periodically called to update the state.
In the serialize
function, we return an object that includes the local privateKey
and the ownerTwo
value. This serialized state is stored in the secured vault, ensuring the safety of sensitive wallet information.
Now let’s focus on ownerOne
, as you may have noticed this is how we create our ownerOne
:
this.ownerOne = params.deserializeState?.privateKey
? new ethers.Wallet(params.deserializeState?.privateKey)
: ethers.Wallet.createRandom();
Above we were creating a new private key (or using the one from deserializeState) in variable ownerOne
. This variable holds that locally created private key. We also saw how this is stored in the trampoline extension when we returned this in serialize
function.
We have successfully created and set two owners for our SCW using the changes made to the constructor
and serialize
functions in account-api
. Moving forward, we need to focus on implementing other required functions such as getAccountInitCode
, signUserOpWithContext
, and createUnsignedUserOp
. These functions are essential for obtaining the initialization code of the account, signing user operations with the respective keys, and creating unsigned user operations for our wallet, respectively.
Let’s see the changes needed for getAccountInitCode
Here we are changing the factory from SimpleAccountFactory__factory
to our own factory TwoOwnerAccountFactory__factory
. As our factory needs two owner’s public addresses we send those as parameters to the function createAccount
. We have already imported the factory like the following:
Now you may ask how is typechain-types
generated. Trampoline auto-generates these for you based on your contracts
written in the contracts directory for you when you run yarn start
. So if you get typechain-types
missing
error, restart the app using yarn start
and the types will be generated.
Let’s check out the changes required for createUnsignedUserOp
:
As you may notice, the createUnsignedUserOp
function was not present by default in our account-api
. This is because the function is already implemented in AccountApiType
, which we extend. However, the default implementation of createUnsignedUserOp
has a problem. It prefills the userOp.preVerificationGas
as if we only need a single signature in our signature field. But for our specific account, we require two signatures, which doubles the length of the signature field. Therefore, we need to increase the amount of preVerificationGas
we send in our userOperation
. That's why we have overwritten the createUnsignedUserOp
function. If your specific wallet doesn't require this modification, you can skip it and use the default implementation provided in AccountApiType
.
Now before we dive into the changes required for signUserOpWithContext
we must first create our transaction UI, the UI we show to the users when a dapp requests a transaction. The function signUserOpWithContext
is only called after the transaction UI is shown to the user.
The Transaction
UI component is responsible for displaying the transaction details to the user and asking for any additional information required to send the transaction as a UserOperation
. In our case, we need to show the transaction details and then ask for the Rainbow wallet signature, since we don't have access to Rainbow wallet in the background script account-api
.
Similar to the Onboarding
component, the Transaction
component also receives a prop called onComplete
. We must call this function once we are done with the Transaction
component. The onComplete
function takes a context as a parameter, and this context will be passed to the signUserOpWithContext
function in your account-api
. If you require any private information, such as the Rainbow wallet signature in our case, you can collect that information in the Transaction
component and pass it to the onComplete
function. This private information can then be used to sign the UserOperation
in the signUserOpWithContext
function.
The complete code for the transaction component for our wallet can be found at commit 378e294.
We will be focusing only on important parts in this blog as a lot of the code in that component is just plain UI.
The first thing we check is if our connection with Rainbow Wallet is still connected, if not we prompt the user to reconnect.
Once the connection is established and the user agrees to the transaction information displayed to them the function onSend
is called in the above Transaction
component.
Here you’ll notice we call a function called callAccountApi
, this is a special react hook that lets you call any function inside your account-api
. In our use case, we are calling the function getUserOpHashToSign
to get the userOpHash
that the Rainbow Wallet has to sign. The result
variable is set once the call to account-api
is finished. So we can check that and the loading
variable to check if the background call to account-api
has completed or not.
Once the call to getUserOpHashToSign
in our account-api
is complete we ask the user to sign the message using the wagmi
function signMessage
. This invokes Rainbow Wallet to sign the userOpHash
.
Once we have our signedMessage
from Rainbow Wallet we can call the onComplete
prop that was passed to us, and invoke that method using our signedMessage
for ownerTwo
As stated before, once we call the onComplete
prop method, the Transaction UI will be unmounted and the function signUserOpWithContext
of our account-api
will be called. So now let’s check what we need to change in signUserOpWithContext
to make use of the context
passed.
Here is the original signUserOpWithContext
we were signing with our single private key but since for our specific SCW we need two signatures, we are encoding the two signatures that we have (one we got from the context and the other we sign using ownerOne
’s private key).
While testing you may run into some common problems. I have listed some of them for your reference.
Change factory_address
in exconfig
. Once you deploy the factory specific to your SCW, don’t forget to change the factory’s address in exconfig
.
You may run into preVerificationGas
issues, check if the default preVerificationGas
is enough, if not try overwriting createUnsignedUserOp
in account-api
like we did.
Warning Auto refresh is disabled by default, so you will have to manually refresh the page. If you make changes to the background script or account-api
, you will also have to refresh the background page. The next section explains how you can do that.
Warning Logs of all the blockchain interactions are shown in the background script. Do keep it open for faster debugging.
How to see and refresh the background page.
Open the extension's page: chrome://extensions/
Find the Trampoline extension, and click Details.
Check the Inspect views
area and click on background page
to inspect its logs.
To refresh click cmd + r
or ctrl + r
in the background inspect page to refresh the background script.
You can safely reload the extension completely, the state is always kept in localstorage
so nothing will be lost.
With the above changes our SCW implementation is complete and you can test your newly created two-owner SCW.
If you plan on using Trampoline in an upcoming hackathon, we recommend following this tutorial at least once to get the hang of it, and if you really want to challenge yourself - try replacing the use of Rainbow Wallet with a different option, like a hardware wallet, a password, or a ubikey.
We hope that Trampoline will prove helpful when you hack with ERC-4337, we can’t wait to see what you build!
** **
🙏 Special thanks to plusminushalf.eth for all of his work on Trampoline, including this guide.