AWSで実践!プライベートブロックチェーンでアプリをゼロから構築する

に公開

はじめに

テクノロジーセンター TechnicalForgeチームです!

前回の記事『ブロックチェーンの基礎を理解する』では、ブロックチェーンを支える基本的な仕組みを学びました。今回はその知識を活かし、クラウド(AWS EC2)上に自分だけのブロックチェーン環境を構築し、実際に手を動かしながらシンプルなアプリケーションをゼロから作り上げていきます。

理論から実践へとステップアップすることで、ブロックチェーン技術がどのように現実のアプリケーションに応用されるのか、その全体像を深く理解することができるでしょう。

この記事で学べること

このチュートリアルを終える頃には、以下のスキルが身についているはずです。

  1. AWS EC2上に、プライベートブロックチェーン(Ethereum)開発ネットワークを構築し、実行できるようになる。
  2. Go言語を使って、ブロックチェーンと安全に対話するためのバックエンドAPIを作成できるようになる。
  3. Androidネイティブアプリを開発し、ユーザーがブロックチェーンとやり取りするためのフロントエンドとして機能させられるようになる。
  4. これら3つのコンポーネントがどのように連携し1つのアプリケーションとして機能するのか、その全体像を把握できるようになる。

対象読者

  • ブロックチェーンの基本的な仕組み(ブロック、トランザクションなど)を理解している方
  • ブロックチェーン開発にはじめて挑戦するエンジニア
  • ブロックチェーンアプリの全体像を実践的に学びたい方

動作環境

  • OS: Windows 11
  • AWS(契約済み)

さあ、準備はいいですか?一緒にブロックチェーンアプリ開発の世界へ飛び込んでいきましょう!

目的 - なぜプライベートなEthereumでアプリケーションを作るのか?

まずはじめに、今回私たちが構築するアプリケーションの全体像と、なぜ「プライベートなEthereumブロックチェーン」という技術選択をするのかについて、初学者の方にも分かりやすく解説します。

今回作成するアプリケーション

今回作成するのは、「レコード登録DApp」です。これは、ユーザーが短いデータを自分たちだけのブロックチェーン上に記録できる、非常にシンプルなアプリケーションです。

この「記録する」という行為は、ブロックチェーン上でトランザクション(取引記録)を生成します。一度トランザクションがブロックに取り込まれると、そのデータは改ざんが極めて困難になり、誰がいつそのデータを記録したのかという「存在証明」が半永久的に保証されます。

このシンプルなアプリは、多くの企業向けブロックチェーンのユースケースの核心を突いています。

DAppとは

DAppは、Decentralized Application分散型アプリケーション)の略です。

これは、従来のアプリケーションのように特定の中央サーバーや管理者(企業や組織)に依存せず、ブロックチェーン技術を利用して分散型ネットワーク上で動作するアプリケーションの総称です。

なぜパブリックではなく「プライベートブロックチェーン」なのか?

ブロックチェーンには、ビットコインやイーサリアムのように誰でも参加できる「パブリックブロックチェーン」と、特定の管理者や組織だけが参加できる「プライベートブロックチェーン」があります。

特徴 パブリックブロックチェーン プライベートブロックチェーン
参加者 誰でも自由に参加可能 許可された人・組織のみ
取引速度 遅い 速い
手数料 高い(変動する) 安い、または不要
データの公開範囲 すべて公開 参加者のみ(制限可能)
主な用途 暗号資産、オープンなDApp 企業内のデータ管理、企業間取引

企業がビジネスでブロックチェーンを利用する場合、取引データを不特定多数に公開したくない、高速な処理が必要、といった理由から、プライベートブロックチェーンが選ばれることが多くあります。

今回のチュートリアルでは、学習目的でプライベートブロックチェーンを選択します。これにより、高額な手数料(ガス代)を気にすることなく、自分たちのペースで自由に開発とテストを行うことができます。

なぜ「Bitcoin」ではなく「Ethereum」なのか?

ビットコインとイーサリアムは、どちらも有名なブロックチェーンですが、その目的が大きく異なります。

  • ビットコイン: 主に「価値の保存」や「送金(決済)」といった、デジタル通貨としての機能に特化しています。
  • イーサリアム: 通貨としての機能に加え、「スマートコントラクト」というプログラムを実行できる「プラットフォーム」としての役割を持っています。

スマートコントラクトとは、「特定の条件が満たされたら、契約内容を自動的に実行する仕組み」のことです。この機能があるおかげで、イーサリアム上ではさまざまなDApp(分散型アプリケーション)を構築できます。

今回はDAppを構築することが目的なので、スマートコントラクトが利用できるイーサリアムを選択します。

システムアーキテクチャの全体像

私たちのDAppは、明確に分離された3つの層(レイヤー)で構成されます。これにより、各コンポーネントが自身の役割に集中でき、メンテナンスしやすく拡張性の高いシステムを構築できます。

レイヤー 技術 DAppにおける役割
フロントエンド層 Android (Android Studio, Kotlin) ユーザーがデータを作成し、ブロックチェーン上へ記録するための直感的なインターフェイスを提供します。
バックエンド層 Go (Gin Framework, go-ethereum) ブロックチェーンの複雑な操作を抽象化し、シンプルなAPIとしてフロントエンドに提供するラッパー。秘密鍵の管理も担当します。
ブロックチェーン層 プライベートEthereum (Geth + Prysm) on Kurtosis デジタル証明書を不変台帳に記録・検証するための分散型基盤。改ざん不可能な信頼の源となります。

プライベートブロックチェーンの作成 (Kurtosis on AWS EC2)

アプリケーションの心臓部であるブロックチェーンネットワークの構築から始めましょう。ここでは、AWS EC2上に環境を用意し、Kurtosisという強力なツールを使って、プライベートなEthereumネットワークを立ち上げます。

構築環境の準備 (AWS EC2)

まず、AWS上に仮想サーバー(EC2インスタンス)を用意します。

EC2インスタンスとは?
Amazon Web Services (AWS) が提供する仮想サーバーのことです。クラウド上に自分専用のコンピューターをレンタルするようなイメージで、OSやスペックを自由に選んで利用できます。

  1. EC2インスタンスの作成: AWSマネジメントコンソールにログインし、EC2サービスからインスタンスを起動します。今回 Amazon Linux 2023 AMIと、t3a.medium インスタンスを選択します。

  1. キーペアの作成/利用: 作成した環境へSSH接続するためのキーペアを作成します。すでに作成済みの場合は、ドロップダウンリストから対象のキーペアを選択します。

  2. セキュリティグループの設定: インスタンス作成後、インスタンスへの接続と後で作成するAPIサーバーのために、以下のポートを開放するインバウンドルールをセキュリティグループに設定します。

  • SSH (ポート 22): SSH接続のために、自分のIPアドレス(マイIP)からのみ接続を許可します。
  • HTTP (ポート 80): APIサーバーのために、自分のIPアドレス(マイIP)からのみ接続を許可します。
  1. インスタンスへの接続: インスタンス作成時に設定したキーペアをダウンロードし、SSHクライアント(ターミナルやPuTTYなど)を使ってインスタンスに接続します。
    ユーザー名は「ec2-user」です。

DockerとKurtosisのインストール

EC2インスタンスに接続したら、必要なツールをインストールします。

  1. Dockerのインストール:
    以下のコマンドでインストール・起動します。

    sudo yum update -y
    sudo yum install -y docker
    
    sudo systemctl enable docker
    sudo systemctl start docker
    
    sudo usermod -aG docker ec2-user
    

    usermodコマンド実行後、一度ログアウトして再接続すると、sudoなしでdockerコマンドを実行できるようになります。
    再ログイン後sudo無しで以下を実行し、バージョン情報が表示されれば正常に動作しています。

    docker version
    
  2. Kurtosis CLIのインストール:
    Kurtosis CLIをインストールします。

    echo '[kurtosis]
    name=Kurtosis
    baseurl=https://yum.fury.io/kurtosis-tech/
    enabled=1
    gpgcheck=0' | sudo tee /etc/yum.repos.d/kurtosis.repo
    
    sudo yum install kurtosis-cli -y
    


  3. Kurtosisエンジンの起動:
    Kurtosisは、CLIからの指示を受けて実際にコンテナーを管理する「エンジン」を持っています。以下のコマンドでエンジンを起動してください。

    kurtosis engine start
    

    初回起動時にメールアドレスの登録があります。
    メールアドレス入力中に「(Optional) Share your email address for occasional updates & outreach for prod」のメッセージが出ることがありますが、
    気にせずメールアドレスを最後まで入力し、エンターキーを押してください。

    イメージのダウンロードに少し時間がかかりますが、Kurtosis engine startedと表示されれば準備完了です。

なぜKurtosisなのか?

現在のEthereumは、トランザクションを実行する「実行層(EL)クライアント」(例: Geth)と、合意形成を担う「コンセンサス層(CL)クライアント」(例: Prysm)の2つを同時に実行する必要があります。この2つのクライアントを正しく設定し、連携させるのは非常に複雑な作業です。

Kurtosisは、このような複雑な分散システムの環境定義を「パッケージ」としてまとめ、単一のコマンドで再現性高く起動できるようにするツールです。特に、ethpandaopsチームが開発したethereum-packageを利用することで、私たちはインフラの複雑な設定作業から解放され、アプリケーション開発に集中できます。

ネットワーク定義ファイルの作成

次に、どのような構成のプライベートネットワークを起動するかを定義するファイルを作成します。

mkdir ~/privatenet
cd ~/privatenet
touch network_params.yaml

network_params.yamlに以下の内容を記述します。

# network_params.yaml

# ネットワークに参加するノードの構成を定義
participants:
  - # 実行レイヤー(EL)クライアントのタイプを指定
    el_type: geth
    # コンセンサスレイヤー(CL)クライアントのタイプを指定
    cl_type: prysm
    # この構成のノードをいくつ起動するかを指定
    count: 1
    el_extra_params:
      - "--http.addr=0.0.0.0" # RPCエンドポイントを全てのIPアドレスで待機させる
      - "--authrpc.addr=0.0.0.0" # Engine APIも全てのIPアドレスで待機させる
# ポート公開の設定
port_publisher:
  # KurtosisにパブリックIPを自動検出させる
  nat_exit_ip: "auto"
  el:
    enabled: true
    # 開始ポート番号を指定
    public_port_start: 32000
  cl:
    enabled: true
    # 開始ポート番号を指定
    public_port_start: 33000

# ブロックチェーン自体のグローバルなパラメータを定義
network_params:
  # ELのネットワークID(Chain ID)
  network_id: "3151908"
  # CLのスロット持続時間(秒)。テストを高速化するために短く設定可能
  seconds_per_slot: 24
  # ジェネシスブロックが生成されるまでの遅延時間(秒)
  genesis_delay: 20
  # バリデータキーを生成するためのニーモニックフレーズ
  # これにより、常に同じバリデータセットが再現可能になる
  # 注意:テスト目的以外ではこのデフォルト値を使用しないこと
  preregistered_validator_keys_mnemonic: "giant issue aisle success illegal bike spike question tent bar rely arctic volcano long crawl hungry vocal artwork sniff fantasy very lucky have athlete"
  # 1ノードあたりに割り当てるバリデータキーの数
  num_validator_keys_per_node: 64
  # Denebハードフォークがアクティベートされるエポックを指定
  deneb_fork_epoch: 0

# ネットワークがファイナリティに到達するまでKurtosisの実行を待機させる
# これにより、チェーンが正常に稼働していることを初期確認できる
wait_for_finalization: true

このファイルでは、「実行層(EL)クライアント」としてGethを、「コンセンサス層(CL)クライアント」としてPrysmを指定したノードを1つ起動するように設定しています。また、開発を高速化するためにポートの指定やブロック生成間隔等を調整しています。

ネットワークの起動と確認

定義ファイルが完成したら、いよいよネットワークを起動します。ターミナルで以下のコマンドを実行してください。

kurtosis run --enclave my-pos-chain github.com/ethpandaops/ethereum-package --args-file ~/privatenet/network_params.yaml

このコマンドは、my-pos-chainという名前の隔離された環境(エンクレーブ)内に、定義ファイルに基づいたEthereumネットワークを構築します。

処理が完了すると、起動したサービスの一覧が表示されます。その中で最も重要なのは、el-client-0というサービスのrpcポート情報です。

UUID Name Ports Status ...
el-1-geth-prysm ... ...
rpc: 8545/tcp -> 127.0.0.1:32003
...
動作確認が完了したら削除しておきましょう。
後ほど、すべての準備が整ったら再度ブロックチェーンを起動します。

ブロックチェーンが起動し続けるとディスク容量を使用し続けてしまい使用量が100%になってしまうことがあります。

kurtosis enclave stop my-pos-chain
kurtosis enclave rm my-pos-chain

ラッパーAPIの作成 (Go言語)

ブロックチェーンネットワークが稼働したので、次はこのネットワークと対話するためのバックエンド、つまり「ラッパーAPI」をEC2インスタンス上に作成します。

なぜラッパーAPIが必要なのか?

フロントエンドのAndroidアプリから直接ブロックチェーンに接続することも技術的には可能ですが、「セキュリティ」と「抽象化」という2つの重要な理由から、中間にAPIサーバーを挟むのが一般的です。

  1. セキュリティ: ブロックチェーンにデータを書き込むには、アカウントの秘密鍵で署名する必要があります。もしこの秘密鍵をAndroidアプリ内に保持してしまうと、アプリが解析された際に秘密鍵が漏洩し、資産が盗まれるなどの深刻なリスクに繋がります。ラッパーAPIを設けることで、秘密鍵をサーバーサイド(EC2上)で安全に管理できます。
  2. 抽象化: ブロックチェーンとの通信は、実はかなり複雑です。ラッパーAPIはこれらの複雑な処理をすべてバックエンドで実行し、フロントエンドには「データを記録する」というシンプルなHTTPリクエストのエンドポイントを提供するだけで済みます。これにより、フロントエンド開発者はブロックチェーンの仕組みを深く知らなくてもアプリを作ることができます。

Goプロジェクトのセットアップ

それでは、Go言語とWebフレームワークGinを使ってAPIサーバーを構築していきましょう。EC2インスタンス上で作業を続けます。

  1. Go言語のインストール:
    Go言語をインストールします。

    sudo yum install golang -y
    
  2. プロジェクトの初期化:

    mkdir ~/fullnode-api
    cd ~/fullnode-api
    go mod init fullnode-api
    
  3. 依存関係のインストール:

    go get -u github.com/gin-gonic/gin
    go get -u github.com/ethereum/go-ethereum
    

APIの実装

fullnode-apiディレクトリ内に「main.go」というファイルを作成し、以下のコードを記述します。

package main

import (
	"context"
	"crypto/ecdsa"
	"errors"
	"fmt"
	"log"
	"math/big"
	"net/http"

	"github.com/ethereum/go-ethereum"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/core/types"
	"github.com/ethereum/go-ethereum/crypto"
	"github.com/ethereum/go-ethereum/ethclient"
	"github.com/gin-gonic/gin"
)

// --- 設定セクション ---
const (
	rpcURL      = "http://127.0.0.1:32003"
	privateKeyA = "bcdf20249abf0ed6d944c0288fad489e33f66b3960d9e6229c1cd214ed3bbe31"
	privateKeyB = "39725efee3fb28614de3bacaffe4cc4bd8c436257e2c8bb887c4b5c4be45e76d"
)

// AppContext は、アプリケーション全体で共有される依存関係を保持します。
type AppContext struct {
	EthClient *ethclient.Client
	Accounts  map[string]*AccountInfo
}

// AccountInfo は、アカウントの秘密鍵とアドレスを保持します。
type AccountInfo struct {
	PrivateKey *ecdsa.PrivateKey
	Address    common.Address
}

// --- 構造体定義 ---

type TransactionRequest struct {
	From string `json:"from" binding:"required"`
	To   string `json:"to" binding:"required"`
	Data string `json:"data" binding:"required"`
}

type TransactionResponse struct {
	TxHash string `json:"txHash"`
}

type ErrorResponse struct {
	Error string `json:"error"`
}

// --- メイン関数とサーバーセットアップ ---

func main() {
	client, err := ethclient.Dial(rpcURL)
	if err != nil {
		log.Fatalf("Failed to connect to Ethereum client: %v", err)
	}

	accounts, err := setupAccounts()
	if err != nil {
		log.Fatalf("Failed to set up accounts: %v", err)
	}

	appCtx := &AppContext{
		EthClient: client,
		Accounts:  accounts,
	}

	router := gin.Default()
	router.POST("/transaction", appCtx.postTransactionHandler)
	router.GET("/transaction/:hash", appCtx.getTransactionHandler)

	fmt.Println("Server is running on port 80")
	if err := router.Run(":80"); err != nil {
		log.Fatalf("Failed to run server: %v", err)
	}
}

func setupAccounts() (map[string]*AccountInfo, error) {
	if privateKeyA == "YOUR_PRIVATE_KEY_A" || privateKeyB == "YOUR_PRIVATE_KEY_B" {
		return nil, errors.New("private keys are not set. Please replace placeholder values in the code")
	}

	pkA, addrA, err := accountFromPrivateKey(privateKeyA)
	if err != nil {
		return nil, fmt.Errorf("failed to process private key A: %w", err)
	}

	pkB, addrB, err := accountFromPrivateKey(privateKeyB)
	if err != nil {
		return nil, fmt.Errorf("failed to process private key B: %w", err)
	}

	log.Printf("Account A Address: %s", addrA.Hex())
	log.Printf("Account B Address: %s", addrB.Hex())

	return map[string]*AccountInfo{
		"A": {PrivateKey: pkA, Address: addrA},
		"B": {PrivateKey: pkB, Address: addrB},
	}, nil
}

func accountFromPrivateKey(pkHex string) (*ecdsa.PrivateKey, common.Address, error) {
	privateKey, err := crypto.HexToECDSA(pkHex)
	if err != nil {
		return nil, common.Address{}, err
	}

	publicKey := privateKey.Public()
	publicKeyECDSA, ok := publicKey.(*ecdsa.PublicKey)
	if !ok {
		return nil, common.Address{}, errors.New("error casting public key to ECDSA")
	}

	address := crypto.PubkeyToAddress(*publicKeyECDSA)
	return privateKey, address, nil
}

// --- APIハンドラ ---

func (app *AppContext) postTransactionHandler(c *gin.Context) {
	var req TransactionRequest
	if err := c.ShouldBindJSON(&req); err != nil {
		c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
		return
	}

	fromAccount, ok := app.Accounts[req.From]
	if !ok {
		c.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid 'from' account specified. Use 'A' or 'B'."})
		return
	}
	toAccount, ok := app.Accounts[req.To]
	if !ok {
		c.JSON(http.StatusBadRequest, ErrorResponse{Error: "Invalid 'to' account specified. Use 'A' or 'B'."})
		return
	}

	txHash, err := app.sendEthTransaction(fromAccount.PrivateKey, toAccount.Address, req.Data)
	if err != nil {
		c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
		return
	}

	c.JSON(http.StatusOK, TransactionResponse{TxHash: txHash})
}

// getTransactionHandler は /transaction/:hash へのGETリクエストを処理します。
func (app *AppContext) getTransactionHandler(c *gin.Context) {
	// URLからトランザクションハッシュを取得
	hashStr := c.Param("hash")
	txHash := common.HexToHash(hashStr)

	// 1. トランザクション自体の情報を取得
	tx, isPending, err := app.EthClient.TransactionByHash(context.Background(), txHash)
	if err != nil {
		if err == ethereum.NotFound {
			c.JSON(http.StatusNotFound, ErrorResponse{Error: "transaction not found"})
			return
		}
		c.JSON(http.StatusInternalServerError, ErrorResponse{Error: fmt.Sprintf("failed to fetch transaction: %v", err)})
		return
	}

	// 送信元アドレスを取得
	signer := types.NewEIP155Signer(tx.ChainId())
	from, err := types.Sender(signer, tx)
	if err != nil {
		c.JSON(http.StatusInternalServerError, ErrorResponse{Error: fmt.Sprintf("failed to derive sender: %v", err)})
		return
	}

	var toAddress string
	if tx.To() != nil {
		toAddress = tx.To().Hex()
	}

	// 2. トランザクションのレシート(実行結果)を取得
	receipt, err := app.EthClient.TransactionReceipt(context.Background(), txHash)
	// レシートが見つからない = まだブロックに取り込まれていない(ペンディング状態)
	if err == ethereum.NotFound {
		c.JSON(http.StatusOK, gin.H{
			"status":    "pending",
			"isPending": isPending,
			"txHash":    tx.Hash().Hex(),
			"from":      from.Hex(),
			"to":        toAddress,
			"nonce":     tx.Nonce(),
			"gasPrice":  tx.GasPrice().String(),
			"gasLimit":  tx.Gas(),
			"value":     tx.Value().String(),
			"data":      string(tx.Data()),
		})
		return
	}
	if err != nil {
		c.JSON(http.StatusInternalServerError, ErrorResponse{Error: fmt.Sprintf("failed to fetch transaction receipt: %v", err)})
		return
	}

	// 実行結果を判定 (1: 成功, 0: 失敗)
	var txStatus string
	if receipt.Status == types.ReceiptStatusSuccessful {
		txStatus = "success"
	} else {
		txStatus = "failed"
	}

	var contractAddress string
	if receipt.ContractAddress != (common.Address{}) {
		contractAddress = receipt.ContractAddress.Hex()
	}

	// すべての情報をまとめてJSONで返す
	c.JSON(http.StatusOK, gin.H{
		"status":          txStatus,
		"isPending":       isPending,
		"txHash":          tx.Hash().Hex(),
		"from":            from.Hex(),
		"to":              toAddress,
		"nonce":           tx.Nonce(),
		"gasPrice":        tx.GasPrice().String(),
		"gasLimit":        tx.Gas(),
		"value":           tx.Value().String(),
		"data":            string(tx.Data()),
		"blockNumber":     receipt.BlockNumber.String(),
		"blockHash":       receipt.BlockHash.Hex(),
		"gasUsed":         receipt.GasUsed,
		"contractAddress": contractAddress,
	})
}

// --- ブロックチェーンサービスロジック ---

func (app *AppContext) sendEthTransaction(privateKey *ecdsa.PrivateKey, toAddress common.Address, data string) (string, error) {
	fromAddress, err := getAddressFromPrivateKey(privateKey)
	if err != nil {
		return "", fmt.Errorf("could not get address from private key: %w", err)
	}

	nonce, err := app.EthClient.PendingNonceAt(context.Background(), fromAddress)
	if err != nil {
		return "", fmt.Errorf("failed to get nonce: %w", err)
	}

	gasPrice, err := app.EthClient.SuggestGasPrice(context.Background())
	if err != nil {
		return "", fmt.Errorf("failed to suggest gas price: %w", err)
	}

	gasLimit := uint64(30000)
	value := big.NewInt(0)
	txData := []byte(data)
	tx := types.NewTransaction(nonce, toAddress, value, gasLimit, gasPrice, txData)

	chainID, err := app.EthClient.NetworkID(context.Background())
	if err != nil {
		return "", fmt.Errorf("failed to get network ID: %w", err)
	}

	signedTx, err := types.SignTx(tx, types.NewEIP155Signer(chainID), privateKey)
	if err != nil {
		return "", fmt.Errorf("failed to sign transaction: %w", err)
	}

	err = app.EthClient.SendTransaction(context.Background(), signedTx)
	if err != nil {
		return "", fmt.Errorf("failed to send transaction: %w", err)
	}

	return signedTx.Hash().Hex(), nil
}

func getAddressFromPrivateKey(privateKey *ecdsa.PrivateKey) (common.Address, error) {
	publicKey := privateKey.Public()
	publicKeyECDSA, ok := publicKey.(*ecdsa.PublicKey)
	if !ok {
		return common.Address{}, errors.New("cannot assert type: publicKey is not of type *ecdsa.PublicKey")
	}
	return crypto.PubkeyToAddress(*publicKeyECDSA), nil
}

上記を実施することで、アカウントAからBへ(あるいはアカウントBからAへ)任意のデータを送信し、それをブロックチェーンへ記録するAPIが作成できました。

APIサーバーのビルドと起動確認

EC2インスタンスのターミナルで以下のコマンドを実行してAPIサーバーを起動します。

cd ~/fullnode-api
sudo go build -o myapp main.go


上記のような依存関係のエラーが発生した場合は、以下のコマンドを実施してください。

sudo go mod tidy

再度コマンドを実施します。

sudo go build -o myapp main.go
sudo setcap 'cap_net_bind_service=+ep' ~/fullnode-api/myapp
./myapp

「Listening and serving HTTP on :80」と表示されれば、APIサーバーの起動は成功です。

起動が確認できたら、EC2インスタンスのターミナルでCtrl+CでAPIサーバーを停止しておきましょう。

API疎通確認

EC2インスタンスのターミナルでブロックチェーンを起動します。

kurtosis run --enclave my-pos-chain github.com/ethpandaops/ethereum-package --args-file ~/privatenet/network_params.yaml

APIを起動します。

cd ~/fullnode-api
./myapp

PCからコマンドプロンプト等で、EC2インスタンスのパブリックIPに対して以下のAPIを実行します。

curl -X POST "http://[EC2インスタンスのパブリックIPV4アドレス]/transaction" -H "Content-Type: application/json" -d "{\"from\": \"B\", \"to\": \"A\", \"data\": \"Hello A from B!!\"}"

以下のような結果が返ってくれば、正常にブロックチェーン、APIが稼働しています。

{"txHash":"0x72565f956920ec8b1da4c5bbb7602d7a5ccffd07d0c33808412267a6497bf1db"}

上記確認が完了したら、EC2インスタンスのターミナルでAPIをCtrl+CでAPIを停止しておきます。

また、同様にブロックチェーンも削除しておきます。

kurtosis enclave stop my-pos-chain
kurtosis enclave rm my-pos-chain

Androidアプリの作成

システムの最後のピース、ユーザーが直接触れるフロントエンドのAndroidアプリを構築します。

なぜAndroid Studio (Kotlin, Jetpack Compose)なのか?

Androidをターゲットに、Kotlinによる簡潔で安全なモダンなコードベースを確立しつつ、Jetpack Composeによって効率的かつ宣言的にUI構築を実現することで、ネイティブアプリとしての高いパフォーマンスと機能性を確保するためです。

Android Studioのインストール

  1. Android Studioをダウンロード・インストールする。
    こちらをクリックし、Android Studioをダウンロードする(ファイルサイズ:約1.4G)

  1. 利用規約に同意する。

  2. ダウンロードしたexeファイルをクリックし、インストールを実施する。

  1. Android Studioを起動する。

プロジェクトの作成と起動

  1. Android Studioで「New Project」テンプレートを選択して新しいプロジェクトを作成します。

  1. Android -> Phone and Tablet -> Empty Activityを選択してNextをクリックする。

  2. 変更せずに、Finishをクリックする。

  3. app/src/main/AndroidManifest.xml
    に、以下を追加します。

    • インターネット接続の許可設定
    • applicationにHTTP通信を許可

+    <uses-permission android:name="android.permission.INTERNET" />

    <application
+       android:usesCleartextTraffic="true"
        android:allowBackup="true"
  1. app/build.gradle.kts
    に、依存関係を追加します。
dependencies {
	...既存の設定...

+    // HTTP通信用
+    implementation("com.squareup.retrofit2:retrofit:3.0.0")
+    implementation("com.squareup.retrofit2:converter-gson:3.0.0")

+    // コルーチン
+    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
+    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
}
  1. app/src/main/java/com/example/myapplication/MainActivity.kt
    に以下のコードを記述します。
package com.example.myapplication

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import com.example.myapplication.ui.theme.MyApplicationTheme
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Body
import retrofit2.http.POST

// データクラス
data class TransactionRequest(
    val from: String,
    val to: String,
    val data: String
)

data class TransactionResponse(
    val txHash: String
)

// Retrofit API インターフェース
interface BlockchainApi {
    @POST("transaction")
    suspend fun sendTransaction(@Body request: TransactionRequest): TransactionResponse
}

// Retrofit インスタンス
object RetrofitInstance {
    private const val BASE_URL = "http://[EC2インスタンスのパブリックIPv4アドレス]/"

    val api: BlockchainApi by lazy {
        Retrofit.Builder()
            .baseUrl(BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .build()
            .create(BlockchainApi::class.java)
    }
}

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MyApplicationTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    TransactionScreen()
                }
            }
        }
    }
}


@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionScreen() {
    var selectedDirection by remember { mutableStateOf("A To B") }
    var expandedDirection by remember { mutableStateOf(false) }
    val directions = listOf("A To B", "B To A")

    var dataValue by remember { mutableStateOf("Hello A from B!!") }
    var responseText by remember { mutableStateOf("") }
    var isLoading by remember { mutableStateOf(false) }
    var errorMessage by remember { mutableStateOf("") }

    // 選択された方向からFromとToを決定
    val (fromValue, toValue) = when (selectedDirection) {
        "A To B" -> Pair("A", "B")
        "B To A" -> Pair("B", "A")
        else -> Pair("A", "B")
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = "Blockchain Transaction",
            style = MaterialTheme.typography.headlineMedium,
            modifier = Modifier.padding(bottom = 24.dp)
        )

        // ドロップダウンリスト
        ExposedDropdownMenuBox(
            expanded = expandedDirection,
            onExpandedChange = { expandedDirection = !expandedDirection },
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 16.dp)
        ) {
            OutlinedTextField(
                value = selectedDirection,
                onValueChange = {},
                readOnly = true,
                label = { Text("Direction") },
                trailingIcon = {
                    ExposedDropdownMenuDefaults.TrailingIcon(expanded = expandedDirection)
                },
                colors = ExposedDropdownMenuDefaults.outlinedTextFieldColors(),
                modifier = Modifier
                    .menuAnchor(MenuAnchorType.PrimaryNotEditable, enabled = true)
                    .fillMaxWidth()
            )

            ExposedDropdownMenu(
                expanded = expandedDirection,
                onDismissRequest = { expandedDirection = false }
            ) {
                directions.forEach { direction ->
                    DropdownMenuItem(
                        text = { Text(direction) },
                        onClick = {
                            selectedDirection = direction
                            expandedDirection = false
                        }
                    )
                }
            }
        }

        OutlinedTextField(
            value = dataValue,
            onValueChange = { dataValue = it },
            label = { Text("Data") },
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 16.dp),
            minLines = 3
        )

        Button(
            onClick = {
                isLoading = true
                errorMessage = ""
                responseText = ""

                // コルーチンでHTTPリクエストを実行
                kotlinx.coroutines.GlobalScope.launch {
                    try {
                        val request = TransactionRequest(
                            from = fromValue,
                            to = toValue,
                            data = dataValue
                        )

                        val response = RetrofitInstance.api.sendTransaction(request)
                        responseText = "Success!\nTx Hash: ${response.txHash}"
                    } catch (e: Exception) {
                        errorMessage = "Error: ${e.message}"
                    } finally {
                        isLoading = false
                    }
                }
            },
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 16.dp),
            enabled = !isLoading
        ) {
            if (isLoading) {
                CircularProgressIndicator(
                    modifier = Modifier.size(24.dp),
                    color = MaterialTheme.colorScheme.onPrimary
                )
            } else {
                Text("Send Transaction")
            }
        }

        if (responseText.isNotEmpty()) {
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(top = 16.dp),
                colors = CardDefaults.cardColors(
                    containerColor = MaterialTheme.colorScheme.primaryContainer
                )
            ) {
                Text(
                    text = responseText,
                    modifier = Modifier.padding(16.dp),
                    style = MaterialTheme.typography.bodyLarge
                )
            }
        }

        if (errorMessage.isNotEmpty()) {
            Card(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(top = 16.dp),
                colors = CardDefaults.cardColors(
                    containerColor = MaterialTheme.colorScheme.errorContainer
                )
            ) {
                Text(
                    text = errorMessage,
                    modifier = Modifier.padding(16.dp),
                    style = MaterialTheme.typography.bodyLarge,
                    color = MaterialTheme.colorScheme.onErrorContainer
                )
            }
        }
    }
}

これでAndroidアプリの構築が完了です。

Androidアプリの起動設定(初回のみ設定が必要)

  1. Android StudioのDevice Managerを選択して、Add a new deviceを選択します。

  2. Create Virtual Deviceを選択します。

  3. Android端末を選択し、Nextをクリックします。

  4. 変更せず、Finishをクリックします。

  5. 端末情報をダウンロードします。

  6. Finishをクリック

  7. Startをクリックし、Virtual Deviceを起動します。

ブロックチェーンの起動

EC2インスタンスのターミナルで以下のコマンドを実行してプライベートブロックチェーンを起動します。

kurtosis run --enclave my-pos-chain github.com/ethpandaops/ethereum-package --args-file ~/privatenet/network_params.yaml

APIサーバーの起動

続いて、EC2インスタンスのターミナルで以下のコマンドを実行してAPIサーバーを起動します。

cd ~/fullnode-api
./myapp

「Listening and serving HTTP on :80」と表示されれば、APIサーバーの起動は成功です。

Androidアプリの起動

Androidアプリを実行し、「Send Transaction」ボタンを押すと、EC2上のAPIを経由してプライベートブロックチェーンにデータが記録され、結果のトランザクションハッシュが画面に表示されます。

まとめ

このチュートリアルでは、ブロックチェーンの基礎知識を元に、実際に動作する分散型アプリケーション(DApp)をゼロから構築する体験をしました。

  1. なぜプライベートEthereumか: 企業ユースケースや学習目的に適したプライベートチェーンの利点と、DApp開発基盤としてのEthereumの優位性を学びました。
  2. AWS EC2でのブロックチェーン環境構築: クラウド上に仮想サーバーを用意し、DockerとKurtosisを使って複雑なPost-Merge Ethereumネットワーク(Geth + Prysm)を、たった1つのコマンドで立ち上げました。
  3. Go言語によるラッパーAPI: ブロックチェーンの複雑な操作を隠蔽し、セキュリティと抽象化を提供する堅牢なバックエンドAPIを実装しました。
  4. モダンなAndroidアプリ開発: ユーザーフレンドリーなフロントエンドを構築しました。

上記3つの層が連携することで、1つのDAppが完成します。
この体験を通じて、ブロックチェーン技術が単なる理論ではなく、現実のアプリケーションを動かすための強力なツールであることを実感していただけたなら幸いです。


今回のDAppは出発点に過ぎません。ここからさらに学びを深めるための次のステップとして、以下のような挑戦をしてみてはいかがでしょうか。

  • スマートコントラクトの実装: Solidityで簡単なスマートコントラクトを書き、Go APIからそれを呼び出してみる。
  • APIの機能拡張: 記録したデータをトランザクションハッシュを使って読み出し、内容を表示するGETエンドポイントをAndoroid側から呼び出してみる。
  • ネットワーク構成の変更: network_params.yamlを編集して、Geth/Prysm以外のクライアントを試してみる。

ブロックチェーンとDApp開発の世界は広大で、常に進化し続けています。このチュートリアルが、皆さんの次なる一歩を踏み出すための確かな土台となることを願っています。

株式会社SCC - テクノロジーセンター

Discussion