• 作者:老汪软件技巧
  • 发表时间:2024-09-24 15:02
  • 浏览量:

接下来,就开始进入文件内部编码的规范了。这一小节,先来总结下合约声明部分。

首先,第一行,我们应该声明 SPDX-License-Identifier,指定要使用的许可证。虽然不声明这个,编译不会报错,但会有警告,为了消除警告,所以我们最好还是把这个声明补上。

第二行,使用 pragma 声明要支持的 solidity 版本。关于这个,我的建议是分情况。如果是 interface、library、抽象合约,建议声明为兼容版本,比如 ^0.8.0。如果是需要部署的主合约则建议声明为固定版本。关于这一点,Uniswap/v4-core 是个很好的示例。其主合约 PoolManager 的 pragma 声明如下:

pragma solidity 0.8.26

可以看到,它声明为了一个固定的版本 0.8.26。

而所有 interface、library、抽象合约全都声明为兼容版本,有的是 ^0.8.0,有的是 ^0.8.24。声明为 ^0.8.24 的通常是因为用到了该版本才开始支持的一些特性。

而关于 import 部分,主要有两个建议:一是建议指定名称导入;二是要 import 的第三方合约存在多个版本时,建议指定版本号。

直接看下面这几个例子:

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20} from "@openzeppelin/contracts@0.5.2/token/ERC20/ERC20.sol";

上面三种 import 的写法,第一种是很多人(也包括我)之前最常用的写法,就是直接导入整个文件。第二种增加了要导入的具体合约名称,这就是前面说的,建议指定名称导入。第三种,在 contracts 后面增加了 @0.5.2 的声明来制定要导入的具体版本号。

这第三种写法就是我们最推荐的一种导入方式。因为 OpenZeppelin 已经发布了多个不同版本的库。

不过,如果要导入的第三方合约并不存在多个不同版本的情况下,则没必要增加版本声明了。比如,@uniswap/v3-core 只有一个版本的库,那导入该库的合约时就没必要指定版本了,如下即可:

import {IUniswapV3Pool} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";

interface

interface 是合约交互的接口,也是前端和后端交互时用到的 abi 的来源,所以我们编写实际代码时,通常都会先定义 interface。这一节就来讲讲关于 interface 编码时有哪些编码规范。

首先,从代码结构上,建议以下面这种顺序进行编排:

首先定义 Errors。error 类型是在 0.8.4 才开始支持的,所以,如果 interface 里面有定义 error,建议声明 pragma 时至少指定为 ^0.8.4。而声明 error 时,命名建议采用大驼峰式命名法,如果有带参数,则参数采用小驼峰式命名法。如下示例:

error ZeroAddress();
error InvalidAddress(address addr);

接着,定义 Events。所定义的事件名采用大驼峰式命名法,事件参数同样也是采用小驼峰式命名法。另外,如果参数较多的情况下,建议每个参数单独一行。如下示例:

event Transfer(address indexed from, address indexed to, uint value);
event TransferBatch(
    address indexed operator,
    address indexed from,
    address indexed to,
    uint256[] ids,
    uint256[] values
);

其实,官方文档里也有说明,建议每一行代码不要超过 120 字符,所以太长的情况下就要用换行的方式进行编排。在 Solidity,每个参数单独一行是很常见的一种编码格式。

紧接着,如果有的话则可以定义 Enums,即枚举类型。命名也是采用大驼峰式命名法,如下示例:

enum OrderStatus {
    Pending,
    Shipped,
    Delivered,
    Canceled
}

之后,则可以定义 Structs,即结构体。结构体的名称采用大驼峰式命名法,里面的字段则采用小驼峰式命名法,如下示例:

struct PopulatedTick {
    int24 tick;
    int128 liquidityNet;
    uint128 liquidityGross;
}

然后,就可以定义 Functions 了。但函数还可以再进行细分,可以根据下面的顺序进行声明:

就是说,我们在 interface 里定义多个函数的时候,建议按照这个顺序对这些函数进行编排。以下是几个示例:

// external function with payable
function burn(uint256 tokenId) external payable;
function createAndInitializePoolIfNecessary(
    address token0,
    address token1,
    uint24 fee,
    uint160 sqrtPriceX96
) external payable returns (address pool);
// external function
function mint(
    address recipient,
    int24 tickLower,
    int24 tickUpper,
    uint128 amount,
    bytes calldata data
) external returns (uint256 amount0, uint256 amount1);
// external function with view
function getPopulatedTicksInWord(address pool, int16 tickBitmapIndex)
    external
    view
    returns (PolulatedTick[] memory populatedTicks);
function ticks(int24 tick)
    external
    view
    returns (
        uint128 liquidityGross,
        int128 liquidityNet,
        uint256 feeGrowthOutside0X128,
        uint256 feeGrowthOutside1X128,
        int56 tickCumulativeOutside,
        uint160 secondsPerLiquidityOutsideX128,
        uint32 secondsOutside,
        bool initialized
    );
    
// external function with pure
function mulDiv(uint a, uint b, uint c) external pure returns (uint d);

除了顺序之外,这里面还有一些细节需要说一说。

首先,可以看到,不管是函数名还是参数名,都是采用了小驼峰式命名法。

其次,还可以看到有不一样的换行格式。createAndInitializePoolIfNecessary 和 mint 这两个函数都是参数各自一行。getPopulatedTicksInWord 则变成了函数修饰符和 returns 语句各自一行,这也是一种换行的写法。最后,ticks 函数的返回参数列表又拆分为每个返回参数单独一行,当返回参数较多的时候这也是常用的写法。

library

接着,来聊聊关于 library 的用法。

library 里无法定义状态变量,所以 library 其实是无状态的。

编码总结__编码列表

从语法上来说,library 可以定义不同可见性的函数,但实际应用中,通常只定义 internal 和 private 的函数,很少会定义外部可见的 external 和 public 函数。如果一个 library 里有定义了外部可见的函数,那部署的时候也会和只有内部函数的 library 不一样。当然,这是另一个话题了。

library 也可以定义常量和 errors。有些当纯只定义了常量的 library,比如 Uniswap/v4-periphery 里有一个 library 如下:

// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity ^0.8.0;
/// @title Action Constants
/// @notice Common constants used in actions
/// @dev Constants are gas efficient alternatives to their literal values
library ActionConstants {
    /// @notice used to signal that an action should use the input value of the open delta on the pool manager
    /// or of the balance that the contract holds
    uint128 internal constant OPEN_DELTA = 0;
    /// @notice used to signal that an action should use the contract's entire balance of a currency
    /// This value is equivalent to 1<<255, i.e. a singular 1 in the most significant bit.
    uint256 internal constant CONTRACT_BALANCE = 0x8000000000000000000000000000000000000000000000000000000000000000;
    /// @notice used to signal that the recipient of an action should be the msgSender
    address internal constant MSG_SENDER = address(1);
    /// @notice used to signal that the recipient of an action should be the address(this)
    address internal constant ADDRESS_THIS = address(2);
}

可以看到,这个 ActionConstants 就只是定义了 4 个常量而已。

library 使用场景最多的其实是对特定类型的扩展功能进行封装。UniswapV3Pool 代码体里一开始就声明了一堆 using for,如下所示:

contract UniswapV3Pool is IUniswapV3Pool, NoDelegateCall {
    using LowGasSafeMath for uint256;
    using LowGasSafeMath for int256;
    using SafeCast for uint256;
    using SafeCast for int256;
    using Tick for mapping(int24 => Tick.Info);
    using TickBitmap for mapping(int16 => uint256);
    using Position for mapping(bytes32 => Position.Info);
    using Position for Position.Info;
    using Oracle for Oracle.Observation[65535];
    ...
}

这些 using 后面的 libraries 封装的函数大多就是对 for 类型的功能扩展。比如,LowGasSafeMath 可以理解为就是对 uint256 类型的功能扩展,其内定义的函数第一个参数都是 uint256 类型的。LowGasSafeMath 里有定义了一个 add 函数,如下:

function add(uint256 x, uint256 y) internal pure returns (uint256 z) {
    require((z = x + y) >= x);
}

在 UniswapV3Pool 使用到 LowGasSafeMath 的 add 函数的地方其实不少,比如下面这个:

uint256 balance0Before = balance0();
require(balance0Before.add(uint256(amount0)) <= balance0(), 'IIA');

其中,balance0Before.add(uint256(amount0)) 其实就是调用了 LowGasSafeMath 的 add 函数。而且,通过这种语法糖的包装调用,不就等于变成了uint256 这个类型本身的扩展函数了,所以才说 library 可以理解为是对特定类型的功能扩展。

最后,虽然 library 本身没法直接访问状态变量,但状态变量是可以通过函数传参的方式间接被 library 函数所访问到的,只要将函数参数声明为 storage 即可。比如 library Position,声明了 get 和 update 函数,这两个函数的第一个参数都带了 storage 修饰,其核心代码如下:

function get(
    mapping(bytes32 => Info) storage self,
    address owner,
    int24 tickLower,
    int24 tickUpper
) internal view returns (Position.Info storage position) {
    position = self[keccak256(abi.encodePacked(owner, tickLower, tickUpper))];
}
function update(
    Info storage self,
    int128 liquidityDelta,
    uint256 feeGrowthInside0X128,
    uint256 feeGrowthInside1X128
) internal {
    ...
    // update the position
    if (liquidityDelta != 0) self.liquidity = liquidityNext;
    self.feeGrowthInside0LastX128 = feeGrowthInside0X128;
    self.feeGrowthInside1LastX128 = feeGrowthInside1X128;
    if (tokensOwed0 > 0 || tokensOwed1 > 0) {
        // overflow is acceptable, have to withdraw before you hit type(uint128).max fees
        self.tokensOwed0 += tokensOwed0;
        self.tokensOwed1 += tokensOwed1;
    }
}

get 函数的 self 参数就是 storage 的,而且数据类型是 mapping 的,意味着传入的这个参数是个状态变量,而返回值也是 storage 的,所以返回的 position 其实也是状态变量。

update 函数的 self 参数也一样是 storage 的,也同样是一个状态变量,在代码里对 self 进行了修改,也是会直接反映到该状态变量在链上的状态变更。

contract

下面就来聊聊 contract 方面的编码规范,主要讲代码结构,一般建议按照以下顺序进行编排:

首先,如果需要用到库,那就先用 using for 声明所使用的库。

接着,定义常量。常量还分为 public 常量和 private 常量,一般建议先声明 public 的再声明 private 的。另外,常量命名采用带下划线的全字母大写法,像这样:UPPER_CASE_WITH_UNDERSCORES。

声明完常量之后,紧接着就可以声明状态变量。状态变量也是先声明 public 的,然后再声明 private 的。命名则通常采用小驼峰式命名法。另外,private 的状态变量通常还会再添加前下划线,以和 public 的变量区分开来。如下示例:

// public state variable
uint256 public totalSupply;
// private state variable
address private _owner;
mapping(address => uint256) private _balanceOf;

之后,如果有事件需要声明,则声明事件。再之后,如果有错误需要声明,则声明错误。

事件和错误,通常会在 interface 中进行声明,所以直接在 contract 里声明的情况相对比较少。

然后,就是声明自定义的 modifiers 了。另外,modifier 的命名采用小驼峰式命名法,以下是一个示例:

modifier onlyOwner() {
    if(msg.sender != _owner) revert OwnableUnauthorizedAccount(msg.sender);
    _;
}

最后,就是一堆函数定义了。因为函数众多,所以我们还需要再进行编排,可按照如下顺序编排函数:

public 函数internal 函数private 函数

可见,先定义构造函数,接着定义 receive 和 fallback 回调函数,如果没有则不需要。

之后,根据可见性进行排序,顺序为 external、public、internal、private。

不同的可见性函数内,又再根据可变性再进行排序,以 external 类函数为例,顺序为 payable 函数、external 写函数、external view 函数、external pure 函数。其他可见性类型函数也是同理。

另外,关于 internal 和 private 函数的命名,建议和私有状态变量一样,添加前下划线作为前缀,以和外部函数区分。如下示例:

// internal functions
function _transfer(address from, address to, uint256 value) internal {}
function _getBalance(address account) internal view returns (uint256) {}
function _tryAdd(uint256 a, uint256 b) internal pure returns (uint256) {}
// private functions
function _turn() private {}
function _isParsed() private view returns (bool) {}
function _canAdd(uint256 a, uint256 b) private pure returns (uint256) {}

最后,每个函数里的多个函数修饰符也需要进行排序,一般建议按照如下顺序进行排序:

直接看以下例子:

function mint() external payable virtual override onlyOwner {}