Tokenize Real-World Assets on Cosmos SDK

Build a compliance-enforced RWA tokenization module on a custom Cosmos blockchain.

Introduction

30 Second Summary

Real estate, private credit, and fund shares are moving onto blockchains. The catch is that these regulated assets can only be held by verified investors, so the blockchain itself needs to enforce compliance rules.

In this project, you will build a custom Cosmos SDK blockchain module that tokenizes real-world assets and enforces KYC compliance at the protocol layer.

What You'll Build

A custom blockchain called rwachain with an x/rwatoken module that handles asset issuance, investor whitelisting, and compliance validation.

By the end of this project, you'll have:

  • A scaffolded Cosmos SDK blockchain with custom Protobuf-defined data types for assets and investors.
  • Message handlers that enforce admin authorization and KYC verification before allowing asset issuance.
  • A running chain with genesis-configured admin and pre-whitelisted investors, testable via CLI commands.
  • Secret Mission: Implement compliance-checked asset transfers between KYC-verified investors.

Are there any prerequisites?

You need WSL (Windows Subsystem for Linux) already installed on your Windows machine. No prior Go or Cosmos SDK experience is required.

This guide walks you through installing Go and Ignite CLI from scratch inside your WSL environment.

Install Go and Ignite CLI in WSL

To build a custom blockchain module, you need two core tools installed in your development environment.

The first is Go, the programming language that Cosmos SDK and Ignite CLI are written in. The second is Ignite CLI itself, which scaffolds Cosmos SDK blockchains and handles code generation. In this step, you will install both inside your WSL environment.

In this step, get ready to:
  • Install Go 1.26.4 in WSL.
  • Install Ignite CLI v28 and confirm it is available on your PATH.
  • Verify both tools work together.
Install Go 1.26.4

Go is the language your entire blockchain will be written in. The Cosmos SDK framework and Ignite CLI both require a recent version of Go to compile code.

  • Check if Go is already installed by running this command in your WSL terminal:
go version

✔️ I see version 1.26 or higher

Go is already installed and up to date. You can skip ahead to the next substep.

ⓧ I see an older version

Your current Go installation is outdated. The install process below will replace it with 1.26.4.

  • Download the Go 1.26.4 tarball by running:
wget https://go.dev/dl/go1.26.4.linux-amd64.tar.gz
  • Remove your old Go installation and extract the new version by running:
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.26.4.linux-amd64.tar.gz
  • Add Go to your PATH by appending this line to your ~/.bashrc file:
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
  • Reload your shell configuration by running:
source ~/.bashrc
  • Verify the new version by running:
go version

You should see go version go1.26.4 linux/amd64 in the output.

  • Clean up the downloaded tarball by running:
rm go1.26.4.linux-amd64.tar.gz

ⓧ Command not found

Go is not installed yet. Follow the steps below to install it fresh.

  • Download the Go 1.26.4 tarball by running:
wget https://go.dev/dl/go1.26.4.linux-amd64.tar.gz
  • Extract Go to /usr/local by running:
sudo rm -rf /usr/local/go && sudo tar -C /usr/local -xzf go1.26.4.linux-amd64.tar.gz
  • Add Go to your PATH by appending this line to your ~/.bashrc file:
echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.bashrc
  • Reload your shell configuration by running:
source ~/.bashrc
  • Verify the installation by running:
go version

You should see go version go1.26.4 linux/amd64 in the output.

  • Clean up the downloaded tarball by running:
rm go1.26.4.linux-amd64.tar.gz

What does the install process do?

The rm -rf /usr/local/go part removes any existing Go installation so you get a clean slate. Then tar -C /usr/local -xzf extracts the new Go files into /usr/local/go.

The PATH export tells your shell where to find the go binary. Adding it to ~/.bashrc makes this permanent across terminal sessions.

Still seeing an old version or command not found?

Make sure the PATH line was added correctly. Run cat ~/.bashrc | grep go and check you see export PATH=$PATH:/usr/local/go/bin at the bottom.

If you forgot to run source ~/.bashrc, your current terminal session does not know about the new PATH. Run it again or close and reopen your terminal.

Help me troubleshoot my Go installation

Install Ignite CLI v28

Ignite CLI is the scaffolding tool that generates all the boilerplate for a Cosmos SDK blockchain. It creates your chain structure, wires up modules, and generates Go code from Protobuf definitions.

  • Check if Ignite CLI is already installed by running:
ignite version

✔️ I see version 28

Ignite CLI v28 is already installed. Skip ahead to the verification substep below.

ⓧ I see an older or newer version

You have a different version of Ignite installed. This project requires v28 specifically because it targets Cosmos SDK v0.50. You need to remove the existing version and install the correct one.

  • Remove your current Ignite installation by running:
sudo rm $(which ignite)
  • Install Ignite CLI v28 by running:
curl https://get.ignite.com/cli@v28.0.0 | bash

What does this command do?

This downloads the Ignite CLI installer script pinned to version v28.0.0 and runs it. The script downloads the correct binary for your system and places it in /usr/local/bin.

  • If you see a permissions error, move the binary manually by running:
sudo mv ignite /usr/local/bin/
  • Verify the installation by running:
ignite version

You should see version 28 in the output.

ⓧ Command not found

Ignite CLI is not installed yet. Follow the steps below to install it.

  • Install Ignite CLI v28 by running:
curl https://get.ignite.com/cli@v28.0.0 | bash

What does this command do?

This downloads the Ignite CLI installer script pinned to version v28.0.0 and runs it. The script downloads the correct binary for your system and places it in /usr/local/bin.

  • If you see a permissions error, move the binary manually by running:
sudo mv ignite /usr/local/bin/
  • Verify the installation by running:
ignite version

You should see version 28 in the output.

Why Ignite CLI v28 specifically?

Each Ignite CLI version targets a specific Cosmos SDK version. Version 28 is the native fit for Cosmos SDK v0.50, which this project uses.

Newer versions of Ignite default to Cosmos SDK v0.53 and may produce scaffolds that are incompatible with the code patterns in this project.

Seeing a permission denied error?

WSL environments sometimes lack write access to /usr/local/bin. If the install script fails, the ignite binary will be in your current directory instead.

Move it to the system path with sudo mv ignite /usr/local/bin/ and try ignite version again.

Help me fix my Ignite CLI installation

Verify the complete toolchain

With both tools installed, confirm everything works together before moving on.

  • Confirm Go is correctly installed by running:
go version
  • Confirm Ignite CLI is correctly installed by running:
ignite version
  • Confirm the ignite scaffold command is available by running:
ignite scaffold --help

You should see help text describing the scaffold subcommands. This confirms Ignite is properly linked and ready to generate blockchain code.

Ignite scaffold command not found?

If ignite version works but ignite scaffold --help does not, your installation may be corrupt. Remove the binary with sudo rm /usr/local/bin/ignite and reinstall using the curl command from the previous substep.

Help me troubleshoot Ignite CLI

Your development environment is ready. Next up, you will scaffold the blockchain itself and create the custom rwatoken module.

Scaffold the Chain and Module

Your Go and Ignite CLI toolchain is ready. Now it's time to use Ignite to generate the foundational code for your blockchain.

In this step, you'll scaffold a full Cosmos SDK chain called rwachain, add a custom module for real-world asset tokenization, and define the transaction messages your module will handle.

In this step, get ready to:
  • Scaffold a new Cosmos SDK blockchain with no default module.
  • Add a custom rwatoken module with an admin parameter.
  • Define two transaction messages for adding investors and issuing assets.
Scaffold the rwachain blockchain

Ignite CLI generates an entire blockchain project from a single command. You'll use the --no-module flag because you want to define your own custom module rather than using an auto-generated default.

  • Navigate to your home directory by running this command:
cd ~
  • Scaffold a new blockchain called rwachain by running this command:
ignite scaffold chain rwachain --no-module

What does this command do?

The ignite scaffold chain command creates a new directory called rwachain containing a fully functional Cosmos SDK blockchain.

The --no-module flag tells Ignite to skip creating a default module. This gives you a clean slate to add your own rwatoken module with custom logic in the next substep.

  • Once the scaffold completes, move into the new project directory by running:
cd rwachain
  • Explore the generated directory structure by running:
ls

You should see directories including app/, cmd/, proto/, and x/ listed in the output.

What are these directories?

  • app/ wires the blockchain together. The key file app.go defines the blockchain type and initializes all modules.
  • cmd/ contains the CLI binary for running the blockchain node and sending transactions.
  • proto/ holds Protocol Buffer files that define your blockchain's data structures and RPC endpoints.
  • x/ is where custom modules live. This is currently empty because you used --no-module.

Don't see the directories?

Make sure the ignite scaffold chain command completed without errors. If you see a permission error, ensure you ran cd ~ first to move to your home directory.

If the command hangs, check that your internet connection is active. Ignite downloads Go module dependencies during scaffolding.

Why did my ignite scaffold chain command fail?

Scaffold the rwatoken module with params

Now you'll add a custom module called rwatoken to your chain. This module will handle all the real-world asset tokenization logic. The --params admin flag creates a configurable admin parameter that controls who can issue assets and manage investors.

  • Scaffold the rwatoken module by running this command:
ignite scaffold module rwatoken --params admin

What does this command do?

This creates an x/rwatoken/ directory with the full module skeleton: a keeper for state management, a types package for data structures, and module wiring code.

The --params admin flag adds a string parameter called admin to the module's configuration. You'll use this later to control which address can perform privileged operations like adding investors and issuing assets.

  • Verify the module was wired into the app by running:
ls x/rwatoken/

You should see directories like keeper/, types/, and module/ inside the rwatoken folder.

How does Ignite wire the module?

Ignite automatically registers the module in app/app.go. This means your blockchain knows about rwatoken and will initialize it when the chain starts. You don't need to manually edit app.go for this.

Seeing a module scaffold error?

Confirm you are inside the rwachain directory. Run pwd to check. The output should end with /rwachain.

If you see an error about the module already existing, it means you may have run the command twice. Delete the module with rm -rf x/rwatoken and try again.

Help me fix my ignite scaffold module error

Scaffold the messages

Your module needs transaction messages to define what actions users can perform on-chain. You'll scaffold two messages: one for adding investors to the KYC whitelist, and one for issuing tokenized assets.

  • Scaffold the MsgAddInvestor message by running this command:
ignite scaffold message add-investor investorAddress kyc:bool jurisdiction --module rwatoken

What fields does MsgAddInvestor have?

  • investorAddress is the blockchain address of the investor being added.
  • kyc:bool indicates whether the investor has passed KYC verification. The :bool suffix tells Ignite this is a boolean field.
  • jurisdiction is a string identifying the investor's legal jurisdiction (defaults to string type).
  • Scaffold the MsgIssueAsset message by running this command:
ignite scaffold message issue-asset name ticker assetType recipientAddress amount:uint legalDocHash --module rwatoken

What fields does MsgIssueAsset have?

  • name is the human-readable name of the asset (e.g., "Manhattan Office REIT").
  • ticker is a short symbol for the asset (e.g., "MNHT").
  • assetType categorizes the asset (e.g., "real_estate", "private_credit").
  • recipientAddress is the whitelisted investor who will receive the issued tokens.
  • amount:uint is the number of tokens to issue. The :uint suffix makes this an unsigned integer.
  • legalDocHash is a hash linking the token to its off-chain legal documentation.
  • Verify the proto files were generated by running:
ls proto/rwachain/rwatoken/

You should see tx.proto listed among the files. This file contains the Protobuf definitions for both MsgAddInvestor and MsgIssueAsset.

Why Protobuf for messages?

Cosmos SDK uses Protocol Buffers (Protobuf) to define all data structures and transaction messages. Protobuf generates type-safe Go code from your .proto files, ensuring that every transaction is validated against a strict schema before it reaches your handler logic.

Don't see tx.proto?

Check that both scaffold message commands completed without errors. Each command prints a success message listing the files it modified.

If you see an error about a missing module, make sure you ran the ignite scaffold module rwatoken --params admin command first. The module must exist before you can add messages to it.

Help me debug my scaffold message error

Your chain skeleton is in place with a custom module and two transaction messages defined. Next up, you'll define custom Protobuf types for the Asset and Investor data structures that your module will store on-chain.

Define Custom Protobuf Types

In the previous step, you scaffolded the rwatoken module and its transaction messages. Now it's time to define the actual data structures your module will store on-chain.

Protocol Buffers (Protobuf) are the serialization format Cosmos SDK uses for all data. You define your types in .proto files, then generate Go structs from them. This step creates the Asset and Investor types that represent tokenized assets and verified investors, and adds query endpoints so external clients can read that data.

In this step, get ready to:
  • Define custom Protobuf messages for assets and investors.
  • Add query service RPCs so clients can list and fetch on-chain data.
  • Regenerate Go code from the updated proto files.
Define the Asset and Investor types

Your module needs two core data types. The Asset type represents a tokenized real-world asset with metadata like name, ticker, and compliance status. The Investor type represents a KYC-verified address that is allowed to hold assets.

  • Create the Asset proto file by running this command:
cat > proto/rwachain/rwatoken/asset.proto << 'EOF'
syntax = "proto3";
package rwachain.rwatoken;

option go_package = "rwachain/x/rwatoken/types";

// Asset represents a tokenized real-world asset
message Asset {
  string id = 1;
  string name = 2;
  string ticker = 3;
  string asset_type = 4;
  string issued_to = 5;
  uint64 amount = 6;
  string legal_doc_hash = 7;
  string status = 8;
}
EOF

What does this proto file define?

  • id is the unique identifier for each asset on-chain.
  • asset_type categorizes the asset (e.g. real_estate, private_credit, fund_share).
  • issued_to stores the Cosmos address of the investor who holds this asset.
  • legal_doc_hash is a hash linking to the off-chain legal documentation.
  • status tracks the lifecycle state (e.g. active, redeemed, frozen).
  • Confirm the file was created by running:
ls proto/rwachain/rwatoken/asset.proto

You should see the file path printed back to you.

File not found?

Make sure you are in the ~/rwachain directory. Run cd ~/rwachain and try the command again.

help me create the asset.proto file in the right location

  • Create the Investor proto file by running this command:
cat > proto/rwachain/rwatoken/investor.proto << 'EOF'
syntax = "proto3";
package rwachain.rwatoken;

option go_package = "rwachain/x/rwatoken/types";

// Investor represents a KYC-verified investor on the network
message Investor {
  string address = 1;
  bool kyc = 2;
  string jurisdiction = 3;
}
EOF

What does the Investor message represent?

  • address is the Cosmos SDK account address of the investor.
  • kyc is a boolean flag indicating whether the investor has passed Know Your Customer verification.
  • jurisdiction records the regulatory jurisdiction the investor falls under (e.g. US, EU, SG).
  • Confirm both proto files now exist by running:
ls proto/rwachain/rwatoken/asset.proto proto/rwachain/rwatoken/investor.proto

You should see both file paths printed.

Define query service endpoints

Ignite already scaffolded a query.proto file with a Params RPC for your module. You need to add three more RPCs so external clients can list all assets, fetch a single asset by ID, and list all investors.

  • Replace the contents of the existing query proto file by running this command:
cat > proto/rwachain/rwatoken/query.proto << 'EOF'
syntax = "proto3";
package rwachain.rwatoken;

import "amino/amino.proto";
import "gogoproto/gogo.proto";
import "google/api/annotations.proto";
import "cosmos/base/query/v1beta1/pagination.proto";
import "rwachain/rwatoken/params.proto";
import "rwachain/rwatoken/asset.proto";
import "rwachain/rwatoken/investor.proto";

option go_package = "rwachain/x/rwatoken/types";

// Query defines the gRPC querier service for the rwatoken module
service Query {
  // Params returns the module parameters
  rpc Params(QueryParamsRequest) returns (QueryParamsResponse) {
    option (google.api.http).get = "/rwachain/rwatoken/params";
  }

  // ListAssets returns all tokenized assets
  rpc ListAssets(QueryListAssetsRequest) returns (QueryListAssetsResponse) {
    option (google.api.http).get = "/rwachain/rwatoken/assets";
  }

  // GetAsset returns a single asset by ID
  rpc GetAsset(QueryGetAssetRequest) returns (QueryGetAssetResponse) {
    option (google.api.http).get = "/rwachain/rwatoken/asset/{id}";
  }

  // ListInvestors returns all registered investors
  rpc ListInvestors(QueryListInvestorsRequest) returns (QueryListInvestorsResponse) {
    option (google.api.http).get = "/rwachain/rwatoken/investors";
  }
}

// QueryParamsRequest is the request type for the Query/Params RPC method
message QueryParamsRequest {}

// QueryParamsResponse is the response type for the Query/Params RPC method
message QueryParamsResponse {
  Params params = 1 [(gogoproto.nullable) = false, (amino.dont_omitempty) = true];
}

// QueryListAssetsRequest is the request type for ListAssets
message QueryListAssetsRequest {}

// QueryListAssetsResponse is the response type for ListAssets
message QueryListAssetsResponse {
  repeated Asset assets = 1;
}

// QueryGetAssetRequest is the request type for GetAsset
message QueryGetAssetRequest {
  string id = 1;
}

// QueryGetAssetResponse is the response type for GetAsset
message QueryGetAssetResponse {
  Asset asset = 1;
}

// QueryListInvestorsRequest is the request type for ListInvestors
message QueryListInvestorsRequest {}

// QueryListInvestorsResponse is the response type for ListInvestors
message QueryListInvestorsResponse {
  repeated Investor investors = 1;
}
EOF

What are these query RPCs for?

Each RPC defines a read-only endpoint that clients can call to inspect blockchain state without submitting a transaction.

  • ListAssets returns every asset stored in the module's state.
  • GetAsset fetches a single asset by its unique ID.
  • ListInvestors returns all registered investors so you can verify who is whitelisted.
  • The google.api.http annotations define REST endpoints that map to each gRPC call.
  • Verify the query proto file was written correctly by checking its first few lines:
head -5 proto/rwachain/rwatoken/query.proto

You should see syntax = "proto3"; and package rwachain.rwatoken; in the output.

Seeing unexpected content in query.proto?

If the file still shows old content, the heredoc may not have executed properly. Make sure you copied the entire command including the closing EOF on its own line.

help me fix my query.proto file

Regenerate Go code from protos

Proto files are just definitions. To use them in your Go code, you need to compile them into Go structs and interfaces. Ignite CLI provides a command that handles this compilation for you.

  • Generate Go source code from all your proto files by running:
ignite generate proto-go

What does this command do?

The ignite generate proto-go command compiles all .proto files in your proto/ directory into Go source code. It produces .pb.go files containing Go structs, serialization methods, and gRPC interfaces that your keeper will implement later.

  • Verify the generated files exist by running:
ls x/rwatoken/types/*.pb.go

You should see files including asset.pb.go, investor.pb.go, and query.pb.go in the output.

Proto generation failed?

If you see errors about missing imports, make sure your asset.proto and investor.proto files are in the correct directory: proto/rwachain/rwatoken/.

If you see a syntax error, double-check that each proto file starts with syntax = "proto3"; and that the package line reads rwachain.rwatoken.

help me fix my proto generation errors

✔️ Awesome, I've got everything!

Great. Make sure you see asset.pb.go, investor.pb.go, and query.pb.go listed in the output.

ⓧ I'd like to double check the full code

Here are the three proto files you should have at this point.

syntax = "proto3";
package rwachain.rwatoken;

option go_package = "rwachain/x/rwatoken/types";

// Asset represents a tokenized real-world asset
message Asset {
  string id = 1;
  string name = 2;
  string ticker = 3;
  string asset_type = 4;
  string issued_to = 5;
  uint64 amount = 6;
  string legal_doc_hash = 7;
  string status = 8;
}
syntax = "proto3";
package rwachain.rwatoken;

option go_package = "rwachain/x/rwatoken/types";

// Investor represents a KYC-verified investor on the network
message Investor {
  string address = 1;
  bool kyc = 2;
  string jurisdiction = 3;
}
syntax = "proto3";
package rwachain.rwatoken;

import "amino/amino.proto";
import "gogoproto/gogo.proto";
import "google/api/annotations.proto";
import "cosmos/base/query/v1beta1/pagination.proto";
import "rwachain/rwatoken/params.proto";
import "rwachain/rwatoken/asset.proto";
import "rwachain/rwatoken/investor.proto";

option go_package = "rwachain/x/rwatoken/types";

// Query defines the gRPC querier service for the rwatoken module
service Query {
  // Params returns the module parameters
  rpc Params(QueryParamsRequest) returns (QueryParamsResponse) {
    option (google.api.http).get = "/rwachain/rwatoken/params";
  }

  // ListAssets returns all tokenized assets
  rpc ListAssets(QueryListAssetsRequest) returns (QueryListAssetsResponse) {
    option (google.api.http).get = "/rwachain/rwatoken/assets";
  }

  // GetAsset returns a single asset by ID
  rpc GetAsset(QueryGetAssetRequest) returns (QueryGetAssetResponse) {
    option (google.api.http).get = "/rwachain/rwatoken/asset/{id}";
  }

  // ListInvestors returns all registered investors
  rpc ListInvestors(QueryListInvestorsRequest) returns (QueryListInvestorsResponse) {
    option (google.api.http).get = "/rwachain/rwatoken/investors";
  }
}

// QueryParamsRequest is the request type for the Query/Params RPC method
message QueryParamsRequest {}

// QueryParamsResponse is the response type for the Query/Params RPC method
message QueryParamsResponse {
  Params params = 1 [(gogoproto.nullable) = false, (amino.dont_omitempty) = true];
}

// QueryListAssetsRequest is the request type for ListAssets
message QueryListAssetsRequest {}

// QueryListAssetsResponse is the response type for ListAssets
message QueryListAssetsResponse {
  repeated Asset assets = 1;
}

// QueryGetAssetRequest is the request type for GetAsset
message QueryGetAssetRequest {
  string id = 1;
}

// QueryGetAssetResponse is the response type for GetAsset
message QueryGetAssetResponse {
  Asset asset = 1;
}

// QueryListInvestorsRequest is the request type for ListInvestors
message QueryListInvestorsRequest {}

// QueryListInvestorsResponse is the response type for ListInvestors
message QueryListInvestorsResponse {
  repeated Investor investors = 1;
}

Your custom data types are now defined and compiled into Go code. Next up, you'll implement the Keeper methods that read and write these types to the blockchain's state store.

Implement Keeper State Management

Your Protobuf types are generated and ready to use as Go structs. The next challenge is making these types persistent.

Right now, your module has no way to save or retrieve Asset and Investor data from the blockchain's state. In this step, you will build the Keeper layer that reads and writes these objects to the chain's key-value store, then expose that data through query endpoints.

In this step, get ready to:
  • Define store key prefixes and implement Asset keeper methods.
  • Implement Investor keeper methods including a KYC whitelist check.
  • Implement query server handlers for listing and retrieving state.
Define store key prefixes and implement Asset keeper methods

Every piece of data stored on-chain needs a unique address. In Cosmos SDK, modules use byte prefixes to carve out isolated namespaces inside the global key-value store. This prevents one module's data from colliding with another's.

  • Open x/rwatoken/types/keys.go in your editor.
  • Add the Asset and Investor prefix constants by pasting the following code at the bottom of the file:
// AssetKeyPrefix is the prefix for storing Asset objects
var AssetKeyPrefix = []byte("Asset/value/")

// InvestorKeyPrefix is the prefix for storing Investor objects
var InvestorKeyPrefix = []byte("Investor/value/")

What do these prefixes do?

Each prefix creates a separate namespace in the store. All assets will be stored under keys that start with Asset/value/, and all investors under Investor/value/.

This lets the keeper iterate over just assets or just investors without scanning the entire module store.

  • Save keys.go.

Now you will add the Asset state management methods to the keeper. These methods handle serializing Asset structs to bytes, writing them to the prefix store, and reading them back.

  • Create a new file at x/rwatoken/keeper/asset.go and paste the following code:
package keeper

import (
	"context"

	"cosmossdk.io/store/prefix"
	"github.com/cosmos/cosmos-sdk/runtime"

	"rwachain/x/rwatoken/types"
)

// SetAsset stores an asset in state using its ID as the key
func (k Keeper) SetAsset(ctx context.Context, asset types.Asset) {
	storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
	store := prefix.NewStore(storeAdapter, types.AssetKeyPrefix)
	// Marshal the asset struct into bytes for storage
	b := k.cdc.MustMarshal(&asset)
	store.Set([]byte(asset.Id), b)
}

// GetAsset retrieves a single asset by ID, returns found=false if not present
func (k Keeper) GetAsset(ctx context.Context, id string) (val types.Asset, found bool) {
	storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
	store := prefix.NewStore(storeAdapter, types.AssetKeyPrefix)
	b := store.Get([]byte(id))
	if b == nil {
		return val, false
	}
	k.cdc.MustUnmarshal(b, &val)
	return val, true
}

// GetAllAssets returns every asset stored in the module
func (k Keeper) GetAllAssets(ctx context.Context) (list []types.Asset) {
	storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
	store := prefix.NewStore(storeAdapter, types.AssetKeyPrefix)
	// Iterate over the entire Asset prefix namespace
	iterator := store.Iterator(nil, nil)
	defer iterator.Close()
	for ; iterator.Valid(); iterator.Next() {
		var val types.Asset
		k.cdc.MustUnmarshal(iterator.Value(), &val)
		list = append(list, val)
	}
	return
}

What does this code do?

Each method follows the same three-step pattern that Cosmos SDK keepers use:

  • Get a store adapter via runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx)). This adapts the v0.50 store service into a classic KVStore interface.
  • Wrap it in a prefix.NewStore so every key operation is scoped to the Asset namespace.
  • Use k.cdc.MustMarshal and k.cdc.MustUnmarshal to convert between Go structs and raw bytes.
  • Save asset.go.

Seeing import errors?

If your editor highlights the cosmossdk.io/store/prefix or github.com/cosmos/cosmos-sdk/runtime imports in red, the Go module cache may need to download the dependencies.

Run go mod tidy from the rwachain directory to resolve them.

help me fix import errors in my Cosmos SDK keeper file

Implement Investor keeper methods

Investor state management follows the same pattern as assets, with one important addition. The IsWhitelisted method combines an existence check with a KYC status check. This is the compliance gate that your message handlers will call before allowing any asset operations.

  • Create a new file at x/rwatoken/keeper/investor.go and paste the following code:
package keeper

import (
	"context"

	"cosmossdk.io/store/prefix"
	"github.com/cosmos/cosmos-sdk/runtime"

	"rwachain/x/rwatoken/types"
)

// SetInvestor stores an investor in state using their address as the key
func (k Keeper) SetInvestor(ctx context.Context, investor types.Investor) {
	storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
	store := prefix.NewStore(storeAdapter, types.InvestorKeyPrefix)
	b := k.cdc.MustMarshal(&investor)
	store.Set([]byte(investor.Address), b)
}

// GetInvestor retrieves a single investor by address
func (k Keeper) GetInvestor(ctx context.Context, address string) (val types.Investor, found bool) {
	storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
	store := prefix.NewStore(storeAdapter, types.InvestorKeyPrefix)
	b := store.Get([]byte(address))
	if b == nil {
		return val, false
	}
	k.cdc.MustUnmarshal(b, &val)
	return val, true
}

// RemoveInvestor deletes an investor from state by address
func (k Keeper) RemoveInvestor(ctx context.Context, address string) {
	storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
	store := prefix.NewStore(storeAdapter, types.InvestorKeyPrefix)
	store.Delete([]byte(address))
}

// GetAllInvestors returns every investor stored in the module
func (k Keeper) GetAllInvestors(ctx context.Context) (list []types.Investor) {
	storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
	store := prefix.NewStore(storeAdapter, types.InvestorKeyPrefix)
	iterator := store.Iterator(nil, nil)
	defer iterator.Close()
	for ; iterator.Valid(); iterator.Next() {
		var val types.Investor
		k.cdc.MustUnmarshal(iterator.Value(), &val)
		list = append(list, val)
	}
	return
}

// IsWhitelisted returns true only if the investor exists AND has kyc set to true
func (k Keeper) IsWhitelisted(ctx context.Context, address string) bool {
	investor, found := k.GetInvestor(ctx, address)
	if !found {
		return false
	}
	return investor.Kyc
}

Why does IsWhitelisted check both existence and KYC?

In regulated asset networks, an investor record might exist in the system before their KYC verification is complete. The IsWhitelisted method enforces both conditions: the investor must be registered AND their Kyc field must be true.

This two-check design prevents assets from being issued to investors whose verification is still pending.

  • Save investor.go.
  • Confirm both files compile cleanly by running:
cd ~/rwachain && go build ./...

You should see no output if everything compiles successfully.

Build fails with undefined references?

If you see errors about k.storeService or k.cdc being undefined, check that your keeper struct (in x/rwatoken/keeper/keeper.go) has storeService and cdc fields. Ignite scaffolded these when you created the module.

help me fix undefined keeper fields in my Cosmos SDK module

Implement query server handlers

Your keeper can now read and write state, but external clients have no way to query it. The query server exposes your keeper methods as gRPC endpoints that CLI commands and front-end apps can call.

In Ignite CLI v28, the query server uses a separate queryServer struct that holds a reference to the keeper. This avoids naming conflicts between keeper state methods and gRPC handler methods.

  • Create a new file at x/rwatoken/keeper/query_server.go and paste the following code:
package keeper

import (
	"context"

	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"

	"rwachain/x/rwatoken/types"
)

// queryServer wraps the keeper to implement the QueryServer interface
type queryServer struct {
	k Keeper
}

// NewQueryServerImpl returns an implementation of the QueryServer interface
func NewQueryServerImpl(k Keeper) types.QueryServer {
	return &queryServer{k: k}
}

var _ types.QueryServer = &queryServer{}

// ListAssets returns all assets stored in the module
func (q queryServer) ListAssets(ctx context.Context, req *types.QueryListAssetsRequest) (*types.QueryListAssetsResponse, error) {
	if req == nil {
		return nil, status.Error(codes.InvalidArgument, "invalid request")
	}
	assets := q.k.GetAllAssets(ctx)
	return &types.QueryListAssetsResponse{Assets: assets}, nil
}

// GetAsset returns a single asset by its ID
func (q queryServer) GetAsset(ctx context.Context, req *types.QueryGetAssetRequest) (*types.QueryGetAssetResponse, error) {
	if req == nil {
		return nil, status.Error(codes.InvalidArgument, "invalid request")
	}
	asset, found := q.k.GetAsset(ctx, req.Id)
	if !found {
		return nil, status.Error(codes.NotFound, "asset not found")
	}
	return &types.QueryGetAssetResponse{Asset: asset}, nil
}

// ListInvestors returns all investors stored in the module
func (q queryServer) ListInvestors(ctx context.Context, req *types.QueryListInvestorsRequest) (*types.QueryListInvestorsResponse, error) {
	if req == nil {
		return nil, status.Error(codes.InvalidArgument, "invalid request")
	}
	investors := q.k.GetAllInvestors(ctx)
	return &types.QueryListInvestorsResponse{Investors: investors}, nil
}

What does the query server do?

Each handler validates the incoming request, then delegates to the keeper methods you wrote earlier. The var _ types.QueryServer = &queryServer{} line is a compile-time check that forces the Go compiler to verify your queryServer struct implements every method defined in the generated QueryServer interface.

The gRPC status codes (codes.InvalidArgument, codes.NotFound) give clients meaningful error responses instead of generic failures.

  • Save query_server.go.

The final piece is wiring the query server into the module so it gets registered when the chain starts.

  • Open x/rwatoken/module/module.go in your editor.
  • Find the RegisterServices method. It should already register the MsgServer. Add the query server registration so it looks like this:
func (am AppModule) RegisterServices(cfg module.Configurator) {
	types.RegisterMsgServer(cfg.MsgServer(), keeper.NewMsgServerImpl(am.keeper))
	types.RegisterQueryServer(cfg.QueryServer(), keeper.NewQueryServerImpl(am.keeper))
}

What does RegisterServices do?

This method is called during chain startup. It tells the Cosmos SDK to route incoming gRPC query requests for the rwatoken module to your query server's handler methods. Without this line, your ListAssets, GetAsset, and ListInvestors endpoints would never be reachable.

  • Save module.go.
  • Verify the full project compiles by running:
cd ~/rwachain && go build ./...

A successful build produces no output. This confirms all your keeper methods, query handlers, and module wiring are syntactically correct and type-safe.

Build fails with QueryServer interface errors?

If you see an error saying your queryServer does not implement QueryServer, confirm that the method signatures in query_server.go match exactly what the generated query.pb.go interface expects. The request and response type names must match your proto definitions (QueryListAssetsRequest, QueryListAssetsResponse, etc.).

help me fix QueryServer interface mismatch in my Cosmos SDK module

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files: keys.go, asset.go, investor.go, and query_server.go.

ⓧ I'd like to double check the full code

Here is the complete code for each file you created or modified in this step.

x/rwatoken/types/keys.go (additions only):

// AssetKeyPrefix is the prefix for storing Asset objects
var AssetKeyPrefix = []byte("Asset/value/")

// InvestorKeyPrefix is the prefix for storing Investor objects
var InvestorKeyPrefix = []byte("Investor/value/")

x/rwatoken/keeper/asset.go:

package keeper

import (
	"context"

	"cosmossdk.io/store/prefix"
	"github.com/cosmos/cosmos-sdk/runtime"

	"rwachain/x/rwatoken/types"
)

// SetAsset stores an asset in state using its ID as the key
func (k Keeper) SetAsset(ctx context.Context, asset types.Asset) {
	storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
	store := prefix.NewStore(storeAdapter, types.AssetKeyPrefix)
	b := k.cdc.MustMarshal(&asset)
	store.Set([]byte(asset.Id), b)
}

// GetAsset retrieves a single asset by ID, returns found=false if not present
func (k Keeper) GetAsset(ctx context.Context, id string) (val types.Asset, found bool) {
	storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
	store := prefix.NewStore(storeAdapter, types.AssetKeyPrefix)
	b := store.Get([]byte(id))
	if b == nil {
		return val, false
	}
	k.cdc.MustUnmarshal(b, &val)
	return val, true
}

// GetAllAssets returns every asset stored in the module
func (k Keeper) GetAllAssets(ctx context.Context) (list []types.Asset) {
	storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
	store := prefix.NewStore(storeAdapter, types.AssetKeyPrefix)
	iterator := store.Iterator(nil, nil)
	defer iterator.Close()
	for ; iterator.Valid(); iterator.Next() {
		var val types.Asset
		k.cdc.MustUnmarshal(iterator.Value(), &val)
		list = append(list, val)
	}
	return
}

x/rwatoken/keeper/investor.go:

package keeper

import (
	"context"

	"cosmossdk.io/store/prefix"
	"github.com/cosmos/cosmos-sdk/runtime"

	"rwachain/x/rwatoken/types"
)

// SetInvestor stores an investor in state using their address as the key
func (k Keeper) SetInvestor(ctx context.Context, investor types.Investor) {
	storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
	store := prefix.NewStore(storeAdapter, types.InvestorKeyPrefix)
	b := k.cdc.MustMarshal(&investor)
	store.Set([]byte(investor.Address), b)
}

// GetInvestor retrieves a single investor by address
func (k Keeper) GetInvestor(ctx context.Context, address string) (val types.Investor, found bool) {
	storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
	store := prefix.NewStore(storeAdapter, types.InvestorKeyPrefix)
	b := store.Get([]byte(address))
	if b == nil {
		return val, false
	}
	k.cdc.MustUnmarshal(b, &val)
	return val, true
}

// RemoveInvestor deletes an investor from state by address
func (k Keeper) RemoveInvestor(ctx context.Context, address string) {
	storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
	store := prefix.NewStore(storeAdapter, types.InvestorKeyPrefix)
	store.Delete([]byte(address))
}

// GetAllInvestors returns every investor stored in the module
func (k Keeper) GetAllInvestors(ctx context.Context) (list []types.Investor) {
	storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
	store := prefix.NewStore(storeAdapter, types.InvestorKeyPrefix)
	iterator := store.Iterator(nil, nil)
	defer iterator.Close()
	for ; iterator.Valid(); iterator.Next() {
		var val types.Investor
		k.cdc.MustUnmarshal(iterator.Value(), &val)
		list = append(list, val)
	}
	return
}

// IsWhitelisted returns true only if the investor exists AND has kyc set to true
func (k Keeper) IsWhitelisted(ctx context.Context, address string) bool {
	investor, found := k.GetInvestor(ctx, address)
	if !found {
		return false
	}
	return investor.Kyc
}

x/rwatoken/keeper/query_server.go:

package keeper

import (
	"context"

	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"

	"rwachain/x/rwatoken/types"
)

type queryServer struct {
	k Keeper
}

func NewQueryServerImpl(k Keeper) types.QueryServer {
	return &queryServer{k: k}
}

var _ types.QueryServer = &queryServer{}

// ListAssets returns all assets stored in the module
func (q queryServer) ListAssets(ctx context.Context, req *types.QueryListAssetsRequest) (*types.QueryListAssetsResponse, error) {
	if req == nil {
		return nil, status.Error(codes.InvalidArgument, "invalid request")
	}
	assets := q.k.GetAllAssets(ctx)
	return &types.QueryListAssetsResponse{Assets: assets}, nil
}

// GetAsset returns a single asset by its ID
func (q queryServer) GetAsset(ctx context.Context, req *types.QueryGetAssetRequest) (*types.QueryGetAssetResponse, error) {
	if req == nil {
		return nil, status.Error(codes.InvalidArgument, "invalid request")
	}
	asset, found := q.k.GetAsset(ctx, req.Id)
	if !found {
		return nil, status.Error(codes.NotFound, "asset not found")
	}
	return &types.QueryGetAssetResponse{Asset: asset}, nil
}

// ListInvestors returns all investors stored in the module
func (q queryServer) ListInvestors(ctx context.Context, req *types.QueryListInvestorsRequest) (*types.QueryListInvestorsResponse, error) {
	if req == nil {
		return nil, status.Error(codes.InvalidArgument, "invalid request")
	}
	investors := q.k.GetAllInvestors(ctx)
	return &types.QueryListInvestorsResponse{Investors: investors}, nil
}

x/rwatoken/module/module.go (RegisterServices method):

func (am AppModule) RegisterServices(cfg module.Configurator) {
	types.RegisterMsgServer(cfg.MsgServer(), keeper.NewMsgServerImpl(am.keeper))
	types.RegisterQueryServer(cfg.QueryServer(), keeper.NewQueryServerImpl(am.keeper))
}

Your keeper layer is complete. The chain now knows how to persist assets and investors to its state tree, enforce KYC checks at the data layer, and serve queries over gRPC. Next up, you will write the message handlers that orchestrate these keeper methods with admin authorization and compliance validation.

Implement Message Handlers and Run the Chain

Your keeper knows how to store and retrieve assets and investors. Now you need the logic that decides when those operations are allowed to happen.

In this step, you will implement the transaction handlers that enforce compliance rules, configure the chain's starting state, and run everything end to end.

In this step, get ready to:
  • Implement the MsgAddInvestor handler with admin-only authorization.
  • Implement the MsgIssueAsset handler with admin and KYC validation.
  • Configure genesis state, build the chain, and test via CLI.
Implement the MsgAddInvestor handler

The MsgAddInvestor handler is the gatekeeper for your compliance whitelist. Only the module admin should be able to add investors to the chain's state.

  • Open x/rwatoken/keeper/msg_server.go in your editor.
  • Find the scaffolded AddInvestor function. It currently has an empty body that just returns the response.
  • Replace the entire AddInvestor function with the following implementation:
func (k msgServer) AddInvestor(goCtx context.Context, msg *types.MsgAddInvestor) (*types.MsgAddInvestorResponse, error) {
	ctx := sdk.UnwrapSDKContext(goCtx)

	// Validate that the message creator is the admin
	params := k.GetParams(ctx)
	if msg.Creator != params.Admin {
		return nil, fmt.Errorf("unauthorized: only admin can add investors")
	}

	// Store the investor in state
	investor := types.Investor{
		Address:      msg.InvestorAddress,
		Kyc:          msg.Kyc,
		Jurisdiction: msg.Jurisdiction,
	}
	k.SetInvestor(ctx, investor)

	// Emit an investor_added event
	ctx.EventManager().EmitEvent(sdk.NewEvent(
		"investor_added",
		sdk.NewAttribute("address", msg.InvestorAddress),
		sdk.NewAttribute("kyc", fmt.Sprintf("%t", msg.Kyc)),
		sdk.NewAttribute("jurisdiction", msg.Jurisdiction),
	))

	return &types.MsgAddInvestorResponse{}, nil
}

What does this code do?

This handler enforces the first layer of compliance. It checks msg.Creator against the admin param stored in the module's on-chain parameters. If they don't match, the transaction is rejected before any state change happens.

When authorization passes, the handler creates an Investor struct and persists it using the SetInvestor keeper method from the previous step. Finally, it emits an investor_added event so off-chain systems can track whitelist changes.

  • Make sure the imports at the top of msg_server.go include fmt alongside the existing imports. Ignite already imports context, sdk, and types for you.

Seeing a compile error about fmt?

If Go reports fmt is imported but unused (or missing), check that both AddInvestor and IssueAsset reference fmt.Errorf or fmt.Sprintf. Go does not allow unused imports. If you haven't added IssueAsset yet, add a temporary _ = fmt.Sprintf line so the file compiles.

help me fix the unused import error in msg_server.go

Implement the MsgIssueAsset handler

The asset issuance handler is where your compliance logic really shows its value. It checks both that the sender is the admin AND that the recipient has passed KYC verification before allowing any token to be created.

  • In the same msg_server.go file, find the scaffolded IssueAsset function.
  • Replace the entire IssueAsset function with the following implementation:
func (k msgServer) IssueAsset(goCtx context.Context, msg *types.MsgIssueAsset) (*types.MsgIssueAssetResponse, error) {
	ctx := sdk.UnwrapSDKContext(goCtx)

	// Validate admin authorization
	params := k.GetParams(ctx)
	if msg.Creator != params.Admin {
		return nil, fmt.Errorf("unauthorized: only admin can issue assets")
	}

	// Validate recipient is whitelisted
	if !k.IsWhitelisted(ctx, msg.RecipientAddress) {
		return nil, fmt.Errorf("recipient %s is not whitelisted", msg.RecipientAddress)
	}

	// Generate a unique asset ID
	assetID := fmt.Sprintf("%s-%s-%d", msg.Ticker, msg.RecipientAddress, ctx.BlockHeight())

	// Create the Asset struct with status "active"
	asset := types.Asset{
		Id:           assetID,
		Name:         msg.Name,
		Ticker:       msg.Ticker,
		AssetType:    msg.AssetType,
		IssuedTo:     msg.RecipientAddress,
		Amount:       msg.Amount,
		LegalDocHash: msg.LegalDocHash,
		Status:       "active",
	}
	k.SetAsset(ctx, asset)

	// Emit an asset_issued event
	ctx.EventManager().EmitEvent(sdk.NewEvent(
		"asset_issued",
		sdk.NewAttribute("id", assetID),
		sdk.NewAttribute("name", msg.Name),
		sdk.NewAttribute("ticker", msg.Ticker),
		sdk.NewAttribute("asset_type", msg.AssetType),
		sdk.NewAttribute("issued_to", msg.RecipientAddress),
		sdk.NewAttribute("amount", fmt.Sprintf("%d", msg.Amount)),
		sdk.NewAttribute("legal_doc_hash", msg.LegalDocHash),
		sdk.NewAttribute("status", "active"),
	))

	return &types.MsgIssueAssetResponse{}, nil
}

What does this code do?

This handler performs two compliance checks before allowing asset creation. First, it validates the sender is the admin. Second, it calls IsWhitelisted to confirm the recipient has passed KYC. If either check fails, the transaction is rejected.

The asset ID is generated by combining the ticker, recipient address, and block height. This guarantees uniqueness without requiring a separate counter. The status field starts as "active" so downstream systems can track the full asset lifecycle.

The asset_issued event includes every field, making the chain fully auditable. Off-chain indexers can subscribe to these events to build compliance dashboards.

  • Save msg_server.go.

Getting a type mismatch on msg.Amount?

If Go reports a type error on msg.Amount, confirm that the amount field in your tx.proto MsgIssueAsset was scaffolded as uint64 (from the amount:uint flag). If it was scaffolded as a string, you will need to convert it. Check x/rwatoken/types/tx.pb.go to see the generated type for Amount.

help me fix the type mismatch on Amount in my IssueAsset handler

✔️ Awesome, I've got everything!

Great. Double check you have saved msg_server.go with both handlers in place.

ⓧ I'd like to double check the full code

Here is the complete msg_server.go file for comparison:

package keeper

import (
	"context"
	"fmt"

	"rwachain/x/rwatoken/types"

	sdk "github.com/cosmos/cosmos-sdk/types"
)

type msgServer struct {
	Keeper
}

// NewMsgServerImpl returns an implementation of the MsgServer interface
func NewMsgServerImpl(keeper Keeper) types.MsgServer {
	return &msgServer{Keeper: keeper}
}

var _ types.MsgServer = msgServer{}

func (k msgServer) AddInvestor(goCtx context.Context, msg *types.MsgAddInvestor) (*types.MsgAddInvestorResponse, error) {
	ctx := sdk.UnwrapSDKContext(goCtx)

	// Validate that the message creator is the admin
	params := k.GetParams(ctx)
	if msg.Creator != params.Admin {
		return nil, fmt.Errorf("unauthorized: only admin can add investors")
	}

	// Store the investor in state
	investor := types.Investor{
		Address:      msg.InvestorAddress,
		Kyc:          msg.Kyc,
		Jurisdiction: msg.Jurisdiction,
	}
	k.SetInvestor(ctx, investor)

	// Emit an investor_added event
	ctx.EventManager().EmitEvent(sdk.NewEvent(
		"investor_added",
		sdk.NewAttribute("address", msg.InvestorAddress),
		sdk.NewAttribute("kyc", fmt.Sprintf("%t", msg.Kyc)),
		sdk.NewAttribute("jurisdiction", msg.Jurisdiction),
	))

	return &types.MsgAddInvestorResponse{}, nil
}

func (k msgServer) IssueAsset(goCtx context.Context, msg *types.MsgIssueAsset) (*types.MsgIssueAssetResponse, error) {
	ctx := sdk.UnwrapSDKContext(goCtx)

	// Validate admin authorization
	params := k.GetParams(ctx)
	if msg.Creator != params.Admin {
		return nil, fmt.Errorf("unauthorized: only admin can issue assets")
	}

	// Validate recipient is whitelisted
	if !k.IsWhitelisted(ctx, msg.RecipientAddress) {
		return nil, fmt.Errorf("recipient %s is not whitelisted", msg.RecipientAddress)
	}

	// Generate a unique asset ID
	assetID := fmt.Sprintf("%s-%s-%d", msg.Ticker, msg.RecipientAddress, ctx.BlockHeight())

	// Create the Asset struct with status "active"
	asset := types.Asset{
		Id:           assetID,
		Name:         msg.Name,
		Ticker:       msg.Ticker,
		AssetType:    msg.AssetType,
		IssuedTo:     msg.RecipientAddress,
		Amount:       msg.Amount,
		LegalDocHash: msg.LegalDocHash,
		Status:       "active",
	}
	k.SetAsset(ctx, asset)

	// Emit an asset_issued event
	ctx.EventManager().EmitEvent(sdk.NewEvent(
		"asset_issued",
		sdk.NewAttribute("id", assetID),
		sdk.NewAttribute("name", msg.Name),
		sdk.NewAttribute("ticker", msg.Ticker),
		sdk.NewAttribute("asset_type", msg.AssetType),
		sdk.NewAttribute("issued_to", msg.RecipientAddress),
		sdk.NewAttribute("amount", fmt.Sprintf("%d", msg.Amount)),
		sdk.NewAttribute("legal_doc_hash", msg.LegalDocHash),
		sdk.NewAttribute("status", "active"),
	))

	return &types.MsgIssueAssetResponse{}, nil
}
Configure genesis, build, and test

Your handlers are ready. Now you need to configure the chain so it starts with an admin address and pre-whitelisted investors in the genesis state. This way you can test asset issuance immediately after the chain launches.

First, you need to add investor list support to the module's genesis state. This requires a small change to genesis.proto and the genesis initialization logic.

  • Open proto/rwachain/rwatoken/genesis.proto in your editor.
  • Add the investor import and a repeated field for the investor list. Replace the file contents with:
syntax = "proto3";
package rwachain.rwatoken;

import "gogoproto/gogo.proto";
import "rwachain/rwatoken/params.proto";
import "rwachain/rwatoken/investor.proto";

option go_package = "rwachain/x/rwatoken/types";

// GenesisState defines the rwatoken module's genesis state
message GenesisState {
  Params params = 1 [(gogoproto.nullable) = false];
  // List of investors to pre-populate at genesis
  repeated Investor investorList = 2 [(gogoproto.nullable) = false];
}

What does the investorList field do?

The repeated keyword in Protobuf defines a list. When the chain starts, InitGenesis reads this list from the genesis state and stores each investor in the module's KV store. This lets you pre-populate whitelisted investors without needing to submit transactions.

  • Save genesis.proto.
  • Regenerate the Go types by running:
cd ~/rwachain && ignite generate proto-go
  • Now open x/rwatoken/module/genesis.go and update the InitGenesis function to iterate through the investor list and store each investor:
// InitGenesis initializes the module's state from a provided genesis state
func InitGenesis(ctx sdk.Context, k keeper.Keeper, genState types.GenesisState) {
	k.SetParams(ctx, genState.Params)

	// Store each pre-whitelisted investor from genesis
	for _, investor := range genState.InvestorList {
		k.SetInvestor(ctx, investor)
	}
}

What does this code do?

The InitGenesis function runs once when the chain first starts. It reads the genesis state and persists it to the module's store. By iterating over genState.InvestorList, each pre-configured investor is stored as if an admin had submitted an add-investor transaction.

  • Save genesis.go.
  • Now open config.yml at the root of your rwachain project. Add the genesis configuration at the bottom of the file:
genesis:
  app_state:
    rwatoken:
      params:
        admin: cosmos1adminplaceholder
      investorList:
        - address: cosmos1investor1placeholder
          kyc: true
          jurisdiction: "US"
        - address: cosmos1investor2placeholder
          kyc: true
          jurisdiction: "EU"

Where do I get the actual addresses?

When you run ignite chain serve for the first time, Ignite generates account addresses for the accounts defined in the accounts section of config.yml (typically alice and bob). It prints these addresses to the terminal.

Copy the alice address and paste it as the admin value. Use bob's address as one investor. For the second investor, you can create a third account in the accounts section (e.g., carol) or use any valid cosmos address. You will update these placeholders after seeing the real addresses.

  • Save config.yml.
  • Start the chain by running:
ignite chain serve

What does ignite chain serve do?

This command compiles your chain binary, initializes the data directory with the genesis file (merging your config.yml overrides), and starts the node in development mode with live reloading. It prints the account addresses at startup.

  • Watch the terminal output. Look for the account addresses printed at the top. Copy them.
  • If your placeholder addresses don't match the generated ones, press Ctrl+C to stop the chain, update config.yml with the real addresses, and run ignite chain serve again.

Once the chain is running with the correct addresses, test your handlers.

  • Open a second WSL terminal (keep the chain running in the first one).
  • Add a new investor by running a transaction. Replace the address placeholders with your actual addresses:
rwachaind tx rwatoken add-investor cosmos1newinvestoraddress true "JP" --from alice --chain-id rwachain --yes
  • You should see a transaction hash and code: 0 in the response, indicating success.
  • Now issue an asset to one of your pre-whitelisted investors:
rwachaind tx rwatoken issue-asset "Manhattan Office" MNHT "real_estate" cosmos1investor1address 1000 "QmLegalDocHash123" --from alice --chain-id rwachain --yes
  • You should again see code: 0 confirming the asset was issued.
  • Query the chain to verify the asset was stored by running:
rwachaind query rwatoken list-assets

You should see your newly created asset with all the fields you provided, including status: active and the generated asset ID.

  • Verify the investor list as well:
rwachaind query rwatoken list-investors

You should see both your genesis-configured investors and any new ones you added via transactions.

Transaction returns unauthorized error?

This means the --from account doesn't match the admin address in your config.yml genesis params. Stop the chain, double-check that the admin value matches the alice account address exactly, and restart with ignite chain serve.

help me fix the unauthorized error when submitting add-investor

Recipient is not whitelisted error?

When issuing an asset, the recipient address must match an investor stored in state with kyc: true. Confirm you're using the same address that appears in your config.yml investorList or that you've already successfully added via the add-investor transaction. Addresses are case-sensitive.

help me debug the not whitelisted error when issuing an asset

Your custom Cosmos SDK module is fully functional. You can issue tokenized real-world assets to verified investors and enforce compliance at the protocol layer. Next up, the Secret Mission challenges you to implement asset transfers with full ownership and KYC validation.

Secret mission

Implement MsgTransferAsset

Your rwatoken module can issue assets to verified investors. But in real markets, assets need to change hands. Can you implement a compliance-checked transfer that validates ownership, verifies the recipient's KYC status, and updates state atomically?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally in WSL, so there are no ongoing costs.

Resources you used:

  • Project directory at ~/rwachain/ containing all source code and module files.
  • Chain data directory at ~/.rwachain/ storing blockchain state and configuration.
  • Go module cache at ~/go/pkg/mod/ holding downloaded dependencies.

Keep everything running

No action needed. Choose this if you're still actively building on your rwatoken module or plan to add more message types.

  • Your chain continues running via ignite chain serve with hot-reloading enabled.
  • The Go module cache stays in ~/go/pkg/mod/ and speeds up future Go builds.
  • Go and Ignite CLI remain available for other Cosmos SDK projects.

Pause - I'll come back to this later

Stop the chain to free up system resources. You can restart anytime without losing data.

  • Press Ctrl+C in the terminal running ignite chain serve to stop the chain.
  • When you're ready to resume, navigate back to ~/rwachain and run ignite chain serve again.
  • Your chain data in ~/.rwachain/ persists between restarts, so previous transactions and state remain intact.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your chain, source code, and cached dependencies.

  • Stop the chain by pressing Ctrl+C if it's still running.
  • Remove the project directory and chain data by running:
rm -rf ~/rwachain ~/.rwachain
  • Clean the Go module cache by running:
go clean -modcache

What about the Go module cache?

The ~/go/pkg/mod/ cache is shared across all Go projects on your system. Running go clean -modcache removes cached modules for every Go project, not just this one.

Skip this command if you have other Go projects that benefit from the cached dependencies.

That's a Wrap!

That's a Wrap!

Nice work! You just built a compliance-enforced blockchain module from scratch using Cosmos SDK and Ignite CLI. Your x/rwatoken module can issue tokenized real-world assets to KYC-verified investors and enforce compliance rules at the protocol layer.

You've learned how to:

  • Scaffolded a full Cosmos SDK blockchain and defined custom Protobuf types (Asset, Investor) for a real-world asset tokenization module.
  • Implemented the Keeper pattern with prefix store state management and built compliance-enforced message handlers that validate admin authorization and KYC status before allowing asset issuance.
  • Configured genesis state with a pre-configured admin and whitelisted investors, then tested the full asset lifecycle via CLI transactions and queries.
  • Secret Mission: Implemented MsgTransferAsset with ownership validation and compliance-checked transfers between KYC-verified investors.

Ready to quiz yourself?