关于 solidity-coding
面向编写 Solidity 合约的开发者,提供编码、初始化、安全性、事件设计和项目组织规范。它强调根据合约场景选择初始化方案与 OpenZeppelin 组件。
主要功能
- 01Solidity 版本与编码规范
- 02初始化方案比较及安全规则
- 03事件设计与命名约定
- 04合约目录和配置管理
- 05OpenZeppelin 组件选择
适用场景
- 01制定合约编码约定
- 02为合约选择初始化方式
- 03设计事件、错误和访问控制
- 04组织 Foundry 项目文件
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ 0xlayerghost/ solidity-agent-kit --skill solidity-coding安装后在 Claude Code 中输入/solidity-coding确认已加载。
附带文件references/complex-function-refactoring.mdreferences/shared-contract-behaviors.md
详细介绍
Solidity 编码规范
语言规则
- 始终使用用户所用的语言回复。 如果用户用中文提问,就用中文回复;如果用英文提问,就用英文回复。
编码原则
- Pragma:使用
pragma solidity ^0.8.19;— 在项目的所有文件中保持一致 - 依赖项:使用 OpenZeppelin Contracts 4.9.x,通过
remappings.txt管理导入 - 错误处理:优先使用自定义错误,而非
require字符串 — 这样可以节省 Gas,表达也更清晰- 定义:
error InsufficientBalance(uint256 available, uint256 required); - 使用:
if (balance < amount) revert InsufficientBalance(balance, amount);
- 定义:
- 文档:所有
public/external函数都必须包含 NatSpec(@notice、@param、@return) - 事件索引:仅对
address类型参数添加indexed— 若对其他类型添加索引,须附上注释 - 特殊关键字:
immutable/constant/unchecked/assembly必须附带行内注释,说明使用原因
初始化策略
当合约涉及状态初始化(外部地址、业务参数、角色分配)时,不要默认采用任何单一方案。请遵循以下工作流:
工作流
- 评估:合约是否可升级?部署时是否已知所有依赖项?是否存在跨合约部署顺序约束?
- 展示选项:向开发者展示以下三种初始化方案及其权衡
- 让开发者选择:不要自行假定方案 — 不同项目和合约有不同需求
- 实施:按照下方列出的安全规则应用所选方案
方案对比
| 方案 A:仅构造函数 | 方案 B:仅初始化器 | 方案 C:混合 | |
|---|---|---|---|
| 方式 | 所有初始化参数都通过构造函数传入 | 部署空合约,然后调用 initialize() | 构造函数处理已知参数;initialize() 处理部署后才能确定的依赖项 |
| 原子性 | 部署失败 = 不会创建任何内容,不存在危险的部分初始化状态 | 存在“已部署但未初始化”的风险窗口 | 部分原子 — 构造函数部分安全,但初始化器部分存在风险窗口 |
| 抢跑风险 | 无 — 构造函数无法被抢跑 | 若未受保护,initialize() 可能被抢跑 | 只有初始化器部分存在风险 |
| Gas | 部署 + 初始化只需 1 笔交易 | 部署交易 + N 笔初始化交易 | 部署交易 + 1 笔初始化交易 |
| 灵活性 | 低 — 部署时必须确定所有参数 | 高 — 可在其他合约部署后设置依赖项 | 中 — 在部署时和部署后之间拆分 |
| 可升级性 | 与代理模式不兼容 | UUPS / Transparent Proxy 必须使用 | 部分兼容 — 构造函数设置代理安全的参数,其余由初始化器处理 |
各方案的适用场景
| 场景 | 建议方案 |
|---|---|
| 不可升级,且部署时已知所有依赖项 | A |
| 可升级合约(UUPS / Transparent Proxy) | B |
| 不可升级,但部分依赖项稍后才部署(例如,LP 交易对在代币部署后创建) | C |
| 存在循环引用的多合约系统 | C |
| 简单的工具类 / 库式合约 | A |
| 需要在部署后由多签 / DAO 配置的合约 | B 或 C |
安全规则(适用于所有方案)
- 一次性锁定:任何仅应调用一次的 setter 都必须通过
require(currentValue == address(0))或bool initialized标志来强制执行——防止重新初始化 - 禁止无保护的初始化函数:
initialize()函数必须设置访问控制(onlyOwner、initializer修饰符或仅允许部署者调用的检查) - 优先使用不可变量:部署时已知的值应使用
immutable而非普通存储——可节省每次读取的 Gas - 构造函数中不执行复杂的外部调用:构造函数不应调用外部合约执行状态变更操作(swap、addLiquidity)——此时合约尚未完全构造完成
- 部署脚本的职责:使用方案 C 时,部署脚本必须在同一事务批次中或紧接着调用
initialize()——记录所需的调用顺序
事件设计模式
带原因标注的事件
当事件表示条件式/分支结果(同一操作可能因不同原因发生)时,添加 string reason 参数,让链上数据能够自我说明:
// GOOD — on-chain data is self-explanatory
event WithdrawalFailed(address indexed user, uint256 amount, string reason);
emit WithdrawalFailed(user, amount, "insufficient balance");
emit WithdrawalFailed(user, amount, "cooldown not expired");
event ProposalRejected(uint256 indexed proposalId, string reason);
emit ProposalRejected(id, "quorum not reached");
emit ProposalRejected(id, "voting period ended");
// BAD — must read contract source to understand why
event WithdrawalFailed(address indexed user, uint256 amount);
适用场景:
- 资金去向分叉:同一笔资金因不同条件流向不同地址
- 操作被拒绝/降级:用户请求未完全满足,附带原因
- 状态转换:同一个状态变更可能由不同触发条件引起
- 管理员操作:记录原因(如
"security incident"、"parameter tuning")
不适用场景:
- 事件只有一种触发路径(无分支)→ 不需要 reason
- 高频事件(每笔 swap/transfer)→ string 会消耗额外 Gas,可用
uint8 reasonCode代替
Reason 实现方式选择
| 场景 | 方式 | Gas 成本 |
|---|---|---|
| 原因种类少且固定(≤5 种) | string reason 字面量 | 低(编译器会优化短字符串) |
| 原因种类多或动态 | uint8 reasonCode + 前端映射表 | 最低 |
| 需要附带动态数据 | 拼接 string reason | 较高,慎用 |
// 方式 A:string literal(推荐,可读性最好)
emit WithdrawalFailed(user, amount, "cooldown not expired");
// 方式 B:uint8 code(高频场景省 gas)
event SwapRejected(address indexed user, uint256 amount, uint8 reasonCode);
// 0 = slippage, 1 = insufficient liquidity, 2 = paused
emit SwapRejected(user, amount, 0);
通用事件设计规则
- 每个改变状态的
external/public函数必须至少触发一个事件 - 事件参数应包含足够的信息,让前端/indexer 无需额外 RPC 调用即可重建完整上下文
- NatSpec
@param必须列出 reason 参数的所有可能值
命名约定
| 元素 | 约定 | 示例 |
|---|---|---|
| 合约/库 | PascalCase | MyToken、StakingPool |
| 接口 | I + PascalCase | IMyToken、IStakingPool |
| 状态变量/函数 | lowerCamelCase | totalSupply、claimDividend |
| 常量/不可变量 | UPPER_SNAKE_CASE | MAX_SUPPLY、ROUTER_ADDRESS |
| 事件 | PascalCase(过去时) | TokenTransferred、PoolCreated |
| 自定义错误 | PascalCase | InsufficientBalance、Unauthorized |
| 函数参数 | setter 的参数以 _ 为前缀 | function setFee(uint256 _fee) |
- 禁止:拼音名称、单字母变量(循环中的
i/j/k除外)、过度缩写 - 返回值命名:当返回变量名与状态变量冲突时,使用语义前缀(
is/can/has/current/total),而不是追加_——让 ABI 对前端使用者保持简洁
代码组织规则
| 情况 | 规则 |
|---|---|
| 跨合约常量 | 放在 src/common/Const.sol |
| 接口定义 | 放在 src/interfaces/I<Name>.sol,与实现分开 |
| 共享的有状态行为 | 放在 src/abstract/<Name>.sol 并通过继承复用,而不是复制实现 |
| 简单的链上查询 | 使用 Foundry cast CLI(call/send) |
| 复杂的多步骤操作 | 使用 Foundry 脚本(*.s.sol) |
| 导入风格 | 使用具名导入:import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; |
共享管理功能
当多个合约需要执行相同的管理操作时,应保留一个规范的函数名称、参数顺序、事件、错误、NatSpec 和实现。添加新变体前,请先搜索代码库。
对于有状态行为或修饰符,应使用共享抽象合约;当外部使用者需要稳定的 ABI 时,应使用接口;仅对无状态逻辑使用库。业务合约 MUST 继承共享实现,而不是复制它。
有关项目规范的所有者控制资产追回函数及继承规则,请阅读 references/shared-contract-behaviors.md。项目政策有意将所有者视为最高权限方,并允许追回合约持有的所有 ERC20 代币和原生货币。
复杂函数重构
当一个函数协调多个业务阶段、将验证与多项状态更改或外部调用混在一起,或接近编译器的栈限制时,请先阅读 references/complex-function-refactoring.md,再进行修改。
不要仅为了增加函数数量而拆分小函数。除非用户明确要求改变行为,否则应保留外部 ABI、执行顺序、回滚行为、状态转换顺序、发出的事件和资金流不变量。
项目目录结构
src/ — Contract source code
interfaces/ — Interface definitions (I*.sol)
abstract/ — Shared stateful base contracts (AssetRescue.sol, etc.)
common/ — Shared constants, types, errors (Const.sol, Types.sol)
test/ — Test files (*.t.sol)
script/ — Deployment & interaction scripts (*.s.sol)
config/ — Network config, parameters (*.json)
deployments/ — Deployment records (latest.env)
docs/ — Documentation, changelogs
lib/ — Dependencies (managed by Foundry)
配置管理
config/*.json— 网络 RPC URL、合约地址、业务参数deployments/latest.env— 最新部署的合约地址,每次部署后必须更新foundry.toml— 编译器版本、优化器设置、重映射- 重要配置变更必须在 PR 描述中记录
OpenZeppelin 库选型标准
编写 Solidity 合约时,应优先使用久经实战检验的 OpenZeppelin 库,而不是自定义实现。根据场景选择合适的库:
访问控制
| 场景 | 库 | 导入路径 |
|---|---|---|
| 单一所有者管理 | Ownable | @openzeppelin/contracts/access/Ownable.sol |
| 所有权转移需要安全保障 | Ownable2Step | @openzeppelin/contracts/access/Ownable2Step.sol |
| 多角色权限(管理员/操作员/铸造者) | AccessControl | @openzeppelin/contracts/access/AccessControl.sol |
| 需要枚举角色成员 | AccessControlEnumerable | @openzeppelin/contracts/access/AccessControlEnumerable.sol |
| 带时间锁延迟的治理 | TimelockController | @openzeppelin/contracts/governance/TimelockController.sol |
所有权模型选择
对于单一所有者合约,开发者 MUST 在生成代码前选择所有权模型:
| 选项 | 行为 | 适用情况 |
|---|---|---|
Ownable | 所有权转移在一笔交易中完成 | 明确优先考虑简洁性和即时转移 |
Ownable2Step | 新所有者必须调用 acceptOwnership() | 优先考虑防止将所有权转移到错误地址 |
不要默认强制使用 Ownable2Step。如果项目尚未记录选择,请展示两个选项并让开发者选择。选定后,项目中的所有位置都应统一使用该模型,包括共享基类、部署脚本、测试和所有权转移命令。从一开始就生成所选模型对应的继承关系和导入,不要先生成 Ownable2Step 再让开发者手动替换。
规则:1 个角色 → 由开发者选择 Ownable 或 Ownable2Step;2 个或更多角色 → AccessControl;治理/DAO → TimelockController
安全防护
| 场景 | 库 | 用法 |
|---|---|---|
| 外部调用 / 代币转账 | ReentrancyGuard | 添加 nonReentrant 修饰符 |
| 需要紧急暂停 | Pausable | 为面向用户的函数添加 whenNotPaused;管理函数保持不暂停 |
| ERC20 代币交互 | SafeERC20 | 使用 safeTransfer / safeTransferFrom / safeApprove,不要使用原始调用 |
规则:任何转移代币或 ETH 的合约都必须使用 ReentrancyGuard + SafeERC20
代币标准
| 场景 | 库 | 说明 |
|---|---|---|
| 同质化代币 | ERC20 | 基础标准 |
| 带销毁机制的代币 | ERC20Burnable | 添加 burn() 和 burnFrom() |
| 带最大供应量上限的代币 | ERC20Capped | 强制执行 totalSupply <= cap |
| 无 Gas 审批(EIP-2612) | ERC20Permit | 节省用户审批交易的 Gas |
| 治理投票代币 | ERC20Votes | 基于快照的投票权 |
| NFT | ERC721 | 基础 NFT 标准 |
| 带枚举功能的 NFT | ERC721Enumerable | 支持 tokenOfOwnerByIndex 查询 |
| 多代币(FT + NFT 混合) | ERC1155 | 游戏道具、批量操作 |
实用工具库
| 场景 | 库 | 用法 |
|---|---|---|
| 白名单 / 空投验证 | MerkleProof | 以低 Gas 成本验证 Merkle 树 |
| 签名验证 | ECDSA + EIP712 | 链下签名 + 链上验证 |
| 自动递增 ID | Counters | 生成代币 ID、订单 ID |
| 批量函数调用 | Multicall | 在一笔交易中执行多项操作 |
| 地址集合 / uint 集合 | EnumerableSet | 可迭代集合,添加 / 删除 / 检查是否包含均为 O(1) |
| 收益分成 | PaymentSplitter | 按份额拆分 ETH / 代币付款 |
| 标准化收益金库 | ERC4626 | DeFi 金库标准 |
合约升级
| 场景 | 库 | 说明 |
|---|---|---|
| 可升级合约(Gas 效率高) | UUPSUpgradeable | 在实现合约中放置升级逻辑 |
| 可升级合约(管理员分离) | TransparentUpgradeableProxy | 在代理合约中放置升级逻辑,Gas 成本较高 |
| 初始化器(替代构造函数) | Initializable | 使用 initializer 修饰符代替构造函数 |
规则:新项目优先使用 UUPSUpgradeable;可升级合约始终使用 Initializable
预言机和链下服务
| 场景 | 库 | 说明 |
|---|---|---|
| 代币价格数据 | AggregatorV3Interface | 仅适用于具有受支持预言机数据馈送的代币 |
| 可验证随机数(彩票 / NFT) | VRFConsumerBaseV2 | 链上可证明公平的随机数 |
| 自动执行(定时任务) | AutomationCompatible | 替代中心化 Keeper |
| 跨链消息传递 | CCIP | 跨链代币 / 消息传输 |
库选择决策流程
Does contract handle user funds/tokens?
├── YES → Add ReentrancyGuard + SafeERC20
│ Does it need emergency stop?
│ ├── YES → Add Pausable
│ └── NO → Skip
└── NO → Skip
How many admin roles needed?
├── 1 role → Ask developer to choose Ownable or Ownable2Step
├── 2+ roles → AccessControl
└── DAO/governance → TimelockController
Does contract need price data?
├── Token has oracle feed → AggregatorV3Interface
├── No oracle feed → Custom TWAP with min-liquidity check
└── No price needed → Skip
Will contract need upgrades?
├── YES → UUPSUpgradeable + Initializable
└── NO → Standard deployment (immutable)
反面模式(请勿使用)
- 请勿编写自定义
transfer封装 — 使用SafeERC20 - 请勿编写自定义访问控制修饰符 — 使用
Ownable/AccessControl - 请勿编写自定义暂停逻辑 — 使用
Pausable - 请勿在 Solidity >= 0.8.0 中使用
SafeMath— 已内置溢出检查 - 请勿使用
require(token.transfer(...))— 通过SafeERC20使用token.safeTransfer(...) - 请勿使用
tx.origin进行身份验证 — 使用Ownable/AccessControl配合msg.sender
MCP 辅助合约生成(如可用)
配置 OpenZeppelinContracts MCP 后,优先使用它生成基础合约,而不是从头编写:
| 合约类型 | MCP 工具 | 使用场景 |
|---|---|---|
| 同质化代币 | solidity-erc20 | 任何新的 ERC20 代币合约 |
| NFT | solidity-erc721 | 任何新的 NFT 合约 |
| 多代币 | solidity-erc1155 | 游戏道具、批量操作 |
| 稳定币 | solidity-stablecoin | 符合 ERC20 标准的稳定币 |
| 现实世界资产 | solidity-rwa | 资产代币化 |
| 智能账户 | solidity-account | ERC-4337 账户抽象 |
| 治理 | solidity-governor | DAO 投票和提案 |
| 自定义 | solidity-custom | 使用 OZ 模式的非标准合约 |
工作流:MCP 生成基础合约 → 应用此技能的命名/结构规则 → 自定义业务逻辑 → 应用 /solidity-security 规则
为何使用 MCP 而非手动编写:MCP 输出经过验证,与 OZ Contracts Wizard 使用相同的规则集——导入、修饰符和安全检查均确保正确。手动编写可能遗漏导入或使用错误的 OZ 版本。
不应使用 MCP 的情况:采用大量自定义非标准模式的合约、不适用于任何 OZ 模板的合约,或需要从第一行开始精细控制的情况。
平稳降级:如果未配置 MCP,请回退到上述 Library Selection Standards,并遵循此技能的所有规则手动编写合约。
Foundry 快速参考
| 操作 | 命令 |
|---|---|
| 创建新项目 | forge init <project-name> |
| 安装依赖 | forge install openzeppelin-contracts |
| 构建合约 | forge build |
| 格式化代码 | forge fmt |
| 更新 remappings | forge remappings |