【Go/Cosmos SDK】application-specific blockchains を構築する
はじめに
今回は、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 をやりとり可能にする
参考リンク
概念理解は今回の記事のメインテーマではないので、ここまでにしておきます。
日本語でもわかりやすく説明してくれている記事があるので参照してみてください。
Cosmos SDK
で Tendermint
上で稼働する blockchain を構築するのですが、それらをさらに容易にする Ignite CLI をメインに使用して開発を進めていきます。
(他の Web 技術で言うと、Ruby on Rails に対する Rails Generator のようなツールです)
それでは始めてみましょう!
ハンズオン
設計イメージ
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 を実装してみます。
- deposit: 雛形となる transaction の作成
- deposit: 実装
- withdraw: 雛形となる transaction の作成
- withdraw: 実装
- 動作確認
- 実装内容補足
- 最初の実装では、あくまで Coin の移動のみにフォーカスします
- 実装内容をシンプルにするために Pool の概念は持ち出しません
実装に入る前に、事前に認識しておくと理解しやすいポイントの説明をしておきます。
- deposit: 雛形となる transaction の作成
- withdraw: 雛形となる transaction の作成
そもそもここで出てきている transaction について説明をします
transaction
- Cosmos SDK で構築される blockchain application に対して状態変更を要求するためのもの
- 実際に application layer に渡すロジックの input となるデータはmessageという
- transaction はこちらを内包している
- 参考
実装/動作確認
- 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 を処理するためのテンプレートファイルは生成されました。
次ステップで実際にロジックを記述してみましょう。
- deposit: 実装
実装の解説前にここで出てくる Keeper
について説明を入れておきます。
Keeper
- Query, Transaction などを受けて処理をする application の logic となる実装をもつコンポーネント
- 1 Module に属するので、
ignite scafold message
によって該当ファイルが以下のように生成されるx/(module_name)/keeper/msg_server_(transaction_name).go
- 参考
ロジックは 前 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 する
-
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 するためには、明示的に
- 利用したい module の keeper を呼び出す module の初期化時に inject する
x/(module_name)/keeper/keeper.go
- 参考: update: prepare to use bank module in handson module · linnefromice/cosmos-sdk_hands-on@5482d4c
- 利用したい module の function の interface を宣言しておく
x/(module_name)/types/expected_keepers.go
を行う必要があります。以下は今回の場合の function の interface を宣言する例です
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
- withdraw: 雛形となる transaction の作成
(withdraw は deposit と考え方や実装フローは同じなので説明は極力割愛します)
deposit と interface は同様です
ignite scaffold message withdraw amount:coin
- withdraw: 実装
withdraw 特有部分について少し説明を入れておきます。
- ユーザーから Lp Coin を受け取り、Burn します
- 以下のような順番で処理をする必要がある
-
- User から Coin を Module に移動する
k.bankKeeper.SendCoinsFromAccountToModule
-
- Module で Coin を Burn する
k.bankKeeper.BurnCoins
- (つまり User 側の Coin を Burn するということを1アクションではできません)
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
}
- 動作確認
(先に後述の"補足: 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 を追加してください。
// 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
Pool 作成
次の Step では 1 Asset を管理する Pool を実装していきます。
ここで初めて、application の storage を利用して blockchain application のデータを保存します。
- Data 保存形式の種類
- 1 Object:
ignite scaffold single
- List:
ignite scaffold list
- Map:
ignite scaffold single
- 1 Object:
今回は、複数の 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 が設定されています
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 を指定できるようにします。
- transaction の interface を更新
-
proto/(module_name)/handson/tx.proto
を修正
- ignite cli を利用して自動生成コードを再生成
-
ignite generate proto-go
- 上記により、
*.pb.go
が再生成されます
- 上記により、
- keeper の更新
- interface の更新に追従させ、deposit/withdraw のロジックを更新します
(deposit のみコードを展開しておきます)
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: 実装完了後のイメージ
Pool ごとに Account を紐付けて Coin を管理する
Pool ごとに Account を生成する
- Pool ごとに Account を作成して、その Account で Pool に預けられた資産を管理するようにする
- 上記のようにすることで、同一 Asset を複数の Pool で扱えるようになリます
- これを実現するために...?
- Pool 作成時に Account を生成し紐づけることが必要
- Deposit/Withdraw 時に Module ではなくて Pool 用の Account に対して操作するようにする
- ロジック内で Account を生成するためには、
auth
module の利用が必要です- (先述のトピックを確認しつつ、依存関係の解決をしてみてください)
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 を利用する
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
}
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: 実装完了後のイメージ
メインとなるロジックの実装はこちらで終わりです、お疲れ様でした!
その他
event
chain での状態更新を把握するために発行する event を Cosmos SDK でも発行することができます。
- sdk に梱包されている event 用 logic を利用します
- Event 発行:
(sdk.Context).EventManager().EmitEvent
- Event オブジェクト自体の生成:
sdk.NewEvent
- Event オブジェクトに含めるパラメータの生成:
sdk.NewAttribute
- Event オブジェクト自体の生成:
- Event 発行:
Pool 作成で発行する event 例を載せておきます。
const (
PoolEventId = "id"
PoolEventAmount = "amount"
PoolEventDenom = "denom"
PoolCreatedEventType = "pool-added"
PoolDepositedEventType = "pool-deposited"
PoolWithdrawedEventType = "pool-withdrawed"
)
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 は生成できないので、近い型で雛形生成しておいて手動生成します
対応フローは以下のイメージです。
- Query の型定義を手動修正します:
query.proto
-
ignite generate proto-go
を実行し、型定義に合わせファイルを再生成する - Query のロジックを実装する
-
x/(module_name)/keeper/grpc_query_pool_balances.go
に手動でロジックを追加する- Account に対する残高の取得は BankKeeper の
GetBalance
を利用する
- Account に対する残高の取得は BankKeeper の
-
x/(module_name)/types/expected_keepers.go
に下記を追加するGetBalance(ctx sdk.Context, addr sdk.AccAddress, denom string) sdk.Coin
実装内容は下記を参考にしてください。
また下記のようにして、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) と比較してみて、こっちの面白さも知ってもらえると良いかなと思います!
最後までお読みいただきありがとうございました!
参考
Discussion