Creating a PWA with Privy and Account Abstraction

Objectives

The goal of this tutorial is to:

  • Implement Privy's template to create a PWA that runs a Next.js application using their embedded wallets.
  • Adjust the application to sign and submit transactions for an Account Abstraction Wallet.

Prerequisites

Before starting this tutorial, you should have a good understanding of the following:

  • React: You need to know how to code with React as this tutorial involves creating a PWA using Next.js, which is based on React.
  • Hardhat: You should be familiar with deploying smart contracts using Hardhat.
  • Fee Delegation: Understanding fee delegation, especially in the context of VeChain, is crucial for implementing Account Abstraction Wallets.

Privy

Introduction

Privy is a service that provides authentication services, which most users use daily, to access web3 wallets.

Privy handles wallet generation, security aspects, and interfaces that manage the signing of transactions or messages.

Setup

Privy offers a setup guide in a template project on GitHub:
https://github.com/privy-io/create-privy-pwa

The steps to be done are:

Clone Template

npx degit privy-io/create-privy-pwa my-pwa-project

Install Dependencies

Run npm install to install the project required dependencies:

cd my-pwa-app
npm install

Configure Privy

Visit https://privy.io to create a new App.

Create the file .env and add your created App Id:

NEXT_PUBLIC_PRIVY_APP_ID=

Start App

You can start the app with npm run dev, and you will be able to sign into the app.

For testing development, its recommend to use ngrok (guide), since privy makes use of crypto.subtle which requires a secure context (https).

Login Methods

To adjust the desired login methods, toggle them on or off in the Privy dashboard for the app at: https://dashboard.privy.io

Account Abstraction

Introduction

Account Abstraction is a standard that allows smart contracts to initiate transactions, providing an on-chain wallet that can replace an externally owned account (EOA).

Example projects using this standard are available on GitHub at:

Setup

For the purpose of this tutorial, the account abstraction contracts from the SimpleAccount example by eth-infinitism are used.

The contracts to be deployed are:

To simplify the process, a modified project is available on GitHub at:
https://github.com/vechain-energy/example-pwa-privy-account-abstraction/tree/main/account-abstraction-contracts

Clone Contracts

git clone https://github.com/vechain-energy/example-pwa-privy-account-abstraction.git

Install Dependencies

Run npm install --legacy-peer-deps to install the project required dependencies:

cd example-pwa-privy-account-abstraction/account-abstraction-contracts/
npm install --legacy-peer-deps

Configure Network

Generate a new private key and create the file .env, to configure it as deployer:

PRIVATE_KEY=0x...

A simple way to generate a private key can be openssl:

echo "PRIVATE_KEY=0x$(openssl rand -hex 32)" > .env

Optionally adjust more network related options in the hardhat.config.ts file.

Deploy Contracts

The deploy scripts are in the deploy/ folder. Inspect for further understanding.

Use the hardhat deploy plugin to deploy the example contracts:

npx hardhat deploy --network vechain_testnet

The output of the command shows the addresses of the deployed contracts:

Generating typings for: 42 artifacts in dir: typechain-types for target: ethers-v6
Successfully generated 120 typings!
Compiled 44 Solidity files successfully (evm target: paris).
deploying "EntryPoint" (tx: 0x3142517bc96bef748c8630af244cf62cb71dd57407470011bf60e7581fd07fba)...: deployed at 0x4C1E3c36d8C0538C88720e0725D7488dc3547fc7 with 4042403 gas
==entrypoint addr= 0x4C1E3c36d8C0538C88720e0725D7488dc3547fc7
deploying "SimpleAccountFactory" (tx: 0xd00c5f3275ae891a1d0259ed5a338d40d6a8689b3ed54abce22180746e5f530a)...: deployed at 0x41Ea0cDa1471d70961bdc81bB9203a09cbf9B65e with 2503344 gas

The deployment addresses and ABIs are archived in the deployments/vechain_testnet folder.

Please document the address of the deployed SimpleAccountFactory for future use. In the above example output, it is 0x30B29698D652f8c82fd3bd10914FDcC108f07865.

Background: Accept Signed Executions

The SimpleAccount restricts access to its owner or the endpoint contract.

Because Privy does not support the VeChain network, the interaction will be based on signed messages. A new function executeWithAuthorization verifies a signature for an execution to be from the owner.

You can add yourself a signature verification to SimpleAccount.sol with these changes:

import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

Call the EIP721 with the constructor by adjusting its definition:

    constructor(IEntryPoint anEntryPoint) EIP712("Wallet", "1") {

And add a new function that verifies signatures and does the same as execute:

/**
    * execute a transaction (called directly from owner, or by entryPoint) authorized via signatures
    
    * @param to destination address to call
    * @param value the value to pass in this call
    * @param data the calldata to pass in this call
    * @param validAfter unix timestamp after which the signature will be accepted
    * @param validBefore unix timestamp until the signature will be accepted
    * @param signature the signed type4 signature
    */
function executeWithAuthorization(
    address to,
    uint256 value,
    bytes calldata data,
    uint256 validAfter,
    uint256 validBefore,
    bytes calldata signature
) external payable {
    require(block.timestamp > validAfter, "Authorization not yet valid");
    require(block.timestamp < validBefore, "Authorization expired");

    /**
        * verify that the signature did sign the function call
        */
    bytes32 structHash = keccak256(
        abi.encode(
            keccak256(
                "ExecuteWithAuthorization(address to,uint256 value,bytes data,uint256 validAfter,uint256 validBefore)"
            ),
            to,
            value,
            keccak256(data),
            validAfter,
            validBefore
        )
    );
    bytes32 digest = _hashTypedDataV4(structHash);

    address recoveredAddress = ECDSA.recover(digest, signature);
    require(recoveredAddress == owner, "Invalid signer");

    // execute the instruction
    _call(to, value, data);
}

Implement Account Abstraction into Privy PWA

Configure Account Abstraction and VeChain

Extend the .env file with the following lines:

NEXT_PUBLIC_AA_FACTORY=0x..
NEXT_PUBLIC_NODE_URL=https://testnet.vechain.org
NEXT_PUBLIC_DELEGATOR_URL=https://sponsor-testnet.vechain.energy/by/90
  • NEXT_PUBLIC_AA_FACTORY is the address of the previously deployed SimpleAccountFactory
  • NEXT_PUBLIC_NODE_URL is the link to the VeChain network to use.
  • NEXT_PUBLIC_DELEGATOR_URL is a fee delegation service, that will pay the VTHO gas fees. Visit vechain.energy to create one or build one yourself.

Install VeChain Dependencies

Install the VeChain SDK to simplify interaction with the VeChain network:

npm install  --save @vechain/sdk-core@1.0.0-beta.16 @vechain/sdk-network@1.0.0-beta.16

useVeChainAccount.tsx

A prepared hook will provide connectivity to the Account Abtractions deployment.

Copy useVeChainAccount.tsx into the components/lib folder.

Background: How the Hook Works

The hook internally performs the following steps:

  1. Generates a random wallet for blockchain interaction, providing an entry point into the network from which transactions can be accepted.
  2. Requests the user to sign a typed message with the Privy embedded wallet.
  3. If the SimpleAccount does not exist yet, it injects a clause to deploy a new SimpleAccount first.
  4. Builds a transaction to interact with the Account Abstraction Wallet.
  5. Requests the Gas Fee Payment from a Fee Delegator to avoid funding the random wallet with VTHO.
  6. Sends the transaction to the VeChain Network.
  7. Returns the transaction ID.

The visualized process is:

sequenceDiagram
    participant User
    participant App
    participant Privy
    participant useVeChainAccount
    participant SimpleAccountFactory
    participant FeeDelegator

    App->>+useVeChainAccount: sendTransaction(to, value, data)
    useVeChainAccount->>SimpleAccountFactory: getAddress(embeddedWallet.address)
    SimpleAccountFactory-->>useVeChainAccount: address
    opt address not deployed yet
      useVeChainAccount-->>useVeChainAccount: add Factory.createAccount() to transaction
    end

    useVeChainAccount->>+Privy: ask to sign execute(AA-Address, vetValue, encodedData)
    Privy->>User: request signTypedData
    User-->>Privy: confirm
    Privy-->>-useVeChainAccount: signature

    useVeChainAccount->>FeeDelegator: request Fee Delegation
    FeeDelegator-->>useVeChainAccount: fee delegation signature

    useVeChainAccount->>+SimpleAccount: executeWithAuthorization(.., to, value, data, signature)
    SimpleAccount-->Blockchain: execute to.abi(args)
    useVeChainAccount-->>-User: confirm tx
Loading

Add VeChainAccountProvider

Add the VeChainAccountProvider into the _app.tsx to wrap the application components.

VeChainAccountProvider is a React Context provider that does the following:

  1. Creates a new ThorClient to connect to the VeChain Network.
  2. Identifies the address of the embedded Privy wallet.
  3. Loads the SimpleAccount address from the SimpleAccountFactory identified by the user's embedded Privy address.
  4. Exports the address, a function to send transactions, and some generic network/connectivity information.

Use useVeChainAccount

Import the useVeChainAccount hook into embedded-wallet.tsx and call it within the component:

const vechain = useVeChainAccount()

Display Address

The address exported from the useVeChainAccount hook is the address from which future transactions will originate.

Add the following snippet to display the account address in the embedded-wallet.tsx:

<Section>
    <p className='text-md mt-2 font-bold uppercase text-gray-300'>
        Your VeChain Address
    </p>
    <textarea
        value={vechain.address}
        className='mt-4 h-12 w-full rounded-md bg-slate-700 p-4 font-mono text-xs text-slate-50 disabled:opacity-25'
        rows={1}
        readOnly
    />
</Section>

Configure Test Call

To test an interaction, replace the existing test transfer with a call to vechain.sendTransaction.

Replace the onTransfer function in embedded-wallet.tsx with the following code:

const onTransfer = async () => {
    if (!vechain.address) return
    try {
        setTxIsLoading(true)
        const _txHash = await vechain.sendTransaction({
            to: recipientAddress as `0x${string}`,
            value: parseEther('0.004'),
        })
        setTxHash(_txHash)
    } catch (e) {
        console.error('Transfer failed with error ', e)
        setTxIsLoading(false)
    }
}

Test the PWA

Open two terminals to run the application and create a public https endpoint:

  • Run npm run dev to start the PWA application.
  • Run ngrok http 3004 to start an ngrok proxy and get a public secure address.

Switch to your browser:

  • Open the forwarding address shown by ngrok.
  • Log in with your social account.
  • Switch to the Embedded Wallet menu.

Fund VeChain Wallet

Visit faucet.vecha.in to claim VET on the TestNet and send 1 VET to the VeChain address shown in your PWA.

Test Transactions

In your PWA, enter a valid address for the transfer and test the Transfer function. A dialog will open and request to sign a transaction. Once signed, the SimpleAccount will transfer 0.004 VET to the entered address.

Interact with Smart Contracts

To interact with other smart contracts, sendTransaction accepts to, data, and value parameters like other similar functions.

Either pass in the data for the smart contract call encoded or pass the data as an object, which will then be used to call encodeFunctionData within the useVeChainAccount hook.

For example:

const _txHash = await vechain.sendTransaction({
    to: '0x8384738C995D49C5b692560ae688fc8b51af1059',
    value: 0,
    data: {
        abi: [{
            "inputs": [],
            "name": "increment",
            "outputs": [],
            "type": "function"
        }],
        functionName: "increment",
        args: []
    },
})

Hide Transaction Signing

Signing messages or transactions can be complex and a hurdle for regular users. To reduce friction, you can disable the signing dialog in the Privy Dashboard under Embedded Wallets > Add confirmation modals.

Disabling this option allows the application to instantly interact with the blockchain, improving the user experience. Pending transactions can be managed with loading animations.

Conclusion

In this article, we've addressed the lack of VeChain support within Privy and viem by creating an Account Abstraction Factory that serves as the interaction source.

By using Fee Delegation, the gas fees are covered for the user, eliminating the need to ask the user to fund it with VTHO first.

An example application for further exploration of Account Abstractions and a Privy PWA is available on GitHub at vechain-energy/example-pwa-privy-account-abstraction and at pwa-privy.examples.vechain.energy.

You can use the information to either build on top of the example project and hook or derive your own process.