👽

【Go/Cosmos SDK】application-specific blockchains を構築する

2023/02/02に公開

はじめに

今回は、Cosmos SDK を用いた blockchain application のハンズオンを作成してみました。

そもそも Cosmos とは、複数の blockchain が相互に連携可能な network (あるいは ecosystem 全体) です。
blockchain 同士が取引可能な仕組みを作ることで、blockchain 間の問題を解消することが大きな目的です。
構成要素となる代表的な特徴は以下です。

Tendermint

  • blockchain の network layer と consensus layer を汎用的にパッケージ化したOSS
  • Application Blockchain Interface (ABCI) という application layer との interface を備えている

Cosmos SDK

Tendermint 上で稼働する application の実装プロセスを簡素化するフレームワーク

Inter-Blockchain Communication Protocol (IBC)

  • blockchain 間の接続を行うための protocol
  • 異なるチェーンで token / data をやりとり可能にする

参考リンク

https://cosmos.network/
https://v1.cosmos.network/intro

概念理解は今回の記事のメインテーマではないので、ここまでにしておきます。
日本語でもわかりやすく説明してくれている記事があるので参照してみてください。

https://zenn.dev/kimurayu45z/books/abf4114858f7c35b775d/viewer/2950c0
https://academy.binance.com/ja/articles/what-is-cosmos-atom

Cosmos SDKTendermint 上で稼働する blockchain を構築するのですが、それらをさらに容易にする Ignite CLI をメインに使用して開発を進めていきます。
(他の Web 技術で言うと、Ruby on Rails に対する Rails Generator のようなツールです)

https://docs.ignite.com/

それでは始めてみましょう!

ハンズオン

設計イメージ

1 Coin の管理をする簡単な Pool を含んだ lending application をイメージして実装をしていきます

  • ユーザーアクション
    • deposit: coin を deposit → lpcoin を mint してもらえる
    • withdraw: coin を withdraw → lpcoin が burn される
  • 今回の Pool について
    • 1 pool は 1 種類の coin を管理している
    • pool ごとに account を生成し、それぞれの account で coin を管理する

初期化 & 起動してみる

ignite scaffold chain により、application-specific blockchains の雛形を生成できます。
実行後 ignite chain serve で起動確認をしてみましょう。

ignite scaffold chain github.com/(YOUR GITHUB ACCOUNT NAME)/handson
cd handson
ignite chain serve

下記のように表示されれば起動完了です!

🛠  Building proto...
📦 Installing dependencies...
🛠  Building the blockchain...
💿 Initializing the app...
🗂  Initialize accounts...
🙂 Added account alice with address cosmos1ggzrecvz4h8668wdljkyg7zmh7z7hdcn0j04s8 and mnemonic: grain must canyon enroll castle pledge advice fly conduct claim worth trigger save please lake chief syrup wealth luxury pet hope wealth neglect present
🙂 Added account bob with address cosmos1yaseq5cfwnw0c8mkgae8dtd4x8pj9lj2q64ujs and mnemonic: twelve verb champion fantasy enact social reduce blush primary spoon apple general lend boost loan coyote adapt glide cause draft humor bird dignity pottery
🌍 Tendermint node: http://0.0.0.0:26657
🌍 Blockchain API: http://0.0.0.0:1317
🌍 Token faucet: http://0.0.0.0:4500

deposit / withdraw

以下の流れで deposit, withdraw を実装してみます。

  1. deposit: 雛形となる transaction の作成
  2. deposit: 実装
  3. withdraw: 雛形となる transaction の作成
  4. withdraw: 実装
  5. 動作確認
  • 実装内容補足
    • 最初の実装では、あくまで Coin の移動のみにフォーカスします
    • 実装内容をシンプルにするために Pool の概念は持ち出しません

実装に入る前に、事前に認識しておくと理解しやすいポイントの説明をしておきます。

  1. deposit: 雛形となる transaction の作成
  2. withdraw: 雛形となる transaction の作成

そもそもここで出てきている transaction について説明をします

transaction

  • Cosmos SDK で構築される blockchain application に対して状態変更を要求するためのもの
  • 実際に application layer に渡すロジックの input となるデータはmessageという
    • transaction はこちらを内包している
  • 参考

実装/動作確認

  1. deposit: 雛形となる transaction の作成

ignite scaffold message により、transaction を受け付けて処理を行うためのテンプレートが生成されます。

  • 補足
    • amount:coinをコマンドに含める
      • 今回ユーザーが指定するためのパラメータにコインの情報を含めるため
    • レスポンス情報はなし
      • レスポンスデータに何らかフィールドを追加する場合は--responseを利用する
  • 参考

以下のコマンドを実行してみましょう。

ignite scaffold message deposit amount:coin

上記コマンドにより以下のようなファイルが生成されます

  • proto/(module_name)/handson/tx.proto: transaction の interface 定義
    • .protoで interface 定義は統一されている
    • 本ファイルにより、golang 用の x/(module_name)/types/tx.pb.go が生成される
  • x/(module_name)/keeper/msg_server_deposit.go: transaction の発行により動作するロジック
    • (transaction に限らず)ロジックは自分で実装する

これにより transaction を処理するためのテンプレートファイルは生成されました。
次ステップで実際にロジックを記述してみましょう。

  1. deposit: 実装

実装の解説前にここで出てくる Keeper について説明を入れておきます。

Keeper

ロジックは 前 step で作成された keeper パッケージで 1 transaction 専用のファイルに記述します。

以下にロジックを添付しますが、このロジックについて一部解説をします。

  • Cosmos SDK の sdk.Coin: Coin を定義するオブジェクト
    • sdk.NewCoin, sdk.NewCoins を利用して Coin オブジェクト自体を生成する
    • 後述の BankKeeper 利用時に用います
  • BankKeeper の function を利用して、Coin の移動を実現する
    • SendCoinsFromAccountToModule: 指定した Coins を Account から Module に transfer する
    • MintCoins: 指定した Coins を Module に mint する
    • SendCoinsFromModuleToAccount: 指定した Coins を Module から Account に transfer する
x/handson/keeper/msg_server_deposit.go
func (k msgServer) Deposit(goCtx context.Context, msg *types.MsgDeposit) (*types.MsgDepositResponse, error) {
  ctx := sdk.UnwrapSDKContext(goCtx)
  sender, _ := sdk.AccAddressFromBech32(msg.Creator)  

  // coin: account -> module
  err := k.bankKeeper.SendCoinsFromAccountToModule(ctx, sender, types.ModuleName, sdk.NewCoins(msg.Amount))
  if err != nil {
    return &types.MsgDepositResponse{}, err
  }

  // lp coin: module -> account
  lpCoin := sdk.NewCoin(fmt.Sprintf("share-%s", msg.Amount.Denom), msg.Amount.Amount)
  err = k.bankKeeper.MintCoins(ctx, types.ModuleName, sdk.NewCoins(lpCoin))
  if err != nil {
    return &types.MsgDepositResponse{}, err
  }
  err = k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, sender, sdk.NewCoins(lpCoin))
  if err != nil {
    return &types.MsgDepositResponse{}, err
  }

  return &types.MsgDepositResponse{}, nil
}

依存モジュールの解決

上記で bank という既に用意されている module を使用していますが、ある module から別 module の機能を call するためには、明示的に

を行う必要があります。以下は今回の場合の function の interface を宣言する例です

x/handson/types/expected_keepers.go
type BankKeeper interface {
  MintCoins(ctx sdk.Context, moduleName string, amt sdk.Coins) error
  SendCoinsFromAccountToModule(ctx sdk.Context, senderAddr sdk.AccAddress, recipientModule string, amt sdk.Coins) error
  SendCoinsFromModuleToAccount(ctx sdk.Context, senderModule string, recipientAddr sdk.AccAddress, amt sdk.Coins) error
}

参考: https://docs.ignite.com/guide/nameservice/keeper#buy-name

  1. withdraw: 雛形となる transaction の作成

(withdraw は deposit と考え方や実装フローは同じなので説明は極力割愛します)

deposit と interface は同様です

ignite scaffold message withdraw amount:coin
  1. withdraw: 実装

withdraw 特有部分について少し説明を入れておきます。

  • ユーザーから Lp Coin を受け取り、Burn します
    • 以下のような順番で処理をする必要がある
      1. User から Coin を Module に移動する
      • k.bankKeeper.SendCoinsFromAccountToModule
      1. Module で Coin を Burn する
      • k.bankKeeper.BurnCoins
    • (つまり User 側の Coin を Burn するということを1アクションではできません)
go
func (k msgServer) Withdraw(goCtx context.Context, msg *types.MsgWithdraw) (*types.MsgWithdrawResponse, error) {
	ctx := sdk.UnwrapSDKContext(goCtx)
	sender, _ := sdk.AccAddressFromBech32(msg.Creator)

	lpCoin := sdk.NewCoin(fmt.Sprintf("share-%s", msg.Amount.Denom), msg.Amount.Amount)
	err := k.bankKeeper.SendCoinsFromAccountToModule(ctx, sender, types.ModuleName, sdk.NewCoins(lpCoin))
	if err != nil {
		return &types.MsgWithdrawResponse{}, err
	}
	err = k.bankKeeper.BurnCoins(ctx, types.ModuleName, sdk.NewCoins(lpCoin))
	if err != nil {
		return &types.MsgWithdrawResponse{}, err
	}

	err = k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, sender, sdk.NewCoins(msg.Amount))
	if err != nil {
		return &types.MsgWithdrawResponse{}, err
	}

	return &types.MsgWithdrawResponse{}, nil
}
  1. 動作確認

(先に後述の"補足: module account の生成"の対応をお願いします。)

ignite cliを利用して、ローカルで動作させた blockchain をコールし動作確認してみましょう

ignite chain serve で blockchain を起動させると application module を含んだ binary が $(go env GOPATH)/bin に生成されます。
これを利用して実際に local で起動した blockchain と連携してみましょう。

MODULE_BIN=$(go env GOPATH)/bin/handsond
# 起動直後のユーザーの残高を確認
$MODULE_BIN q bank balances $($MODULE_BIN keys show alice -a) --output json | jq
{
  "balances": [
    {
      "denom": "stake",
      "amount": "100000000"
    },
    {
      "denom": "token",
      "amount": "20000"
    }
  ],
  ...
}

$MODULE_BIN q bank balances $($MODULE_BIN q auth module-accounts --output json | jq '.accounts[] | select(.name == "handson") | .base_account.address' | sed s/\"//g) --output json | jq
{
  "balances": [],
}

# deposit 実行
$MODULE_BIN tx handson deposit 5000stake --from alice
$MODULE_BIN tx handson deposit 300token --from alice
## 残高を確認
### user (alice)
{
  "balances": [
    {
      "denom": "share-stake",
      "amount": "5000"
    },
    {
      "denom": "share-token",
      "amount": "300"
    },
    {
      "denom": "stake",
      "amount": "99995000"
    },
    {
      "denom": "token",
      "amount": "19700"
    }
  ],
  ...
}
### module
{
  "balances": [
    {
      "denom": "stake",
      "amount": "5000"
    },
    {
      "denom": "token",
      "amount": "300"
    }
  ],
  ...
}

# withdraw
$MODULE_BIN tx handson withdraw 2000stake --from alice
$MODULE_BIN tx handson withdraw 150token --from alice
## 残高を確認
### user (alice)
{
  "balances": [
    {
      "denom": "share-stake",
      "amount": "3000"
    },
    {
      "denom": "share-token",
      "amount": "150"
    },
    {
      "denom": "stake",
      "amount": "99997000"
    },
    {
      "denom": "token",
      "amount": "19850"
    }
  ],
  ...
}
### module
{
  "balances": [
    {
      "denom": "stake",
      "amount": "3000"
    },
    {
      "denom": "token",
      "amount": "150"
    }
  ],
  ...
}

実装完了後のイメージは下記のリンクを参照ください。

topic/deposit and withdraw by linnefromice · Pull Request #1 · linnefromice/cosmos-sdk_hands-on

補足: module account の生成

transaction 生成の作業とは異なる部分だったため、流れで説明を加えられなかった部分についてこちらで補足を入れます。

Mint, Burn を行う際に、Module に紐づく Account に対して行う必要がありますが、プロジェクト初期生成時からそのまま利用していると、Module の Account が存在しないままになります。

以下を修正して、自分で実装中の Module に Account を追加してください。

app/app.go
	// module account permissions
	maccPerms = map[string][]string{
		authtypes.FeeCollectorName:     nil,
		distrtypes.ModuleName:          nil,
		icatypes.ModuleName:            nil,
		minttypes.ModuleName:           {authtypes.Minter},
		stakingtypes.BondedPoolName:    {authtypes.Burner, authtypes.Staking},
		stakingtypes.NotBondedPoolName: {authtypes.Burner, authtypes.Staking},
		govtypes.ModuleName:            {authtypes.Burner},
		ibctransfertypes.ModuleName:    {authtypes.Minter, authtypes.Burner},
+		handsonmoduletypes.ModuleName:  {authtypes.Minter, authtypes.Burner},
		// this line is used by starport scaffolding # stargate/app/maccPerms
	}

補足: Permission

https://docs.cosmos.network/main/modules/bank#permissions

Pool 作成

次の Step では 1 Asset を管理する Pool を実装していきます。

ここで初めて、application の storage を利用して blockchain application のデータを保存します。

  • Data 保存形式の種類
    • 1 Object: ignite scaffold single
    • List: ignite scaffold list
    • Map: ignite scaffold single

今回は、複数の Pool を利用するために、List での保存領域を確保しましょう。

  • ignite scaffold listを利用します
    • データの保存領域と同時に、そのデータに対する CRUD function も生成されます
  • 補足
    • --no-message
      • 更新するための function の生成は行わない
ignite scaffold list pool address:string denom:string is_active:bool deposited:uint borrowed:uint --no-message

次に、上記 Pool を生成するための transaction を生成します。

ignite scaffold message create_pool denom:string --response pool_id:uint

上記で生成された transaction に対して、Pool 生成のロジックを実装します。

  • list に add するようにデータを永続化します
    • (k msgServer).Append(ObjectName)
  • 上記処理で永続化した後に、返った Object には自動採番された Id が設定されています
x/handson/keeper/msg_server_create_pool.go
func (k msgServer) CreatePool(goCtx context.Context, msg *types.MsgCreatePool) (*types.MsgCreatePoolResponse, error) {
	ctx := sdk.UnwrapSDKContext(goCtx)

	pool := types.Pool{
		Address:   msg.Creator, // temp
		Denom:     msg.Denom,
		IsActive:  true,
		Deposited: 0,
		Borrowed:  0,
	}
	k.AppendPool(ctx, pool)

	return &types.MsgCreatePoolResponse{
		PoolId: pool.Id,
	}, nil
}

Deposit/Withdraw で Pool を利用する

この step では deposit/withdraw 時に pool を指定できるようにします。

  1. transaction の interface を更新
  • proto/(module_name)/handson/tx.proto を修正
  1. ignite cli を利用して自動生成コードを再生成
  • ignite generate proto-go
    • 上記により、*.pb.go が再生成されます
  1. keeper の更新
  • interface の更新に追従させ、deposit/withdraw のロジックを更新します

(deposit のみコードを展開しておきます)

x/handson/keeper/msg_server_deposit.go
func (k msgServer) Deposit(goCtx context.Context, msg *types.MsgDeposit) (*types.MsgDepositResponse, error) {
  ctx := sdk.UnwrapSDKContext(goCtx)
  sender, _ := sdk.AccAddressFromBech32(msg.Creator)  

+  pool, found := k.GetPool(ctx, msg.PoolId)
+  if !found {
+    return &types.MsgDepositResponse{}, sdkerrors.Wrap(sdkerrors.ErrKeyNotFound, fmt.Sprintf("key %d doesn't exist", msg.PoolId))
+  }
+  if msg.Amount.Denom != pool.Denom {
+    return &types.MsgDepositResponse{}, sdkerrors.Wrapf(types.ErrIncorrectDenom, "input: %s, supported: %s", msg.Amount.Denom, pool.Denom)
+  }

  // coin: account -> module
  err := k.bankKeeper.SendCoinsFromAccountToModule(ctx, sender, types.ModuleName, sdk.NewCoins(msg.Amount))
  if err != nil {
    return &types.MsgDepositResponse{}, err
  }

  // lp coin: module -> account
  lpCoin := sdk.NewCoin(fmt.Sprintf("share-%s", msg.Amount.Denom), msg.Amount.Amount)
  err = k.bankKeeper.MintCoins(ctx, types.ModuleName, sdk.NewCoins(lpCoin))
  if err != nil {
    return &types.MsgDepositResponse{}, err
  }
  err = k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, sender, sdk.NewCoins(lpCoin))
  if err != nil {
    return &types.MsgDepositResponse{}, err
  }

+ pool.Deposited += msg.Amount.Amount.Uint64()
+ k.SetPool(ctx, pool)

  return &types.MsgDepositResponse{}, nil
}

NOTE: 実装完了後のイメージ

https://github.com/linnefromice/cosmos-sdk_hands-on/pull/2

Pool ごとに Account を紐付けて Coin を管理する

Pool ごとに Account を生成する

  • Pool ごとに Account を作成して、その Account で Pool に預けられた資産を管理するようにする
    • 上記のようにすることで、同一 Asset を複数の Pool で扱えるようになリます
  • これを実現するために...?
    • Pool 作成時に Account を生成し紐づけることが必要
    • Deposit/Withdraw 時に Module ではなくて Pool 用の Account に対して操作するようにする
  • ロジック内で Account を生成するためには、auth module の利用が必要です
    • (先述のトピックを確認しつつ、依存関係の解決をしてみてください)
x/handson/keeper/msg_server_create_pool.go
  ctx := sdk.UnwrapSDKContext(goCtx)

+ base := fmt.Sprintf("%d-%s", k.GetPoolCount(ctx), msg.Denom)
+ accAddr := sdk.AccAddress(address.Module(types.ModuleName, []byte(base)))
+ accI := k.accountKeeper.NewAccount(
+   ctx,
+   authtypes.NewModuleAccount(
+     authtypes.NewBaseAccountWithAddress(accAddr),
+     accAddr.String(),
+   ),
+ )
+ k.accountKeeper.SetAccount(ctx, accI)

  pool := types.Pool{
    Address:   msg.Creator, // temp
    Denom:     msg.Denom,
    IsActive:  true,
    Deposited: 0,
    Borrowed:  0,
  }

Deposit/Withdraw で Module ではなく Pool の Account で Coin をやりとりする

Deposit/Withdraw にて、SendCoinsFromAccountToModule ではなく、SendCoins を利用して、個別で用意した Account を利用する

x/handson/keeper/msg_server_deposit.go
  pool, found := k.GetPool(ctx, msg.PoolId)
  ...
+ poolAddr, _ := sdk.AccAddressFromBech32(pool.Address)
+ err := k.bankKeeper.SendCoins(ctx, sender, poolAddr, sdk.NewCoins(msg.Amount))
  if err != nil {
    return &types.MsgDepositResponse{}, err
  }
x/handson/keeper/msg_server_withdraw.go
  err = k.bankKeeper.BurnCoins(ctx, types.ModuleName, sdk.NewCoins(lpCoin))
  if err != nil {
    return &types.MsgWithdrawResponse{}, err
  }
  
+ poolAddr, _ := sdk.AccAddressFromBech32(pool.Address)
+ err = k.bankKeeper.SendCoins(ctx, poolAddr, sender, sdk.NewCoins(msg.Amount))
  if err != nil {
    return &types.MsgWithdrawResponse{}, err
  }
  
  pool.Deposited -= msg.Amount.Amount.Uint64()

NOTE: 実装完了後のイメージ

https://github.com/linnefromice/cosmos-sdk_hands-on/pull/3

メインとなるロジックの実装はこちらで終わりです、お疲れ様でした!

その他

event

chain での状態更新を把握するために発行する event を Cosmos SDK でも発行することができます。

  • sdk に梱包されている event 用 logic を利用します
    • Event 発行: (sdk.Context).EventManager().EmitEvent
      • Event オブジェクト自体の生成: sdk.NewEvent
      • Event オブジェクトに含めるパラメータの生成: sdk.NewAttribute

Pool 作成で発行する event 例を載せておきます。

x/handson/types/keys.go
const (
  PoolEventId     = "id"
  PoolEventAmount = "amount"
  PoolEventDenom  = "denom"

  PoolCreatedEventType    = "pool-added"
  PoolDepositedEventType  = "pool-deposited"
  PoolWithdrawedEventType = "pool-withdrawed"
)
x/handson/keeper/msg_server_create_pool.go
k.AppendPool(ctx, pool)

+ ctx.EventManager().EmitEvent(
+   sdk.NewEvent(types.PoolDepositedEventType,
+     sdk.NewAttribute(types.PoolEventId, fmt.Sprint(pool.Id)),
+     sdk.NewAttribute(types.PoolEventDenom, fmt.Sprint(msg.Denom))),
+ )

return &types.MsgCreatePoolResponse{

query 作成

こちらでは 独自の Query を作成してみましょう

Pool ごとの Account で資産管理するようになったので、残高情報をも含めて取得できる Query を生成してみます

ignite scaffold query で Query のテンプレートを生成できます。

ignite scaffold type pool-balance pool_id:uint balance:coin
ignite scaffold query pool-balances --response balances:array.coin

補足ですが、現状任意のオブジェクトでの array は生成できないので、近い型で雛形生成しておいて手動生成します

https://github.com/ignite/cli/issues/3094

対応フローは以下のイメージです。

  1. Query の型定義を手動修正します: query.proto
  2. ignite generate proto-go を実行し、型定義に合わせファイルを再生成する
  3. Query のロジックを実装する
  • x/(module_name)/keeper/grpc_query_pool_balances.go に手動でロジックを追加する
    • Account に対する残高の取得は BankKeeper の GetBalance を利用する
  • x/(module_name)/types/expected_keepers.go に下記を追加する
    • GetBalance(ctx sdk.Context, addr sdk.AccAddress, denom string) sdk.Coin

実装内容は下記を参考にしてください。

https://github.com/linnefromice/cosmos-sdk_hands-on/pull/4

また下記のようにして、local で起動後に動作確認が可能です。

handson module に実装したので、(module binary) query handson (実装した Query) で実行可能です。

$MODULE_BIN query handson pool-balances
{
  "balances": [
    {
      "poolId": "0",
      "balance": {
        "denom": "stake",
        "amount": "500"
      }
    },
    {
      "poolId": "1",
      "balance": {
        "denom": "token",
        "amount": "100"
      }
    },
    {
      "poolId": "2",
      "balance": {
        "denom": "USDC",
        "amount": "0"
      }
    }
  ]
}

まとめ

また新しく別の言語での blockchain 開発の紹介として Cosmos SDK でのハンズオンをやってみました。
Cosmos 自体は聞いたことあるが、実装レベルでの日本語の記事もあまりなかったので、触ってみようかなという方の助けになれば幸いです。

以前 Move を紹介してみましたが、今回の Cosmos SDK は利用例は少ないまでも Tendermint, Cosmos SDK, Ignite CLI とメインのツールが非常によくできているのと、golang がベースになっているので非常に融通が効く、という印象でした。

是非 Solidity (EVM) や Move (Move VM) と比較してみて、こっちの面白さも知ってもらえると良いかなと思います!

最後までお読みいただきありがとうございました!

参考

https://docs.cosmos.network/main
https://docs.ignite.com/
https://tutorials.cosmos.network/

Discussion