Skip to content

Thực hành Account Abstraction với Biconomy SDK

Posted on:June 9, 2023

Intro

Trong bài này ta sẽ kiểm tra lại lý thuyết ta đã tìm hiểu tại chuỗi bài Account Abstraction với ERC-4337. Sử dụng Biconomy SDK.

Biconomy SDK là một trong những bộ toolkit triển khai ERC-4337 hoàn thiện nhất hiện nay cho Account Abstraction.

fullstack-account-abstraction-sdk

Trong bài này ta sẽ sử dụng Polygon Mumbai testnet.

Ta sẽ kiểm tra các thứ sau:

Hands-on

Clone project quickstart của Biconomy về:

git clone [email protected]:bcnmy/quickstart.git

project này lúc đầu chỉ có các dependencies chứ chưa có code gì cả, ta sẽ dần thêm code vào đây. Đầu tiên cứ cài đặt dependencies trước đã

yarn install

Tạo một .env file và paste private key vào đây. Đây sẽ là owner của Account của ta. Lưu ý rằng account này không cần phải có tiền. Nó chỉ đơn giản đóng vai trò là owner mà thôi.

PRIVATE_KEY = "enter some private key";

Load dotenv config tại src/index.ts:

import { config } from "dotenv";

config();

Bây giờ là vào phần chính, ta tiếp tục import các thành phần khác từ biconomy SDK:

import { IBundler, Bundler } from "@biconomy/bundler";
import { DEFAULT_ENTRYPOINT_ADDRESS } from "@biconomy/account";
import { ethers } from "ethers";
import { ChainId } from "@biconomy/core-types";

Tạo config cho bundler:

const bundler: IBundler = new Bundler({
  bundlerUrl:
    "https://bundler.biconomy.io/api/v2/80001/nJPK7B3ru.dd7f7861-190d-41bd-af80-6877f74b8f44",
  chainId: ChainId.POLYGON_MUMBAI,
  entryPointAddress: DEFAULT_ENTRYPOINT_ADDRESS,
});

Ở đây ta sẽ dùng bundlerentryPointAddress mặc định của Biconomy.

Tiếp tục import thêm BiconomySmartAccountV2 vào, đây là class sẽ dùng để tạo ra Account của ta.

import {
  BiconomySmartAccountV2,
  DEFAULT_ENTRYPOINT_ADDRESS,
} from "@biconomy/account";

Tiếp theo load providerowner wallet ra.

const provider = new ethers.providers.JsonRpcProvider(
  "https://rpc.ankr.com/polygon_mumbai"
);
const wallet = new ethers.Wallet(process.env.PRIVATE_KEY || "", provider);

Một điều nữa ta cần nắm được là module cho Account, trong bài này ta sẽ tạo account sử dụng ECDSA module.

import {
  ECDSAOwnershipValidationModule,
  DEFAULT_ECDSA_OWNERSHIP_MODULE,
} from "@biconomy/modules";

Tạo Account

Tiến hành tạo account:

async function createAccount() {
  const module = await ECDSAOwnershipValidationModule.create({
    signer: wallet,
    moduleAddress: DEFAULT_ECDSA_OWNERSHIP_MODULE,
  });

  let biconomySmartAccount = await BiconomySmartAccountV2.create({
    bundler,
    chainId: ChainId.POLYGON_MUMBAI,
    rpcUrl: "https://rpc.ankr.com/polygon_mumbai",
    entryPointAddress: DEFAULT_ENTRYPOINT_ADDRESS,
    defaultValidationModule: module,
    activeValidationModule: module,
  });

  console.log("address: ", await biconomySmartAccount.getAccountAddress());
  return biconomySmartAccount;
}
createAccount();

Chạy thử ta sẽ nhận được một địa chỉ giống thế này:

address: 0x76ae99af1c6493e22085613aec00e01f224b9534;

Đây chính là địa chỉ Smart Account của ta. Tiếp theo ta sẽ test các kịch bản.

Ở các kịch bản ta có phần chung hầu như giống nhau là phần setup Account, bundler. Nên đối với mỗi kịch bản độc giả có thể viết script riêng để chạy, hoặc viết tất cả vào trong index.ts cũng được.

Test gửi native token (MATIC)

Ta sẽ test việc gửi native token từ Account qua một địa chỉ khác. Đầu tiên ta cần fund cho Account một ít MATIC để nó có thể đủ balance để gửi đi, và trả phí giao dịch.

Sau khi Account đã được fund, ta viết hàm chuyển 0.001 MATIC:

async function sendETH() {
  const smartAccount = await createAccount();
  try {
    const transaction = {
      to: "0xC3a005E15Cb35689380d9C1318e981BcA9339942",
      data: "0x",
      value: ethers.utils.parseEther("0.001"),
    };

    const userOp = await smartAccount.buildUserOp([transaction]);
    userOp.paymasterAndData = "0x";

    const userOpResponse = await smartAccount.sendUserOp(userOp);

    const transactionDetail = await userOpResponse.wait();

    console.log("transaction detail below");
    console.log(
      `https://mumbai.polygonscan.com/tx/${transactionDetail.receipt.transactionHash}`
    );
  } catch (error) {
    console.log(error);
  }
}

sendETH();

chạy script, kết quả trông như thế này:

address:  0x76Ae99AF1C6493E22085613AEc00e01f224b9534
transaction detail below
https://mumbai.polygonscan.com/tx/0xefcf4c0dcb97bb8407e6b6740295f85cddafee5f1a434a5b149b5fc49c7afe0a

thử kiểm tra trên explorer xem có đúng transaction chuyển đi 0.001 MATIC cho địa chỉ nhận hay không?

send-native

Đọc hiểu kết quả một chút:

Nó hoàn toàn khớp với lý thuyết mà ta đã nói đến trong chuỗi bài Account Abstraction với ERC-4337.

Test gửi ERC-20 token

Ta sẽ gửi ERC-20 token từ Account tới một địa chỉ nhận. Ở đây ta vẫn dùng Account cũ 0x76ae99af1c6493e22085613aec00e01f224b9534 đã có sẵn native token để trả phí.

Lên OpenZeppelin wizard để tạo một ERC-20 và deploy nó bằng REMIX, đồng thời gửi cho Account của ta một ít token:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/[email protected]/token/ERC20/ERC20.sol";

contract BICOTKN is ERC20 {
    constructor() ERC20("BICOTKN", "BICOTKN") {
        _mint(0x76Ae99AF1C6493E22085613AEc00e01f224b9534, 1000 * 10 ** decimals());
    }
}

Tiến hành deploy, lưu địa chỉ token contract đã deploy lại: 0xD798956327E1648ECBe76478B023188F25eB75B8

Đồng thời lưu abi của contract tại src/BICOTKN.json.

Tiến hành import token contract abi:

import abi from "./BICOTKN.json";

Các bước tạo account ta giữ như cũ, địa chỉ Account cũng là địa chỉ cũ 0x76ae99af1c6493e22085613aec00e01f224b9534.

Viết hàm sendToken gửi thử 12 BICOTKN:

async function sendToken() {
  const smartAccount = await createAccount();
  const receiver = "0xC3a005E15Cb35689380d9C1318e981BcA9339942";
  const tokenAddress = "0xD798956327E1648ECBe76478B023188F25eB75B8";
  const amount = "12000000000000000000"; // 12 tokens

  try {
    const bicoTokenInterface = new ethers.utils.Interface(abi);
    const data = bicoTokenInterface.encodeFunctionData("transfer", [
      receiver,
      amount,
    ]);
    const transaction = { to: tokenAddress, data };

    const userOp = await smartAccount.buildUserOp([transaction]);
    userOp.paymasterAndData = "0x";

    const userOpResponse = await smartAccount.sendUserOp(userOp);

    const transactionDetail = await userOpResponse.wait();

    console.log("transaction detail below");
    console.log(
      `https://mumbai.polygonscan.com/tx/${transactionDetail.receipt.transactionHash}`
    );
  } catch (error) {
    console.log(error);
  }
}

sendToken();

chạy hàm và kết quả:

address:  0x76Ae99AF1C6493E22085613AEc00e01f224b9534
transaction detail below
https://mumbai.polygonscan.com/tx/0x69e30218b4bdd1ead85024cc056f3d486ff7a0b53c95d6ac04087e984798e052

thử kiểm tra trên explorer xem có đúng transaction chuyển đi 12 token BICOTKN và Account còn lại 988 token hay không?

send-token

Về các thông số thì ta đọc tương tự như ở phần gửi native token bên trên.

Test paymaster

Setup Paymaster

Trong bài này ta sẽ sử dụng Polygon Mumbai testnet.

Ta sẽ setup paymaster theo hướng dẫn ở đây: https://docs.biconomy.io/dashboard/paymaster

add-paymaster

register-paymaster

first-time-gastank

deposit-funds

đằng sau việc này là gọi hàm depositFor(address) của VerifySingletonPaymaster để gửi ETH vào EntryPoint.

withdraw-funds

tương tự trên, action này gọi withdrawTo(address, uint256) từ VerifySingletonPaymaster.

Trong overview ta sẽ thấy ở phần Mode có 2 tick là ERC-20Sponsored, tức là ta có thể sử dụng paymaster để trả phí hộ user, và cũng có thể chấp nhận việc user trả phí bằng ERC-20 token thay vì ETH. Chỉ có một số lượng nhất định ERC-20 được chấp nhận ở đây, hầu hết là các token nổi tiếng và có thanh khoản dồi dào.

paymaster-mode

Test gas sponsoring với paymaster: chuyển BICOTKN token

Ta sẽ chuyển BICOTKN từ một Account mới toanh tới địa chỉ nhận mà không cần Account phải hold chút native token nào.

Để test phần này, ta dùng PRIVATE_KEY2 mới trong .env để tạo ra một Account mới toanh, đồng thời thêm vào địa chỉ paymaster mà ta đã setup bên trên.

PRIVATE_KEY2 = "new private key";
PAYMASTER_URL = "pay master url";

Các bước tạo account ta làm như cũ.

Ta nhận được Account mới với địa chỉ: 0x2283bccbA5ed74e50F2382fF8eb60Eb8d6a1D78e.

Hiện giờ Account mới chưa có một token nào, cũng không có native token (MATIC), ta cần gửi cho nó một ít BICOTKN. Đơn giản là ta chạy lại hàm gửi ERC-20 token ở bên trên với địa chỉ nhận là Account mới của ta.

Giờ đây Account mới có 12 BICOTKN.

Tiến hành import các class cần thiết để tạo paymaster:

import {
  IPaymaster,
  IHybridPaymaster,
  SponsorUserOperationDto,
  BiconomyPaymaster,
  PaymasterConfig,
  PaymasterMode,
} from "@biconomy/paymaster";

tạo paymaster

const paymasterConfig: PaymasterConfig = {
  paymasterUrl: process.env.PAYMASTER_URL!,
};

const paymaster: IPaymaster = new BiconomyPaymaster(paymasterConfig);

thêm paymaster vào lúc khởi tạo smart account:

let biconomySmartAccount = await BiconomySmartAccountV2.create({
  bundler,
  chainId: ChainId.POLYGON_MUMBAI,
  rpcUrl: "https://rpc.ankr.com/polygon_mumbai",
  entryPointAddress: DEFAULT_ENTRYPOINT_ADDRESS,
  defaultValidationModule: module,
  activeValidationModule: module,
  paymaster,
});

Viết hàm sendTokenPaymaster:

async function sendTokenPaymaster() {
  const smartAccount = await createAccount();
  const receiver = "0xC3a005E15Cb35689380d9C1318e981BcA9339942";
  const tokenAddress = "0xD798956327E1648ECBe76478B023188F25eB75B8";
  const amount = "2000000000000000000"; // 2 tokens

  try {
    const bicoTokenInterface = new ethers.utils.Interface(abi);
    const data = bicoTokenInterface.encodeFunctionData("transfer", [
      receiver,
      amount,
    ]);
    const transaction = { to: tokenAddress, data };

    const userOp = await smartAccount.buildUserOp([transaction]);
    const bicoPaymaster =
      smartAccount.paymaster as IHybridPaymaster<SponsorUserOperationDto>;
    let paymasterServiceData: SponsorUserOperationDto = {
      mode: PaymasterMode.SPONSORED,
      smartAccountInfo: {
        name: "BICONOMY",
        version: "2.0.0",
      },
    };
    const paymasterAndDataResponse = await bicoPaymaster.getPaymasterAndData(
      userOp,
      paymasterServiceData
    );

    userOp.paymasterAndData = paymasterAndDataResponse.paymasterAndData;
    userOp.callGasLimit = paymasterAndDataResponse.callGasLimit;
    userOp.verificationGasLimit = paymasterAndDataResponse.verificationGasLimit;
    userOp.preVerificationGas = paymasterAndDataResponse.preVerificationGas;

    const userOpResponse = await smartAccount.sendUserOp(userOp);

    const transactionDetail = await userOpResponse.wait();

    console.log("transaction detail below");
    console.log(
      `https://mumbai.polygonscan.com/tx/${transactionDetail.receipt.transactionHash}`
    );
  } catch (error) {
    console.log(error);
  }
}

sendTokenPaymaster();

chạy script được kết quả:

address:  0x2283bccbA5ed74e50F2382fF8eb60Eb8d6a1D78e
transaction detail below
https://mumbai.polygonscan.com/tx/0xcf80979bf5c8ee2c80178e0c77cf8135bce2136fe3bf45eb63fb6fd94cb9a237

thử kiểm tra trên explorer xem có đúng transaction chuyển đi 2 BICOTKN hay không?

send-token-paymaster

Đọc hiểu sơ bộ các thông số tương tự như trước:

Ta thấy rằng rõ ràng Account mới của ta không hề có native token, nhưng vẫn hoàn thành được giao dịch, đó là nhờ có paymaster.

Nhưng ở đây ta có thể thắc mắc là tại sao lại là Entry Point trả phí giao dịch chứ không phải là Paymaster? Lý do chính là ở bước setup Paymaster bên trên, ta đã tiến hành deposit từ Paymaster vào trong Entry Point rồi. Từ đó Entry Point sẽ sử dụng phần deposit này để đại diện Paymaster trả phí, khi lượng deposit này không đủ thì sẽ yêu cầu Paymaster deposit thêm vào.

Điều này cũng vẫn hoàn toàn khớp với lý thuyết ta đã tìm hiểu. Cool.

Test trả phí bằng non-native token với paymaster: chuyển BICOTKN token, trả phí bằng ERC-20 Token

Vẫn là ví mới ta vừa tạo, không có native token. Ta sẽ test việc gửi đi BICOTKN như cũ, nhưng sẽ trả phí bằng ERC-20 thay vì MATIC như trước.

Không phải mọi ERC-20 đều được chấp nhận dùng làm token trả phí, sẽ chỉ có một vài token được support mà thôi, chủ yếu là các token có dồi dào thanh khoản.

Để biết token nào được support, trước hết tiến hành import thêm PaymasterFeeQuote

import { PaymasterFeeQuote } from "@biconomy/paymaster";

sau đó chạy đoạn code này:

const feeQuotesResponse = await bicoPaymaster.getPaymasterFeeQuotesOrData(
  userOp,
  {
    mode: PaymasterMode.ERC20,
    tokenList: [],
  }
);
const feeQuotes = feeQuotesResponse.feeQuotes as PaymasterFeeQuote[];
console.log(feeQuotes);

Với Polygon Mumbai testnet, thì các token được support sẽ như sau:

[
  {
    symbol: "WMATIC",
    decimal: 18,
    tokenAddress: "0x9c3c9283d3e44854697cd22d3faa240cfb032889",
    maxGasFee: 0.0006582146580199368,
    maxGasFeeUSD: 0.0005551777354534959,
    exchangeRate: 1003331515424560800,
    logoUrl: "https://polygonscan.com/token/images/wMatic_32.png",
    premiumPercentage: "12",
    validUntil: 1699542938,
  },
  {
    symbol: "USDT",
    decimal: 18,
    tokenAddress: "0xeabc4b91d9375796aa4f69cc764a4ab509080a58",
    maxGasFee: 0.0005300908633286532,
    maxGasFeeUSD: 0.0005303930151207506,
    exchangeRate: 845787900896489100,
    logoUrl:
      "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/128/color/usdt.png",
    premiumPercentage: "7",
    validUntil: 1699542938,
  },
  {
    symbol: "USDC",
    decimal: 6,
    tokenAddress: "0xda5289fcaaf71d52a80a254da614a192b693e977",
    maxGasFee: 0.0005305471936710001,
    maxGasFeeUSD: 0.0005303933349848354,
    exchangeRate: 846516,
    logoUrl:
      "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/128/color/usdc.png",
    premiumPercentage: "7",
    validUntil: 1699542938,
  },
  {
    symbol: "DAI",
    decimal: 18,
    tokenAddress: "0x27a44456bedb94dbd59d0f0a14fe977c777fc5c3",
    maxGasFee: 0.0005300378897346283,
    maxGasFeeUSD: 0.0005303930151207506,
    exchangeRate: 845703378736246800,
    logoUrl:
      "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/128/color/dai.png",
    premiumPercentage: "7",
    validUntil: 1699542938,
  },
  {
    symbol: "SAND",
    decimal: 18,
    tokenAddress: "0xe03489d4e90b22c59c5e23d45dfd59fc0db8a025",
    maxGasFee: 0.0013380033631058149,
    maxGasFeeUSD: 0.0005551777354534958,
    exchangeRate: 2039548839563299800,
    logoUrl:
      "https://raw.githubusercontent.com/spothq/cryptocurrency-icons/master/128/color/sand.png",
    premiumPercentage: "12",
    validUntil: 1699542938,
  },
];

Ta sẽ chọn USDC (0xda5289fcaaf71d52a80a254da614a192b693e977) để trả phí.

Trước tiên ta phải kiếm cách fund cho Account một ít USDC để trả phí.

Các đơn giản nhất là dùng một EOA thông thường có MATIC, lên Uniswap swap lấy USDC và chuyển cho Account mới của ta.

OK, giờ đây Account của ta đã có USDC đủ để trả phí giao dịch.

Ta sẽ viết hàm sendTokenPayMasterERC20 như sau:

async function sendTokenPaymasterERC20() {
  const smartAccount = await createAccount();
  const receiver = "0xC3a005E15Cb35689380d9C1318e981BcA9339942";
  const tokenAddress = "0xD798956327E1648ECBe76478B023188F25eB75B8";
  const amount = "2000000000000000000";

  try {
    const bicoTokenInterface = new ethers.utils.Interface(abi);
    const data = bicoTokenInterface.encodeFunctionData("transfer", [
      receiver,
      amount,
    ]);
    const transaction = { to: tokenAddress, data };

    let userOp = await smartAccount.buildUserOp([transaction]);

    const bicoPaymaster =
      smartAccount.paymaster as IHybridPaymaster<SponsorUserOperationDto>;

    const feeQuotesResponse = await bicoPaymaster.getPaymasterFeeQuotesOrData(
      userOp,
      {
        mode: PaymasterMode.ERC20,
        tokenList: ["0xda5289fcaaf71d52a80a254da614a192b693e977"],
      }
    );

    const feeQuotes = feeQuotesResponse.feeQuotes as PaymasterFeeQuote[];
    const spender = feeQuotesResponse.tokenPaymasterAddress || "";
    const usdcFeeQuotes = feeQuotes[0];

    userOp = await smartAccount.buildTokenPaymasterUserOp(userOp, {
      feeQuote: usdcFeeQuotes,
      spender: spender,
      maxApproval: false,
    });

    let paymasterServiceData: SponsorUserOperationDto = {
      mode: PaymasterMode.ERC20,
      feeTokenAddress: usdcFeeQuotes.tokenAddress,
      smartAccountInfo: {
        name: "BICONOMY",
        version: "2.0.0",
      },
    };

    const paymasterAndDataWithLimits = await bicoPaymaster.getPaymasterAndData(
      userOp,
      paymasterServiceData
    );
    userOp.paymasterAndData = paymasterAndDataWithLimits.paymasterAndData;
    userOp.callGasLimit = paymasterAndDataWithLimits.callGasLimit;
    userOp.verificationGasLimit = paymasterAndDataWithLimits.verificationGasLimit;
    userOp.preVerificationGas = paymasterAndDataWithLimits.preVerificationGas;

    const userOpResponse = await smartAccount.sendUserOp(userOp);

    const transactionDetail = await userOpResponse.wait();

    console.log("transaction detail below");
    console.log(
      `https://mumbai.polygonscan.com/tx/${transactionDetail.receipt.transactionHash}`
    );
  } catch (error) {
    console.log(error);
  }
}

sendTokenPaymasterERC20();

chạy script được kết quả:

address:  0x2283bccbA5ed74e50F2382fF8eb60Eb8d6a1D78e
transaction detail below
https://mumbai.polygonscan.com/tx/0x7c43386615f302d5606c05f9f799a34e8afbb52056e17876d0c1e3ab05ab3ff0

Thử kiểm tra trên explorer:

send-token-paymaster-erc20

Đọc hiểu sơ bộ kết quả:

Như vậy ta đã tiến hành trả phí giao dịch bằng USDC thành công. Quá trình này diễn ra hầu như giống với giao dịch gas sponsoring bên trên, khi paymaster trả hộ phí giao dịch. Chỉ khác là ở đây ta sẽ phải trả lại phí cho paymaster bằng USDC, với rate đã được tính toán tại thời điểm giao dịch. Lưu ý rằng ở đây không nảy sinh giao dịch swap để convert lại USDC về MATIC (điều này rõ ràng tốn phí), mà chỉ đơn thuần Paymaster nhận về USDC với giá trị tương đương phí giao dịch mà thôi.

Và mọi thứ vẫn hoạt động đúng như lý thuyết ta đã tìm hiểu. So cool.

Bạn đọc có thể tham khảo code tại đây: https://github.com/dotrungkien/aa-biconomy

Tham khảo