从入门到实战:我的全套 Web3 学习路径(2025版)
你好,我是 Keegan 小钢。 如果你刚开始接触 Web3、准备从 Web2 转型,或者正在寻找一套真正系统、可落地、能带你从入门做到链上实战的学习路径,那么这篇文章会对你非常有帮助。 我是一名有 16 年经验的互联网从业者,过去 8 年专注于 Web3 技术方向,同时持续做个人 IP 超过 13 年。长期以来,我在公众号、知乎、B 站持续输出 Web3 的学习路线、开发知识、工程实践以及真实链上项目的研发过程。如果你对我的经历好奇,可以阅读这篇文章:《复盘我的 13 年个人 IP 之路》。 这几年,越来越多同学加我咨询,他们大多会问:我应该从哪里开始学 Web3?我有一些基础,但做不出完整项目怎么办?有没有适合“从入门到能做项目”的学习路径?我想顺利找到 Web3 工作,该怎么准备?本质上,这些问题可以归结成一句话:“我现在这个水平,下一步该学什么?”为了让不同阶段的同学都能快速找到最适合自己的学习路径,我把过去几年输出的所有 Web3 内容——免费课程、付费课程、AI+Web3 实战营、以及深度服务——做了一次系统性的梳理。 这篇文章是你最清晰、最完整的 「Web3 学习路...
万字长文聊聊Web3的现状与趋势
整体数据现状与趋势首先,先来看看 Web3 的搜索热度情况,我们可以从 GoogleTrends 中看到一些数据。下图是关于 Web3 在全球过去 5 年内的搜索热度趋势图:从图中可以看出,前面几年的搜索热度一直很低,热度值一直保持在 10 以下,但从 2021 年下半年开始逐渐飙升,在 2021 年 12 月底达到了顶峰。虽然随后开始有所回落,但依然保持在很高的热度。 如果再按区域来看搜索热度,就会发现,搜索热度最高的竟然是在中国,且与其他区域的搜索热度差距很大,如下图所示这说明,中国依然是 Web3 最大的潜在市场。 接着,再来看看整个加密货币总市值的趋势图,某种程度上,这也代表了整个 Web3 行业的总市值。下图的数据来自 CoinMarketCap:从图中可以看到,总市值也是在 2021 年出现大幅度飙升,2021 年底到达顶峰,达到了将近 3 万亿美元的总市值。随后不断回落,在 2022 年底跌到了最低点,总市值降到低于 1 万亿,相比高点,跌去了三分之二。但是,就算是最低点也依然比 2021 年之前那些年的总市值高得多。 加密货币的总市值看上去好像不低,但如果跟全球股...
万字长文聊聊Web3的组成架构
Web3 发展至今,生态已然初具雏形,如果将当前阶段的 Web3 生态组成架构抽象出一个鸟瞰图,由下而上可划分为四个层级:区块链网络层、中间件层、应用层、访问层。下面我们来具体看看每一层级都有什么。另外,此章节会涉及到很多项目的名称,因为篇幅原因不会一一进行介绍,有兴趣的可以另外去查阅相关资料进行深入了解。区块链网络层最底层是「区块链网络层」,也是 Web3 的基石层,主要由各区块链网络所组成。 组成该层级的区块链网络还不少,Bitcoin、Ethereum、BNB Chain(BSC)、Polygon、Arbitrum、Polkadot、Cosmos、Celestia、Avalanche、Aptos、Sui 等等,还有很多。根据 Blockchain-Comparison 的统计,截止撰文之日的区块链至少有 150 条。这里我们主要说的是公链,联盟链不包括在内。因为区块链实在太多,会有些眼花缭乱,所以有必要进行分门别类。 首先,不同区块链之间存在着分层结构,有 Layer0、Layer1、Layer2 之分。其次,Web3 的繁荣发展,依赖于智能合约技术,而智能合约的运行环境为...
Blockchain engineer
从入门到实战:我的全套 Web3 学习路径(2025版)
你好,我是 Keegan 小钢。 如果你刚开始接触 Web3、准备从 Web2 转型,或者正在寻找一套真正系统、可落地、能带你从入门做到链上实战的学习路径,那么这篇文章会对你非常有帮助。 我是一名有 16 年经验的互联网从业者,过去 8 年专注于 Web3 技术方向,同时持续做个人 IP 超过 13 年。长期以来,我在公众号、知乎、B 站持续输出 Web3 的学习路线、开发知识、工程实践以及真实链上项目的研发过程。如果你对我的经历好奇,可以阅读这篇文章:《复盘我的 13 年个人 IP 之路》。 这几年,越来越多同学加我咨询,他们大多会问:我应该从哪里开始学 Web3?我有一些基础,但做不出完整项目怎么办?有没有适合“从入门到能做项目”的学习路径?我想顺利找到 Web3 工作,该怎么准备?本质上,这些问题可以归结成一句话:“我现在这个水平,下一步该学什么?”为了让不同阶段的同学都能快速找到最适合自己的学习路径,我把过去几年输出的所有 Web3 内容——免费课程、付费课程、AI+Web3 实战营、以及深度服务——做了一次系统性的梳理。 这篇文章是你最清晰、最完整的 「Web3 学习路...
万字长文聊聊Web3的现状与趋势
整体数据现状与趋势首先,先来看看 Web3 的搜索热度情况,我们可以从 GoogleTrends 中看到一些数据。下图是关于 Web3 在全球过去 5 年内的搜索热度趋势图:从图中可以看出,前面几年的搜索热度一直很低,热度值一直保持在 10 以下,但从 2021 年下半年开始逐渐飙升,在 2021 年 12 月底达到了顶峰。虽然随后开始有所回落,但依然保持在很高的热度。 如果再按区域来看搜索热度,就会发现,搜索热度最高的竟然是在中国,且与其他区域的搜索热度差距很大,如下图所示这说明,中国依然是 Web3 最大的潜在市场。 接着,再来看看整个加密货币总市值的趋势图,某种程度上,这也代表了整个 Web3 行业的总市值。下图的数据来自 CoinMarketCap:从图中可以看到,总市值也是在 2021 年出现大幅度飙升,2021 年底到达顶峰,达到了将近 3 万亿美元的总市值。随后不断回落,在 2022 年底跌到了最低点,总市值降到低于 1 万亿,相比高点,跌去了三分之二。但是,就算是最低点也依然比 2021 年之前那些年的总市值高得多。 加密货币的总市值看上去好像不低,但如果跟全球股...
万字长文聊聊Web3的组成架构
Web3 发展至今,生态已然初具雏形,如果将当前阶段的 Web3 生态组成架构抽象出一个鸟瞰图,由下而上可划分为四个层级:区块链网络层、中间件层、应用层、访问层。下面我们来具体看看每一层级都有什么。另外,此章节会涉及到很多项目的名称,因为篇幅原因不会一一进行介绍,有兴趣的可以另外去查阅相关资料进行深入了解。区块链网络层最底层是「区块链网络层」,也是 Web3 的基石层,主要由各区块链网络所组成。 组成该层级的区块链网络还不少,Bitcoin、Ethereum、BNB Chain(BSC)、Polygon、Arbitrum、Polkadot、Cosmos、Celestia、Avalanche、Aptos、Sui 等等,还有很多。根据 Blockchain-Comparison 的统计,截止撰文之日的区块链至少有 150 条。这里我们主要说的是公链,联盟链不包括在内。因为区块链实在太多,会有些眼花缭乱,所以有必要进行分门别类。 首先,不同区块链之间存在着分层结构,有 Layer0、Layer1、Layer2 之分。其次,Web3 的繁荣发展,依赖于智能合约技术,而智能合约的运行环境为...
Blockchain engineer

Subscribe to Keegan小钢

Subscribe to Keegan小钢
Share Dialog
Share Dialog
<100 subscribers
<100 subscribers
上周,完成了 Solidity 编码规范的视频录制并上传到了 B 站、Youtube 和视频号。总共分为了 6 个小节,在 B 站的合集地址为:
https://space.bilibili.com/60539794/channel/collectiondetail?sid=3780183
为了方便一些更习惯看文章的同学,这两天我又整理出了文字版本,以下就是该 Solidity 编码规范汇总篇。
我们知道,每一门编码语言都会有各自的编码规范。那为什么需要编码规范呢?我总结出以下几个原因:
提高代码可读性: 编码规范使得代码结构更加清晰,命名更加直观,逻辑更加明确,这样其他开发者(包括编写代码的开发者自己)可以更容易地理解和维护代码。
促进团队协作: 在团队开发中,统一的编码规范使得不同开发者编写的代码风格一致,避免了代码风格上的差异导致的混乱和误解,有助于团队成员之间的合作和代码的整合。
减少错误: 良好的编码规范通常包括一些最佳实践,如变量命名规则、代码注释等。这些规范可以帮助开发者避免常见的错误,提高代码的可靠性和健壮性。
提高维护性: 当代码风格统一时,维护代码的工作量会显著减少。开发者可以快速理解代码的逻辑,进行修改和扩展,降低维护的成本和难度。
有助于代码审查: 编码规范使得代码审查变得更加高效,审查者可以专注于代码的逻辑和功能,而不是在代码风格上浪费时间。
提升代码质量: 统一的编码规范通常伴随着对代码质量的关注,如代码的可扩展性、可测试性等。这些规范有助于编写出高质量的代码。
遵循行业标准: 编码规范通常是根据行业标准或社区共识制定的,遵循这些规范可以确保代码与其他项目或开源库兼容,促进代码的复用和共享。
总的来说,编码规范对于保证代码的质量、提高开发效率、促进团队合作、降低维护成本等方面都有着重要作用,因此每一门编程语言都需要编码规范,Solidity 作为智能合约的开发语言也不例外。下面就会正式介绍 Solidity 这门语言的编码规范。
我们编码的第一步就是需要创建源文件,所以第一步我想从文件命名说起。而关于文件命名这部分的编码规范,我总结下来可以归为四点:
采用大驼峰式命名法
与合约组件名保持一致
每个文件里只定义一个合约组件
interface 命名额外添加大写 I 作为前缀
在 solidity 官方文档里,有列出几种不同的命名风格,如下:
b (single lowercase letter)
B (single uppercase letter)
lowercase
UPPERCASE
UPPER_CASE_WITH_UNDERSCORES
CapitalizedWords (or CapWords)
mixedCase (differs from CapitalizedWords by initial lowercase character!)
首先,所谓大驼峰式命名法,其实就是 CapitalizedWords 这种风格的命名法,就是由首字母大写的单词组合而成的写法。而 mixedCase 也称为小驼峰式命名法。
合约组件,其实就是 interface、library、contract。文件名要与合约组件名保持一致,就是说要与文件里所定义的 interface/library/contract 的名称一样。比如文件名为 UniswapV3Pool.sol,文件里定义的 contract 名称就是 UniswapV3Pool。
每个文件里只定义一个合约组件,就是不要在文件里同时定义多个合约组件。比如,Uniswap 的 v3-core 里,有定义了几个回调类的接口:
IUniswapV3FlashCallback
IUniswapV3MintCallback
IUniswapV3SwapCallback
这三个接口,每个接口里面都只定义了一个函数。从语法上来说,是可以只用一个文件来包含这三个接口的,比如就用 UniswapV3Callback.sol,文件里则大致如下:
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity >=0.5.0
interface IUniswapV3FlashCallback {
...
}
interface IUniswapV3MintCallback {
...
}
interface IUniswapV3SwapCallback {
...
}
从语法上来说,这是没有错的。但是,Uniswap 则将其分为了三个文件,如下:
IUniswapV3FlashCallback.sol
IUniswapV3MintCallback.sol
IUniswapV3SwapCallback.sol
这就是每个文件里只定义一个合约组件。
另外,上面这个例子也涉及到了最后一点命名规范,即 interface 命名额外添加大写 I 作为前缀。你可以看到这几个 interface 都是加了大写 I 作为前缀的,而 library 和 contract 则不会有这种前缀。遵循这种规范,就可以直接从文件命名区分出哪些是 interface 了。
在一个项目中,通常都不会只有少数几个文件,那文件多了,自然是需要做好文件编排了。那关于文件编排这块的规范,我的建议就是,可以将不同类型的多个合约文件归类到不同文件夹下。
比如,存在多个 interface 时,可以将这些 interface 文件统一归类到 interfaces 目录下。而多个 library 文件则可以统一归类到 libraries 目录下。抽象合约则可以放置于 base 或 utils 目录下。而需要进行部署的主合约则大多直接放在 contracts 或 src 主目录下。
另外,文件比较多的情况下还可以进一步拆分目录。比如,Uniswap/v3-core 的 interfaces 下还再划分出了 callback 和 pool 两个子目录。
而像 OpenZeppelin 这种库则又不一样,它更多的是按功能模块进行目录分类,不同的合约文件是划分到不同的功能目录下的。在这些功能目录下,大多就没有再根据合约组件类型进行进一步的目录划分。比如,access 目录下就直接放置了 IAccessControl.sol,一来,并没有那么多 interface,二来,目录编排更多是根据功能设置的。但它依然也有一个 interfaces 目录存放了所有的接口文件。
接下来,就开始进入文件内部编码的规范了。这一小节,先来总结下合约声明部分。
首先,第一行,我们应该声明 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 是合约交互的接口,也是前端和后端交互时用到的 abi 的来源,所以我们编写实际代码时,通常都会先定义 interface。这一节就来讲讲关于 interface 编码时有哪些编码规范。
首先,从代码结构上,建议以下面这种顺序进行编排:
Errors
Events
Enums
Structs
Functions
首先定义 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 了。但函数还可以再进行细分,可以根据下面的顺序进行声明:
External functions with payable
External functions
External functions with view
External functions with pure
就是说,我们在 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 可以定义不同可见性的函数,但实际应用中,通常只定义 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 方面的编码规范,主要讲代码结构,一般建议按照以下顺序进行编排:
using for
常量
状态变量
Events
Errors
Modifiers
Functions
首先,如果需要用到库,那就先用 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);
_;
}
最后,就是一堆函数定义了。因为函数众多,所以我们还需要再进行编排,可按照如下顺序编排函数:
构造函数
receive 函数
fallback 函数
external 函数
external payable
external
external view
external pure
public 函数
public payable
public
public view
public pure
internal 函数
internal
internal view
internal pure
private 函数
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) {}
最后,每个函数里的多个函数修饰符也需要进行排序,一般建议按照如下顺序进行排序:
可见性(external/public/internal/private)
可变性(payable/view/pure)
Virtual
Override
自定义 modifiers
直接看以下例子:
function mint() external payable virtual override onlyOwner {}
以上就是关于 contract 部分的编码规范建议了。
最后聊聊注释。
首先,要记住一点:良好的命名即注释。
即合约、事件、错误、状态变量、函数、参数等等,所有的命名本身都应该能很好地说明其所代表的含义,这本身就是一种最直观的注释。额外添加的注释更多其实是为了进行补充说明用的。
Solidity 里支持的注释标签包括以下这些:
@title
@author
@notice
@dev
@param
@return
@inheritdoc
@custom:...
这些标签的说明在官方文档中也有说明,如下图:

官方编码规范建议对于所有公开的接口添加完全的注释,也即是说,我们可以给 interface 添加完整注释。Uniswap 就是一个很好的实践方,你可以看到 Uniswap 的所有 interface 都有很完整的注释。
另外,添加的注释内容还可以在区块浏览器上看到注释内容。比如,我们查看 COMP 代币合约:
在交互页面,点开 approve 时如下:

再查看源代码里该 approve 函数的注释:
/**
* @notice Approve `spender` to transfer up to `amount` from `src`
* @dev This will overwrite the approval amount for `spender`
* and is subject to issues noted [here](https://eips.ethereum.org/EIPS/eip-20#approve)
* @param spender The address of the account which may transfer tokens
* @param rawAmount The number of tokens that are approved (2^256-1 means infinite)
* @return Whether or not the approval succeeded
*/
function approve(address spender, uint rawAmount) external returns (bool) {
...
}
可以看出,@notice、@dev 和 @param 的内容都展示在了区块浏览器交互页面上了。
上周,完成了 Solidity 编码规范的视频录制并上传到了 B 站、Youtube 和视频号。总共分为了 6 个小节,在 B 站的合集地址为:
https://space.bilibili.com/60539794/channel/collectiondetail?sid=3780183
为了方便一些更习惯看文章的同学,这两天我又整理出了文字版本,以下就是该 Solidity 编码规范汇总篇。
我们知道,每一门编码语言都会有各自的编码规范。那为什么需要编码规范呢?我总结出以下几个原因:
提高代码可读性: 编码规范使得代码结构更加清晰,命名更加直观,逻辑更加明确,这样其他开发者(包括编写代码的开发者自己)可以更容易地理解和维护代码。
促进团队协作: 在团队开发中,统一的编码规范使得不同开发者编写的代码风格一致,避免了代码风格上的差异导致的混乱和误解,有助于团队成员之间的合作和代码的整合。
减少错误: 良好的编码规范通常包括一些最佳实践,如变量命名规则、代码注释等。这些规范可以帮助开发者避免常见的错误,提高代码的可靠性和健壮性。
提高维护性: 当代码风格统一时,维护代码的工作量会显著减少。开发者可以快速理解代码的逻辑,进行修改和扩展,降低维护的成本和难度。
有助于代码审查: 编码规范使得代码审查变得更加高效,审查者可以专注于代码的逻辑和功能,而不是在代码风格上浪费时间。
提升代码质量: 统一的编码规范通常伴随着对代码质量的关注,如代码的可扩展性、可测试性等。这些规范有助于编写出高质量的代码。
遵循行业标准: 编码规范通常是根据行业标准或社区共识制定的,遵循这些规范可以确保代码与其他项目或开源库兼容,促进代码的复用和共享。
总的来说,编码规范对于保证代码的质量、提高开发效率、促进团队合作、降低维护成本等方面都有着重要作用,因此每一门编程语言都需要编码规范,Solidity 作为智能合约的开发语言也不例外。下面就会正式介绍 Solidity 这门语言的编码规范。
我们编码的第一步就是需要创建源文件,所以第一步我想从文件命名说起。而关于文件命名这部分的编码规范,我总结下来可以归为四点:
采用大驼峰式命名法
与合约组件名保持一致
每个文件里只定义一个合约组件
interface 命名额外添加大写 I 作为前缀
在 solidity 官方文档里,有列出几种不同的命名风格,如下:
b (single lowercase letter)
B (single uppercase letter)
lowercase
UPPERCASE
UPPER_CASE_WITH_UNDERSCORES
CapitalizedWords (or CapWords)
mixedCase (differs from CapitalizedWords by initial lowercase character!)
首先,所谓大驼峰式命名法,其实就是 CapitalizedWords 这种风格的命名法,就是由首字母大写的单词组合而成的写法。而 mixedCase 也称为小驼峰式命名法。
合约组件,其实就是 interface、library、contract。文件名要与合约组件名保持一致,就是说要与文件里所定义的 interface/library/contract 的名称一样。比如文件名为 UniswapV3Pool.sol,文件里定义的 contract 名称就是 UniswapV3Pool。
每个文件里只定义一个合约组件,就是不要在文件里同时定义多个合约组件。比如,Uniswap 的 v3-core 里,有定义了几个回调类的接口:
IUniswapV3FlashCallback
IUniswapV3MintCallback
IUniswapV3SwapCallback
这三个接口,每个接口里面都只定义了一个函数。从语法上来说,是可以只用一个文件来包含这三个接口的,比如就用 UniswapV3Callback.sol,文件里则大致如下:
// SPDX-License-Identifier: GPL-2.0-or-later
pragma solidity >=0.5.0
interface IUniswapV3FlashCallback {
...
}
interface IUniswapV3MintCallback {
...
}
interface IUniswapV3SwapCallback {
...
}
从语法上来说,这是没有错的。但是,Uniswap 则将其分为了三个文件,如下:
IUniswapV3FlashCallback.sol
IUniswapV3MintCallback.sol
IUniswapV3SwapCallback.sol
这就是每个文件里只定义一个合约组件。
另外,上面这个例子也涉及到了最后一点命名规范,即 interface 命名额外添加大写 I 作为前缀。你可以看到这几个 interface 都是加了大写 I 作为前缀的,而 library 和 contract 则不会有这种前缀。遵循这种规范,就可以直接从文件命名区分出哪些是 interface 了。
在一个项目中,通常都不会只有少数几个文件,那文件多了,自然是需要做好文件编排了。那关于文件编排这块的规范,我的建议就是,可以将不同类型的多个合约文件归类到不同文件夹下。
比如,存在多个 interface 时,可以将这些 interface 文件统一归类到 interfaces 目录下。而多个 library 文件则可以统一归类到 libraries 目录下。抽象合约则可以放置于 base 或 utils 目录下。而需要进行部署的主合约则大多直接放在 contracts 或 src 主目录下。
另外,文件比较多的情况下还可以进一步拆分目录。比如,Uniswap/v3-core 的 interfaces 下还再划分出了 callback 和 pool 两个子目录。
而像 OpenZeppelin 这种库则又不一样,它更多的是按功能模块进行目录分类,不同的合约文件是划分到不同的功能目录下的。在这些功能目录下,大多就没有再根据合约组件类型进行进一步的目录划分。比如,access 目录下就直接放置了 IAccessControl.sol,一来,并没有那么多 interface,二来,目录编排更多是根据功能设置的。但它依然也有一个 interfaces 目录存放了所有的接口文件。
接下来,就开始进入文件内部编码的规范了。这一小节,先来总结下合约声明部分。
首先,第一行,我们应该声明 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 是合约交互的接口,也是前端和后端交互时用到的 abi 的来源,所以我们编写实际代码时,通常都会先定义 interface。这一节就来讲讲关于 interface 编码时有哪些编码规范。
首先,从代码结构上,建议以下面这种顺序进行编排:
Errors
Events
Enums
Structs
Functions
首先定义 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 了。但函数还可以再进行细分,可以根据下面的顺序进行声明:
External functions with payable
External functions
External functions with view
External functions with pure
就是说,我们在 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 可以定义不同可见性的函数,但实际应用中,通常只定义 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 方面的编码规范,主要讲代码结构,一般建议按照以下顺序进行编排:
using for
常量
状态变量
Events
Errors
Modifiers
Functions
首先,如果需要用到库,那就先用 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);
_;
}
最后,就是一堆函数定义了。因为函数众多,所以我们还需要再进行编排,可按照如下顺序编排函数:
构造函数
receive 函数
fallback 函数
external 函数
external payable
external
external view
external pure
public 函数
public payable
public
public view
public pure
internal 函数
internal
internal view
internal pure
private 函数
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) {}
最后,每个函数里的多个函数修饰符也需要进行排序,一般建议按照如下顺序进行排序:
可见性(external/public/internal/private)
可变性(payable/view/pure)
Virtual
Override
自定义 modifiers
直接看以下例子:
function mint() external payable virtual override onlyOwner {}
以上就是关于 contract 部分的编码规范建议了。
最后聊聊注释。
首先,要记住一点:良好的命名即注释。
即合约、事件、错误、状态变量、函数、参数等等,所有的命名本身都应该能很好地说明其所代表的含义,这本身就是一种最直观的注释。额外添加的注释更多其实是为了进行补充说明用的。
Solidity 里支持的注释标签包括以下这些:
@title
@author
@notice
@dev
@param
@return
@inheritdoc
@custom:...
这些标签的说明在官方文档中也有说明,如下图:

官方编码规范建议对于所有公开的接口添加完全的注释,也即是说,我们可以给 interface 添加完整注释。Uniswap 就是一个很好的实践方,你可以看到 Uniswap 的所有 interface 都有很完整的注释。
另外,添加的注释内容还可以在区块浏览器上看到注释内容。比如,我们查看 COMP 代币合约:
在交互页面,点开 approve 时如下:

再查看源代码里该 approve 函数的注释:
/**
* @notice Approve `spender` to transfer up to `amount` from `src`
* @dev This will overwrite the approval amount for `spender`
* and is subject to issues noted [here](https://eips.ethereum.org/EIPS/eip-20#approve)
* @param spender The address of the account which may transfer tokens
* @param rawAmount The number of tokens that are approved (2^256-1 means infinite)
* @return Whether or not the approval succeeded
*/
function approve(address spender, uint rawAmount) external returns (bool) {
...
}
可以看出,@notice、@dev 和 @param 的内容都展示在了区块浏览器交互页面上了。
private pure
private pure
No activity yet