Solidity 与代币标准
# Solidity 与代币标准
合约首先是一组公开可执行的业务规则,其次才是一段 Solidity 代码。先定义谁能操作、状态怎样变化、失败怎样处理,再选择语法和组件。
已有项目中的 Solidity、Foundry 和 OpenZeppelin 选型复用 Web3 大学合约部分。本篇补上从代码读懂规则的过程。
# 先分清几个常用声明
| 声明 | 含义 | 注意点 |
|---|---|---|
public / external | 可从外部调用;public 也可内部直接调用 | 对外可调用不等于任何人都应该有业务权限 |
private / internal | 限制 Solidity 源码层的访问范围 | 链上数据不因此保密 |
view / pure | view 不修改状态;pure 不读取或修改合约状态 | 调用方式决定是否发交易,不能理解为永远零费用 |
payable | 函数可接收随调用发送的原生币 | 不代表自动支持 ERC-20 转账 |
error / revert | 定义并返回失败原因 | ABI 应包含错误,方便前端解析 |
event / emit | 声明并发出日志 | 合约不能直接把历史日志当 storage 读取 |
msg.sender 是当前调用者,不一定是最初点按钮的钱包;通过其他合约转调后,它会变化。不要用 tx.origin 代替权限设计。
# 一个可以在本地测试的完整合约
下面使用 Solidity 0.8.24 演示 “仅管理员能记录完成状态” 。它不收款、不发代币,也不是完整课程平台。把代码保存为 Foundry 项目的 src/CompletionRegistry.sol,下一篇会直接测试它。
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract CompletionRegistry {
// immutable 在部署时确定;本例不支持换管理员或代理升级。
address public immutable owner;
// 以用户地址记录是否完成;public 自动生成查询函数。
mapping(address => bool) public completed;
error Unauthorized(address caller);
error ZeroAddress();
error AlreadyCompleted(address learner);
// indexed 允许链下按 learner 过滤日志,而不必下载全部记录。
event Completed(address indexed learner);
constructor() {
owner = msg.sender; // 部署者成为管理员,实际生产应设计权限移交。
}
function markCompleted(address learner) external {
// 先检查权限和输入,保证非法调用不会改变状态。
if (msg.sender != owner) revert Unauthorized(msg.sender);
if (learner == address(0)) revert ZeroAddress();
if (completed[learner]) revert AlreadyCompleted(learner);
completed[learner] = true; // storage 中的长期事实。
emit Completed(learner); // 给链下索引器提供历史记录。
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
前端读取 completed(address),后端通过 Completed 同步查询数据。管理员私钥被盗后仍能错误地标记完成,所以真正上线时还要考虑多签、角色拆分、撤销或纠错机制;代码没有明显语法错误不代表业务安全。
# ERC-20、ERC-721、ERC-1155 怎么选
ERC 是 Ethereum Request for Comments 的缩写,这里表示约定好的合约接口标准,不是某个官方发币服务。
| 标准 | 表示什么 | 关键接口与场景 |
|---|---|---|
| ERC-20 | 可互换的数量,如某种积分或稳定币余额 | balanceOf、transfer、approve、transferFrom |
| ERC-721 | 每个 tokenId 独立的资产 | ownerOf、safeTransferFrom;证书、独立收藏品 |
| ERC-1155 | 同一合约管理多个 ID,每个 ID 有数量 | 支持批量操作;多类道具、票券 |
标准主要约束行为和接口,不保证价格、价值、可转让策略或元数据永久可用。ERC-721 证书若要求不可转让,要明确额外规则;不能因为叫证书就假定转账入口已经禁用。
# approve 到底批准了什么
用户对某个 ERC-20 调用 approve(spender, amount),是在代币合约里记录 “这个 spender 最多可以代我花多少” 。随后 spender 调用 transferFrom 才真正移动代币。连接钱包、登录签名和 approve 是三种不同权限。
金额使用整数最小单位。例如 decimals 为 6 的代币,1.5 个代币应传 1,500,000;不是所有代币都为 18 位。前端用 bigint 与 parseUnits / formatUnits,不要经由 JavaScript 浮点数计算资产金额。
接入 ERC-20 时复用 OpenZeppelin SafeERC20 处理返回值差异;还需明确是否支持转账扣费、余额重基等特殊代币。不能只凭接口存在就认定到账金额等于请求金额。
# 用一次 ERC-20 支付把余额和授权分开
假设用户有 100 USDC,要向课程合约支付 20 USDC。先读取实际代币 decimals 并用整数单位表达金额;用户给课程合约 20 的 allowance,随后课程合约调用 transferFrom 收款。approve 本身不把 20 转出去,交易成功后还要继续完成购买调用。
要分别检查余额、allowance 和支付状态:余额够但授权不够会失败;授权够但余额已被花掉也会失败。前端禁用按钮只能减少误点,合约仍需拒绝重复购买或重复结算。
无限授权减少以后授权的步骤,也扩大 spender 被攻击后的影响范围。默认可按本次需求授权,并提供查询与撤销入口;但撤销仍需交易确认,不保证抢在攻击交易之前。修改已有非零授权时,还要考虑代币兼容性与授权变更竞争,不能把直接覆盖数值当作绝对安全。
# SafeERC20 为什么不保证实际收款金额
SafeERC20 解决一些代币返回 false 或不返回标准值的兼容问题,不改变代币的经济规则。比如请求转入 100,代币收取 2 手续费,合约实际只收到 98;如果业务按 100 增加可提现余额,就产生 2 的资金缺口。
接入时可以明确只接受经过核验的标准行为代币;确实支持扣费代币,则要根据转账前后余额变化和业务模型记账,并分析重入及余额重基等额外情况。余额差也不是支持所有特殊代币的万能办法。
# NFT 的所有权与网站权益不要混为一谈
ERC-721 的 ownerOf 表示当前链上持有人,网站可以据此定义访问权益,但转移后旧持有人的缓存权限要失效。证书若设计为不可转让,需要合约明确限制,而不是只在页面隐藏转让按钮。
safeTransferFrom 给合约接收者转 NFT 时,会执行接收回调来检查是否支持接收。回调意味着外部代码可能运行,所以相关状态仍要遵守先更新再交互等安全设计。元数据链接能访问也不等于内容会永久保存,要单独安排内容持久化。
# 面试时可以这样回答
用了 OpenZeppelin,为什么还需要测试?参考答案
成熟库能降低标准实现的风险,但不知道我们的业务规则。比如谁能铸造、是否允许重复购买、哪些代币可以充值,都由项目代码决定。我会复用标准组件,再针对权限、金额、状态迁移和外部回调测试,不能把库的审计等同于整个项目已经安全。
approve 成功为什么还没完成付款?参考答案
approve 只是让代币合约记录 spender 可以代扣的额度,资产还没有移动。真正支付由业务合约调用 transferFrom 完成,还要验证余额、金额和订单状态。所以页面要区分授权与支付两个阶段,后端根据实际购买事件确认权益,不能看到授权成功就当成收款。
支持所有符合 ERC-20 接口的代币可以吗?参考答案
不能只看接口。不同代币可能有转账扣费、暂停、黑名单或余额重基,导致实际到账和业务预期不同。我会先限定支持范围,使用 SafeERC20 处理返回值兼容,再按实际资产行为设计会计和测试。库能解决调用兼容,不能替我们保证资产模型正确。