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 代币

  • 标准 ERC20transfer() / 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;
        }
    }
}

这里的关键点是:

  1. try/catch 捕获外部调用异常,而非依赖 require
  2. 非标准代币直接忽略返回值,成功调用即视为成功
  3. 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_ROLEBURNER_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;

这种「存储 + 逻辑」分离的好处:

  1. 升级友好:逻辑合约可替换,存储合约不变
  2. 关注点分离:数据计算在 LendingPool,数据存储交给 LendingPoolStorage
  3. 可扩展性:添加新的储备资产不需要修改存储逻辑

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 开发要点:

  1. 接口兼容性设计——用 try/catch 适配非标准代币
  2. 多种支付模式——从单笔到多代币混合的渐进式实现
  3. 链上游戏数据建模——枚举 + 结构体 + 工厂合约管理对局
  4. DeFi 协议设计模式——aToken 利息代币 + 存储逻辑分离

下一步计划:

  • Multi-Token-Payment:添加元交易(EIP-2771)支持,实现 Gasless 支付
  • Solidity-Game:补全 LendingPoolInterestRateStrategy 的核心逻辑,完成还款清算机制
  • 两者结合:让斗地主的赢取奖金直接存入借贷池生息

完整的源代码已开源在 GitHub:

欢迎 Star、Fork 和 PR,一起在链上开发中找到乐趣 🚀


本文为 Solidity 学习实践分享,代码仅供学习参考,未经审计,请勿直接用于生产环境。