DeFi: Create a Loan Module

Introduction
Decentralized finance (DeFi) is a rapidly growing sector that is transforming the way we think about financial instruments and provides an array of inventive financial products and services. These include lending, borrowing, spot trading, margin trading, and flash loans, all of which are available to anyone possessing an internet connection.
A DeFi loan represents a financial contract where the borrower is granted a certain asset, like currency or digital tokens.
In return, the borrower agrees to pay an additional fee and repay the loan within a set period of time.
To secure a loan, the borrower provides collateral that the lender can claim in the event of default.
This tutorial was last tested with Ignite CLI v29.0.0.
Setup and Scaffold
- Create a New Blockchain:
ignite scaffold chain loan --no-module && cd loan
Notice the --no-module
flag, in the next step we make sure the bank
dependency is included with scaffolding the module.
- Create a Module:
Create a new "loan" module that is based on the standard Cosmos SDK bank
module.
ignite scaffold module loan --dep bank
- Define the loan Module:
The "list" scaffolding command is used to generate files that implement the logic for storing and interacting with data stored as a list in the blockchain state.
ignite scaffold list loan amount fee collateral deadline state borrower lender --no-message
- Scaffold the Messages:
Scaffold the code for handling the messages for requesting, approving, repaying, liquidating, and cancelling loans.
- Handling Loan Requests
ignite scaffold message request-loan amount fee collateral deadline
- Approving and Canceling Loans
ignite scaffold message approve-loan id:uint
ignite scaffold message cancel-loan id:uint
- Repaying and Liquidating Loans
ignite scaffold message repay-loan id:uint
ignite scaffold message liquidate-loan id:uint
Additional Features
- Extend the BankKeeper:
Ignite takes care of adding the bank
keeper, but you still need to tell the loan module which bank methods you will be using. You will be using three methods: SendCoins
, SendCoinsFromAccountToModule
, and SendCoinsFromModuleToAccount
.
Remove the SpendableCoins
function from the BankKeeper
.
Add these to the Bankkeeper
interface.
x/loan/types/expected_keepers.go
package types
import (
"context"
sdk "github.com/cosmos/cosmos-sdk/types"
)
// AuthKeeper defines the expected interface for the Auth module.
type AuthKeeper interface {
AddressCodec() address.Codec
GetAccount(context.Context, sdk.AccAddress) sdk.AccountI // only used for simulation
// Methods imported from account should be defined here
}
// BankKeeper defines the expected interface for the Bank module.
type BankKeeper interface {
// SpendableCoins(context.Context, sdk.AccAddress) sdk.Coins
// Methods imported from bank should be defined here
SendCoins(ctx context.Context, fromAddr sdk.AccAddress, toAddr sdk.AccAddress, amt sdk.Coins) error
SendCoinsFromAccountToModule(ctx context.Context, senderAddr sdk.AccAddress, recipientModule string, amt sdk.Coins) error
SendCoinsFromModuleToAccount(ctx context.Context, senderModule string, recipientAddr sdk.AccAddress, amt sdk.Coins) error
}
// ParamSubspace defines the expected Subspace interface for parameters.
type ParamSubspace interface {
Get(context.Context, []byte, interface{})
Set(context.Context, []byte, interface{})
}
Create a new loan.go
file in your x/loan/keeper/
directory.
package keeper
import (
"encoding/binary"
"loan/x/loan/types"
"cosmossdk.io/store/prefix"
"github.com/cosmos/cosmos-sdk/runtime"
sdk "github.com/cosmos/cosmos-sdk/types"
)
// GetLoanCount get the total number of loan
func (k Keeper) GetLoanCount(ctx sdk.Context) uint64 {
storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
store := prefix.NewStore(storeAdapter, []byte(types.LoanCountKey))
byteKey := []byte(types.LoanCountKey)
bz := store.Get(byteKey)
// Count doesn't exist: no element
if bz == nil {
return 0
}
// Parse bytes
return binary.BigEndian.Uint64(bz)
}
// SetLoanCount set the total number of loan
func (k Keeper) SetLoanCount(ctx sdk.Context, count uint64) {
storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
store := prefix.NewStore(storeAdapter, []byte(types.LoanCountKey))
byteKey := []byte(types.LoanCountKey)
bz := make([]byte, 8)
binary.BigEndian.PutUint64(bz, count)
store.Set(byteKey, bz)
}
// AppendLoan appends a loan in the store with a new id and update the count
func (k Keeper) AppendLoan(ctx sdk.Context, loan types.Loan) uint64 {
// Create the loan
count := k.GetLoanCount(ctx)
// Set the ID of the appended value
loan.Id = count
storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
store := prefix.NewStore(storeAdapter, []byte(types.LoanKey))
appendedValue := k.cdc.MustMarshal(&loan)
store.Set(GetLoanIDBytes(loan.Id), appendedValue)
// Update loan count
k.SetLoanCount(ctx, count+1)
return count
}
// SetLoan set a specific loan in the store
func (k Keeper) SetLoan(ctx sdk.Context, loan types.Loan) {
storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
store := prefix.NewStore(storeAdapter, []byte(types.LoanKey))
b := k.cdc.MustMarshal(&loan)
store.Set(GetLoanIDBytes(loan.Id), b)
}
// GetLoan returns a loan from its id
func (k Keeper) GetLoan(ctx sdk.Context, id uint64) (val types.Loan, found bool) {
storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
store := prefix.NewStore(storeAdapter, []byte(types.LoanKey))
b := store.Get(GetLoanIDBytes(id))
if b == nil {
return val, false
}
k.cdc.MustUnmarshal(b, &val)
return val, true
}
// RemoveLoan removes a loan from the store
func (k Keeper) RemoveLoan(ctx sdk.Context, id uint64) {
storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
store := prefix.NewStore(storeAdapter, []byte(types.LoanKey))
store.Delete(GetLoanIDBytes(id))
}
// GetAllLoan returns all loan
func (k Keeper) GetAllLoan(ctx sdk.Context) (list []types.Loan) {
storeAdapter := runtime.KVStoreAdapter(k.storeService.OpenKVStore(ctx))
store := prefix.NewStore(storeAdapter, []byte(types.LoanKey))
iterator := store.Iterator(nil, nil)
defer iterator.Close()
for ; iterator.Valid(); iterator.Next() {
var val types.Loan
k.cdc.MustUnmarshal(iterator.Value(), &val)
list = append(list, val)
}
return
}
// GetLoanIDBytes returns the byte representation of the ID
func GetLoanIDBytes(id uint64) []byte {
bz := make([]byte, 8)
binary.BigEndian.PutUint64(bz, id)
return bz
}
// GetLoanIDFromBytes returns ID in uint64 format from a byte array
func GetLoanIDFromBytes(bz []byte) uint64 {
return binary.BigEndian.Uint64(bz)
}
Using the Platform
- As a Borrower:
Implement RequestLoan
keeper method that will be called whenever a user requests a loan. RequestLoan
creates a new loan; Set terms like amount, fee, collateral, and repayment deadline. The collateral from the borrower's account is sent to a module account, and adds the loan to the blockchain's store.
Replace your scaffolded templates with the following code.
x/loan/keeper/msg_server_request_loan.go
package keeper
import (
"context"
"strconv"
"loan/x/loan/types"
errorsmod "cosmossdk.io/errors"
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
)
func (k msgServer) RequestLoan(ctx context.Context, msg *types.MsgRequestLoan) (*types.MsgRequestLoanResponse, error) {
if _, err := k.addressCodec.StringToBytes(msg.Creator); err != nil {
return nil, errorsmod.Wrap(err, "invalid authority address")
}
_, err := sdk.AccAddressFromBech32(msg.Creator)
if err != nil {
return nil, errorsmod.Wrapf(sdkerrors.ErrInvalidAddress, "invalid creator address (%s)", err)
}
amount, _ := sdk.ParseCoinsNormalized(msg.Amount)
if !amount.IsValid() {
return nil, errorsmod.Wrap(sdkerrors.ErrInvalidRequest, "amount is not a valid Coins object")
}
if amount.Empty() {
return nil, errorsmod.Wrap(sdkerrors.ErrInvalidRequest, "amount is empty")
}
fee, _ := sdk.ParseCoinsNormalized(msg.Fee)
if !fee.IsValid() {
return nil, errorsmod.Wrap(sdkerrors.ErrInvalidRequest, "fee is not a valid Coins object")
}
deadline, err := strconv.ParseInt(msg.Deadline, 10, 64)
if err != nil {
return nil, errorsmod.Wrap(sdkerrors.ErrInvalidRequest, "deadline is not an integer")
}
if deadline <= 0 {
return nil, errorsmod.Wrap(sdkerrors.ErrInvalidRequest, "deadline should be a positive integer")
}
collateral, _ := sdk.ParseCoinsNormalized(msg.Collateral)
if !collateral.IsValid() {
return nil, errorsmod.Wrap(sdkerrors.ErrInvalidRequest, "collateral is not a valid Coins object")
}
if collateral.Empty() {
return nil, errorsmod.Wrap(sdkerrors.ErrInvalidRequest, "collateral is empty")
}
borrower, err := sdk.AccAddressFromBech32(msg.Creator)
if err != nil {
panic(err)
}
var loan = types.Loan{
Amount: msg.Amount,
Fee: msg.Fee,
Collateral: msg.Collateral,
Deadline: msg.Deadline,
State: "requested",
Borrower: msg.Creator,
}
sdkError := k.bankKeeper.SendCoinsFromAccountToModule(ctx, borrower, types.ModuleName, collateral)
if sdkError != nil {
return nil, sdkError
}
k.AppendLoan(sdk.UnwrapSDKContext(ctx), loan)
return &types.MsgRequestLoanResponse{}, nil
}
As a borrower, you have the option to cancel a loan you have created if you no longer want to proceed with it. However, this action is only possible if the loan's current status is marked as "requested".
x/loan/keeper/msg_server_cancel_loan.go
package keeper
import (
"context"
"loan/x/loan/types"
errorsmod "cosmossdk.io/errors"
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
)
func (k msgServer) CancelLoan(ctx context.Context, msg *types.MsgCancelLoan) (*types.MsgCancelLoanResponse, error) {
if _, err := k.addressCodec.StringToBytes(msg.Creator); err != nil {
return nil, errorsmod.Wrap(err, "invalid authority address")
}
loan, found := k.GetLoan(sdk.UnwrapSDKContext(ctx), msg.Id)
if !found {
return nil, errorsmod.Wrapf(sdkerrors.ErrKeyNotFound, "key %d doesn't exist", msg.Id)
}
if loan.Borrower != msg.Creator {
return nil, errorsmod.Wrap(sdkerrors.ErrUnauthorized, "Cannot cancel: not the borrower")
}
if loan.State != "requested" {
return nil, errorsmod.Wrapf(types.ErrWrongLoanState, "%v", loan.State)
}
borrower, _ := sdk.AccAddressFromBech32(loan.Borrower)
collateral, _ := sdk.ParseCoinsNormalized(loan.Collateral)
err := k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, borrower, collateral)
if err != nil {
return nil, err
}
loan.State = "cancelled"
k.SetLoan(sdk.UnwrapSDKContext(ctx), loan)
return &types.MsgCancelLoanResponse{}, nil
}
- As a Lender:
Approve loan requests and liquidate loans if borrowers fail to repay.
x/loan/keeper/msg_server_approve_loan.go
package keeper
import (
"context"
"loan/x/loan/types"
errorsmod "cosmossdk.io/errors"
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
)
func (k msgServer) ApproveLoan(ctx context.Context, msg *types.MsgApproveLoan) (*types.MsgApproveLoanResponse, error) {
if _, err := k.addressCodec.StringToBytes(msg.Creator); err != nil {
return nil, errorsmod.Wrap(err, "invalid authority address")
}
loan, found := k.GetLoan(sdk.UnwrapSDKContext(ctx), msg.Id)
if !found {
return nil, errorsmod.Wrapf(sdkerrors.ErrKeyNotFound, "key %d doesn't exist", msg.Id)
}
if loan.State != "requested" {
return nil, errorsmod.Wrapf(types.ErrWrongLoanState, "%v", loan.State)
}
lender, _ := sdk.AccAddressFromBech32(msg.Creator)
borrower, _ := sdk.AccAddressFromBech32(loan.Borrower)
amount, err := sdk.ParseCoinsNormalized(loan.Amount)
if err != nil {
return nil, errorsmod.Wrap(types.ErrWrongLoanState, "Cannot parse coins in loan amount")
}
err = k.bankKeeper.SendCoins(ctx, lender, borrower, amount)
if err != nil {
return nil, err
}
loan.Lender = msg.Creator
loan.State = "approved"
k.SetLoan(sdk.UnwrapSDKContext(ctx), loan)
return &types.MsgApproveLoanResponse{}, nil
}
x/loan/keeper/msg_server_repay_loan.go
package keeper
import (
"context"
"loan/x/loan/types"
errorsmod "cosmossdk.io/errors"
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
)
func (k msgServer) RepayLoan(ctx context.Context, msg *types.MsgRepayLoan) (*types.MsgRepayLoanResponse, error) {
if _, err := k.addressCodec.StringToBytes(msg.Creator); err != nil {
return nil, errorsmod.Wrap(err, "invalid authority address")
}
loan, found := k.GetLoan(sdk.UnwrapSDKContext(ctx), msg.Id)
if !found {
return nil, errorsmod.Wrapf(sdkerrors.ErrKeyNotFound, "key %d doesn't exist", msg.Id)
}
if loan.State != "approved" {
return nil, errorsmod.Wrapf(types.ErrWrongLoanState, "%v", loan.State)
}
lender, _ := sdk.AccAddressFromBech32(loan.Lender)
borrower, _ := sdk.AccAddressFromBech32(loan.Borrower)
if msg.Creator != loan.Borrower {
return nil, errorsmod.Wrap(sdkerrors.ErrUnauthorized, "Cannot repay: not the borrower")
}
amount, _ := sdk.ParseCoinsNormalized(loan.Amount)
fee, _ := sdk.ParseCoinsNormalized(loan.Fee)
collateral, _ := sdk.ParseCoinsNormalized(loan.Collateral)
err := k.bankKeeper.SendCoins(ctx, borrower, lender, amount)
if err != nil {
return nil, err
}
err = k.bankKeeper.SendCoins(ctx, borrower, lender, fee)
if err != nil {
return nil, err
}
err = k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, borrower, collateral)
if err != nil {
return nil, err
}
loan.State = "repayed"
k.SetLoan(sdk.UnwrapSDKContext(ctx), loan)
return &types.MsgRepayLoanResponse{}, nil
}
x/loan/keeper/msg_server_liquidate_loan.go
package keeper
import (
"context"
"strconv"
"loan/x/loan/types"
errorsmod "cosmossdk.io/errors"
sdk "github.com/cosmos/cosmos-sdk/types"
sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
)
func (k msgServer) LiquidateLoan(ctx context.Context, msg *types.MsgLiquidateLoan) (*types.MsgLiquidateLoanResponse, error) {
if _, err := k.addressCodec.StringToBytes(msg.Creator); err != nil {
return nil, errorsmod.Wrap(err, "invalid authority address")
}
sdkCtx := sdk.UnwrapSDKContext(ctx)
loan, found := k.GetLoan(sdkCtx, msg.Id)
if !found {
return nil, errorsmod.Wrapf(sdkerrors.ErrKeyNotFound, "key %d doesn't exist", msg.Id)
}
if loan.Lender != msg.Creator {
return nil, errorsmod.Wrap(sdkerrors.ErrUnauthorized, "Cannot liquidate: not the lender")
}
if loan.State != "approved" {
return nil, errorsmod.Wrapf(types.ErrWrongLoanState, "%v", loan.State)
}
lender, _ := sdk.AccAddressFromBech32(loan.Lender)
collateral, _ := sdk.ParseCoinsNormalized(loan.Collateral)
deadline, err := strconv.ParseInt(loan.Deadline, 10, 64)
if err != nil {
panic(err)
}
if sdkCtx.BlockHeight() < deadline {
return nil, errorsmod.Wrap(types.ErrDeadline, "Cannot liquidate before deadline")
}
err = k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleName, lender, collateral)
if err != nil {
return nil, err
}
loan.State = "liquidated"
k.SetLoan(sdkCtx, loan)
return &types.MsgLiquidateLoanResponse{}, nil
}
Add the custom errors ErrWrongLoanState
and ErrDeadline
:
x/loan/types/errors.go
package types
import (
"cosmossdk.io/errors"
)
var (
ErrInvalidSigner = errors.Register(ModuleName, 1100, "expected gov account as only signer for proposal message")
ErrWrongLoanState = errors.Register(ModuleName, 2, "wrong loan state")
ErrDeadline = errors.Register(ModuleName, 3, "deadline")
)
Testing the Application
- Add Test Tokens:
Configure config.yml to add tokens (e.g., 10000foocoin) to test accounts.
config.yml
version: 1
validation: sovereign
default_denom: stake
accounts:
- name: alice
coins:
- 20000token
- 10000foocoin
- 200000000stake
- name: bob
coins:
- 10000token
- 100000000stake
client:
openapi:
path: docs/static/openapi.yml
faucet:
name: bob
coins:
- 5token
- 100000stake
validators:
- name: alice
bonded: 100000000stake
- Start the Blockchain:
ignite chain serve
If everything works successful, you should see the Blockchain is running
message in the Terminal.
- Request a loan:
In a new terminal window, request a loan of 1000token
with 100token
as a fee and 1000foocoin
as a collateral from Alice's account. The deadline is set to 500
blocks:
loand tx loan request-loan 1000token 100token 1000foocoin 500 --from alice --chain-id loan
- Approve the loan:
loand tx loan approve-loan 0 --from bob --chain-id loan
- Repay a loan:
loand tx loan repay-loan 0 --from alice --chain-id loan
- Liquidate a loan:
loand tx loan request-loan 1000token 100token 1000foocoin 20 --from alice --chain-id loan -y
loand tx loan approve-loan 1 --from bob --chain-id loan -y
loand tx loan liquidate-loan 1 --from bob --chain-id loan -y
At any state in the process, use q list loan
to see the active state of all loans.
loand q loan list-loan
Query the blockchain for balances to confirm they have changed according to your transactions.
loand q bank balances $(loand keys show alice -a)
Conclusion
This tutorial outlines the process of setting up a decentralized platform for digital asset loans using blockchain technology. By following these steps, you can create a DeFi platform that allows users to engage in secure and transparent lending and borrowing activities.