Solidity随笔

本文介绍了Solidity的关键特性,包括this与msg.sender的区别,纯函数与视图函数,以及公共与外部访问修饰符。还详细讲解了ABI、函数选择器、合约创建和调用方法。同时,文章提到了OpenZeppelin库的常见功能,如ERC20和ERC721标准,以及合约的可升级性。此外,还讨论了hardhat的使用和UniswapV2的合约测试技术栈。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

TIPS:

常用命令

solcjs --include-path node_modules/ --include-path contracts/libraries --include-path interfaces --base-path .-o. --abi contracts/A3SQueue.sol

~/gopath/bin/abigen --bin=OneHiController_sol_OneHiController.bin --abi=OneHiController_sol_OneHiController.abi --pkg=oneHi --out=one_hi_controller.go

想调用某个合约的方法,找ABI时可以试试到区块浏览器找。

一、solidity基本特性

1、this和msg.sender的区别

this指的是合约本身,msg.sender指的是合约的调用者。

2、pure和view的区别

都是用于修饰函数,当函数不读不改状态变量时用pure
当函数只读不改状态变量时用view

3、在一个合约中可以直接调用另一个合约的方法。

方法是:首先写出要调用的合约(例如夺宝调用Fracton,被调用合约是Fracton)的对应接口,里面定义方法名和参数、返回值。
然后在主合约中(如夺宝)的构造方法内把被调用合约的地址放到本地状态变量中,import被调用合约,然后接口名(合约地址)实例化该被调用合约,就可以调用这个合约的方法了。

    address fftAddr = IFractonSwap(fractonSwapAddr).miniNFTtoFFT(miniNftAddr);

4、public和external的区别

public修饰的变量和函数,任何用户或者合约都能调用和访问。
private修饰的变量和函数,只能在其所在的合约中调用和访问,即使是其子合约也没有权限访问。
internal 和 private 类似,不过, 如果某个合约继承自其父合约,这个合约即可以访问父合约中定义的“内部”函数。
external 与public 类似,只不过这些函数只能在合约之外调用 - 它们不能被合约内的其他函数调用。

5、memory关键字

memory可直接理解为"内存",用于存储临时的变量,是相对于状态变量而言的,费用更便宜一些。

6、type关键字

type是关键字,但目前已知的用法只有:
type(contractName).creationCode;
type(int256).max
type(int256).min

7、abi是什么?abi.encode和abi.encodePacked是什么?

abi可以理解为接口文档,encode和encodePacked可以将若干个参数以任意顺序编码为bytecode,区别是encode会将每个参数编码为32字节并补0,而encodePacked不会补0。例如openZeppelin的工具utils/Create2 使用时传入的第三个参数,需要bytecode类型,虽然这两个方法参数的顺序可以任意,但为了输出的结果能被Create2使用,还是得按Create2要的那个顺序来。

8、状态变量加上public修饰符,编译器自动生成一个getter函数

9、mapping是无法被遍历的,想遍历mapping必须配合一个数组切片

10、如何创建合约?

可以通过关键字new或者调用内置函数create2,其中create2是加盐的,可以推导出合约地址。

11、函数的返回值

函数可以有多个返回值。
函数的返回值如果定义了变量名,那么可以在函数体中赋值,然后省略return语句。

12、修饰符可以用于加锁

    uint private unlocked = 1;
    modifier lock() {
        require(unlocked == 1, 'UniswapV2: LOCKED');
        unlocked = 0;
        _;
        unlocked = 1;
    }
//使用时:
function mint(address to) external lock returns (uint liquidity) {
//...
}

13、keccak256 是比SHA-256更优越的一种哈希函数

14、什么是4字节函数选择器?

"内置函数"的更严谨的说法应该是“全局变量”

abi.encodeWithSelector是将4字节函数选择器和参数进行编码。
参考:Solidity极简入门: 29. 函数选择器Selector

4字节函数选择器就是把函数签名(函数名+参数名)编码为4个字节的字节码
可用于选择调用的函数

参考:简书-Solidity Call函数
这里的token.call是底层的合约调用方法,不建议用。
推荐的方法是通过接口实例化后调用。

bytes4 private constant SELECTOR = bytes4(keccak256(bytes('transfer(address,uint256)')));


function _safeTransfer(address token, address to, uint value) private {
        (bool success, bytes memory data) = token.call(abi.encodeWithSelector(SELECTOR, to, value));
        require(success && (data.length == 0 || abi.decode(data, (bool))), 'UniswapV2: TRANSFER_FAILED');
    }

15、取时间戳的最佳实践

原因:时间戳是uint256,只需要保留最低的32位即可,但不可直接强转。
好处:节省gas。

uint32 blockTimestamp = uint32(block.timestamp % 2**32);

16、assembly关键字的作用是?

uniswap v2有这么一段代码:

function createPair(address tokenA, address tokenB) external returns (address pair) {
        //...
        assembly {
            pair := create2(0, add(bytecode, 32), mload(bytecode), salt)
        }
        //...
    }

查文档得知assembly为solidity内置的内联汇编语言。

这段代码是很底层的了,目前的开发者已经不需要再写这种代码,因为OpenZeppelin的utils/Create2已经封装了这个代码到deploy方法中。

17、solidity继承的父子合约,其构造函数的执行顺序?

contract TestToken is ERC20{}
如果在TestToken的构造函数后面给ERC20传参了,是先执行ERC20构造函数再执行TestToken构造函数.

如果没传参,会编译报错,编译器会要求开发者把TestToken改为抽象。

如果父合约的构造函数不需要参数,那么子合约也不需要往父合约构造函数中传参。

// contracts/OurToken.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.7;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

contract OurToken is ERC20 {
  constructor(uint256 initialSupply) ERC20("OurToken", "OT") {
    _mint(msg.sender, initialSupply);
  }
}

二、OpenZeppelin特性

1、OpenZeppelin的常用功能:

1.1、access/Ownable

access/Ownable 主要是提供了一个onlyOwner修饰符
继承Ownable的合约内部的方法,只要使用了onlyOwner修饰符,都会校验方法的调用者是否owner

Ownable源代码

1.2、token/ERC20

ERC20是以太坊上最基本和常用的一种代币合约标准。

此目录下有ERC20.sol和IERC20.sol
如果是发一个ERC20代币直接继承ERC20即可,如果是想在合约中调用某个代币的方法,则需要import IERC20.sol,然后将该代币合约实例化后调用。
例如夺宝项目中:

    import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
    function _swapNFT() internal {
        //...
        IERC20(hiToken).approve(swapAddr, targetAmount*1e18);
       //...
    }
    function _splitProfit() internal returns(uint256, uint256) {
        //...
        uint256 balance = IERC20(hiToken).balanceOf(address(this));        
        //...
        IERC20(hiToken).transfer(maker, amountOfMaker);
        //...
    }

ERC20.sol中实现了transfer、balanceOf、approve等方法,提供internal的_mint _burn 方法。

1.3、token/ERC721

ERC721是以太坊的NFT标准。用法类似ERC20章节。
NFT是非同质化代币的意思,mint出来的每个代币都是与众不同的。
在其元数据MetaData内有着自己独特的属性。
每个NFT代币都会有一个tokenId
ERC721内部会有一个mapping记录owner和tokenId的映射关系。
NFT可以在不同的地址之间转移transfer。

1.4、token/ERC1155

ERC1155既是ERC20又是ERC721,且可以批量处理转账、查询等功能。

IERC1155(miniNFT).setApprovalForAll(swapAddr, true);

1.5、utils/Create2

常用deploy方法,封装了创建合约的assembly语句create2(…)
用于部署合约

//源码
function deploy(uint256 amount, bytes32 salt, bytes memory bytecode) internal returns (address addr) {
        require(address(this).balance >= amount, "Create2: insufficient balance");
        require(bytecode.length != 0, "Create2: bytecode length is zero");
        /// @solidity memory-safe-assembly
        assembly {//这段和uniswap-v2-core:createPair中使用的是一样的
            addr := create2(amount, add(bytecode, 0x20), mload(bytecode), salt)
        }
        require(addr != address(0), "Create2: Failed on deploy");
    }

示例:

//create table方法内部:
		address tableAddr = Create2.deploy(
            0,
            salt,
            TableHelper.getBytecode(address(this), fftAddr, targetAmount, msg.sender)
        );

//
	function getBytecode(address controllerAddr, address fftAddr, uint256 targetAmount,
        address makerAddr) public pure returns (bytes memory) {

        bytes memory bytecode = type(OneHiTable).creationCode;
        //这里调用abi.encodePacked传入创建合约的bytecode和参数bytecode
        //是Create2方法要求的,参数bytecode的生成需要使用abi.encode方法
        return abi.encodePacked(bytecode, abi.encode(controllerAddr, fftAddr, targetAmount,
            makerAddr));
    }

1.6、utils/Address

一般使用这个库的话需要using Address for address;或Address.xxx(address)
看夺宝项目的代码时发现导入了这个库但代码中没有用到,这种情况虽然不影响编译、部署和运行的正常进行,但会影响到部署gas费。

2、可升级合约

2.1、 可升级合约的底层原理

假设有两个合约ContractA和ContractB,A引入了B,A的函数func1调用了B的func2,对B的数据var_b进行了操作。这个是正常的调用流程。其底层使用了CALL

假设A有数据var_a,现在需要让A使用B的函数func2对A的数据var_a做修改,那么需要使用的底层调用源码是DELEGATECALL - 委托调用。

可升级合约基于DELEGATECALL - 委托调用实现。

可升级合约的实现需要两种合约角色:
代理合约proxy contract + 实现合约implement contract。

代理合约中保存了数据且其合约地址永久不变。
实现合约中写了对数据增删改查的逻辑,供代理合约调用。
升级就是代理合约使用了新的实现合约。

2.2、OpenZeppelin可升级合约实操

OpenZeppelin提供了一整套的可升级合约库和工具包,开箱即用,大致分为以下三步:
1、写实现合约代码,继承OpenZeppelin的XxxUpgradeable库
代码中不要写构造函数,改为普通的命名为initialize的函数,以initializer为修饰符。
initialize函数内应该依次调用各个Upgradeable的库的init函数以初始化。
如果是UUPS类型的实现合约,那么应该把升级相关代码也实现出来。
2、使用OpenZeppelin的可升级合约工具包部署实现合约,该工具会自动生成代理合约并一起部署。
3、升级:写新的实现合约的代码并使用工具包部署。

2.3、参考

智能合约升级原理1:起源

三、hardhat的使用

1、初始化项目的命令

如果是新创建项目,运行:

yarn add --dev hardhat
yarn hardhat init

如果是拉取了别人的项目,会提示某些文件已经存在,则运行

yarn add --dev hardhat
yarn hardhat ***
//任意命令都会提示创建,给了一些选项,此时选择生成空白的hardhat.config.js
✔ What do you want to do? · Create an empty hardhat.config.js
✨ Config file created ✨

2、跑本地节点和部署合约、调用合约方法的命令

yarn hardhat node

注意,此节点会持续运行,除非Ctrl+c退出

直接运行yarn hardhat run scripts/*.js是启动自带节点,运行完节点也就关掉了。

想运行到本地持久节点上,需要加参数–network localhost

yarn hardhat run --network localhost scripts/index.js

3、如果项目使用Typescript如何运行

应下载typescript相关依赖并把hardhat.config.js改为hardhat.config.ts

//下载相关依赖
yarn add --dev ts-node typescript
yarn add --dev chai @types/node @types/mocha @types/chai

四、Uniswap v2 代码解读

1、测试文件是如何跑通的?

拉取项目到本地后,先尝试按hardhat项目编译和跑测试发现不行,遇到了各种报错。最后看官方文档发现,一是不需要hardhat,二是原来的依赖版本有很多已经升级了,一些过时的代码导致了报错。

解决办法是:
a.首先删除yarn.lock文件
b.修改package.json文件中的依赖,增加命令
c.yarn命令更新依赖
d.把测试文件中相关代码更新为最新的写法
e.运行命令:yarn test:evm
详见github.com/ScopeLift/ovm-uniswap-v2-core

2、参考

深入理解 Uniswap v2 合约代码
https://mirror.xyz/adshao.eth/VY6aLzdjwXGif9O1C7UMuYFmivC4q5jDQqQUho6tLWY
Uniswap V2 core源码解析:
https://juejin.cn/post/7185379590162874429

3、合约测试技术栈都有哪些?

Mocha 、chai、waffle
3.1 首先是mocha:
mocha是测试框架,提供了mocha命令。
可配置于package.json:

"test:evm": "yarn compile && mocha",

describe
it(‘should be correct’,async()=>{…})//测试单元,是mocha提供的

3.2 然后是chai:
提供了断言语句
写法有三种风格,常用expect语句

//测试方法返回值符合预期:
expect(await contractObject.doSomething()).to.eq(targetValue)
//如果想测试事件则这么写:
expect(await contractObject.doSomething())
.to.emit(contractObject,'EventName')
.withArgs(eventParam1,eventParam2,eventParam3)
//断言某种调用会失败:
it('transfer:fail', async () => {
    await expect(token.transfer(other.address, TOTAL_SUPPLY.add(1)))
    .to.be.reverted 
    await expect(token.connect(other).transfer(wallet.address, 1))
    .to.be.reverted 
  })

3.3 最后是waffle
用于提供本地区块链环境,部署合约到本地,生成合约对象。

const provider = new MockProvider({
    ganacheOptions:{
      hardfork: 'istanbul',
      mnemonic: 'horn horn horn horn horn horn horn horn horn horn horn horn',
      gasLimit: 9999999
    }
    
  })
  const [wallet, other] = provider.getWallets()

  let token: Contract
  
  beforeEach(async () => {
  //beforeEach是mocha提供的钩子,在每个测试单元运行之前运行。
    token = await deployContract(wallet, ERC20, [TOTAL_SUPPLY])
  })

4、为什么swap方法中没有校验转入金额就直接开始执行转出token的操作了?

一笔交易可以理解为一个事务,具有原子性,要么全成功,要么全失败,虽然在swap方法内首先执行了转出,但如果后面的校验转入不成功,那么连带着转出操作也不会成功。

一笔交易中可以对多个函数进行调用,同样的,这些函数的操作同处于一个事务中,只要有一个函数失败了,那么交易就会失败。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值