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);  // 给链下索引器提供历史记录。
    }
}
1
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 处理返回值兼容,再按实际资产行为设计会计和测试。库能解决调用兼容,不能替我们保证资产模型正确。

# 官方参考