Introduction

I made it my mission in 2022 to learn everything I could about blockchain and as the year ends, I feel like I accomplished my goal. Love it, hate it, or don’t want to know nothing about it, I think it’s important to push your opinions aside and understand how this technology works. Even with the current collapse of several large crypto companies in 2022, blockchain isn’t going to disappear. Bitcoin and Ethereum are here to stay. If anything, this past year has shown how entrenched blockchain has become in the world and in people’s lives.

Smart contracts are at the heart of blockchain applications. They are public APIs with private mutable storage that run inside of a virtual machine executed by a blockchain node. The purpose of a smart contract is to allow applications to manage money and assets within the blockchain’s ecosystem and infrastructure. Since you write smart contracts using a Turing-Complete language like Solidity, you can add your own security and protocols beyond what the host blockchain is providing.

In this post, I am going to share with you how to leverage the Go programming language to create and test Ethereum smart contracts. With this knowledge you will have a better understanding of how software interacts with the Ethereum blockchain.

Project

The code we will use in this post can be found in Github and will focus on the code located within the app/basic/ folder.

Figure 1

The basic folder contains a very basic smart contract and it’s a great place to start. The repo does have more complex smart contracts including a smart contract named book with a Go package I wrote that wraps the smart contract for an application I’m building. I consider that a real world example when you are ready to look at it.

The basic/contract folder contains the smart contract code written in Solidity, and the basic/cmd folder contains scratch programs written in Go to deploy and execute the smart contract’s API on an Ethereum node. Inside the contract folder there is also a Go test that tests the smart contract’s API against a simulated Ethereum node. The test code is what I will focus on in this post.

Finally, there is a wiki with more details about the project. Later on you can also take a look at the makefile to see how to build and execute the other smart contracts in the project.

Smart Contract

Smart contracts can get complex very quickly. The more code and state the contract is maintaining, the more expensive the contract is to deploy and use. At some point the tradeoff of code, state, and cost must be discussed as part of the design. I’m always afraid developers will want to reduce integrity inside the contract to reduce cost.

Before we can talk about more complex and real world smart contracts, you need to see a simple smart contract and understand the process of deployment and testing.

Listing 1

01 pragma solidity ^0.8.0;
02
03 contract Basic {
04     string public Version;
05     mapping (string => unit256) public Items;
06
07     event ItemSet(string key, unit256 value);
08
09     constructor() {
10         Version = "1.1";
11     }
12
13     function SetItem(string memory key, unit256 value) external {
14         Items[key] = value;
15         emit ItemSet(key, value);
16     }
17 }

Listing 1 shows a simple smart contract written in a programming language called Solidity. It contains two variables for data storage, a single event, a constructor, and a function. Let me break the smart contract down into its different parts.

On line 01, the pragma statement is telling the Solidity compiler what minimal version is required to compile this code. As of writing this post, the current version I am using is 0.8.17.

Listing 2

# Install dependencies
# https://geth.ethereum.org/docs/install-and-build/installing-geth
# https://docs.soliditylang.org/en/v0.8.17/installing-solidity.html

dev.setup:
   brew update
   brew list ethereum || brew install ethereum
   brew list solidity || brew install solidity

Listing 2 is taken from the project’s makefile and shows how to install the Solidity compiler and Ethereum.

Listing 3

03 contract Store {
04     string public Version;

In listing 3, a simple variable named Version is defined on line 04. This variable holds a string and is marked as public. By making this public, the Solidity compiler will automatically create a getter API for the variable.

Listing 4

external − External functions are meant to be called by other contracts. They cannot be used for internal calls.

public − Public functions/variables can be used both externally and internally. For public state variables, Solidity automatically creates a getter function.

internal − Internal functions/variables can only be used internally or by derived contracts.

private − Private functions/variables can only be used internally and not even by derived contracts.

Listing 4 shows you the four different visibility specifiers that exist in the language. There is no good idiom for naming the variables so I am using the Go idiom of starting the identifier with a capital letter if it is exported and a lowercase letter if it is unexported from the smart contract. My brain is already wired into this idiom and it helps me.

Listing 5

05     mapping (string => uint256) public Items;

In listing 5, the declaration of a map with a key of type string and a value of type uint256 is declared. When storing money in a smart contract the uint256 type is what you will use. This map will serve as a way to store some monetary amount for a user. Once again, I get a free getter API to access the map since I am using the public visibility specifier.

Listing 6

07     event ItemSet(string key, unit256 value);

In listing 6, an event is declared that can be used to record information for any transaction based smart contract call that is made. You can receive these events real time if you are connected to a node over a unix socket, however I usually get this information out of the transaction receipt. Events are a great way of capturing read only information in a much cheaper way than traditional storage.

Listing 7

09     constructor() {
10         Version = "1.1";
11     }

Listing 7 shows how a smart contract has a constructor that only gets executed at the time it’s deployed on the blockchain. Deploying is the act of installing the smart contract for use. In this case, I am manually setting the version of the smart contract to 1.1 as a way of noting to myself what version of code this represents.

Listing 8

13     function SetItem(string memory key, unit256 value) external {
14         Items[key] = value;
15         emit ItemSet(key, value);
16     }

Finally in listing 8, I have a function named SetItem that is defined with the visibility specifier set to external. This function allows an application to set or update a value in the Items map. It also produces an event with the information being sent.

Building The Contract

With the smart contract written, the next step is to build the smart contract to produce the following artifacts: the smart contract’s ABI file, BIN file, and Go package source code file.

Listing 9

solc --abi app/basic/contract/src/basic/basic.sol -o app/basic/contract/abi/basic --overwrite

solc --bin app/basic/contract/src/basic/basic.sol -o app/basic/contract/abi/basic --overwrite

abigen --bin=app/basic/contract/abi/basic/Basic.bin --abi=app/basic/contract/abi/basic/Basic.abi --pkg=basic --out=app/basic/contract/go/basic/basic.go

Listing 9 shows the basic-build command from the makefile that is used to build all the components needed for the basic smart contract. The solc command is the Solidity compiler and the abigen command is installed when you install Solidity. I’m using the Solidity compiler to produce two artifacts, a text based file with the ABI information and a binary file with the compiled code. The ABI file is used by the Ethereum API to know what the smart contract’s API looks like. The binary file is used to deploy the smart contract.

The abigen tool is used to generate a Go package that provides API calls based on the ABI and binary files produced by the Solidity compiler. This Go package will be used by tests and any applications we write in Go.

Testing The Contract

Thanks to the abigen command, a Go package named basic was created and provides a Go API for interacting with the smart contract. To test the API, you need a running Ethereum node or you can use a simulated Ethereum node. Luckily there is support for connecting to a real or simulated node in the ArdanLabs Ethereum module.

This module has all the support a Go developer needs for using the generated Go smart contract package API. There is also great support for currency conversions, event log decoding, and error handling. I will use this module to write the Go test code.

Listing 10

01 package basic_test
02
03 import (
04     "context"
05     "math/big"
06     "testing"
07
08     "github.com/ardanlabs/ethereum"
09     "github.com/ardanlabs/smartcontract/app/basic/contract/go/basic"
10 )
11
12 func TestBasic(t *testing.T) {
13     ctx := context.Background()

Listing 10 shows the very beginning of the source code file containing the Go test. There will be one test function named TestBasic. Look at lines 08 and 09 to see how the test is importing the ArdanLabs ethereum package and the generated Go package that provides the API for the smart contract.

Listing 11

12 func TestBasic(t *testing.T) {
13     ctx := context.Background()
14
15     const numAccounts = 1
16     const autoCommit = true
17     var accountBalance = big.NewInt(100)
18
19     backend, err := ethereum.CreateSimulatedBackend(
           numAccounts,
           autoCommit,
           accountBalance)
20     if err != nil {
21         t.Fatalf("unable to create simulated backend: %s", err)
22     }
23     defer backend.Close()

Listing 11 shows the test function. The first thing the test function does is create a simulated backend instance using the ArdanLabs module. This simulated backend is a fully functional Ethereum node, so if the code works against this simulator, it will also work against a running instance of Ethereum.

The CreateSimulatedBackend API takes three arguments: the number of accounts to create that will pre-exist inside the node, whether to auto-commit every transaction that is performed, and the amount of ETH each pre-existing account will have as a balance. Once the call to CreateSimulatedBackend is complete, it returns a value that represents a connection to that simulated node.

Listing 12

25     clt, err := ethereum.NewClient(backend, backend.PrivateKeys[0])
26     if err != nil {
27         t.Fatalf("unable to create ethereum api: %s", err)
28     }

The next call seen in listing 12 is always next regardless of the type of backend you pick. The call to NewClient returns a client value that provides all the support you need to interact with Ethereum and the smart contract. The API call takes a backend and then a private key for the account that will be associated with this new client connection. In this case, the private key comes from the backend value and is a private key we asked the function CreateSimulatedBackend to create.

If you were using a Dialed backend, you would be responsible for knowing how to access your private key so it can be passed into the API.

Listing 13

01 backend, err := ethereum.CreateDialedBackend(ctx, ethereum.NetworkLocalhost)
02 if err != nil {
03     return err
04 }
05 defer backend.Close()
06
07 privateKey, err := ethereum.PrivateKeyByKeyFile(keyStoreFile, passPhrase)
08 if err != nil {
09     return err
10 }
11
12 clt, err := ethereum.NewClient(backend, privateKey)
13 if err != nil {
14     return err
15 }

Listing 13 shows an example of connecting to a running instance of an Ethereum node. On line 07, you can see how the ArdanLabs ethereum package has a function that can read a key file to get the private key. The calls on line 01 and 12 are basically the same as what I was doing in the test. What’s awesome is everything else you do is the same regardless of the backend type you are using.

Listing 14

32     const gasLimit = 1600000
33     const valueGwei = 0.0
34     tranOpts, err := clt.NewTransactOpts(ctx, gasLimit, big.NewFloat(valueGwei))
35     if err != nil {
36         t.Fatalf("unable to create transaction opts for deploy: %s", err)
37     }
38
39     contractID, tx, _, err := basic.DeployBasic(tranOpts, clt.Backend)
40     if err != nil {
41         t.Fatalf("unable to deploy basic: %s", err)
42     }

Listing 14 brings me back to the test function and shows the code that will deploy the smart contract. The Go package generated by abigen provides the DeployBasic function and requires two parameters. The first parameter is a pointer of type bind.TransactOpts which provides the transaction configuration needed to execute any transaction related call on Ethereum. The second parameter requires a value that implements the bind.ContractBackend interface. Luckily, the client value created on line 25 has a field called Backend that implements this interface.

The two values that are needed to create a TransactionOpts value is the gas limit and a value. Value represents the amount of money you want to give the account receiving the transaction. In this case, a value of zero can be used since this transaction isn’t crediting funds to an account.

The gas limit represents the max amount of money you’re willing to spend to complete the transaction. This prevents people from having their wallets drained if a bug in a smart contract call just starts burning endless cpu cycles. Each transaction that is executed on Ethereum requires resources and those resources cost money. A simple transaction has a fixed cost, but some smart contract calls have a cost that can’t be determined until the function is executed. This might be due to the use of iteration based on some variable information.

In this case, I am telling Ethereum I’m willing to spend up to 1.6 million units of gas multiplied by the current price of gas to get this transaction complete. If Ethereum ends up needing more gas, the transaction will fail. The downside of this is I still owe the 1.6 million units used and get nothing for my money. It’s really important on the test or simulation systems to see how much gas is being used to properly measure the cost. The number will be consistent across local, test, and mainnet.

Listing 15

44     if _, err := clt.WaitMined(ctx, tx); err != nil {
45         t.Fatalf("waiting for deploy: %s", err)
46     }

Listing 15 shows the call to clt.WaitMined which needs to be executed after any transaction. Everything on Ethereum is asynchronous and you can’t move on until you know that transaction was executed and properly mined. The context is used to specify the timeout for how long to wait. When running a test I know this will execute quickly, but when running on a testnet or mainnet, you have no clue how long it will take. It’s critical to make sure you wait long enough.

Though I am not using it here, the clt.WaitMined function returns a receipt value that can be used to extract events and other detailed information about the completed transaction.

Writing The Test Code

After this function call returns, I have the smart contract deployed and ready for testing.

Listing 16

48     testBasic, err := basic.NewBasic(contractID, clt.Backend)
49     if err != nil {
50         t.Fatalf("error creating basic: %s", err)
51     }

In listing 16, the first thing I do is use the generated Go package to create an instance of the smart contract API. This requires the smart contract address (which is provided when the smart contract is deployed) and the client connection. Go back and look at listing 14, line 39 to see how the smart contract address is returned from the deploy call.

Listing 17

55     callOpts, err := clt.NewCallOpts(ctx)
56     if err != nil {
57         t.Fatalf("unable to create call opts: %s", err)
58     }
59
60     ver, err := testBasic.Version(callOpts)
61     if err != nil {
62         t.Fatalf("unable to get version: %s", err)
63     }
64
65     if ver != "1.1" {
66         t.Fatalf("should get the correct version, got %s  exp %s", ver, "1.1")
67     }

Listing 17 shows the first lines of code that attempt to execute one of the smart contract APIs. On line 60, a call to Version is made to extract the version information that was stored when the smart contract was deployed. This is a non-transactional call since the API is a read-only getter function.

The parameter to the call for Version requires a pointer to a bind.CallOpts. Once again the Ardan Labs Ethereum module provides an API to construct one, which you can see on line 55. Once the call to Version is made, the value is evaluated on line 65 to ensure it matches the expected version.

Listing 18

71     tranOpts, err = clt.NewTransactOpts(ctx, gasLimit, big.NewFloat(valueGwei))
72     if err != nil {
73         t.Fatalf("unable to create transaction opts for setitem: %s", err)
74     }
75
76     key := "bill"
77     value := big.NewInt(1_000_000)
78
79     tx, err = testBasic.SetItem(tranOpts, key, value)
80     if err != nil {
81         t.Fatalf("should be able to set item: %s", err)
82     }
83
84     if _, err := clt.WaitMined(ctx, tx); err != nil {
85         t.Fatalf("waiting for setitem: %s", err)
86     }

Listing 18 shows the code used to test the smart contract SetItem call. This function is used to store a value inside the smart contract. Similar to deploying the smart contract, a TransactOpts value is created using the same gas limit and value as before on line 71. Then on line 76 and 77, two variables are defined and initialized that represent the data to be stored in the map inside the smart contract. Then on line 79, the smart contract call is made and if there is no error making the call, the code waits on line 84 to see if the transaction completes successfully.

Listing 19

 90   callOpts, err = clt.NewCallOpts(ctx)
 91   if err != nil {
 92       t.Fatalf("unable to create call opts: %s", err)
 93   }
 94
 95   item, err := testBasic.Items(nil, key)
 96   if err != nil {
 97       t.Fatalf("should be able to retrieve item: %s", err)
 98   }
 99
100   if item.Cmp(value) != 0 {
101       t.Fatalf("wrong value, got %s  exp %s", item, value)
102   }

To finish the test, listing 19 shows the smart contract call to the getter function Items is used to get the value just stored for the specified key. The value returned is compared with the value that was stored. The tests will pass if the values compared are equal.

Conclusion

In this post, I shared with you how to leverage the Go programming language to create and test an Ethereum smart contracts API. With this knowledge you should have a better understanding of what’s at the core of these blockchain based applications. Though this post is starting out with a simple smart contract, it provides a good starting point to explore and develop more complex smart contracts. If you can’t wait for the next post, don’t hesitate to look at the other smart contract examples you can find in the Ardan Labs smart contract repo.

Trusted by top technology companies

We've built our reputation as educators and bring that mentality to every project. When you partner with us, your team will learn best practices and grow along the way.

30,000+

Engineers Trained

1,000+

Companies Worldwide

12+

Years in Business