pseudoyu

pseudoyu

Blockchain | Programming | Photography | Boyi
github
twitter
telegram
mastodon
bilibili
jike

Solidity Smart Contract Development - Basics

Preface#

Last year, while studying for my master's degree at HKU in the course <COMP7408 Distributed Ledger and Blockchain Technology>, I learned about Ethereum smart contract development and created a simple library management DApp. For my graduation project, I also chose to develop a music copyright application based on Ethereum, detailed in Uright - Blockchain Music Copyright Management DApp, which gave me a basic understanding of Solidity development.

After starting work, I mainly focused on consortium chains and business development, and I hadn't dealt with contracts for a long time. My understanding of the syntax and some underlying concepts had become vague. Recently, I worked on a project based on an EVM chain that involved developing some basic evidence storage, verification, and migration-related contracts. Debugging was somewhat challenging, so I decided to systematically study and organize my notes into articles to encourage myself to think and summarize.

This series of articles will also be included in my personal knowledge base project, 《Blockchain Beginner's Guide》, with the hope of continuous improvement during the learning process. Interested friends can also visit the project repository to contribute or provide suggestions.

This article is the first in the series, mainly covering the basics of Solidity.

Smart Contracts and Solidity Language#

A smart contract is a program that runs on the blockchain. Contract developers can use smart contracts to interact with on-chain assets/data, and users can call contracts through their on-chain accounts to access assets and data. Due to the blockchain's characteristics of retaining historical records in a chain structure, decentralization, and immutability, smart contracts can be more fair and transparent compared to traditional applications.

However, because smart contracts need to interact with the chain, operations such as deployment and data writing incur certain costs, and the costs of data storage and changes are relatively high. Therefore, resource consumption needs to be carefully considered when designing contracts. Additionally, conventional smart contracts cannot be modified once deployed, so security, upgradability, and scalability should also be prioritized during contract design.

Solidity is a high-level programming language designed for implementing smart contracts, running on the EVM (Ethereum Virtual Machine). Its syntax is similar to JavaScript and is currently the most popular smart contract language, essential for entering the blockchain and Web3 space. Solidity provides relatively complete solutions for the aforementioned contract writing issues, which will be explained in detail later.

Development/Debugging Tools#

Unlike conventional programming languages, Solidity smart contract development often cannot be conveniently debugged directly through an IDE or local environment; instead, it requires interaction with an on-chain node. Development and debugging typically do not directly interact with the mainnet (the chain where real assets, data, and business reside), as this would incur high transaction fees. Currently, there are several main methods and frameworks for development and debugging:

  1. Truffle. Truffle is a very popular JavaScript Solidity contract development framework that provides a complete development, testing, and debugging toolchain, allowing interaction with local or remote networks.
  2. Brownie. Brownie is a Python-based Solidity contract development framework that offers a convenient toolchain for debugging and testing with concise Python syntax.
  3. Hardhat. Hardhat is another JavaScript-based development framework that provides a rich plugin system, suitable for developing complex contract projects.

In addition to development frameworks, familiarity with some tools is also necessary for better Solidity development:

  1. Remix IDE. The Ethereum official browser-based Remix development tool allows for debugging, providing a complete IDE, compilation tools, deployment debugging test node environment, accounts, etc., making testing very convenient. This is the tool I used most during my learning. Remix can also interact directly with testnets and mainnets through the MetaMask plugin, and some production environments use it for compilation and deployment.
  2. Remix IDE's syntax suggestions are not perfect, so it can be paired with Visual Studio Code and Solidity for a better experience.
  3. MetaMask. A commonly used wallet application that allows developers to interact with testnets and mainnets through a browser plugin during development, facilitating debugging.
  4. Ganache. Ganache is an open-source virtual local node that provides a virtual chain network, allowing interaction with various Web3.js, Remix, or some framework tools, suitable for local debugging and testing of projects of a certain scale.
  5. Infura. Infura is an IaaS (Infrastructure as a Service) product that allows us to apply for our own Ethereum node and interact through the API provided by Infura, making debugging very convenient and closer to the production environment.
  6. OpenZeppelin. OpenZeppelin provides a wealth of contract development libraries and applications that ensure security and stability while offering developers a better development experience and reducing contract development costs.

Contract Compilation/Deployment#

Solidity contracts are files with a .sol suffix that cannot be executed directly; they need to be compiled into bytecode recognizable by the EVM (Ethereum Virtual Machine) to run on the chain.

compile_solidity

After compilation, the contract account deploys it to the chain, and other accounts can interact with the contract through wallets to implement on-chain business logic.

Core Syntax#

Having gained some understanding of Solidity development, debugging, and deployment, let's specifically learn about the core syntax of Solidity.

Data Types#

Similar to common programming languages, Solidity has some built-in data types.

Basic Data Types#

  • boolean, the boolean type has two values: true and false, which can be defined as bool public boo = true;, with a default value of false.
  • int, integer type, can specify int8 to int256, defaulting to int256, defined as int public int = 0;, with a default value of 0. You can also check the minimum and maximum values of the type using type(int).min and type(int).max.
  • uint, non-negative integer type, can specify uint8, uint16, uint256, defaulting to uint256, defined as uint8 public u8 = 1;, with a default value of 0.
  • address, address type, defined as address public addr = 0xCA35b7d915458EF540aDe6068dFe2F44E8fa733c;, with a default value of 0x0000000000000000000000000000000000000000.
  • bytes, shorthand for byte[], divided into fixed-size arrays and dynamic arrays, defined as bytes1 a = 0xb5;.

There are also some relatively complex data types that we will explain separately.

Enum#

Enum is an enumeration type, defined using the following syntax:

enum Status {
    Unknown,
    Start,
    End,
    Pause
}

And updated and initialized using the following syntax:


// Instantiate the enum type
Status public status;

// Update the enum value
function pause() public {
    status = Status.Pause;
}

// Initialize the enum value
function reset() public {
    delete status;
}

Arrays#

Arrays are an ordered collection of elements of the same type, defined as uint[] public arr;. You can predefine the size of the array when defining it, such as uint[10] public myFixedSizeArr;.

It is important to note that we can create arrays in memory (the differences between memory and storage will be explained in detail later), but they must be of fixed size, such as uint[] memory a = new uint[](5);.

Array types have some basic operation methods, as follows:

// Define array type
uint[7] public arr;

// Add data
arr.push(7);

// Remove the last data
arr.pop();

// Remove data at a specific index
delete arr[1];

// Get array length
uint len = arr.length;

Mapping#

mapping is a mapping type, defined using mapping(keyType => valueType), where the key must be a built-in type, such as bytes, int, string, or contract type, while the value can be any type, including nested mapping types. Note that mapping types cannot be iterated; if iteration is needed, the corresponding index must be implemented manually.

Here are some operations explained:

// Define nested mapping type
mapping(string => mapping(string => string)) nestedMap;

// Set value
nestedMap[id][key] = "0707";

// Read value
string value = nestedMap[id][key];

// Delete value
delete nestedMap[id][key];

Struct#

struct is a structure type. For complex business logic, we often need to define our own structures to combine related data, which can be defined within the contract:

contract Struct {
    struct Data {
    	string id;
    	string hash;
    }

    Data public data;

    // Add data
    function create(string calldata _id) public {
    	data = Data{id: _id, hash: "111222"};
    }

    // Update data
    function update(string _id) public {
    	// Query data
    	string id = data.id;

        // Update
        data.hash = "222333";
    }
}

You can also define all required structure types in a separate file and import them as needed:

// 'StructDeclaration.sol'

struct Data {
	string id;
	string hash;
}
// 'Struct.sol'

import "./StructDeclaration.sol";

contract Struct {
	Data public data;
}

Variables/Constants/Immutable#

Variables are a data structure in Solidity that can change values, divided into the following three types:

  • local variables
  • state variables
  • global variables

Among them, local variables are defined within methods and are not stored on the chain, such as string var = "Hello";; while state variables are defined outside methods and are stored on the chain, defined as string public var;, where writing a value sends a transaction, but reading a value does not; global variables provide global variables with chain information, such as the current block timestamp variable, uint timestamp = block.timestamp;, and the contract caller address variable, address sender = msg.sender;, etc.

Variables can be declared using different keywords to indicate different storage locations.

  • storage, stored on the chain
  • memory, exists only when the method is called
  • calldata, exists when passed as parameters to the calling method

Constants are variables whose values cannot change, and using constants can save gas costs. We can define a constant as string public constant MY_CONSTANT = "0707";. immutable is a special type that can be initialized in the constructor but cannot be changed again. Flexibly using these types can effectively save gas costs and ensure data security.

Functions#

In Solidity, functions are used to define specific business logic.

Permission Declaration#

Functions have different visibility, declared using different keywords:

  • public, callable by any contract
  • private, callable only within the contract that defines the method
  • internal, callable only in inherited contracts
  • external, callable only by other contracts and accounts

Contract functions for querying data also have different declaration methods:

  • view can read variables but cannot change them
  • pure cannot read or modify

Function Modifiers#

modifier function modifiers can be called before/after a function runs, mainly used for permission control, validating input parameters, and preventing reentrancy attacks. These three functional modifiers can be defined using the following syntax:

modifier onlyOwner() {
	require(msg.sender == owner, "Not owner");
    _;
}

modifier validAddress(address _addr) {
	require(_addr != address(0), "Not valid address");
	_;
}

modifier noReentrancy() {
	require(!locked, "No reentrancy");
	locked = true;
	_;
	locked = false;
}

Using function modifiers requires adding the corresponding modifier in the function declaration, such as:

function changeOwner(address _newOwner) public onlyOwner validAddress(_newOwner) {
	owner = _newOwner;
}

function decrement(uint i) public noReentrancy {
	x -= i;

	if (i > 1) {
		decrement(i - 1);
	}
}

Function Selector#

When a function is called, the first four bytes of calldata must specify which function to confirm, known as the function selector.

addr.call(abi.encodeWithSignature("transfer(address,uint256)", 0xSomeAddress, 123))

The first four bytes of the return value from abi.encodeWithSignature() in the above code are the function selector. If we pre-calculate the function selector before execution, we can save some gas costs.

contract FunctionSelector {
	function getSelector(string calldata _func) external pure returns (bytes4) {
		return bytes4(keccak256(bytes(_func)));
	}
}

Conditional/Loop Structures#

Conditionals#

Solidity uses the if, else if, and else keywords to implement conditional logic:

if (x < 10) {
	return 0;
} else if (x < 20) {
	return 1;
} else {
	return 2;
}

A shorthand form can also be used:

x < 20 ? 1 : 2;

Loops#

Solidity uses the for, while, and do while keywords to implement loop logic, but since the latter two can easily reach the gas limit boundary, they are generally not used.

for (uint i = 0; i < 10; i++) {
	// Business logic
}
uint j;
while (j < 10) {
	j++;
}

Contracts#

Constructor#

Solidity's constructor can be executed when creating a contract, mainly used for initialization:

constructor(string memory _name) {
	name = _name;
}

If there is an inheritance relationship between contracts, the constructor will also follow the inheritance order.

Interface#

Interface allows contract interaction by declaring interfaces, with the following requirements:

  • Cannot implement any methods
  • Can inherit other interfaces
  • All methods must be declared as external
  • Cannot declare constructors
  • Cannot declare state variables

Interfaces are defined using the following syntax:

contract Counter {
	uint public count;

	function increment() external {
		count += 1;
	}
}

interface ICounter {
	function count() external view returns (uint);
	function increment() external;
}

Calling is done through:

contract MyContract {
	function incrementCounter(address _counter) external {
		ICounter(_counter).increment();
	}

	function getCount(address _counter) external view returns (uint) {
		return ICounter(_counter).count();
	}
}

Inheritance#

Solidity contracts support inheritance and can inherit multiple contracts simultaneously using the is keyword.

Functions can be overridden; the methods to be inherited must be declared as virtual, and the overridden methods must use the override keyword.

// Define parent contract A
contract A {
	function foo() public pure virtual returns (string memory) {
		return "A";
	}
}

// Contract B inherits from contract A and overrides the function
contract B is A {
	function foo() public pure virtual override returns (string memory) {
		return "B";
	}
}

// Contract D inherits from contracts B and C and overrides the function
contract D is B, C {
	function foo() public pure override(B, C) returns (string memory) {
		return super.foo();
	}
}

It is important to note that the order of inheritance can affect business logic, and state variables cannot be inherited.

If a child contract wants to call a parent contract, it can do so directly or use the super keyword, as shown below:

contract B is A {
	function foo() public virtual override {
        // Direct call
		A.foo();
	}

	function bar() public virtual override {
    	// Call using super keyword
		super.bar();
	}
}

Contract Creation#

In Solidity, you can create another contract from one contract using the new keyword:

function create(address _owner, string memory _model) public {
	Car car = new Car(_owner, _model);
	cars.push(car);
}

Since solidity 0.8.0, the create2 feature has been supported for contract creation:

function create2(address _owner, string memory _model, bytes32 _salt) public {
	Car car = (new Car){salt: _salt}(_owner, _model);
	cars.push(car);
}

Importing Contracts/External Libraries#

In complex business scenarios, we often need multiple contracts to work together, which can be done using the import keyword to import contracts, divided into local imports import "./Foo.sol"; and external imports import "https://github.com/owner/repo/blob/branch/path/to/Contract.sol";.

External libraries are similar to contracts but cannot declare state variables or send assets. If all methods of a library are internal, it will be embedded in the contract; if not internal, the library needs to be deployed in advance and linked.

library SafeMath {
	function add(uint x, uint y) internal pure returns (uint) {
		uint z = x + y;
		require(z >= x, "uint overflow");
		return z;
	}
}
contract TestSafeMath {
	using SafeMath for uint;
}

Events#

The event mechanism is a very important design in contracts. Events allow information to be recorded on the blockchain, and applications like DApps can implement business logic by listening to event data, with very low storage costs. Here is a simple log emission mechanism:

// Define events
event Log(address indexed sender, string message);
event AnotherLog();

// Emit events
emit Log(msg.sender, "Hello World!");
emit Log(msg.sender, "Hello EVM!");
emit AnotherLog();

When defining events, you can pass the indexed attribute, but a maximum of three can be added, allowing filtering of parameters for this attribute, var event = myContract.transfer({value: ["99","100","101"]});.

Error Handling#

Error handling on-chain is also an important aspect of contract writing. Solidity can throw errors in several ways.

require is used to validate conditions before execution; if not met, it throws an exception.

function testRequire(uint _i) public pure {
	require(_i > 10, "Input must be greater than 10");
}

revert is used to mark errors and perform rollbacks.

function testRevert(uint _i) public pure {
	if (_i <= 10) {
		revert("Input must be greater than 10");
	}
}

assert requires certain conditions to be met.

function testAssert() public view {
	assert(num == 0);
}

Note that in Solidity, when an error occurs, all state changes that happened in the transaction are rolled back, including all assets, accounts, contracts, etc.

try / catch can also catch errors, but only errors from external function calls and contract creations.

event Log(string message);
event LogBytes(bytes data);

function tryCatchNewContract(address _owner) public {
	try new Foo(_owner) returns (Foo foo) {
		emit Log("Foo created");
	} catch Error(string memory reason) {
		emit Log(reason);
	} catch (bytes memory reason) {
		emit LogBytes(reason);
	}
}

payable Keyword#

We can set methods to receive ether from contracts by declaring the payable keyword.

// Address type can be declared payable
address payable public owner;

constructor() payable {
	owner = payable(msg.sender);
}

// Method declared payable to receive Ether
function deposit() public payable {}

Interacting with Ether#

Interacting with Ether is an important application scenario for smart contracts, mainly divided into sending and receiving, each implemented by different methods.

Sending#

Sending is mainly implemented through transfer, send, and call methods, among which call optimizes the prevention of reentrancy attacks, and it is recommended to use it in practical applications (but generally not to call other functions).

contract SendEther {
  function sendViaCall(address payable _to) public payable {
  	(bool sent, bytes memory data) = _to.call{value: msg.value}("");
  	require(sent, "Failed to send Ether");
  }
}

If you need to call another function, you generally use delegatecall.

contract B {
	uint public num;
	address public sender;
	uint public value;

	function setVars(uint _num) public payable {
		num = _num;
		sender = msg.sender;
		value = msg.value;
	}
}

contract A {
	uint public num;
	address public sender;
	uint public value;

	function setVars(address _contract, uint _num) public payable {
		(bool success, bytes memory data) = _contract.delegatecall(
			abi.encodeWithSignature("setVars(uint256)", _num)
		);
	}
}

Receiving#

Receiving Ether is mainly done using receive() external payable and fallback() external payable.

When a function that does not accept any parameters and does not return any parameters is called, or when Ether is sent to a contract but the receive() method is not implemented or msg.data is non-empty, the fallback() method will be called.

contract ReceiveEther {

	// When msg.data is empty
	receive() external payable {}

    // When msg.data is non-empty
	fallback() external payable {}

	function getBalance() public view returns (uint) {
		return address(this).balance;
	}
}

Gas Fees#

Executing transactions in the EVM requires gas fees. gas spent indicates how much gas is needed, while gas price is the unit price of gas, with Ether and Wei as price units, where 1 ether == 1e18 wei.

Contracts impose gas limits; the gas limit is set by the user initiating the transaction, indicating the maximum gas to be spent, while the block gas limit is determined by the blockchain network, indicating the maximum gas allowed in that block.

In contract development, we should especially consider ways to save gas costs, with several common techniques:

  1. Use calldata instead of memory
  2. Load state variables into memory
  3. Use i++ instead of ++i
  4. Cache array elements
function sumIfEvenAndLessThan99(uint[] calldata nums) external {
	uint _total = total;
	uint len = nums.length;

	for (uint i = 0; i < len; ++i) {
		uint num = nums[i];
		if (num % 2 == 0 && num < 99) {
			_total += num;
		}
	}

	total = _total;
}

Summary#

This concludes the first article in our series on the basics of Solidity. Subsequent articles will summarize common applications and practical coding techniques, and I welcome everyone to continue following along.

References#

  1. Solidity by Example
  2. Ethereum Blockchain! Introduction to Smart Contracts and Decentralized Applications
  3. Blockchain Beginner's Guide
  4. Uright - Blockchain Music Copyright Management DApp
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.