1、规范
(1)命名
习惯上函数中的参数变量是以_(下划线)开头,以区别全局变量;私有函数名字也用_(下划线)开头。
pragma solidity ^0.6.0;
contract Student {
string name;
//私有函数名_create 与参数变量_name,都以_开头
function _create(string _name) private {
name = _name;
}
}
(2)注释
solidity使用的一个注释标准被称作natspec的格式:
使用三个反斜杠 /// 进行注释
/// @title 一个简单的基础运算合约
/// @author Tracy
/// @notice 目前这个合约只添加一个加法
contract Math {
/// @notice 两个数相加
/// @param x 第一个 uint
/// @param y 第二个 uint
/// @return z (x + y) 的结果
/// @dev 目前这个方法不检查溢出
function add(uint x, uint y) external pure returns (uint z) {
// 这只是个普通的注释,不会被 natspec 解释
z = x + y;
}
}
我们在查看ERC20或ERC721接囗规范时,都能看到这样的natspec格式,以下简单解释下标签作用:
@title 标题
@author 作者
@notice 须知,解释这个方法或者合约是做什么的
@dev 开发者,向开发者解释更多细节
@param 参数,描述方法需要传入什么参数
@return 返回值 ,描述方法返回什么值
以上标签都是可选的,并不需要都用,但最少用一个@dev注释来解释每个方法是做什么的。
使用双星开头的块 /** ... */ 进行注释
/** @title 一个简单的基础运算合约
@author Tracy
@notice 目前这个合约只添加一个加法
*/
contract Math {
/** @notice 两个数相加
@param x 第一个 uint
@param y 第二个 uint
@return z (x + y) 的结果
@dev 目前这个方法不检查溢出
*/
function add(uint x, uint y) external pure returns (uint z) {
// 这只是个普通的注释,不会被 natspec 解释
z = x + y;
}
}
2、变量、变量类型、变量修饰符
(1)状态变量
状态变量会被永久地保存在合约中,也就是它们会被写入以太坊区块链中。
(2)动态数组
动态数组可以不断添加元素,在合约中创建动态数组保存数据是非常有意义的;在动态数组添加元素array.push(),新元素加在数组的尾部,所以元素在数组中的顺序就是添加的顺序。array.push()返回数组的长度,类型是uint。
若动态数组是memory时,则不能使用push添加元素,可以先定义一个数组长度,再添加元素,举个列子:
/// @dev 查看所有男生/女生
function list_sex(bool _sex) external override view returns (StudentInfo[] memory) {
StudentInfo[] memory retList = new StudentInfo[](getSexCount(_sex));
StudentInfo memory studentTmp;
uint256 index = 0;
for (uint256 i = 0; i < studentList.length; i++) {
if (studentList[i].sex == _sex) {
studentTmp = studentList[i];
retList[index] = studentTmp;
index++;
}
}
return retList;
}
/// @dev 查看所有男生/女生数量
function getSexCount(bool _sex) internal view returns (uint256) {
uint256 count = 0;
for (uint256 i = 0; i < studentList.length; i++) {
if (studentList[i].sex == _sex) {
count++;
}
}
return count;
}
若上面代码中直接使用 retList.push(studentTmp),则会报错:TypeError: Member "push" is not available in struct StudentInfo memory[] memory outside of storage.
(3)数组初始化
// 初始化一个长度为3的内存数组
uint[] memory values = new uint[](3);
(4)字符串
solidity不支持原生的字符串比较,需要通过比较两个字符的keccak256哈希值进行判断
//比较2个字符类型是否相等
function isEqual(string memory a, string memory b) internal pure returns(bool) {
bytes32 hashA = keccak256(abi.encode(a));
bytes32 hashB = keccak256(abi.encode(b));
return ( hashA == hashB );
}
(5)storage与memory区别
storage变量永久存储在区块链中,memory变量则是临时的,当外部函数对某合约调用完成时,memory变量即被移除。状态变量(在函数之外声明的变量)思想文化 为storage,并永久写入区块链;而在函数内部声明的变量是memory,函数调用结束后即消失。
3、函数相关
(1)函数修饰符 public
solidity定义的函数的属性默认为public,即表示任何合约都可以调用这个函数。
(2)函数修饰符 internal与private区别
internal与private类似,区别在于,如果某个合约继承父合约,这个合约可以访问父合约中的internal函数,但不能访问private函数。
(3)函数修饰符 external与public区别
external与public类似,区别在于,external只能在合约之外调用,不能被合约内其他函数调用,public即可被合约外调用,也可被合约内的函数调用。
(4)函数返回多个值
函数返回多个值时,如果只想要其中一个返回变量, 可以使用,(逗号分隔)对其他字段留空;注意返回值使用()括号括起来:
function multipleReturns() internal returns(uint a, uint b, uint c) {
return (1, 2, 3);
}
function getLastReturnValue() external {
uint c;
// 对其他字段留空:
(,,c) = multipleReturns();
}
(5)检查 public和external函数
仔细 检查所有声明public和external函数,一个个排除用户滥用它们的可能,谨防安全漏洞,如果这些函数没有类似onlyOwner(权限控制)这样的函数修饰符,用户可能利用各种可能的参数去调用这些函数。onlyOwner可参见 Access Control - OpenZeppelin Docs
4、gas优化相关
(1)结构体封装会省gas
在solidity中,uintx的大小无论是多少(如uint8、uint16、uint32等),都为它保留256位的存储空间,因此不会节省任何gas。但把一些uint绑定到struct中,solidity会这些uint打包在一起,从而占用较少的存储空间。
struct A {
uint a;
uint b;
uint c;
}
struct B{
uint32 a;
uint32 b;
uint c;
}
// 因为使用了结构打包,`B` 比 `A` 占用的空间更少
A a = A(10, 20, 30);
B b = B(10, 20, 30);
当uint定义在一个struct中时,尽量使用最小的整数子类型(如uint8、uint16、uint32等)以节约空间; 并且把同样类型的变量放在一起(在struct中将把变量按照类型依次放置),这样solidity可以将存储空间最小化。如下结构体C比结构体D占用空间更少,所以gas消耗也会更少。
struct C {
uint32 a;
uint32 b;
uint c;
}
struct D{
uint32 a;
uint b;
uint32 c;
}
(2)view修饰符在外部调用时(external view)不花费gas
view函数不会改变区块链上的任何数据,只会读取数据,运行view函数只需要查询自己本地以太坊节点,它不需要在区块链上创建一个事务(事务需要运行在每个节点上,因此花费gas),因此不需要gas。
注意:如果一个view函数在另一个函数的内部被调用,而调用函数与view函数不属于同一个合约,也会产生调用成本。这是因为如果主调函数在以太坊创建了一个事务,它仍然需要逐个节点去验证。所以标记为view的函数只有在外部调用时才是免费的。
(3)storage动态数组元素不要删除(元素只添加或修改,不删除)
针对storage的动态数组变量,如果删除数组元素,删除元素后面的元素都要前移,即执行写操作,由于写入存储是solidity中最费gas的操作之一,所以删除元素非常消耗gas。更糟糕的是,每次删除元素不一定是同一位置,所以调用函数时每次的gas都不同。当然我们也可以把数组中最后一个元素移到删除元素的位置,并将数组长度减一,但这样每做一笔交易,都会改变数组元素的顺序。
5、时间
(1)now(<0.7.0)block.timestamp(0.7.0+)
now 返回当前的unix时间戳(即自1970-01-01 00:00:00以来经过的秒数),返回类型默认是uint256,unix时间一般会用一个32位的整数uint32进行存储,但会导致“2038年”的问题,即到2038年32位的整型时间戳不够用,会产出溢出。
now在0.7.0版本被移除了,0.7.0及以后版本使用block.timestamp。
(2)时间单位
solidity时间单位包含秒(seconds)、分钟(minutes)、小时(hours)、天(days)、周(weeks)、年(years)等,都是复数加s(1 days),它们都会转换成对应的秒数放入uint中。所以1分钟是60秒,1小时是3600秒,1天是86400秒,依次类推。
1 == 1 seconds
1 minutes = 60 seconds
1 hours = 60 minutes
1 days = 24 hours
1 weeks = 7 days
years在0.5.0版本移除了,因为闰年的原因。
6、事件
事件是合约和区块链通讯的一种机制,前端 应用“监听”某些事件,并做出反应。
7、合约调用合约
一个合约调B用另一个合约A的函数a,需要先定义一个接囗,接囗中声明合约A中的函数(接囗中没有状态变量或其它函数,且函声明的函数没有使用{},只用;(分号)结束函数声明),合约B声明合约A中要使用的函数a,可参见 solidity 合约通过接囗调用另一合约方法_ling1998的博客-优快云博客
8、合约与库的区别
库(library)使用using关键字,可以把库的所有方法添加给一个数据类型,如下面是一个安全运算库,里面有一个加法方法:
library SafeMath {
//两数相加
function add(uint256 a, uint256 b) internal pure returns (uint256) {
uint256 c = a + b;
assert(c >= a);
return c;
}
}
在合约中使用using来引用库的所有方法添加给数据类型uint,库在调用方法的时候uint自动被作为第一个参数传递:
contract Test {
//引入库
using SafeMath for uint;
function add(uint a, uint b) external pure returns (uint) {
//使用库中加法
return a.add(b);
}
}
在上面引用库作用于类型uint(using SafeMath for uint),如果在uint8上使用库中的add方法,并不能防止溢出,因为会把uint8的变量都转化为uint。如果要作用于uint8,需要再写一个库支持uint8安全运算,如下所示(方法中除uint256与uint8类型不一样,其它都一样):
library SafeMath8 {
//两数相加
function add(uint8 a, uint8 b) internal pure returns (uint8) {
uint8 c = a + b;
assert(c >= a);
return c;
}
}
9、判断是否是合约地址(2种方法)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;
contract isContract{
function isContract1(address _addr) external view returns (bool) {
//若为外部账户false,若为合约账户true
return _addr.code.length > 0;
}
function isContract2(address _addr) external view returns (bool) {
uint256 _size;
//若为外部账户_size = 0,若为合约账户 _size > 0
assembly { _size := extcodesize(_addr) }
return _size > 0;
}
}
10、导入import
通过url导入合约
注意:
- 版本号与导入合约版本一致
- 可为导入合约起别名,调用导入的合约时加上别名,别名不能为关键字(如 alias)
// SPDX-License-Identifier: MIT
pragma solidity ^0.7.6;
import "https://github.com/tracyzhang1998/smartcontract/blob/main/Security/Reentrancy/reentrancy.sol" as alias_new;
contract TestImport {
function getAddr() external returns (address) {
//调用导入合约时,加上别名
alias_new.EtherStore addr = new alias_new.EtherStore();
return address(addr);
}
}
11、send与transfer区别
send是transfer的低级版本,如果执行失败,send会返回false,当前合约不会因为异常而终止。transfer如果执行失败,则会因异常而终止。
send 与 transfer 发送 2300gas 矿工费,且不可调节。
测试示例源码
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;
contract TestSendAndTransfer {
/// @dev 状态变量 - 用于测试send与trnasfer
string message;
/// @dev 构造函数,初始化状态变量message
constructor() {
message = "hello";
}
/// @dev 存款,若部署时忘记存款,可直接调用此函数向合约账户存款
function deposit() external payable {
}
/// @dev 发送以太 - send
function sendEther(address _addr) external returns (bool) {
bool result = payable(_addr).send(2);
if (result) {
message = "send success";
} else {
message = "send fail";
}
return result;
}
/// @dev 发送以太 - transfer
function transferEther(address _addr) external {
payable(_addr).transfer(2);
message = "transfer success";
}
/// @dev 查看合约账户余额
function getContractBalance() external view returns (uint256) {
return address(this).balance;
}
/// @dev 查看message
function getMsg() external view returns (string memory) {
return message;
}
/// @dev 设置message
function setMsg(string memory _message) external {
message = _message;
}
}
测试send失败的情况
步骤:
- 当合约账户没有余额时,向另一个账户地址发送以太(或是当前合约账户有余额,向一个没有fallback函数的合约账户发送以太),查看交易 数据,返回false
- 查看状态变量message,已由初始值”hello“变为”send fail“
测试send成功的情况
步骤:
- 向合约账户充值,查看合约账户余额,确保大于2Wei
- 调用sendEther函数,向用户账户转账
- 查看合约账户余额,已经少了2Wei
- 查看message已变为“send success”
测试transfer失败的情况
合约账户没有余额时,向用户账户转账