Building a custom wallet with Trampoline - a step-by-step guide

What is Trampoline?

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:

  1. Setting up Trampoline: We'll show you how to clone the repository and set up the necessary URLs and dependencies.

  2. 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.

Setting up Trampoline

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:

  1. Go to chrome://extensions/

  2. Enable Developer mode

  3. Click on Load unpacked extension

  4. Select the build folder.

With these steps completed, you're ready to start building your custom smart contract wallet using Trampoline.

Building a demo project

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:

  1. Contracts

  2. Onboarding UI

  3. Account API

  4. 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.

Contracts

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:

https://github.com/eth-infinitism/trampoline/pull/21/files#diff-0ef065286e7441d1739339474709d9297039ee83c48a8c264eeb5450ee3a132fR8
https://github.com/eth-infinitism/trampoline/pull/21/files#diff-0ef065286e7441d1739339474709d9297039ee83c48a8c264eeb5450ee3a132fR8

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.

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

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

https://github.com/eth-infinitism/trampoline/pull/21/commits/4652e7f3b541b0ebbeeb2594dbcd982af66fe793
https://github.com/eth-infinitism/trampoline/pull/21/commits/4652e7f3b541b0ebbeeb2594dbcd982af66fe793

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.

https://github.com/eth-infinitism/trampoline/pull/21/files#diff-221464352cf87f7853201043c3fd4ced9b2f5620ae8fa71a864d53b298ec5230R66
https://github.com/eth-infinitism/trampoline/pull/21/files#diff-221464352cf87f7853201043c3fd4ced9b2f5620ae8fa71a864d53b298ec5230R66

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

https://github.com/eth-infinitism/trampoline/pull/21/files#diff-221464352cf87f7853201043c3fd4ced9b2f5620ae8fa71a864d53b298ec5230R87
https://github.com/eth-infinitism/trampoline/pull/21/files#diff-221464352cf87f7853201043c3fd4ced9b2f5620ae8fa71a864d53b298ec5230R87

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:

https://github.com/eth-infinitism/trampoline/pull/21/files#diff-221464352cf87f7853201043c3fd4ced9b2f5620ae8fa71a864d53b298ec5230R10
https://github.com/eth-infinitism/trampoline/pull/21/files#diff-221464352cf87f7853201043c3fd4ced9b2f5620ae8fa71a864d53b298ec5230R10

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:

https://github.com/eth-infinitism/trampoline/pull/21/files#diff-221464352cf87f7853201043c3fd4ced9b2f5620ae8fa71a864d53b298ec5230R162
https://github.com/eth-infinitism/trampoline/pull/21/files#diff-221464352cf87f7853201043c3fd4ced9b2f5620ae8fa71a864d53b298ec5230R162

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.

Transaction UI

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.

https://github.com/eth-infinitism/trampoline/pull/21/files#diff-2707db88e1ea8245c680c0ccb5802e55fb5abae1540cbab0ee8914c8861ada10R325
https://github.com/eth-infinitism/trampoline/pull/21/files#diff-2707db88e1ea8245c680c0ccb5802e55fb5abae1540cbab0ee8914c8861ada10R325

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.

https://github.com/eth-infinitism/trampoline/pull/21/files#diff-2707db88e1ea8245c680c0ccb5802e55fb5abae1540cbab0ee8914c8861ada10R255
https://github.com/eth-infinitism/trampoline/pull/21/files#diff-2707db88e1ea8245c680c0ccb5802e55fb5abae1540cbab0ee8914c8861ada10R255
https://github.com/eth-infinitism/trampoline/pull/21/files#diff-2707db88e1ea8245c680c0ccb5802e55fb5abae1540cbab0ee8914c8861ada10R286
https://github.com/eth-infinitism/trampoline/pull/21/files#diff-2707db88e1ea8245c680c0ccb5802e55fb5abae1540cbab0ee8914c8861ada10R286

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.

https://github.com/eth-infinitism/trampoline/pull/21/files#diff-2707db88e1ea8245c680c0ccb5802e55fb5abae1540cbab0ee8914c8861ada10R274
https://github.com/eth-infinitism/trampoline/pull/21/files#diff-2707db88e1ea8245c680c0ccb5802e55fb5abae1540cbab0ee8914c8861ada10R274

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

https://github.com/eth-infinitism/trampoline/pull/21/files#diff-2707db88e1ea8245c680c0ccb5802e55fb5abae1540cbab0ee8914c8861ada10R266
https://github.com/eth-infinitism/trampoline/pull/21/files#diff-2707db88e1ea8245c680c0ccb5802e55fb5abae1540cbab0ee8914c8861ada10R266

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.

https://github.com/eth-infinitism/trampoline/pull/21/files#diff-221464352cf87f7853201043c3fd4ced9b2f5620ae8fa71a864d53b298ec5230R146
https://github.com/eth-infinitism/trampoline/pull/21/files#diff-221464352cf87f7853201043c3fd4ced9b2f5620ae8fa71a864d53b298ec5230R146

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).

Tips and tricks

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.

  1. Open the extension's page: chrome://extensions/

  2. Find the Trampoline extension, and click Details.

  3. Check the Inspect views area and click on background page to inspect its logs.

  4. To refresh click cmd + r or ctrl + r in the background inspect page to refresh the background script.

  5. 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.

Subscribe to erc4337
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.