Try   HackMD

Empowering Dapps: The Ultimate Handbook for Smart Wallet Adoption

As Ethereum transitions from Externally Owned Accounts (EOAs) to smart accounts, the potential for actually bringing an improved experience of Dapps to average users becomes increasingly evident.

As the adoption of account abstraction through ERC-4337 gains momentum, a renewed push is underway to enhance the user experience of web3. However, despite the shared goal of advancing both AA and user experience, Dapps have somewhat lagged behind, posing a challenge to facilitating a meaningful transition.

In this handbook, we cast a spotlight on three core domains for dapps to adopt smart wallets and to unleash the complete potential of their interfaces:

  1. Enable Smart Wallet Connection: Using WalletConnect and Injected Wallets providers
  2. Leverage Smart Wallet Features: improve UX and onboarding
  3. Required standards to adopt: Differences with EOAs: signature verification, and tracking transaction status

Enable Smart Wallet Connection

WalletConnect

Smart Contract wallets are fully supported by the WalletConnect protocol.

However, there are some considerations to be taken when integrating WalletConnect in your dapp for Smart Contract wallets. WalletConnect V2 allows setting specific chains and methods to be specified as required or optional via session namespaces.

Because smart contract wallets may be not deployed on all chains, but deployed on optional chains, you might break the connection if you set a required chain or method in the configuration.

Required and Optional Methods

By default, EthereumProvider specifies eth_sendTransaction and personal_sign as required methods. For those that want to request additional methods for the session, we recommend passing these through optionalMethods.

Using @walletconnect/ethereum-provider

import { EthereumProvider } from '@walletconnect/ethereum-provider'

const provider = await EthereumProvider.init({
  ...
  optionalMethods: ["eth_signTypedData"],
  ...
})

Using @web3-onboard/walletconnect library

import walletConnectModule from "@web3-onboard/walletconnect";

const wcV2InitOptions = {
  ...
  additionalOptionalMethods: ["eth_signTypedData"],
  ...
};
const walletConnect = walletConnectModule(wcV2InitOptions);
Required and Optional Chains

The recommended config depends on how you designed your interface when a user first connects their wallet.

  1. You let users select the chain before they connect their wallet

    Set the required chain id to match the one that the user selected. Pass the rest of the chains your dapp support in the optional field.

    Using @walletconnect/ethereum-provider

    ​​​​import { EthereumProvider } from '@walletconnect/ethereum-provider'
    
    ​​​​const provider = await EthereumProvider.init({
    ​​​​  ...
    ​​​​})
    
    ​​​​provider.connect({
    ​​​​    chains: [10], // user selected optimism
    ​​​​    optionalChains: [1, 137]
    ​​​​});
    

    Using @web3-onboard/walletconnect library

    ​​​​import walletConnectModule from "@web3-onboard/walletconnect";
    
    ​​​​const wcV2InitOptions = {
    ​​​​  ...
    ​​​​  requiredChains: [137], // user selected polygon
    ​​​​  optionalChains: [1, 10],
    ​​​​  ...
    ​​​​};
    ​​​​const walletConnect = walletConnectModule(wcV2InitOptions);
    

    Using Web3Modal & Wagmi

    ​​​​import { connect } from '@wagmi/core'
    ​​​​import { optimism } from '@wagmi/core/chains'
    ​​​​import { WalletConnectConnector } from '@wagmi/core/connectors/walletConnect'
    
    ​​​​const connector = new WalletConnectConnector({
    ​​​​  options: {
    ​​​​    projectId: '...',
    ​​​​  },
    ​​​​})
    
    ​​​​const result = await connect({
    ​​​​  chainId: optimism.id,
    ​​​​  connector,
    ​​​​})
    

    Source: https://wagmi.sh/core/actions/connect

  2. You let users connect their wallet before they select the chain

    Pass all chains your dapp support in optional field.

    Using @walletconnect/ethereum-provider

    ​​​​import { EthereumProvider } from '@walletconnect/ethereum-provider'
    
    ​​​​const provider = await EthereumProvider.init({
    ​​​​  ...
    ​​​​  optionalChains: [1, 10],
    ​​​​  ...
    ​​​​})
    

    Using @web3-onboard/walletconnect library

    ​​​​import walletConnectModule from "@web3-onboard/walletconnect";
    
    ​​​​const wcV2InitOptions = {
    ​​​​  ...
    ​​​​  optionalChains: [1, 10],
    ​​​​  ...
    ​​​​};
    ​​​​const walletConnect = walletConnectModule(wcV2InitOptions);
    

    Using Web3Modal & Wagmi (WIP)

    Web3Modal and Wagmi currently do no support passing in an empty chains array to EthereumProvider.init.

    However, you can show the network selector view and let the user choose the network they want to connect to. They will pass the network as a required chain for the wallet to support.

    ​​​​import { useWeb3Modal } from '@web3modal/react'
    ​​​​
    ​​​​const { open } = useWeb3Modal();
    ​​​​
    ​​​​open({ route: 'SelectNetwork' });
    

    Source: https://docs.walletconnect.com/2.0/web3modal/react/wagmi/hooks#useweb3modal

Testing

Use the walletconnect required namespace (wc-rns) tool to test your required namespace communication. Just paste a walletconnect project id, and the uri from your dapp. wc-rns will then display the required namespaces.

https://wc-rns.surge.sh/

Documentation Resources

Required and Optional Chains
Required and Optional Methods

Leverage Smart Wallet Features

Batching

Batched transactions allow you to perform multiple transactions in one single on-chain transaction.

If you are building a dapp, you communicate the actions to the wallet that the user performs. Instead of sending a single transaction one after the other, you can send an array of transactions, and the smart wallet will execute the batch. This greatly improves the user experience and allows for one click interfaces.

Each wallet has one way or the other to execute batch transactions. Safe uses the multicall contract to batch, and other wallets might use different methods.

EIP-5792 allows us to standardise JSON-RPC methods for Dapps to communicate bundle calls to wallets using:

  • wallet_sendFunctionCallBundle
  • wallet_getBundleStatus
  • wallet_showBundleStatus

A simple example:

{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "wallet_sendFunctionCallBundle",
  "params": [
    {
      "chainId": 1,
      "from": "0xd46e8dd67c5d32be8058bb8eb970870f07244567",
      "calls": [
        {
          "to": "0xd46e8dd67c5d32be8058bb8eb970870f07244567",
          "gas": "0x76c0",
          "value": "0x9184e72a",
          "data": "0xd46e8dd67c5d32be8d46e8dd67c5d32be8058bb8eb970870f072445675058bb8eb970870f072445675"
        },
        {
          "to": "0xd46e8dd67c5d32be8058bb8eb970870f07244567",
          "gas": "0xdefa",
          "value": "0x182183",
          "data": "0xfbadbaf01"
        }
      ]
    }
  ]
}

For more complete example: checkout this guide on how to leverage batch transactions for your dapp.

Sponsoring

Sponsored transactions give your dapp the ability to pay for users' transaction fees.

Overview

Without sponsored transactions, anyone who sends an Ethereum transaction needs to have ETH to pay for gas fees. This forces new users to pass KYC and purchase ETH before they can start using your dapp. This can be a major hurdle for users without prior crypto experience that are unfamiliar with the concept of needing to keep ETH in their wallet for gas.

Thanks to smart wallets, your users can now interact with Ethereum smart contracts without needing ETH for transaction fees.

Example of sponsored transactions:

  • Onboarding: Enable a frictionless onboarding process for new users
  • Pay for gas indirectly: Allow users to pay for gas indirectly via a purchase on your dapp with stablecoins
  • Privacy: Enabling ETH-less withdrawal of tokens sent to stealth addresses

Two Ways to allow for sponsored transactions:

  1. Partner with Paymasters

    Many wallets rely on 3rd party paymaster services, or equivilant, for sponsored transactions. By partnering with a paymaster provider, you can enable sponsored transactions with all participating wallets.

  2. Partner with Smart Wallets

    You can partner directly with individual smart wallets based on your usecase to allow for sponsored transactions on your dapp.

Required Standards To Adopt

Signature Verification

Normally, when verifying signatures from regular accounts, which are Externally Owned Accounts (EOAs), you would use an ECDSA method called ecrecover() to retrieve the corresponding public key, which will then map to an address.

In the case of Smart Contract Wallets, you are not able to sign a message with the smart contract account.

Therefore, the recommended way is to check for smart contract wallet signatures is using ERC-6492, which extends ERC-1271. The reasons to use it:

  • It adds support for undeployed contracts too
  • It is a single eth_call() to do all checks: either ecrecover, isValidSignature on existing contract or isValidSignature on pre-deployed contract.

ERC-6492

https://eips.ethereum.org/EIPS/eip-6492

You can verify whether a signature on behalf of a given counterfactual contract (even if not deployed yet) is valid. This standard extends ERC-1271.

The first argument is the address of the account, the second is the _hash argument which accepts the hash of the message digest, and the third argument _signature is the signed payload returned by the wallet upon signing.

import ethers from 'ethers';
import { verifyMessage } from '@ambire/signature-validator';

const provider = new ethers.providers.JsonRpcProvider('https://polygon-rpc.com')

async function run() {
	// Replace `ethers.verifyMessage(message, signature) === signer` with this:
	const isValidSig = await verifyMessage({
	    signer: '0xaC39b311DCEb2A4b2f5d8461c1cdaF756F4F7Ae9',
	    message: 'My funds are SAFU with Ambire Wallet',
	    signature: '0x9863d84f3119ac01d9e3bf9294e6c0c3572a07780fc7c49e8dc913806f4b1dbd4cc075462dc84422a9b981b2556f9c9197d76da7ba3603e53e9300869c574d821c',
	    // this is needed so that smart contract signatures can be verified
	    provider,
	})
	console.log('is the sig valid: ', isValidSig)
}
run().catch(e => console.error(e))
Using ethers library:
const isValidSignature = '0x01' === await provider.call({
  data: ethers.utils.concat([
    validateSigOffchainBytecode,
    (new ethers.utils.AbiCoder()).encode(['address', 'bytes32', 'bytes'], [signer, hash, signature])
  ])
})

You can also take a reference from the following ethSign_6492.js test.

Don't use tx.origin

tx.origin: This represents the original external user (i.e., an externally owned account or an externally deployed contract) that initiated the transaction. It is the very first sender of the transaction, even if the transaction was forwarded through multiple contracts. It's important to note that this can change if the transaction is forwarded through multiple contracts, and it can lead to unexpected behavior if used in certain situations.

Smart Contracts

tx.origin breaks compatibility. Using it means that your contract cannot be used by another contract, because a contract can never be the tx.origin. It makes smart contract wallets incompatible with your contract if you assume it is equal to msg.sender.

Tracking Transaction Status

Don't assume that tx.origin will be the one executing the transaction on chain. Smart Contract wallets commonly use relayer. ERC-4337 utilises a Bundlers network to sign and submit user operations onchains. Others might use a private relayer to do so.

Your dapp will receive the transaction hash in order to monitor the status of the transaction, and events will be emitted as usual. The "from" of this transaction will change, and that's execpted behaviour. Don't do like uniswap..

Extras

1. Detecting a Smart Wallet

If you followed this guide, there's no real reason why you should need to detect whether the wallet connected is a smart wallet or not. However, if you really need: you can detect smart contract wallets by verifying on-chain if the exposed account address has any associated code deployed.

Using etherjs

import { providers, utils } from 'ethers'

const provider = new providers.JsonRpcProvider(rpcUrl)

const bytecode = await provider.getCode(address)

const isSmartContract = bytecode && utils.hexStripZeros(bytecode) !== '0x'

Using web3js

import Web3 from 'web3'

const web3 = new Web3(rpcUrl)

const bytecode = await web3.eth.getCode(address)

const isSmartContract = bytecode && utils.hexStripZeros(bytecode) !== '0x'

Using viem

import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'

export const publicClient = createPublicClient({
  chain: mainnet,
  transport: http()
});

const bytecode = await publicClient.getBytecode({
  address,
});

const isSmartContract = bytecode && utils.hexStripZeros(bytecode) !== '0x'

While this method effectively identifies smart contract wallets with deployed code, it's essential to be aware that this approach does not account for undeployed smart contract wallets