🤖

非同期スマートコントラクト(NFT発行)の構築と呼び出しハンズオン

に公開1

こんにちは、Komlock LabでWeb3エンジニアの阿部です!

スマートコントラクトの書き込み実行処理は時間がかかりますよね。
UX的にスマートコントラクトへの書き込み処理は非同期的で行なうケースが多いです。

ユーザーに対するNFTの発行が一例です。

このハンズオンは、
非同期スマートコントラクト(NFT発行)を作成し、AWSのサーバーレスアーキテクチャを活用して呼び出すシーケンスを学べるように設計しています。

実務での活用するシーケンスかと思いますので、役に立てていただければ幸いです!

想定読者

  • hardhat, openzeppelinを利用してFNTスマートコントラクトを開発できる方
    • まず基本的なスマートコントラクト開発を学びたい方はこちらの記事をご参照ください。
  • 基本的なAWSリソースを利用したことがある方
    • 簡単なIAM利用、S3利用をしたことがあればOKです!

概要

このハンズオンでは、以下の流れを学びます:

  1. AWSアカウントのセットアップ
  2. Terraform CLIのセットアップ
  3. IAMロールとポリシーの作成
  4. API Gatewayでリクエスト送信
  5. SNSでイベント発火
  6. SQSでイベントキャッチとLambda関数呼び出し
  7. Lambda関数でNFTスマートコントラクトを実行
  8. (本来はWebhook等でクライアントにコントラクト完了を通知することが多いですが、今回は割愛)

シーケンス図

AWS構成図

github レポジトリ

https://github.com/takupeso/async-nft


準備

Node.jsとnpmのインストール

Node.js, npm(推奨バージョン: LTS)をインストールしてください。

以下のコマンドでインストールされていることを確認してください。

node -v
npm -v

以下のようになっていたらOKです。

v23.0.0
11.0.0

Alchemyアカウントの作成とApp作成

AWSリソース構築フロー

ステップ 1: AWSアカウントのセットアップ

手順

  1. AWS公式サイトから無料アカウントを作成
  2. IAMユーザーを作成し、アクセスキーとシークレットキーを取得
    • 必要なポリシー: AdministratorAccess(一時的に使用)
  3. AWS公式サイトを参考にAWS CLIをインストール
  4. 以下のコマンドで設定:
    aws configure
    
    • IAMのアクセスキー、シークレットキー、リージョンを入力。

検証

以下のコマンドで認証情報が正しいことを確認:

aws sts get-caller-identity

以下のような表示になっていればOKです。

{
    "UserId": "XXX",
    "Account": "XXX",
    "Arn": "arn:aws:iam::XXX:user/iam名"
}

ステップ 2: プロジェクトディレクトリの作成

mkdir async-nft
cd async-nft

ステップ 3: NFTスマートコントラクト作成とデプロイ

contractsディレクトリ作成とHardhatインストール

contractsディレクトリを作成し、Hardhatをインストールします。

mkdir async-nft/contracts
cd async-nft/contracts
npm init -y
npm install --save-dev hardhat
npm install --save-dev @nomicfoundation/hardhat-toolbox

Hardhatプロジェクトを初期化

npx hardhat init

「Create a basic sample project」を選択し、指示に従ってセットアップしてください。

必要なライブラリをinstall

npm install @openzeppelin/contracts

Hardhat設定の更新

Sepolia Testnet用にhardhat.config.jsを編集します。

require("@nomicfoundation/hardhat-toolbox");

module.exports = {
  solidity: "0.8.28",
  networks: {
    sepolia: {
      url: "https://eth-sepolia.g.alchemy.com/v2/YOUR_ALCHEMY_PROJECT_ID",
      accounts: ["YOUR_PRIVATE_KEY"]
    }
  }
};

YOUR_ALCHEMY_PROJECT_ID: ALCHEMYで取得したプロジェクトID。
YOUR_PRIVATE_KEY: MetaMaskウォレットの秘密鍵。

不要なファイルを削除

rm YOUR_PATH/async-nft/contracts/contracts/Lock.sol
rm -rm YOUR_PATH/async-nft/contracts/ignition
rm -rf contracts/test

NFTスマートコントラクト作成

async-nft/contracts/contracts/NFT.sol

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

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract MyNFT is ERC721 {
    uint256 public tokenCounter;

    constructor() ERC721("MyNFT", "MNFT") {
        tokenCounter = 0;
    }

    function createNFT(address recipient) public returns (uint256) {
        uint256 newTokenId = tokenCounter;
        _safeMint(recipient, newTokenId);
        tokenCounter++;
        return newTokenId;
    }
}

NFTコントラクトについて詳しく知りたい方は、openzeppelin/contracts/token/ERC721/ERC721.solをコードリーディングすると勉強になります。

デプロイスクリプト作成

async-nft/contracts/scripts/deploy.js

async function main() {
  const [deployer] = await ethers.getSigners();
  console.log("Deploying contracts with the account:", deployer.address);

  const MyNFT = await ethers.getContractFactory("MyNFT");
  const myNFT = await MyNFT.deploy();

  await myNFT.waitForDeployment();

  console.log("Contract deployed to:", myNFT.target);
}

main()
  .then(() => process.exit(0))
  .catch(error => {
      console.error(error);
      process.exit(1);
  });

コントラクトのコンパイル

npx hardhat compile

コントラクトのデプロイ

npx hardhat run scripts/deploy.js --network sepolia

実行後以下のようにスマートコントラクトのアドレスが表示されるので、保管しておきます。
後のLambdaでのスマートコントラクト呼び出し設定の際に利用します。

Contract deployed to: YOUR_SMARTCONTRACT_ADDRESS

ステップ 4: Lambdaでスマートコントラクト呼び出すためのjsファイルを作成・圧縮

async-nft/lambda/index.js`を作成

const { Web3 } = require('web3');

exports.handler = async (event) => {
    try {
        // Web3インスタンス作成
        const web3 = new Web3(process.env.ALCHEMY_ENDPOINT);

        // コントラクト情報
        const contractAddress = process.env.CONTRACT_ADDRESS;
        const abi = JSON.parse(process.env.CONTRACT_ABI);
        const contract = new web3.eth.Contract(abi, contractAddress);

        console.log("Received event:", JSON.stringify(event, null, 2));

        // メッセージを取得
        const record = event.Records[0];
        const sqsMessage = JSON.parse(record.body);
        const messageData = JSON.parse(sqsMessage.Message);

        // トランザクションデータ
        const recipient = messageData.recipient;
        console.log("recipient:", recipient);
        const txData = contract.methods.createNFT(recipient).encodeABI();

        // トランザクション署名用アカウント情報
        const privateKey = `0x${process.env.WALLET_PRIVATE_KEY}`;
        const account = web3.eth.accounts.privateKeyToAccount(privateKey);

        // ガス関連情報取得
        const gasPrice = await web3.eth.getGasPrice();
        const estimatedGas = await contract.methods.createNFT(recipient).estimateGas({ from: account.address });

        // トランザクションオブジェクトの作成
        const tx = {
            from: account.address,
            to: contractAddress,
            gas: estimatedGas,
            gasPrice,
            data: txData,
        };

        // トランザクションに署名
        const signedTx = await web3.eth.accounts.signTransaction(tx, privateKey);

        // トランザクションを送信
        const receipt = await web3.eth.sendSignedTransaction(signedTx.rawTransaction);

        console.log('Transaction successful with hash:', receipt.transactionHash);
        
        return {
            statusCode: 200,
            body: JSON.stringify({ message: 'NFT successfully minted!', transactionHash: receipt.transactionHash }),
        };
    } catch (error) {
        console.error('Error minting NFT:', error);
        
        return {
            statusCode: 500,
            body: JSON.stringify({ message: 'Failed to mint NFT', error: error.message }),
        };
    }
};

async-nft/lambda/index.js`を圧縮

以下の手順でZIPファイルを作成します。

cd async-nft/lambda
npm install @aws-sdk/client-sns
npm install web3
zip -r lambda_function.zip index.js node_modules/

ステップ 5: LambdaでSNS呼び出すためのjsファイルを作成・圧縮

async-nft/lambda/sns_handler.js`を作成

const { SNS } = require('@aws-sdk/client-sns');
const sns = new SNS();

exports.handler = async (event) => {
    const body = JSON.parse(event.body);
    const message = {
        recipient : body.recipient,
        metadata : body.metadata,
    };

    await sns.publish({
        TopicArn : process.env.SNS_TOPIC_ARN,
        Message : JSON.stringify(message),
    });

    return {
        statusCode : 200,
        body : JSON.stringify({ message : 'Message sent to SNS successfully.' }),
    };
};

async-nft/lambda/sns_handler.js`を圧縮

以下の手順でZIPファイルを作成します。

zip gateway_to_sns_lambda.zip sns_handler.js node_modules

ステップ 6: CroudFormation用のテンプレートを作成する

AWS CloudFormationは、AWSが提供するInfrastructure as Code (IaC)サービスで、AWSリソースをコード化して自動的にプロビジョニング、管理することができます。

  1. LocalStackを利用してローカル上で実行環境を構築し、エンドポイントのcallからスマートコントラクトが実行されるかを検証
  2. AWS上で実行環境を構築し、エンドポイントのcallからスマートコントラクトが実行されるかを検証

async-nft/template.yml`を作成

AWSTemplateFormatVersion: "2010-09-09"
Description: Template for async NFT minting using AWS serverless architecture.

Resources:
  # NftLambdaFunction用のLambda実行ロール
  # このロールは、Lambda関数が必要なAWSリソースにアクセスするための権限を持っています。
  NftLambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: NftLambdaExecutionPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                  - sqs:ReceiveMessage
                  - sqs:DeleteMessage
                  - sqs:GetQueueAttributes
                  - s3:GetObject
                Resource: "*"

  # ApiGatewayToSNSLambdaFunction用のLambda実行ロール
  # このロールは、API GatewayからSNSにメッセージを送信するための権限を持っています。
  ApiGatewayToSNSLambdaExecutionRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: ApiGatewayToSNSLambdaExecutionPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                  - sns:Publish
                  - s3:GetObject
                Resource: "*"

  # NFTの作成を行うLambda関数
  # この関数は、SQSからメッセージを受け取り、NFTを作成します。
  NftLambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Handler: index.handler
      Runtime: nodejs18.x
      Code:
        S3Bucket: YOUR_BUCKET_NAME
        S3Key: lambda_function.zip
      MemorySize: 512
      Timeout: 300
      Role: !GetAtt NftLambdaExecutionRole.Arn
      Environment:
        Variables:
          ALCHEMY_ENDPOINT: "https://eth-sepolia.g.alchemy.com/v2/YOUR_ALCHEMY_PROJECT_ID"
          CONTRACT_ADDRESS: "YOUR_SMARTCONTRACT_ADDRESS"
          WALLET_PRIVATE_KEY: "YOUR_PRIVATE_KEY"
          CONTRACT_ABI: |
            [
              {
                "constant": false,
                "inputs": [{"name": "recipient", "type": "address"}],
                "name": "createNFT",
                "outputs": [{"name": "", "type": "uint256"}],
                "type": "function"
              }
            ]

  # API GatewayからSNSにメッセージを送信するLambda関数
  # この関数は、API Gatewayからのリクエストを受け取り、SNSにメッセージを送信します。
  ApiGatewayToSNSLambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Handler: sns_handler.handler
      Runtime: nodejs18.x
      Code:
        S3Bucket: YOUR_BUCKET_NAME
        S3Key: gateway_to_sns_lambda.zip
      MemorySize: 128
      Timeout: 300
      Role: !GetAtt ApiGatewayToSNSLambdaExecutionRole.Arn
      Environment:
        Variables:
          SNS_TOPIC_ARN: !Ref NftSnsTopic

  # SNSトピック
  # メッセージを受け取り、SQSキューに転送します。
  NftSnsTopic:
    Type: AWS::SNS::Topic
    Properties:
      TopicName: nft-sns-topic

  # SQSキュー
  # SNSからのメッセージを受け取り、Lambda関数がポーリングします。
  NftSqsQueue:
    Type: AWS::SQS::Queue
    Properties:
      QueueName: nft-sqs-queue
      VisibilityTimeout: 310

  # SNSからSQSへのサブスクリプション
  # SNSトピックからSQSキューへのメッセージ転送を設定します。
  NftSnsSubscription:
    Type: AWS::SNS::Subscription
    Properties:
      TopicArn: !Ref NftSnsTopic
      Protocol: sqs
      Endpoint: !GetAtt NftSqsQueue.Arn

  # SNSがSQSにメッセージを送信するためのポリシー
  # SNSトピックからSQSキューへのメッセージ送信を許可します。
  NftSqsPolicy:
    Type: AWS::SQS::QueuePolicy
    Properties:
      Queues:
        - !Ref NftSqsQueue
      PolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal: "*"
            Action: sqs:SendMessage
            Resource: !GetAtt NftSqsQueue.Arn
            Condition:
              ArnEquals:
                aws:SourceArn: !Ref NftSnsTopic

  # Lambda関数がSQSメッセージをポーリングするための設定
  # SQSキューからメッセージを取得し、Lambda関数をトリガーします。
  NftSQSEventSourceMapping:
    Type: AWS::Lambda::EventSourceMapping
    Properties:
      BatchSize: 10
      EventSourceArn: !GetAtt NftSqsQueue.Arn
      FunctionName: !GetAtt NftLambdaFunction.Arn

  # API Gatewayの設定
  # REST APIを作成し、Lambda関数をトリガーします。
  ApiGatewayResource:
    Type: AWS::ApiGateway::RestApi
    Properties:
      Name: NFT API Gateway

  # API Gatewayのデプロイメント
  # APIをデプロイし、特定のステージで利用可能にします。
  ApiGatewayDeployment:
    Type: AWS::ApiGateway::Deployment
    DependsOn:
      - ApiGatewayResourceMethodTriggerNFTPost
    Properties:
      RestApiId: !Ref ApiGatewayResource
      StageName: test

  # API Gatewayのリソースパス設定
  # 特定のパスでAPIを利用可能にします。
  ApiGatewayResourceTriggerNFTPath:
    Type: AWS::ApiGateway::Resource
    Properties:
      ParentId: !GetAtt ApiGatewayResource.RootResourceId
      PathPart: trigger-nft
      RestApiId: !Ref ApiGatewayResource

  # API Gatewayのメソッド設定
  # POSTメソッドを設定し、Lambda関数をトリガーします。
  ApiGatewayResourceMethodTriggerNFTPost:
    Type: AWS::ApiGateway::Method
    Properties:
      RestApiId: !Ref ApiGatewayResource
      ResourceId: !Ref ApiGatewayResourceTriggerNFTPath
      HttpMethod: POST
      AuthorizationType: NONE
      Integration:
        Type: AWS_PROXY # Lambda統合のためAWS_PROXYを使用します。
        IntegrationHttpMethod: POST
        Uri: !Sub arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${ApiGatewayToSNSLambdaFunction.Arn}/invocations

  # API GatewayがSNSにメッセージを送信するためのIAMロール
  # API GatewayがSNSにメッセージを送信する権限を持ちます。
  ApiGatewayRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: Allow
            Principal:
              Service: apigateway.amazonaws.com
            Action: sts:AssumeRole
      Policies:
        - PolicyName: ApiGatewayPublishToSNSPolicy
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - sns:Publish
                Resource: !Ref NftSnsTopic

  # API GatewayがLambda関数を呼び出すための権限
  # API GatewayがLambda関数をトリガーする権限を付与します。
  ApiGatewayToSNSLambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt ApiGatewayToSNSLambdaFunction.Arn
      Principal: apigateway.amazonaws.com

YOUR_BUCKET_NAME: グローバルで任意の名前
YOUR_ALCHEMY_PROJECT_ID: ALCHEMYで取得したプロジェクトID。
YOUR_PRIVATE_KEY: MetaMaskウォレットの秘密鍵。
YOUR_SMARTCONTRACT_ADDRESS: 上記で保存したスマートコントラクトのアドレス

ステップ 7: LocalStackを利用してローカル環境にデプロイし、検証

必要なライブラリをinstall

 brew install awscli-local

async-nft/docker-compose.ymlを作成

version: "3.8"
services:
  localstack:
    image: localstack/localstack
    container_name: localstack_main
    ports:
      - "4566:4566" # LocalStack Gateway Port
    environment:
      - SERVICES=cloudformation,lambda,sns,sqs,apigateway,iam,s3
      - DEBUG=1
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock"
      - ./init-s3.sh:/usr/local/bin/init-s3.sh
      - ./lambda/lambda_function.zip:/usr/local/bin/lambda_function.zip
      - ./lambda/gateway_to_sns_lambda.zip:/usr/local/bin/gateway_to_sns_lambda.zip
      - ./init-s3.sh:/etc/localstack/init/ready.d/init-s3.sh

async-nft/init-s3.shを作成

Container内のzipファイルをS3にアップロードするためのshellファイルを作成します。

#!/bin/bash

echo "Creating S3 bucket and uploading files to LocalStack..."

# S3バケットの作成
awslocal s3 mb s3://YOUR_BUCKET_NAME

# ファイルのアップロード
awslocal s3 cp /usr/local/bin/lambda_function.zip s3://YOUR_BUCKET_NAME/
awslocal s3 cp /usr/local/bin/gateway_to_sns_lambda.zip s3://YOUR_BUCKET_NAME/

Dockerコンテナーを起動

docker-compose up -d

Docker上でAWS環境を構築

以下のCloudFormationのコマンドを実行します。

awslocal cloudformation create-stack --stack-name my-stack --template-body file://template.yml --endpoint-url=http://localhost:4566

api idを取得

awslocal apigateway get-rest-apis

コマンドを実行すると以下が表示のように表示されます。

{
    "items": [
        {
            "id": "YOUR_API_ID",
            ...
        }
    ]
}

API gatewayのエンドポイントをcurlで呼び出し

curl -X POST -H "Content-Type: application/json"  -d '{"message": "NFT minting request", "recipient": "YOUR_WALLET_ADDRESS"}' http://localhost:4566/restapis/mqsiz0fik0/test/_user_request_/trigger-nft 

ログを確認

docker logs localstack_main
Transaction successful with hash: 0xADDRESS

etherscanで検索する

etherscanにて、トランザクションが作成されていることhashで検索することにより確認します。

ステップ 8: 本番環境環境にデプロイし、検証

必要なライブラリをinstall

brew install awscli

S3のbucketを作成し、zipファイルを配置

S3バケットの作成

  1. AWS Management Consoleにログインします。
  2. サービスメニューから「S3」を選択します。
  3. 「バケットを作成」をクリックします。
  4. 以下の情報を入力します:
    • バケット名: グローバルで一意の名前(例: my-unique-bucket-name)。
    • リージョン: データを保存するリージョン(例: ap-northeast-1)。
  5. 必要に応じて以下の設定を行います:
    • パブリックアクセス設定: デフォルトで「すべてのパブリックアクセスをブロック」が有効。
    • バージョニング: バージョン管理を有効化する場合はチェック。
  6. 設定内容を確認し、「バケットを作成」をクリックします。

zipファイルのアップロード

  1. 作成したバケット名をクリックして開きます。
  2. 「アップロード」ボタンをクリックします。
  3. lambda_function.zip, gateway_to_sns_lambda.zipをドラッグ&ドロップするか、「ファイルの追加」をクリックして選択します。
  4. 「アップロード」をクリックしてファイルをアップロードします。

本番でAWS環境を構築

aws cloudformation create-stack \
  --stack-name my-stack \
  --template-body file://template.yml \
  --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM

api idを取得

awslocal apigateway get-rest-apis

コマンドを実行すると以下が表示のように表示されます。

{
    "items": [
        {
            "id": "YOUR_API_ID",
            ...
        }
    ]
}

API gatewayのエンドポイントをcurlで呼び出し

curl -X POST \                                     
     https://YOUR_API_ID.execute-api.ap-northeast-1.amazonaws.com/test/trigger-nft \                   
     -H "Content-Type: application/json" \                                 
     -d '{         
           "recipient": "YOUR_WALLET_ADDRESS",
           "metadata": {
             "name": "My NFT",
             "description": "This is a test NFT"
           }
         }'

ログを確認

CloudWatch > ロググループ > /aws/lambda/my-stack-NftLambdaFunction-xxx > ログストリーム に遷移

ログイベントの中で以下のメッセージを探す

Transaction successful with hash: 0xADDRESS

etherscanで検索する

etherscanにて、トランザクションが作成されていることhashで検索することにより確認します。

ステップ 9: 本番環境環境を削除する

CloudFormationのmy-stockを削除する


ハンズオンは以上になります!
お疲れさまでした!

こちらのハンズオンが皆さんのスキルアップの一助になれば幸いです!

もっとスキルアップをしたいという方は、是非是非Komlockが運営している、
Komlock Discordにご参加ください!

Web3の最新情報についてやり取りをしたり、Web3イベントに関しての共有を行なっています。

また、Komlock labはWeb3の未来を共創していきたいメンバーを募集しています!!
気軽にDM等でお声がけください。

Komlock labの企業アカウント
https://x.com/komlocklab

PR記事とCEOの創業ブログ
https://prtimes.jp/main/html/rd/p/000000332.000041264.html
https://note.com/komlock_lab/n/n2e9437a91023

Komlock lab

Discussion

YutaYuta

2点気になったためコメントしておきます!

docker-compose up -dの前にchmod +x init-s3.shしておくと、パーミッションでつまづくことがなくなるかと思いました。

またcreate-stackしただけではAPIを叩くことはできず、awslocal apigateway create-deployment --rest-api-id YOUR_API_ID --stage-name testを実行してあげる必要があるかと思いました。

その他はスムーズにハンズオンできました!