Lab 1/Project 1 - Introduction to Smart Contract Development
COMP6452 Software Architecture for Blockchain Applications
2025 Term 2
1 Learning Outcomes
In this lab, which also leads to Project 1, you will learn how to write a smart contract using Solidity and deploy it on the Ethereum testnet blockchain. After completing the lab/project, you will be able to:
• develop a simple smart contract using Solidity
• test your smart contract manually and automatically by issuing transactions and running unit tests
• create and fund your account on the Ethereum testnet
• deploy your contract to the Ethereum testnet
This lab provides step-by-step instructions to develop, test, and deploy your first smart contract.
Then as Project 1, which is graded, you will extend that smart contract to fix several defects and add additional functionality.
2 Introduction
Smart contracts are user-defined code deployed on and executed by nodes in a blockchain. In addition to executing instructions, smart contracts can hold, manage, and transfer digitalised assets. For example, a smart contract could be seen as a bunch of if/then conditions that are an algorithmic implementation of a financial service such as trading, lending, and insurance.
Smart contracts are deployed to a blockchain as transaction data. Execution of a smart contract function is triggered using a transaction issued by a user (or a system acting on behalf of a user) or another smart contract, which was in turn triggered by a user-issued transaction. A smart contract does not auto-execute and must be triggered using a user-issued transaction. Inputs to a smart contract function are provided through a transaction and the current state of the blockchain. Due to blockchains’ immutability, transparency, consistency, and integrity properties, smart contract code is immutable and deterministic making its execution trustworthy. While “code is law” [1] is synonymous with smart contracts, smart contracts are neither smart nor legally binding per the contract law. However, they can be used to execute parts of a legal contract.
While Bitcoin [2] supports an elementary form of smart contracts, it was Ethereum [3] that demon- strated the true power of smart contracts by developing a Turing complete language and a run-time environment to code and execute smart contracts. Smart contracts in Ethereum are deployed and exe- cuted as bytecode, i.e., binary code results from compiling code written in a high-level language. Bytecode runs on each blockchain node’s Ethereum Virtual Machine (EVM) [4]. This is analogous to Java bytecode executing on Java Virtual Machine (JVM).
Solidity [5] is the most popular smart contract language for Ethereum. Contracts in Solidity are like classes in object-oriented languages, and contracts deployed onto the blockchain are like objects. A Solidity smart contract contains persistent data in state variables, and functions that can access and modify these state variables. A deployed contract resides at a specific address on the Ethereum blockchain. Solidity is a high-level, object-oriented language that is syntactically similar to JavaScript. It is statically typed and supports inheritance, libraries, and user-defined types. As Solidity code is ultimately compiled into Ethereum bytecode, other blockchain platforms that support the EVM, such as Hyperledger Besu, can also execute it.
Fig. 1 shows the typical development cycle of a smart contract. Like any program, it starts with requirement analysis and modelling. State diagrams, Unified Modelling Language (UML), and Business Process Model and Notation (BPMN) are typically used to model smart contracts. The smart contract code is then developed using a suitable tool ranging from Notepad to sophisticated IDEs. Various libraries and Software Development Kits (SDKs) may be used to minimise errors and enhance productivity. De- pending on the smart contract language, code may also need to be compiled, e.g., Solidity. Smart contract code and results must be bug-free because they are immutable and transparent.
Figure 1: Smart contract development cycle.
Because transactions trigger smart contract functions, we need to pay fees to deploy and execute smart contracts on a public blockchain. In Ethereum, this fee is referred to as gas. More formally, gas is a unit of account within the EVM used in calculating a transaction’s fee, which is the amount of Ether (ETH) a transaction’s sender must pay to the miner/validator who includes the transaction in the blockchain. The amount of gas needs to execute a smart contract depends on several factors such as the computational complexity of the code, the volume of data in memory and storage, and bandwidth requirements. There is also a cost to deploy a smart contract depending on the length of the bytecode. Therefore, it is essential to extensively test and optimise a smart contract to keep the cost low. The extent that one can test (e.g., unit testing), debug, and optimise the code depends on the smart contract language and tool availability. While Ethereum has a rich set of tools, in this lab, we will explore only a tiny subset of them.
Most public blockchains also host a test/development network, referred to as the testnet, that is identical in functionality to the production network. Further, they usually provide fast transaction finality and do not charge real transaction fees. It is highly recommended to test a smart contract on a testnet. Testnet can also be used to estimate transaction fees you may need to pay in the production network.
Once you are confident that the code is ready to go to the production/public blockchain network, the next step is to deploy the code using a transaction. Once the code is successfully deployed, you will get an address (aka identifier or handler) for future interactions with the smart contract. Finally, you can interact with the smart contract by issuing transactions with the smart contract address as the recipient (i.e., to address). The smart contract will remain active until it is disabled, or it reaches a terminating state. Due to the immutability of blockchains, smart contract code will remain in the blockchain even though it is deactivated and cannot be executed.
This lab has two parts. In part one (Section 3 to 8), you will develop, test, and deploy a given smart contract to the Ethereum Sepolia testnet by following step-by-step instructions. Part two (Section 9) is Project 1 where you will update the smart contract and unit tests to fix some of its functional weaknesses, and then deploy it onto the testnet. You are not expected to complete this handout during tutorial/lab time. Instead, you will need additional time alone to complete the lab and Project 1. Tutors will provide online and offline support.
3 Developing a Smart Contract
In this lab, we will write a smart contract and deploy it to the public Ethereum Sepolia testnet. The motivation of our Decentralised Application (DApp) is to solve a million-Dollar question: Where to have lunch?
The basic requirements for our DApp to determine the lunch venue are as follows:
1. The contract deployer SHOULD be able to nominate a list of restaurants r to vote for.
2. The contract deployer SHOULD be able to create a list of voters/friends v who can vote for r restaurants.
3. A voter MUST be able to cast a vote only once.
4. The contract MUST stop accepting votes when the quorum is met (e.g., number of votes > |v|/2) and declare the winning restaurant as the lunch venue.
The following code shows a smart contract written in Solidity to decide the lunch venue based on votes. Line 1 is a machine-readable license statement that indicates that the source code is unlicensed. Lines starting with //, ///, and /** are comments.
1 /// SPDX-License-Identifier: UNLICENSED
2
3 pragma solidity ^0.8.0;
4
5 /// @title Contract to agree on the lunch venue
6 /// @author Dilum Bandara, CSIRO’s Data61
7
8 contract LunchVenue{
9
10 struct Friend {
11 string name;
12 bool voted; //Vote state
13 }
14
15 struct Vote {
16 address voterAddress;
17 uint restaurant;
18 }
19
20 mapping (uint => string) public restaurants; //List of restaurants (restaurant no, name)
21 mapping(address => Friend) public friends; //List of friends (address, Friend)
22 uint public numRestaurants = 0;
23 uint public numFriends = 0;
24 uint public numVotes = 0;
25 address public manager; //Contract manager
26 string public votedRestaurant = ""; //Where to have lunch
27
28 mapping (uint => Vote) public votes; //List of votes (vote no, Vote)
29 mapping (uint => uint) private _results; //List of vote counts (restaurant no, no of votes)
30 bool public voteOpen = true; //voting is open
31
32 /**
33 * @dev Set manager when contract starts
34 */
35 constructor () {
36 manager = msg.sender; //Set contract creator as manager
37 }
38
39 /**
40 * @notice Add a new restaurant
41 * @dev To simplify the code, duplication of restaurants isn’t checked
42 *
43 * @param name Restaurant name
44 * @return Number of restaurants added so far
45 */
46 function addRestaurant(string memory name) public restricted returns (uint){
47 numRestaurants++;
48 restaurants[numRestaurants] = name;
49 return numRestaurants;
50 }
51
52 /**
53 * @notice Add a new friend to voter list
54 * @dev To simplify the code duplication of friends is not checked
55 *
56 * @param friendAddress Friend’s account/address
57 * @param name Friend’s name
58 * @return Number of friends added so far
59 */
60 function addFriend(address friendAddress, string memory name) public restricted returns (uint){
61 Friend memory f;
62 f.name = name;
63 f.voted = false;
64 friends[friendAddress] = f;
65 numFriends++;
66 return numFriends;
67 }
68
69 /**
70 * @notice Vote for a restaurant
71 * @dev To simplify the code duplicate votes by a friend is not checked
72 *
73 * @param restaurant Restaurant number being voted
74 * @return validVote Is the vote valid? A valid vote should be from a registered
75 * friend to a registered restaurant
76 */
77 function doVote(uint restaurant) public votingOpen returns (bool validVote){
78 validVote = false; //Is the vote valid?
79 if (bytes(friends[msg.sender].name).length != 0) { //Does friend exist?
80 if (bytes(restaurants[restaurant]).length != 0) { //Does restaurant exist?
81 validVote = true;
82 friends[msg.sender].voted = true;
83 Vote memory v;
84 v.voterAddress = msg.sender;
85 v.restaurant = restaurant;
86 numVotes++;
87 votes[numVotes] = v;
88 }
89 }
90
91 if (numVotes >= numFriends/2 + 1) { //Quorum is met
92 finalResult();
93 }
94 return validVote;
95 }
96
97 /**
98 * @notice Determine winner restaurant
99 * @dev If top 2 restaurants have the same no of votes, result depends on vote order
100 */
101 function finalResult() private{
102 uint highestVotes = 0;
103 uint highestRestaurant = 0;
104
105 for (uint i = 1; i <= numVotes; i++){ //For each vote
106 uint voteCount = 1;
107 if(_results[votes[i].restaurant] > 0) { // Already start counting
108 voteCount += _results[votes[i].restaurant];
109 }
110 _results[votes[i].restaurant] = voteCount;
111
112 if (voteCount > highestVotes){ // New winner
113 highestVotes = voteCount;
114 highestRestaurant = votes[i].restaurant;
115 }
116 }
117 votedRestaurant = restaurants[highestRestaurant]; //Chosen restaurant
118 voteOpen = false; //Voting is now closed
119 }
120
121 /**
122 * @notice Only the manager can do
123 */
124 modifier restricted() {
125 require (msg.sender == manager, "Can only be executed by the manager");
126 _;
127 }
128
129 /**
130 * @notice Only when voting is still open
131 */
132 modifier votingOpen() {
133 require(voteOpen == true, "Can vote only while voting is open.");
134 _;
135 }
136 }
Line 3 tells that the code is written for Solidity and should not be used with a compiler earlier than version 0.8.0. The ∧ symbol says that the code is not designed to work on future compiler versions, e.g., 0.9.0. It should work on any version labelled as 0.8 .xx. This ensures that the contract is not compilable with a new (breaking) compiler version, where it may behave differently. These constraints are indicated using the pragma keyword, an instruction for the compiler. As Solidity is a rapidly evolving language and smart contracts are immutable, it is desirable even to specify a specific version such that all contract participants clearly understand the smart contract’s behaviour. You can further limit the compiler version using greater and less than signs, e.g., pragma solidity >=0 .8 .2 <0 .9 .0.
In line 8, we declare our smart contract as LunchVenue. The smart contract logic starts from this line and continues till line 135 (note the opening and closing brackets and indentation). Between lines 10 and 18, we define two structures that help to keep track of a list of friends and votes. We keep track of individual votes to avoid repudiation. Then we define a bunch of variables between lines 20 and 30. The address is a special data type in Solidity that refers to a 160-bit Ethereum address/account. An address could refer to a user or a smart contract. string and bool have usual meanings. uint stands for unsigned integer data type, i.e., nonnegative integers.
Lines 20-21 and 28-29 define several hash maps (aka map, hash table, or key-value store) to keep track of the list of restaurants, friends, votes, and results. A hash map is like a two-column table, e.g., in the restaurants hash map the first column is the restaurant number and the second column is the restaurant name. Therefore, given the restaurant number, we can find its name. Similarly, in friends, the first column is the Ethereum address of the friend, and the second column is the Friend structure that contains the friend’s name and vote status. Compared to some of the other languages, Solidity cannot tell us how many keys are in a hash map or cannot iterate on a hash map. Thus, the number of entries is tracked separately (lines 22-24). Also, a hash map cannot be defined dynamically.
The manager is used to keep track of the smart contract deployer’s address. Most voted restaurant and vote open state are stored in variables votedRestaurant and voteOpen, respectively.
Note the permissions of these variables. results variable is used only when counting the votes to determine the most voted restaurant. Because it does not need to be publically accessible it is marked as a private variable. Typically, private variables start with an underscore. All other three variables are public. Public variables in a Solidity smart contract can be accessed through smart contract functions, while private variables are not. The compiler automatically generates getter functions for public variables.
Lines 35-37 define the constructor, a special function executed when a smart contract is first created.
It can be used to initialise state variables in a contract. In line 36, we set the transaction/message sender’s address (msg.sender) that deployed the smart contract as the contract’s manager. The msg variable (together with tx and block) is a special global variable that contains properties about the blockchain. msg .sender is always the address where the external function call originates from.
addRestaurant and addFriend functions are used to populate the list of restaurants and friends that can vote for a restaurant. Each function also returns the number of restaurants and friends added to the contract, respectively. The memory keyword indicates that the name is a string that should be held in the memory as a temporary value. In Solidity, all string, array, mapping, and struct type variables must have a data location (see line 61). EVM provides two other areas to store data, referred to as storage and stack. For example, all the variables between lines 20 and 30 are maintained in the storage, though they are not explicitly defined.
restricted is a function modifier, which is used to create additional features or to apply restrictions on a function. For example, the restricted function (lines 124-127) indicates that only the manager can invoke this function. Therefore, function modifiers can be used to enforce access control. If the condition is satisfied, the function body is placed on the line beneath ;. Similarly, votingOpen (lines 132-135) is used to enforce that votes are accepted only when voteOpen is true.
The doVote function is used to vote, insofar as the voting state is open, and both the friend and restaurant are valid (lines 77-95). The voter’s account is not explicitly defined, as it can be identified from the transaction sender’s address (line 79). This ensures that only an authorised user (attested through their digital signature attached to the transaction) can transfer tokens from their account. It further returns a Boolean value to indicate whether voting was successful. In line 91, after each vote, we check whether the quorum is reached. If so, the finalResults private function is called to choose the most voted reataurant. This function uses a hash map to track the vote count for each restaurant, and the one with the highest votes is declared as the lunch venue. The voting state is also marked as no longer open by setting voteOpen to false (line 118).
Let us now create and compile this smart contract. For this, we will use Remix IDE, an online Integrated Development Environment (IDE) for developing, testing, deploying, and administering smart contracts for Ethereum-like blockchains. Due to zero setup and a simple user interface, it is a good learning platform for smart contract development.
Step 1. Using your favourite web browser, go to https://remix.ethereum.org/.
Step 2. Click on the File explorer icon (symbol with two documents) from the set of icons on the left. Select the contracts folder - the default location for smart contracts on Remix. Then click on Create new file icon (small document icon), and enter LunchVenue .sol as the file name.
Alternatively, you can click on the New File link in Home tab. If the file is created outside the contracts folder, make sure to move it into the contracts folder.
Step 3. Type the above smart contract code in the editor. Better not copy and paste the above code from PDF, as it may introduce hidden characters or unnecessary spaces preventing the contract from compiling.
Step 4. As seen in Fig. 2, set the compiler options are as follows, which can be found under Solidity compiler menu option on the left:
• Compiler - 0 .8 .5+ . . . . (any commit option should work)
• In Advanced Configurations select Compiler configuration — Language - Solidity
— EVM Version - default
• Make sure Hide warnings is not ticked. Others are optional.
Step 5. Then click on the Compile LunchVenue .sol button. Carefully fix any errors and warnings. While Remix stores your code on the browser storage, it is a good idea to link it to your Github account. You may also save a local copy.
Step 6. Once compiled, you can access the binary code by clicking on the Bytecode link at the bottom left, which will copy it to the clipboard. Paste it to any text editor to see the binary code and EVM instructions (opcode).
Similarly, using the ABI link, you can check the Application Binary Interface (ABI) of the smart contract. ABI is the interface specification to interact with a contract in the Ethereum ecosystem. Data are encoded as a JSON (JavaScript Object Notation) schema that describes the set of functions, their parameters, and data formats. Also, click on the Compilation Details button to see more details about the contract and its functions.
4 Deploying the Smart Contract
First, let us test our smart contract on Remix JavaScript VM to ensure that it can be deployed without much of a problem. Remix JavaScript VM is a simulated blockchain environment that exists in your browser. It also gives you 10 pre-funded accounts to test contracts. Such simulated testing helps us validate a smart contract’s functionality and gives us an idea about the transaction fees.
Ethereum defines the transaction fee as follows:
transaction fee = gas limit × gasprice (1)
The gas limit defines the maximum amount of gas we are willing to pay to deploy or execute a smart contract. This should be determined based on the computational and memory complexity of the code, the volume of data it handles, and bandwidth requirements. If the gas limit is set too low, the smart contract could terminate abruptly as it runs out of gas. If it is too high, errors such as infinite loops could consume all our Ether. Hence, it is a good practice to estimate the gas limit and set a bit higher
Figure 2: Compiler options.
value to accommodate any changes during the execution (it is difficult to estimate the exact gas limit as the execution cost depends on the state of the blockchain).
The gas price determines how much we are willing to pay for a unit of gas. When a relatively higher gas price is offered, the time taken to include the transaction in a block typically reduces. Most blockchain explorers, such as Etherscan.io, provide statistics on market demand for gas price. It is essential to consider such statistics when using the Ethereum production network to achieve a good balance between transaction latency and cost.
Step 7. Select Deploy & run transactions menu option on the left. Then set the options as follows (see Fig. 3):
• Environment - Remix VM (Shanghai)
• Account - Pick one of the accounts with some Ether
• Gas Limit - 3000000 (use the default)
• Value - 0 (we are not transferring any Ether to the smart contract)
• Contract - LunchVenue
Step 8. Click on the Deploy button. This should generate a transaction to deploy the LunchVenue contract. As seen in Fig. 4, you can check the transaction details and other status information, including any errors at the bottom of Remix (called Remix Console area). Click on the ∨ icon next to Debug button at the bottom left of the screen. Note values such as status, contract address, transaction cost, and execution cost. In the next section, we interact with our contract.
Figure 3: Deployment settings.
Figure 4: Details of the transaction that deployed the contract.
5 Manual Testing
Now that you have deployed the LunchVenue contract onto the Remix JavaScript VM, let us test it manually via Remix to make sure it works as intended.
Figure 5: Interacting with the contract.
Step 9. As seen in Fig. 5, we can interact with the deployed contract using the functions under Deployed Contracts. Expand the user interface by clicking on the > symbol where it says LUNCHVENUE AT 0X . . .
Those buttons can be used to generate transactions to invoke respective functions. For example, by clicking on the manager button, we can see that manager’s address is set to the address of the account we used to deploy the smart contract. The address selected in the ACCOUNT drop-down (scroll up to see the drop-down list) is the one we used to deploy the contract. When we click the button, Remix issues a transaction to invoke the getter function that returns the manager’s address. The respective transaction will appear on the bottom of the screen. Getter functions are read-only (when the compiler generates them, they are marked as view only functions), so they are executed only on the node where the transaction is submitted. A read-only transaction does not consume gas as it is not executed across the blockchain network.
Similarly, check the numFriends, numVotes, and voteOpen variables by clicking on the respective buttons.
Step 10. To add yourself as a voter/friend, click on the ∨ icon next to the addFriend button, which should show two textboxes to enter input parameters. As the address selected in the ACCOUNT drop-down list was used to deploy the contract, let us consider that to be your address. Copy this address by clicking on the icon with two documents. Then paste it onto the friendAddress: textbox. Enter your name in the name: textbox. Then click the transact button.
This generates a new transaction, and you can check the transaction result in the Remix Console area. Note that decoded output attribute in Remix Console indicates the function returned the number of friends in the contract as one. Alternatively, the getter function provided by the numFriends button can be used to verify that a friend is added to the contract. We can also recall details of a friend by providing his/her address to the friends getter function.
Step 11. Go to the ACCOUNT drop-down list and select any address other than the one you used to deploy the contract. Copy that address. Return to the addFriend function and fill up the two textboxes with the copied address and a dummy friend name. Then click the transact button.
This transaction should fail. Check Remix Console area for details. While you cannot find the reason for failure (more on this later), you will see that the transaction still got mined and gas was consumed. The transaction failed due to the access control violation where the transaction’s from address (i.e., msg.sender) did not match the manager’s address stored in the contract (see lines 123-126).
Step 12. Let us add another friend as a voter. Go to the ACCOUNT drop-down list and copy the second address. After copying, reselect the address used to deploy the contract from the drop-down. Return to the addFriend function and paste the address we copied to friendAddress: textbox and enter the friend’s name. Click the transact button. This should generate a new transaction. Check the transaction details, as well as make sure that numFriends is now increased to two.
Repeat this step three more times to add a total of five voters. Each time make sure to copy a different address from the ACCOUNT drop-down list.
Step 13. Next, we add restaurants. Expand the addRestaurant function by clicking on the ∨ icon. Enter a restaurant name and then click the transact button. Check the transaction details on the Remix Console. Use numRestaurants and restaurants getter functions to make sure the restaurant is successfully added. Also, note the difference in gas consumed by addRestaurant and addFriend functions.
Repeat this step once or twice to add total of two to three restaurants.
Step 14. It is time to vote. Let us first vote as a friend. Go to the ACCOUNT drop-down list and select the second address. Expand the doVote function. Enter one of the restaurant numbers into the restaurant: textbox. If you do not remember a restaurant’s number, use restaurants getter function to find it. Then click the transact button.
You can check the function’s output under decoded output in Remix Console. A successful vote should be indicated by true.
Step 15. This time try to vote for a restaurant that does not exist, e.g., if you added three restaurants try voting for restaurant number four. While the transaction will be successful you will see that decoded output is set to false indicating that vote is invalid.
Step 16. Next, try to vote from an address that is not in the friend list. Go to the ACCOUNT drop-down list and select an address that you did not register as a voter. Return to doVote function and then vote for a valid restaurant number. While the transaction will be successful you will see that decoded output is again set to false indicating that vote is invalid.
Step 17. Go to the ACCOUNT drop-down list and select an address that you registered as a friend. Return to doVote function and then vote for a valid restaurant number. Keep voting from different valid addresses to valid restaurants.
Once the quorum is reached, the contract will select the more voted restaurant as the lunch venue.
The selected venue can be found by calling the votedRestaurant getter function.
Try to issue another vote and see what happens to the transaction.