🏧

Web3.jsとAlchemyを使ってAccount AbstractionでガスレスにNFTをミントしてみる

2024/03/17に公開

EthereumのAAの勉強ついでにテストネットでガスレスにNFTをミントすることにチャレンジしてみたのでコードなど色々記事にします。

理解間違いやミスなどありましたらご指摘いただけると嬉しいです。

また、記事中のコードを利用される際には自己責任でお願いします。

記事中で扱うこと

この記事では、Factoryを使用したコントラクトウォレットの作成から、そのウォレットからのNFTミントまでを扱います。

使用するノード:

ライブラリ:

  • Web3.js
  • React

MetaMaskを使うために、Reactを使ってWebアプリとして実装します。

事前準備

必要な事前準備です。

Reactプロジェクトの作成

適当な名前でプロジェクトを作成します。

私はTypeScriptで作成しましたが、JavaScriptでも大丈夫だと思います。

同時にWeb3.jsもインストールします。

npm install web3

Alchemyの登録

Alchemyに登録して、テストネットでAppを作成します。

私は、PolygonのMumbaiで作成しました。

次に「Gas Manager」からポリシーを作成します。
*メインネットでポリシーを作成するためには、プランをfreeからアップグレードする必要があるみたいです。

AppのhttpsエンドポイントとGas Managerのpolicy Idを使用するのでメモしておきます。

NFTコントラクトのデプロイ

Openzeppelinなどで適当にERC721のコントラクトを作ってデプロイします。

私はどのコントラクトウォレットからでも自由にミントできるようにonlyOwnerを外してデプロイしました。

ABIの収集

必要なコントラクトのABI一覧です。
ReactのPJディレクトリにjsonファイルを作って置いておきます。

定数など用意

面倒なのでコードに直書きしています。

const api = "Alchemyのエンドポイント"
const PolicyID = "ポリシーID"
const factoryAddress = "0x9406Cc6185a346906296840746125a0E44976454"
const entryAddress = "0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789"
const nftAddress = "NFTのコントラクトアドレス"
const walletSalt = 0

画面にアドレスやハッシュなどを表示するためにstateも用意しました。

const [createWalletTransaction,setCreateWalletTransaction] = useState<string>("")
const [walletAddress,setWalletAddress] = useState<string>("")
const [opHash,setOpHash] = useState<string>("")

コントラクトウォレットの作成

ウォレットを作成

const createWallet = async () => {
    // @ts-ignore
    const web3 = new Web3(globalThis.window.ethereum);
    const addressList= await web3.eth.requestAccounts()
    const factory = new web3.eth.Contract(factoryABI.abi,factoryAddress)
    const transaction = await factory.methods.createAccount(addressList[0],walletSalt).send({
            from:addressList[0]
    })
    setCreateWalletTransaction(transaction.transactionHash)
}

Factoryコントラクトからウォレットを作るトランザクションをMetaMask経由で送っています。

*タイトルにガスレスとありますが、ここでガス代が発生しています。
addressList[0]の箇所を自分以外のアドレスに変えても動くはずなので、ユーザーにガス代を負担させずに完結させることもできると思います。

ウォレットアドレスを取得

const getWallet = async () => {
    // @ts-ignore
    const web3 = new Web3(globalThis.window.ethereum);
    const addressList= await web3.eth.requestAccounts()
    const factory = new web3.eth.Contract(factoryABI.abi,factoryAddress)
    const address = await factory.methods.getAddress(addressList[0],walletSalt).call()
    setWalletAddress(String(address))
}

ウォレットを作ったときにパラメータで渡したオーナーのアドレスとソルトをFactoryのgetAddressに渡すとコントラクトウォレットのアドレスが返ってきます。

後で必要なのと画面にも表示したいので保存しておきます。

NFTをミント

処理毎に関数を分けて書いていきます。

Nonceを取得

const getNonce = async () => {
    // @ts-ignore
    const web3 = new Web3(api)
    const entryPoint = new web3.eth.Contract(entryABI.abi,entryAddress)
    const result = await entryPoint.methods.getNonce(walletAddress,0).call()
    const bigIntValue = BigInt(String(result));
    return '0x' + bigIntValue.toString(16)
}

リプレイ攻撃対策用の値です。
EntryPointコントラクトに問い合わせることで取得できます。

CallDataの作成

const createCallData = async () => {
    const contract = new Contract(erc721ABI.abi, nftAddress)
    const abiData = contract.methods.safeMint(walletAddress,21).encodeABI();
    const accountContract = new Contract(simpleABI.abi,walletAddress)
return accountContract.methods.executeBatch([nftAddress], [abiData]).encodeABI()
}

CallDataは実行される内容になります。

普通のトランザクションと違い、作成したDataをさらにexecuteBatchで囲む必要があります。

PaymasterAndDataの作成&ガス代見積もり

const getPaymasterData = async (nonce:string,callData:string) => {
    const options = {
        method: 'POST',
        headers: {accept: 'application/json', 'content-type': 'application/json'},
        body: JSON.stringify({
                id: 1,
                jsonrpc: '2.0',
                method: 'alchemy_requestGasAndPaymasterAndData',
                params: [
                    {
                        policyId: PolicyID,
                        entryPoint: entryAddress,
                        dummySignature: '0xfffffffffffffffffffffffffffffff0000000000000000000000000000000007aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1c',
                        userOperation: {
                            sender: walletAddress,
                            nonce: nonce,
                            initCode: "0x",
                            callData: callData,
                        }
                    }
                ]
            })
        };

        const result = await fetch(api, options)
        return await result.json()
}

用意したNonceとCallDataをAlchemyのAPIに渡してpaymasterAndDataを取得します。

(PaymasterのAlchemyにガス代を支払わせるためのデータと理解しています)

ついでにレスポンスにガス代の見積もりなども含まれているためそのまま使います。

署名

const sign = async (callData:string,nonce:string,callGasLimit:string,verificationGasLimit:string,preVerificationGas:string,maxFeePerGas:string,maxPriorityFeePerGas:string,paymasterData:string) =>{
        // @ts-ignore
    const web3 = new Web3(globalThis.window.ethereum);
    const address = await web3.eth.requestAccounts()

    const packedData = web3.eth.abi.encodeParameters(
            [
                "address",
                "uint256",
                "bytes32",
                "bytes32",
                "uint256",
                "uint256",
                "uint256",
                "uint256",
                "uint256",
                "bytes32",
            ],
            [
                walletAddress,
                nonce,
                web3.utils.keccak256("0x"),
                web3.utils.keccak256(callData),
                callGasLimit,
                verificationGasLimit,
                preVerificationGas,
                maxFeePerGas,
                maxPriorityFeePerGas,
                web3.utils.keccak256(paymasterData),
            ]
        );

    const enc = web3.eth.abi.encodeParameters(
            ["bytes32", "address", "uint256"],
            [web3.utils.keccak256(packedData), entryAddress, 80001]
    );

    const userOpHash = web3.utils.keccak256(enc);

    return await web3.eth.personal.sign(userOpHash,address[0],"")
}

トランザクション送信時のパラメータのうち、signature以外のパラメータ+チェーンIDをまとめて署名しています。
チェーンIDはPolygon Mumbaiなので80001です。

送信

    const sendTransaction = async (callData:string,nonce:string,callGasLimit:string,verificationGasLimit:string,preVerificationGas:string,maxFeePerGas:string,maxPriorityFeePerGas:string,paymasterData:string,signture:string) => {
        const options = {
            method: 'POST',
            headers: {accept: 'application/json', 'content-type': 'application/json'},
            body: JSON.stringify({
                id: 1,
                jsonrpc: '2.0',
                method: 'eth_sendUserOperation',
                params: [
                    {
                        sender: walletAddress,
                        nonce: nonce,
                        initCode: '0x',
                        callData: callData,
                        callGasLimit: callGasLimit,
                        verificationGasLimit: verificationGasLimit,
                        preVerificationGas: preVerificationGas,
                        maxFeePerGas: maxFeePerGas,
                        maxPriorityFeePerGas: maxPriorityFeePerGas,
                        signature:signture,
                        paymasterAndData: paymasterData
                    },
                    entryAddress
                ]
            })
        };

        const result = await fetch(api, options)
        const response = await result.json()
        return response.result
    }

実行用にまとめる

const mintNFT = async () => {
    const nonce = await getNonce();
    const callData = await createCallData();
    const paymaster = await getPaymasterData(nonce,callData);

    const paymasterAndData = paymaster.result.paymasterAndData
    const callGasLimit = paymaster.result.callGasLimit
    const verificationGasLimit = paymaster.result.verificationGasLimit
    const preVerificationGas = paymaster.result.preVerificationGas
    const maxFeePerGas = paymaster.result.maxFeePerGas
    const maxPriorityFeePerGas = paymaster.result.maxPriorityFeePerGas

    const signture = await sign(callData,nonce,callGasLimit,verificationGasLimit,preVerificationGas,maxFeePerGas,maxPriorityFeePerGas,paymasterAndData)
    const userOpHash = await  sendTransaction(callData,nonce,callGasLimit,verificationGasLimit,preVerificationGas,maxFeePerGas,maxPriorityFeePerGas,paymasterAndData,signture);
    setOpHash(userOpHash)
    }

表示部分も作ります。

return <div>
        <button onClick={createWallet}>
            コントラクトウォレット作成
        </button>
        <br/>
        ウォレット作成トランザクションハッシュ:{createWalletTransaction}
        <br/>
        <button onClick={getWallet}>
            ウォレットアドレス取得
        </button>
        <br/>
        ウォレットアドレス:{walletAddress}
        <br/>
        <button onClick={mintNFT}>
            Mint
        </button>
        <br/>
        userOpHash:{opHash}
        <br/>
</div>

実行

(初回実行時のみ「コントラクトウォレット作成」を始めにクリック)

「ウォレットアドレス取得」→「Mint」の順でクリックするとウォレットアドレスとUserOpのハッシュが表示されます。

UserOpの中身はjiffyscanで表示できます。(PolygonScanでは表示できません)

また、Alchemyのコンソールにも出てきます。

Discussion