Cosmos SDK Introduction and First Step for Building Your Own Blockchain

Published: 2023-01-10

TL;DR

この記事では以下について解説します。

Cosmos SDK Overview

Cosmos SDKは、Proof-of-Stake(PoS)やProof-of-Authority(PoA)のブロックチェーンを開発するためのオープンソースフレームワークで、あるアプリケーションに特化したApplication-specific Blockchainを開発するこができます。

Application-specific Blockchainは、Cosmos SDKにおいて中心となる思想です。 Ethereumなどの汎用チェーンでdAppsを開発する場合、開発者は自身の開発するdAppのGovernanceの他に、そのdAppの基盤となるEthereumチェーン自体のGovernanceも考慮しなければなりません。(2 LayerのGovernance)
一方Application-specific BlockchainではBlockchainレイヤーからアプリケーションを開発できるため、1 LayerのGovernanceのみを考慮すれば良いことになります。

dApp同士のTokenの互換性はどうでしょうか?
EthereumではERC-20ERC-721というToken規格に従っていれば、dApps間でそれぞれのTokenを扱うことができ、同じチェーン上にdAppが存在しているためTransferなどの操作も可能です。
一方Application-specific Blockchainを含む異なるGovernanceのチェーン間のToken操作はBridgeが必用となり厄介です。 そこで生み出されたのが、 Inter-Blockchain Communication Protocol (IBC)です。 IBCは、2つのブロックチェーン間でPermissionlessに認証とデータ転送を処理するためのProtocolで、機能についてはInterchain Standard(ICS)で定められています。 例えばICS-20では、ERC-20のようなFungible Token Transferが定義されています。 Cosmos SDKを用いればIBCの実装も容易に可能です。

Architecture

次にApplication-specific Blockchainのアーキテクチャについて述べます。
アーキテクチャーはモジュラーに設計されており、以下のような構成になります。

Reference: Cosmos SDK Developer Portal - Introduction to Cosmos

Building the Blockchain

Ignite CLIを使って環境を作ってい行きます。 Ignite CLIはCosmos SDKを用いたBlockchainの開発環境です。 コマンド1つでBlockchainをScaffoldingできたり、Local環境での実行を行えたりします。

Blogを作成するシンプルなチュートリアルとなっています。 記事作成時点でのIgnite CLIのVersionは0.25.2です。
完成版のRepositoryはこちら: GitHub

Install Ignite CLI

まずIginite CLIをインストールします。

curl https://get.ignite.com/cli! | bash

macOSの場合は別途Permissionが必要なため以下の手順でインストールします。

sudo curl https://get.ignite.com/cli! | sudo bash

記事作成時点でのIgnite CLIのVersionは0.25.2です。
Versionを指定したい場合、以下のように指定することができます。

curl https://get.ignite.com/cli@v0.25.2! | bash

Create your blog chain

blogというChainをScaffoldingしていきます。
ignite scaffoldについてのドキュメントはこちら

ignite scaffold chain blog

するとblog/に以下のようなストラクチャのファイルが作成されます。
各フォルダの解説についてはこちらにあります。 主に編集するディレクトリはproto/x/になります

blog
|+ .git/
|+ .github/
|+ app/
|+ cmd/
|+ docs/
|- proto/
 |- blog/
  |- blog/
   |  genesis.proto
   |  params.proto
   |  query.proto
   |  tx.proto
|+ testutil/
|+ ts-client/
|+ vue/
|- x/
 |- blog/
  |+ client/
  |+ keeper/
  |+ simulation/
  |+ types/
  |  genesis.go
  |  genesis_test.go
  |  module.go
  |  module_simulation.go
|  .gitignore
|  config.yml
|  go.mod
|  go.sum
|  readme.md

Store Object

Blogの情報を保存する以下のようなStoreを作成します。

PostCount: ブログの記事数のCounter, uint64
StoredPost: ブログ記事のMap

PostCount

message PostCount {
  uint64 count = 1; 
}

Ignite CLIでScaffoldします。 このとき注意する点としては、コマンド実行前後にかならずCommitを行うようにしましょう。 Ignite CLIでは、ScaffoldをRevertするコマンドが用意されていないので、Gitで管理する必用があります。

ignite scaffold single postCount count:uint \
    --module blog \
    --no-message

単一のStoreを作成するときは、ignite scaffold single NAME [field]... を用います。
no-messageのフラグを省略すると、PostCount Objectを上書きするサービスも作成されていまします。 今回はアプリケーション内でコードを記述し制御したいため追加する必用があります。 また、ignite scaffold ではfieldのdefault typeはstringとなっているため:intで整数型を宣言します。 ignite scaffold singleについてのドキュメントはこちらです。

StoredPost

message StoredPost {
  string index = 1; 
  string title = 2; 
  string body = 3; 
}

同様にignite scaffold map NAME [field]... を実行します。
Indexの変数については--indexで指定可能です。今回はindexとしています。

ignite scaffold map storedPost title body \
  --index index \
  --module blog  \
  --no-message

ignite scaffold mapについてのドキュメントはこちらです。

次にPostCountのGenesis valueを設定します。 デフォルトでは、PostCountnullableに設定されているので修正します。

// GenesisState defines the blog module's genesis state.
message GenesisState {
  Params params = 1 [(gogoproto.nullable) = false];
  PostCount postCount = 2 [(gogoproto.nullable) = false];
  repeated StoredPost storedPostList = 3 [(gogoproto.nullable) = false];
  // this line is used by starport scaffolding # genesis/proto/state
}

protoファイルを変更したので、再コンパイルします。

ignite generate proto-go

次にGenesisでのValueを設定します。今回はPostCount.Count=0としています。

func DefaultGenesis() *GenesisState {
	return &GenesisState{
		PostCount: PostCount{
			Count: uint64(0),
		},
		StoredPostList: []StoredPost{},
		// this line is used by starport scaffolding # genesis/types/default
		Params: DefaultParams(),
	}
}

型の修正を行います。

// InitGenesis initializes the module's state from a provided genesis state.
func InitGenesis(ctx sdk.Context, k keeper.Keeper, genState types.GenesisState) {
	k.SetPostCount(ctx, genState.PostCount)
	// Set all the storedPost
	for _, elem := range genState.StoredPostList {
		k.SetStoredPost(ctx, elem)
	}
	// this line is used by starport scaffolding # genesis/module/init
	k.SetParams(ctx, genState.Params)
}
// ExportGenesis returns the module's exported genesis
func ExportGenesis(ctx sdk.Context, k keeper.Keeper) *types.GenesisState {
	genesis := types.DefaultGenesis()
	genesis.Params = k.GetParams(ctx)

	// Get all postCount
	postCount, found := k.GetPostCount(ctx)
	if found {
		genesis.PostCount = postCount
	}
	genesis.StoredPostList = k.GetAllStoredPost(ctx)
	// this line is used by starport scaffolding # genesis/module/export

	return genesis
}

その他テストなどの修正差分はこちらです。

Create a Message

Storeを変更するMessage作成します。 以下のコマンドでcreatePostというMessageを作成します。 --responseで返すfield名を指定できます。(Document

ignite scaffold message createPost title body \
  --module blog \
  --response postIndex

ignite scaffold messageを実行すると/x/[module]/keeper/msg_server_[name].goが作成されるので実装していきます。 (今回はmsg_server_create_post.go)

Keeperについて
Mutistore and Keepers
KeeperはModule内のすべてのStoreのアクセスを担います。 Module内で定義されたStoreは、Keeperで定義されたMethodによってのみ読み書きが行われます。 Module-View-Controller(MVC)で言うControllerの役割というとイメージが容易でしょう。

func (k msgServer) CreatePost(goCtx context.Context, msg *types.MsgCreatePost) (*types.MsgCreatePostResponse, error) {
	ctx := sdk.UnwrapSDKContext(goCtx)

  // Get postCount from store
	postCount, found := k.Keeper.GetPostCount(ctx)
	if !found {
		panic("postCount is not found")
	}

	newIndex := strconv.FormatUint(postCount.Count, 10)
	storedPost := types.StoredPost{
		Index: newIndex,
		Title: msg.Title,
		Body:  msg.Body,
	}

  // Store storedPost and postCount
	k.Keeper.SetStoredPost(ctx, storedPost)
	postCount.Count++
	k.Keeper.SetPostCount(ctx, postCount)

  // Return newIndex
	return &types.MsgCreatePostResponse{
		PostIndex: newIndex,
	}, nil
}

msg_server_create_post_test.goを作成し、テストを書いていきます。

package keeper_test

import (
	keepertest "blog/testutil/keeper"
	"blog/x/blog"
	"blog/x/blog/keeper"
	"blog/x/blog/types"
	"context"
	"testing"

	sdk "github.com/cosmos/cosmos-sdk/types"
	"github.com/stretchr/testify/require"
)

func setupMsgServerCreatePost(t testing.TB) (types.MsgServer, keeper.Keeper, context.Context) {
	k, ctx := keepertest.BlogKeeper(t)
	blog.InitGenesis(ctx, *k, *types.DefaultGenesis())
	return keeper.NewMsgServerImpl(*k), *k, sdk.WrapSDKContext(ctx)
}

func TestCreatePostSuccess(t *testing.T) {
	msgServer, _, context := setupMsgServerCreatePost(t)
	createResponse, err := msgServer.CreatePost(context, &types.MsgCreatePost{
		Title: "Test",
		Body:  "This is a test",
	})
	require.Nil(t, err)
	require.EqualValues(t, types.MsgCreatePostResponse{
		PostIndex: "0",
	}, *createResponse)
}

差分はこちら
テストを実行し、結果を確認します。

cd x/blog/keeper && go test

またローカル環境でチェーンを立ち上げて実行してみましょう。

ignite chain serve -r

create-postを実行します。
blogd tx blogでmessageを送ることができます。 実行可能なコマンドに関してはblogd tx blog -hで参照できます。

blogd tx blog create-post "Test" "This is a test"  --from alice

queryでpostがあるか確認します。
blogd q queryでqueryを送ることができます。 実行可能なコマンドに関してはblogd q blog -hで参照できます。

blogd q blog list-stored-post
pagination:
  next_key: null
  total: "0"
storedPost:
- body: This is a test
  index: "0"
  title: Test

Handle Errors

今回はCreatePostTitleBodyをValidateするコードを記述します。

エラー定義を記述します。

var (
	ErrSample           = sdkerrors.Register(ModuleName, 1100, "sample error")
	ErrMissingPostTitle = sdkerrors.Register(ModuleName, 1101, "title is missing")
	ErrMissingPostBody  = sdkerrors.Register(ModuleName, 1102, "body is missing")
)

x/blog/types/full_post.goを作成しValidateのコードを記述します。

package types

import "fmt"

func (storedPost StoredPost) GetPostTitle() (title string, err error) {
	if len(storedPost.Title) <= 0 {
		return title, ErrMissingPostTitle.Wrap(fmt.Sprintf("index = %s", storedPost.Index))
	}
	return title, nil
}

func (storedPost StoredPost) GetPostBody() (body string, err error) {
	if len(storedPost.Body) <= 0 {
		return body, ErrMissingPostBody.Wrap(fmt.Sprintf("index = %s", storedPost.Index))
	}
	return body, nil
}

func (storedPost StoredPost) Validate() (err error) {
	_, err = storedPost.GetPostTitle()
	if err != nil {
		return err
	}
	_, err = storedPost.GetPostBody()
	if err != nil {
		return err
	}
	return err
}

Validateのコードを追記します。

err := storedPost.Validate()
if err != nil {
  return nil, err
}

テストを追記します。

func TestCreatePostBadTitle(t *testing.T) {
	msgServer, _, context := setupMsgServerCreatePost(t)
	createResponse, err := msgServer.CreatePost(context, &types.MsgCreatePost{
		Title: "",
		Body:  "This is a test",
	})
	require.Nil(t, createResponse)
	require.EqualError(t, err, "index = 0: title is missing")
}

func TestCreatePostBadBody(t *testing.T) {
	msgServer, _, context := setupMsgServerCreatePost(t)
	createResponse, err := msgServer.CreatePost(context, &types.MsgCreatePost{
		Title: "Test",
		Body:  "",
	})
	require.Nil(t, createResponse)
	require.EqualError(t, err, "index = 0: body is missing")
}

テストを実行し、結果を確認します。

cd x/blog/keeper && go test

差分はこちら

Handle Events

Eventを発火させることで、Transaction logにEvent情報を残すことが可能です。 実際にnew-post-createdというEventを発火させてログを確認していきます。

Eventを定義します。

// CreatePost Events
const (
	PostCreatedEventType = "new-post-created"
	PostCreatedCreator   = "creator"
	PostCreatedPostIndex = "post-index"
	PostCreatedTitle     = "title"
	PostCreatedBody      = "body"
)

Storeに書き込んだあとEventを発火させます。

ctx.EventManager().EmitEvent(
		sdk.NewEvent(types.PostCreatedEventType,
			sdk.NewAttribute(types.PostCreatedCreator, msg.Creator),
			sdk.NewAttribute(types.PostCreatedPostIndex, newIndex),
			sdk.NewAttribute(types.PostCreatedTitle, msg.Title),
			sdk.NewAttribute(types.PostCreatedBody, msg.Body),
		),
	)

テストを記述していきます。

package testutil

const (
	Alice = "cosmos1jmjfq0tplp9tmx4v9uemw72y4d2wa5nr3xn9d3"
	Bob   = "cosmos1xyxs3skf3f4jfqeuv89yyaqvjc6lffavxqhc8g"
	Carol = "cosmos1e0w5t53nrq7p66fye6c8p0ynyhf6y24l4yuxd7"
)
func TestCreatePostEmitted(t *testing.T) {
	msgServer, _, context := setupMsgServerCreatePost(t)
	_, err := msgServer.CreatePost(context, &types.MsgCreatePost{
		Creator: testutil.Alice,
		Title:   "Test",
		Body:    "This is a test",
	})
	require.Nil(t, err)

	ctx := sdk.UnwrapSDKContext(context)
	require.NotNil(t, ctx)
	events := sdk.StringifyEvents(ctx.EventManager().ABCIEvents())
	require.Len(t, events, 1)
	event := events[0]
	require.EqualValues(t, sdk.StringEvent{
		Type: "new-post-created",
		Attributes: []sdk.Attribute{
			{Key: "creator", Value: testutil.Alice},
			{Key: "post-index", Value: "0"},
			{Key: "title", Value: "Test"},
			{Key: "body", Value: "This is a test"},
		},
	}, event)
}

差分はこちら

CLIで動作を確認します。

blogd tx blog create-post "Test" "This is a test"  --from alice
...
txhash: FB80FE1BF5A3FA7CBA588F9A7A064B72A1B29CFC0D8B16AB52180BFE19119CF9

txhashをqueryします。

blogd q tx FB80FE1BF5A3FA7CBA588F9A7A064B72A1B29CFC0D8B16AB52180BFE19119CF9 --output json | jq

Eventが発火していることを確認します。

...
  "logs": [
    {
      "msg_index": 0,
      "log": "",
      "events": [
        {
          "type": "message",
          "attributes": [
            {
              "key": "action",
              "value": "/blog.blog.MsgCreatePost"
            }
          ]
        },
        {
          "type": "new-post-created",
          "attributes": [
            {
              "key": "creator",
              "value": "cosmos1yf2aal544uf8n0ruecl3ytx066glfjjphskhdh"
            },
            {
              "key": "post-index",
              "value": "1"
            },
            {
              "key": "title",
              "value": "Test"
            },
            {
              "key": "body",
              "value": "This is a test"
            }
          ]
        }
      ]
    }
  ],
...

References