title: 从多代币支付到链上游戏:Solidity 智能合约开发实战 slug: solidity-smart-contract-projects description: 深入两个开源 Solidity 项目,探讨多代币支付系统的非标准代币兼容方案,以及链上游戏与 DeFi 借贷池的融合设计。
从多代币支付到链上游戏:Solidity 智能合约开发实战
前言
在区块链开发的学习道路上,动手写真实项目是最好的进阶方式。最近我完成了两个 Solidity 开源项目的开发——Multi-Token-Payment(多代币支付系统)和 Solidity-Game(链上斗地主 + DeFi 借贷池),覆盖了从支付基建到游戏金融的完整场景。本文将复盘这两个项目中的设计决策、技术挑战和实现细节,希望能给同样在学 Solidity 的开发者一些启发。
一、Multi-Token-Payment:兼容非标准代币的支付系统
1.1 项目背景
现实中的 DApp 往往需要支持多种代币支付。但 Ethereum 生态中存在两类 ERC20 代币:
- 标准 ERC20:
transfer()/transferFrom()返回bool - 非标准代币(如 USDT):同名函数无返回值,Solidity 层面无法通过接口返回值判断成功与否
如果直接用 IERC20(token).transferFrom(...) 调用 USDT,交易会直接 revert,因为编译器期望的 bool 返回值不存在。
1.2 核心设计:try/catch + 双接口模式
我的解决方案是定义两套接口——标准 IERC20 和非标准 IUSDT:
// USDT 等非标准代币的接口(无返回值)
interface IUSDT {
function transfer(address to, uint256 value) external;
function transferFrom(address from, address to, uint256 value) external;
function approve(address spender, uint256 value) external;
function allowance(address owner, address spender) external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
}
然后在内部转账函数中,根据代币类型动态路由:
function _safeTransferFrom(address token, address from, address to, uint256 amount)
internal returns (bool)
{
if (isNonStandardToken[token]) {
// USDT 风格:无返回值,调用成功则不 revert
try IUSDT(token).transferFrom(from, to, amount) {
return true;
} catch (bytes memory) {
return false;
}
} else {
// 标准 ERC20:有返回值,额外检查 true
try IERC20(token).transferFrom(from, to, amount) returns (bool success) {
return success;
} catch (bytes memory) {
return false;
}
}
}
这里的关键点是:
try/catch捕获外部调用异常,而非依赖require- 非标准代币直接忽略返回值,成功调用即视为成功
bytes memory捕获完整错误数据,便于后续日志
1.3 代币标准自动探测
Owner 添加代币时,合约会自动探测该代币是否标准:
function addSupportedTokenAuto(address token) public onlyOwner {
// ... 省略基础校验
bool isNonStandard = knownNonStandardTokens[token];
// 尝试读取 ERC20Metadata 标准接口
if (!isNonStandard) {
try IERC20Metadata(token).symbol() returns (string memory s) {
// 成功 — 标准代币
} catch {
isNonStandard = true; // 失败 — 非标准
}
}
// 二次验证
if (!isNonStandard) {
isNonStandard = checkTokenStandard(token);
}
// ...
}
这种设计让部署者不需要手动区分代币类型,一人添加,全员可用。
1.4 三种支付模式
合约提供了三种支付方式,适应不同的业务场景:
| 模式 | 函数 | 适用场景 |
|---|---|---|
| 单笔支付 | makePayment(token, to, amount) |
用户向单个地址付款 |
| 批量支付 | batchPayments(token, recipients[], amounts[]) |
发工资、空投(同一代币) |
| 多代币混合支付 | makeMultiTokenPayment(tokens[], recipients[], amounts[]) |
购物车结算(不同商品用不同代币) |
makeMultiTokenPayment 的实现需要在一次交易中逐个校验每个代币的授权和余额:
function makeMultiTokenPayment(
address[] calldata tokens,
address[] calldata recipients,
uint256[] calldata amounts
) external nonReentrant returns (bool) {
require(tokens.length == recipients.length
&& recipients.length == amounts.length, "Arrays length mismatch");
for (uint256 i = 0; i < tokens.length; i++) {
require(isTokenSupported[tokens[i]], "Token not supported");
require(recipients[i] != address(0), "Invalid recipient");
uint256 allowedAmount = _safeAllowance(tokens[i], msg.sender, address(this));
require(allowedAmount >= amounts[i], "Insufficient allowance");
bool success = _safeTransferFrom(tokens[i], msg.sender, recipients[i], amounts[i]);
require(success, "Transfer failed for token");
}
return true;
}
配合 OpenZeppelin 的 ReentrancyGuard,所有支付函数都加了 nonReentrant 修饰器,防御重入攻击。
1.5 TokenExtractorV2:低级别调用的妙用
独立的 TokenExtractorV2 合约负责代币提取和误打回收。它使用了 address.call() 的低级方式调用 transferFrom:
function _safeTransferFrom(address token, address from, address to, uint256 amount) internal {
// 0x23b872dd = transferFrom(address,address,uint256) 的函数选择器
(bool ok, bytes memory data) = token.call(
abi.encodeWithSelector(0x23b872dd, from, to, amount)
);
require(ok && (data.length == 0 || abi.decode(data, (bool))), "transferFrom failed");
}
这里有一个精妙之处:data.length == 0 的判断——对于 USDT 这类无返回值的代币,调用成功但 data 为空;对于标准 ERC20,abi.decode(data, (bool)) 返回 true。一个条件覆盖了两种情况。
二、Solidity-Game:当斗地主遇上 DeFi
2.1 为什么要做这个项目?
链游(GameFi)的核心是让游戏资产具有金融属性。Solidity-Game 的设想是:玩家在斗地主中赢得的游戏币可以存入借贷池生息,或者作为抵押品借出其他资产。这比单纯的「挖矿-卖出」模式更有可持续性。
2.2 斗地主合约:链上卡牌对战
FightLandlord.sol 实现了经典斗地主的基础框架。核心数据模型:
enum Role { Landlord, Farmer } // 地主 / 农民
enum Status { Ready, InGame, End } // 对局状态
contract GamePlayer is UserBaseData {
Role private role;
Status private status;
uint256 private Game_id;
constructor() {
role = Role.Farmer;
status = Status.Ready;
playerAddress = msg.sender;
balance = 1000; // 注册即送 1000 初始余额
Game_id = 0;
}
}
对局管理采用了工厂模式——FightLandlord 主合约负责创建 Game 实例:
contract FightLandlord {
mapping(uint256 => Game) public games;
mapping(address => GamePlayer) private players;
function register() public {
players[msg.sender] = new GamePlayer();
}
modifier isLogin() {
address userAddr = address(players[msg.sender]);
require(userAddr != address(0), "Please login first");
_;
}
function createGame(uint256 gameId) public isLogin {
address gameAddress = address(games[gameId]);
if (gameAddress == address(0)) {
games[gameId] = new Game();
players[msg.sender].setGame_id(gameId);
}
}
}
Card.sol 定义了完整的 52 张扑克牌(四种花色 × 十三种点数):
enum suits { Spades, Hearts, Diamonds, Clubs }
enum ranks { Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King, Ace }
contract Hand {
struct Card {
suits suit;
ranks rank;
}
Card[] private deck;
function createDeck() public {
for (uint i = 0; i < 4; i++) {
for (uint j = 0; j < 13; j++) {
deck.push(Card(suits(i), ranks(j)));
}
}
}
}
2.3 AToken:类 Aave 的利息代币
AToken.sol 参考了 Aave 协议的设计:用户存入底层资产(如 USDC),获得对应的 aToken,aToken 随时间自动累积利息。
contract AToken is ERC20, AccessControl {
bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
bytes32 public constant BURNER_ROLE = keccak256("BURNER_ROLE");
address public underlyingAsset; // 底层资产地址
address public lendingPool; // 唯一授权的借贷池
function mint(address _user, uint256 _amount, uint256 _index)
external onlyLendingPool returns (bool)
{
_mint(_user, _amount);
emit Mint(_user, _amount, _index);
return true;
}
function burn(address _user, uint256 _amount, uint256 _index)
external onlyLendingPool returns (bool)
{
_burn(_user, _amount);
emit Burn(_user, _amount, _index);
return true;
}
function transferUnderlyingTo(address _to, uint256 _amount)
external onlyLendingPool
{
IERC20(underlyingAsset).safeTransfer(_to, _amount);
}
}
设计要点:
- 权限控制:
MINTER_ROLE和BURNER_ROLE仅授予LendingPool合约,避免随意增发 onlyLendingPool:铸造和销毁只能由借贷池调用,保证 aToken 数量与底层资产 1:1 锚定transferUnderlyingTo:兑换时由借贷池代用户提取底层资产,用户只需 burn aToken
2.4 LendingPoolStorage:数据与逻辑分离
参照 Aave 的代理模式,我将存储层单独抽取为 LendingPoolStorage:
struct ReserveData {
address aTokenAddress;
address interestRateStrategyAddress;
uint8 decimals;
uint256 reserveFactor; // 储备金比例
bool borrowingEnabled; // 是否启用借贷
bool isActive;
uint256 totalLiquidity;
uint256 availableLiquidity;
uint256 totalBorrows;
uint256 liquidityRate;
uint256 variableBorrowRate;
uint256 liquidityIndex;
uint256 variableBorrowIndex;
uint40 lastUpdateTimestamp;
}
mapping(address => ReserveData) public reserves;
mapping(address => mapping(address => UserBorrowData)) public userBorrows;
mapping(address => mapping(address => uint256)) public userDeposits;
这种「存储 + 逻辑」分离的好处:
- 升级友好:逻辑合约可替换,存储合约不变
- 关注点分离:数据计算在
LendingPool,数据存储交给LendingPoolStorage - 可扩展性:添加新的储备资产不需要修改存储逻辑
2.5 Game × DeFi 的融合设想
在完整实现中,斗地主游戏和借贷池将这样互动:
玩家存款 USDC → 获得 aUSDC → aUSDC 按利率增长
↓
玩家用 aUSDC 作为抵押品 → 借出游戏币 → 参与斗地主
↓
赢得对局 → 获得游戏币奖励 → 偿还借款 + 赚取利息差
ProFileSatus.sol 提供的概率计算(二项式分布)可用于抽奖、卡牌掉落等随机场景,为游戏增加经济变量:
function binomialDistribution(uint256 n) public pure returns (uint256[] memory) {
uint256[] memory probabilities = new uint256[](n + 1);
for (uint256 k = 0; k <= n; k++) {
probabilities[k] = combination(n, k); // C(n, k)
}
return probabilities;
}
三、技术挑战与经验总结
3.1 非标准代币兼容(最难啃的骨头)
USDT 的「不合规」是 Ethereum 生态的历史遗留问题。处理方案总结如下:
| 方案 | 优点 | 缺点 |
|---|---|---|
try/catch 双接口 |
彻底兼容,代码清晰 | Gas 略高 |
address.call() 低级调用 |
通用性强 | 需要手动编解码 selector |
纯 IERC20 忽略返回值 |
简单 | 部分场景无法区分失败原因 |
推荐:交易类操作用 try/catch,提取类操作用 address.call()。
3.2 安全设计
- 重入锁:所有支付函数加
nonReentrant - 校验优先:转账前先检查余额和授权,失败提前 revert 节省 Gas
- 零地址防护:
require(recipient != address(0)) - 权限分级:Owner 管代币,LendingPool 管 aToken,职责分离
3.3 Git 提交规范
两个项目都使用了 Conventional Commits 风格,例如:
feat: add try/catch pattern for USDT compatibility
refactor: extract _safeTransferFrom for dual-interface routing
feat: implement batch payments with single token type
这种提交规范在开源协作中特别有用——通过 git log --oneline 可以快速了解项目演进历史。
3.4 技术栈选择
| 维度 | Multi-Token-Payment | Solidity-Game |
|---|---|---|
| 框架 | Truffle | Hardhat |
| Solidity | ^0.8.0 | ^0.7.4 / ^0.8.0 |
| 安全库 | OpenZeppelin Ownable + ReentrancyGuard | OpenZeppelin ERC20 + AccessControl |
| 前端 | Express + Bootstrap 5 | — |
| 网络 | Ethereum / BSC / Polygon | — |
Truffle 适合快速原型和前端结合的项目;Hardhat 的 console.log 调试和丰富的插件生态更适合复杂合约开发。
四、总结与下一步
通过这两个项目,我完整实践了以下 Solidity 开发要点:
- 接口兼容性设计——用
try/catch适配非标准代币 - 多种支付模式——从单笔到多代币混合的渐进式实现
- 链上游戏数据建模——枚举 + 结构体 + 工厂合约管理对局
- DeFi 协议设计模式——aToken 利息代币 + 存储逻辑分离
下一步计划:
- Multi-Token-Payment:添加元交易(EIP-2771)支持,实现 Gasless 支付
- Solidity-Game:补全
LendingPool和InterestRateStrategy的核心逻辑,完成还款清算机制 - 两者结合:让斗地主的赢取奖金直接存入借贷池生息
完整的源代码已开源在 GitHub:
- Multi-Token-Payment —
feat:/refactor:/ MIT - Solidity-Game — 游戏模块 GPL-3.0 / DeFi 模块 MIT
欢迎 Star、Fork 和 PR,一起在链上开发中找到乐趣 🚀
本文为 Solidity 学习实践分享,代码仅供学习参考,未经审计,请勿直接用于生产环境。
评论