React Native DApp 开发全栈实战·从 0 到 1 系列(永续合约交易-合约部分)

  • 木西
  • 发布于 5小时前
  • 阅读 40

前言本文基于openzeppelinV5,solidity0.8.20,chainlink实现一套可编译、可部署、可清算的迷你永续合约:先发行一枚ERC-20保证金代币(USDC);再部署一个可手动更新的ChainlinkMock喂价器;最后上线一个20倍杠杆、5%

前言

本文基于openzeppelinV5 ,solidity 0.8.20,chainlink实现一套可编译、可部署、可清算的迷你永续合约:

  1. 先发行一枚 ERC-20 保证金代币(USDC);
  2. 再部署一个可手动更新的 Chainlink Mock 喂价器;
  3. 最后上线一个 20 倍杠杆、5 % 维持保证金的永续交易核心,支持多空、盈亏结算、强制清算、角色权限控制。
    整套合约仅 200 行,却浓缩了真实永续协议必备的四件套:仓位结构、杠杆检查、价格源、清算引擎。配合 Hardhat 的 deploy 脚本与 Mocha 测试用例,读者可在 10 分钟内跑完“开仓→涨价盈利→降价亏损→大幅爆仓”全生命周期,直观理解链上永续的数学与风险边界。

    永续合约

    概念

    永续合约(Perpetual Contract,简称“永续”)是一种没有到期日、可长期持有的衍生品合约,本质上是永不交割的期货 。它通过资金费率(Funding Rate)机制把合约价格锚定现货价格,让交易者无需实物交割就能进行高杠杆多空双向交易

    核心特征

  4. 无到期日
    传统期货每周/每季度交割,永续永不交割,想持有多久就多久。
  5. 资金费率(Funding)
    每 1~8 小时多空双方互相支付一次利息:
    • 合约价 > 现货价 → 多头付空头,鼓励做空压价;
    • 合约价 < 现货价 → 空头付多头,鼓励做多抬价。
      把溢价/折价“掰”回现货价,实现软锚定
  6. 高杠杆
    主流平台支持 1~100 倍杠杆,保证金亏损触及维持保证金即触发强制平仓(清算)
  1. 盈亏结算币本位
    绝大多数永续以 USDT/USDC 等稳定币计价、结算,盈亏直接增减账户余额,无需交割实物资产。
  2. 指数价格 + 标记价格
    • 指数价格:多家现货交易所加权,防单点操纵;
    • 标记价格:用指数价格+资金费率推算,用于计算未实现盈亏与强平,减少“插针”。

      一句话总结:

永续合约 = “永久持仓的杠杆押注” ,用资金费率代替交割,把合约价格永远“拧”回现货,是目前加密世界成交量最大的衍生品。

合约核心代码

1.代币合约

// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.22;

import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC20Burnable} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import "hardhat/console.sol";
contract MyToken2 is ERC20, ERC20Burnable, Ownable {
    constructor(string memory name_,string memory symbol_,address initialOwner)
        ERC20(name_, symbol_)
        Ownable(initialOwner)
    {
        _mint(msg.sender, 1000 * 10 ** decimals());
    }

    function mint(address to, uint256 amount) public onlyOwner {
        _mint(to, amount);
    }
}

2.喂价合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import { AggregatorV3Interface } from "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";

contract MockV3Aggregator is AggregatorV3Interface {
    uint256 public constant versionvar = 4;

    uint8 public decimalsvar;
    int256 public latestAnswer;
    uint256 public latestTimestamp;
    uint256 public latestRound;
    mapping(uint256 => int256) public getAnswer;
    mapping(uint256 => uint256) public getTimestamp;
    mapping(uint256 => uint256) private getStartedAt;
    string private descriptionvar;

    constructor(
        uint8 _decimals,
        string memory _description,
        int256 _initialAnswer
    ) {
        decimalsvar = _decimals;
        descriptionvar = _description;
        updateAnswer(_initialAnswer);
    }

    function updateAnswer(int256 _answer) public {
        latestAnswer = _answer;
        latestTimestamp = block.timestamp;
        latestRound++;
        getAnswer[latestRound] = _answer;
        getTimestamp[latestRound] = block.timestamp;
        getStartedAt[latestRound] = block.timestamp;
    }

    function getRoundData(uint80 _roundId)
        external
        view
        override
        returns (
            uint80 roundId,
            int256 answer,
            uint256 startedAt,
            uint256 updatedAt,
            uint80 answeredInRound
        )
    {
        return (
            _roundId,
            getAnswer[_roundId],
            getStartedAt[_roundId],
            getTimestamp[_roundId],
            _roundId
        );
    }

    function latestRoundData()
        external
        view
        override
        returns (
            uint80 roundId,
            int256 answer,
            uint256 startedAt,
            uint256 updatedAt,
            uint80 answeredInRound
        )
    {
        return (
            uint80(latestRound),
            latestAnswer,
            getStartedAt[latestRound],
            latestTimestamp,
            uint80(latestRound)
        );
    }

    function decimals() external view override returns (uint8) {
        return decimalsvar;
    }

    function description() external view override returns (string memory) {
        return descriptionvar;
    }

    function version() external  pure override returns (uint256) {
        return versionvar;
    }
}

3.永续合约交易(核心)

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/access/AccessControl.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";
import "@chainlink/contracts/src/v0.8/shared/interfaces/AggregatorV3Interface.sol";
import "hardhat/console.sol";   // &lt;── 只需这一行
contract PerpTrade is ReentrancyGuard, AccessControl {
    bytes32 public constant LIQUIDATOR_ROLE = keccak256("LIQUIDATOR_ROLE");

    /* ========== 状态变量 ========== */
    IERC20 public immutable collateralToken; // 保证金代币(如 USDC)
    AggregatorV3Interface public immutable priceFeed; // Chainlink 价格源

    uint256 public constant PRICE_PRECISION = 1e8;
    uint256 public constant COLLATERAL_PRECISION = 1e18;
    uint256 public constant MAX_LEVERAGE = 20 * 1e18; // 20x
    uint256 public constant MAINTENANCE_MARGIN = 5; // 5 % 5 * 1e16

    /* ========== 仓位结构 ========== */
    struct Position {
        bool isLong;
        uint256 size; // 名义价值(美元)
        uint256 collateral; // 保证金数量
        uint256 entryPrice; // 1e8
        uint256 lastUpdate;
        bool isActive;
    }
    mapping(address => Position[]) public positions;

    /* ========== 事件 ========== */
    event Opened(address indexed user, uint256 indexed pid, bool isLong, uint256 size, uint256 collateral, uint256 entryPrice);
    event Closed(address indexed user, uint256 indexed pid, uint256 exitPrice, int256 pnl);
    event Liquidated(address indexed user, uint256 indexed pid, address indexed liquidator, uint256 price, uint256 reward);

    /* ========== 构造函数 ========== */
    constructor(address _collateral, address _priceFeed) {
        collateralToken = IERC20(_collateral);
        priceFeed = AggregatorV3Interface(_priceFeed);
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
        _grantRole(LIQUIDATOR_ROLE, msg.sender);
    }

    /* ========== 视图:最新价格 ========== */
    function getPrice() public view returns (uint256) {
        (, int256 ans,,,) = priceFeed.latestRoundData();
        require(ans > 0, "Invalid price");
        return uint256(ans);
    }

    /* ========== 开仓 ========== */
    function open(bool isLong, uint256 collateral, uint256 leverage) external nonReentrant {
        require(collateral > 0 && leverage > 1e18 && leverage &lt;= MAX_LEVERAGE, "Bad params");
        uint256 size = (collateral * leverage) / 1e18;
        uint256 price = getPrice();
        // uint256 allowed = collateralToken.allowance(msg.sender, address(this));
        // console.log("Allowed:", allowed);
        // require(allowed >= collateral, "Allowance too low");

        positions[msg.sender].push(Position({
            isLong: isLong,
            size: size,
            collateral: collateral,
            entryPrice: price,
            lastUpdate: block.timestamp,
            isActive: true
        }));

        emit Opened(msg.sender, positions[msg.sender].length - 1, isLong, size, collateral, price);
    }

    /* ========== 平仓 ========== */
    function close(uint256 pid) external nonReentrant {
        Position storage p = positions[msg.sender][pid];
        require(p.isActive, "!active");
        _close(msg.sender, pid, p);
    }

    /* ========== 清算 ========== */
    function liquidate(address user, uint256 pid) external nonReentrant onlyRole(LIQUIDATOR_ROLE) {
        Position storage p = positions[user][pid];
        require(p.isActive, "!active");
        (bool unsafe,,) = _pnl(user, pid, getPrice());
        require(unsafe, "Not liquidatable");
        uint256 reward = p.collateral / 20; // 5 %
        p.collateral -= reward;
        _close(user, pid, p);
        collateralToken.transfer(msg.sender, reward);
        emit Liquidated(user, pid, msg.sender, getPrice(), reward);
    }

    /* ========== 内部:盈亏计算 ========== */
    function _pnl(address user, uint256 pid, uint256 price) internal view returns (bool unsafe, bool profit, uint256 pnlValue) {
        Position memory p = positions[user][pid];
        uint256 delta;
        if (p.isLong) delta = price > p.entryPrice ? price - p.entryPrice : p.entryPrice - price;
        else delta = price &lt; p.entryPrice ? p.entryPrice - price : price - p.entryPrice;

        pnlValue = (p.size * delta) / p.entryPrice;
        profit = (p.isLong && price >= p.entryPrice) || (!p.isLong && price &lt;= p.entryPrice);
        unsafe = pnlValue * 100 >= p.collateral * MAINTENANCE_MARGIN;
    }

    /* ========== 内部:统一平仓 ========== */
    // function _close(address user, uint256 pid, Position storage p) internal {
    //     uint256 price = getPrice();
    //     (,, uint256 pnlValue) = _pnl(user, pid, price);
    //     bool profit = (p.isLong && price >= p.entryPrice) || (!p.isLong && price &lt;= p.entryPrice);
    //     uint256 payOut = profit ? p.collateral + pnlValue : p.collateral - pnlValue;
    //     if (payOut > 0) collateralToken.transfer(user, payOut);
    //     p.isActive = false;
    //     emit Closed(user, pid, price, profit ? int256(pnlValue) : -int256(pnlValue));
    // }
    function _close(address user, uint256 pid, Position storage p) internal {
    uint256 price = getPrice();
    (,, uint256 pnlValue) = _pnl(user, pid, price);
    bool profit = (p.isLong && price >= p.entryPrice) ||
                  (!p.isLong && price &lt;= p.entryPrice);

    uint256 payOut;
    if (profit) {
        payOut = p.collateral + pnlValue;
    } else {
        payOut = (pnlValue >= p.collateral) ? 0 : p.collateral - pnlValue;
    }

    if (payOut > 0) collateralToken.transfer(user, payOut);
    p.isActive = false;
    emit Closed(user, pid, price,
                profit ? int256(pnlValue) : -int256(pnlValue));
}
    /* ========== 视图:仓位数 ========== */
    function positionCount(address user) external view returns (uint256) {
        return positions[user].length;
    }
}
  • 编译指令npx hardhat compile
  • 编译说明生成对应的json文件供ethers交互使用

    合约测试

    特别说明

  • 测试时一定要保证有足够的代币
  • 实现清算时要保证参数单位的一致性

    const { expect } = require("chai");
    const { ethers, deployments } = require("hardhat");
    // const { loadFixture } = require("@nomicfoundation/hardhat-network-helpers");
    describe("PriceConsumer + MockPriceFeed", function () {
    let MockV3Aggregator, PerpTrade,USDC;
    let owner;//合约部署者
    let alice;//用户alice
    let bob;//用户bob
    //   const DECIMALS = 8;
    const INITIAL_PRICE = 2000_0000_0000; // 8 位小数 1eth=2000usdc
    const COLLATERAL_1000 = ethers.parseEther("1000");
    const COLLATERAL_100  = ethers.parseEther("100");
    beforeEach(async () => {
    [owner, alice, bob] = await ethers.getSigners();
    // 自动跑 喂价
    await deployments.fixture(["token2","MockV3Aggregator","PerpTrade"]);
    const USDCDeployment= await deployments.get("MyToken2");
    const MockV3AggregatorDeployment = await deployments.get("MockV3Aggregator");
    const PerpTradeDeployment = await deployments.get("PerpTrade");
    USDC=await ethers.getContractAt("MyToken2", USDCDeployment.address);
    MockV3Aggregator=await ethers.getContractAt("MockV3Aggregator", MockV3AggregatorDeployment.address);
    PerpTrade=await ethers.getContractAt("PerpTrade", PerpTradeDeployment.address);
    console.log("USDC地址            :", await USDC.getAddress());
    console.log("MockV3Aggregator地址:", await MockV3Aggregator.getAddress());
    console.log("PerpTrade地址       :", await PerpTrade.getAddress());
    console.log(await USDC.symbol())
    /* 3. 给用户打钱 */
    // await USDC.mint(alice.address,ethers.parseUnits("10000", 6));
    // await USDC.mint(bob.address,ethers.parseUnits("1000", 6));
    });
    it("开仓10x + 涨价 20%+平仓 盈利场景", async () => {
    const collateral = ethers.parseUnits("1000", 6); // 1000 USDC
    const perpAddr   = await PerpTrade.getAddress();
    
    // 0. 给合约打钱(赔付准备金)
    await USDC.connect(owner).mint(alice.address, ethers.parseUnits("10000", 6));//用户
    await USDC.connect(owner).mint(bob.address, ethers.parseUnits("10000", 6));//清算人
    // 1. 给用户打钱
    await USDC.connect(alice).transfer(perpAddr, ethers.parseUnits("3000", 6));//用户给合约转了1000
    console.log(await USDC.balanceOf(alice.address))//7000(10000-3000)
    // 2. 授权 & 开仓
    await USDC.connect(alice).approve(perpAddr, collateral);
    console.log(await USDC.balanceOf(alice.address))
    console.log(await USDC.allowance(alice.address, perpAddr))
    await PerpTrade.connect(alice).open(true, collateral, ethers.parseEther("10"));
    console.log("盈利",await PerpTrade.getPrice())
    // 3. 涨价
    await MockV3Aggregator.updateAnswer(2200_0000_0000);
    console.log("盈利",await PerpTrade.getPrice())
    // 4. 平仓
    await PerpTrade.connect(alice).close(0);
    console.log("Alice 最终余额:", ethers.formatUnits(await USDC.balanceOf(alice.address), 6));
    });
    it("开仓10x + 降价 20%+平仓 亏损场景", async () => {
    const collateral = ethers.parseUnits("1000", 6); // 1000 USDC
    const perpAddr   = await PerpTrade.getAddress();
    
    // 0. 给合约打钱(赔付准备金)
    await USDC.connect(owner).mint(alice.address, ethers.parseUnits("10000", 6));
    await USDC.connect(alice).transfer(perpAddr, ethers.parseUnits("3000", 6));
    console.log(await USDC.balanceOf(alice.address))//1000(10000-3000)
    // 1. 给用户打钱
    //   await USDC.connect(owner).mint(alice.address, ethers.parseUnits("1000", 6));
    // console.log(await USDC.balanceOf(alice.address))
    // 2. 授权 & 开仓
    await USDC.connect(alice).approve(perpAddr, collateral);
    console.log(await USDC.balanceOf(alice.address))
    console.log(await USDC.allowance(alice.address, perpAddr))
    await PerpTrade.connect(alice).open(true, collateral, ethers.parseEther("10"));
    console.log("盈利",await PerpTrade.getPrice())
    // 3. 降价
    await MockV3Aggregator.updateAnswer(1800_0000_0000);
    console.log("盈利",await PerpTrade.getPrice())
    // 4. 平仓
    await PerpTrade.connect(alice).close(0);
    console.log("Alice 最终余额:", ethers.formatUnits(await USDC.balanceOf(alice.address), 6));
    });
    it("开仓10x + 降价 75%  清算场景", async () => {
    const LIQUIDATOR_ROLE = ethers.keccak256(ethers.toUtf8Bytes("LIQUIDATOR_ROLE"));
    await PerpTrade.connect(owner).grantRole(LIQUIDATOR_ROLE, bob.address);
    const collateral = ethers.parseUnits("1000", 6); // 1000 USDC
    const perpAddr   = await PerpTrade.getAddress();
    
    // 0. 给合约打钱(赔付准备金)
    await USDC.connect(owner).mint(alice.address, ethers.parseUnits("10000", 6));//用户
    await USDC.connect(owner).mint(bob.address, ethers.parseUnits("10000", 6));//清算人
    await USDC.connect(alice).transfer(perpAddr, ethers.parseUnits("3000", 6));
    console.log(await USDC.balanceOf(alice.address))//1000(10000-3000)
    // 1. 给用户打钱
    //   await USDC.connect(owner).mint(alice.address, ethers.parseUnits("1000", 6));
    // console.log(await USDC.balanceOf(alice.address))
    // 2. 授权 & 开仓
    await USDC.connect(alice).approve(perpAddr, collateral);
    console.log(await USDC.balanceOf(alice.address))
    console.log(await USDC.allowance(alice.address, perpAddr))
    await PerpTrade.connect(alice).open(true, collateral, ethers.parseEther("10"));
    console.log("盈利",await PerpTrade.getPrice())
    // 3. 涨价
    await MockV3Aggregator.updateAnswer(500_0000_0000);
    console.log("盈利",await PerpTrade.getPrice())
    // 清算人调用
    const liqBalBefore = await USDC.balanceOf(bob.address);
    console.log("清算人余额:", liqBalBefore);
    await PerpTrade.connect(bob).liquidate(alice.address, 0);
    const liqBalAfter = await USDC.balanceOf(bob.address);
    console.log("清算人余额:", liqBalAfter);
    // // 4. 平仓
    // await PerpTrade.connect(alice).close(0);
    console.log("Alice 最终余额:", ethers.formatUnits(await USDC.balanceOf(alice.address), 6));
    });
    });

    测试指令npx hardhat test ./test/xxxx.js

    合约部署

  • 永存合约部署
  • 特别说明部署时要保证Token合约的唯一性

    
    module.exports = async  ({getNamedAccounts,deployments})=>{
    const getNamedAccount = (await getNamedAccounts()).firstAccount;
    const {deploy,log} = deployments;
    // 1. 先部署 mock 代币与 mock 价格源(本地/测试网)
    const MyUSDC=await deploy("MyToken2",{
        from:getNamedAccount,
        args: ["MyUSDC","USDC",getNamedAccount],//参数
        log: true,
    });
    console.log("MyUSDC 合约地址:", MyUSDC.address);
    
    //执行MockV3Aggregator部署合约
    const MockV3Aggregator=await deploy("MockV3Aggregator",{
        from:getNamedAccount,
        args: [8,"USDC/USD", 200000000000],//参数
        log: true,
    })
    console.log("MockV3Aggregator合约地址:", MockV3Aggregator.address);
    
    // 2. 部署交易合约
    const PerpTrade=await deploy("PerpTrade",{
        from:getNamedAccount,
        args: [MyUSDC.address,MockV3Aggregator.address],//参数 代币,喂价
        log: true,
    })
    
    console.log("PerpTrade合约地址:", PerpTrade.address);
    }

module.exports.tags = ["all", "PerpTrade"];


* **部署指令**:**npx hardhat deploy --tags token2,MockV3Aggregator,PerpTrade**
* **部署说明**:**部署代币,喂价,永续合约交易**
# 总结
1.  代码层面:

    -   代币合约采用 OpenZeppelin 5.x 模板,自带铸造、燃烧、Ownable 权限,可直接对接主流钱包。
    -   MockV3Aggregator 完全实现 Chainlink AggregatorV3Interface,支持 roundId、时间戳、精度、描述等字段,方便本地单元测试与前后端联调。
    -   PerpTrade 合约通过 ReentrancyGuard、AccessControl 把“杠杆开仓、统一平仓、清算奖励”三大高危函数加上重入锁与角色隔离;利用链上实时价格计算浮动盈亏,并在维持保证金不足时允许清算人拿走 5 % 奖励,剩余返还给用户,杜绝协议负债。

1.  测试层面:

    -   用 Hardhat deployments 自动完成“先代币→再喂价→最后交易”的依赖部署顺序,避免手动传参错误。
    -   三个典型场景(上涨 20 % 多头盈利、下跌 20 % 多头亏损、下跌 75 % 触发清算)全部断言最终余额,验证盈亏公式与清算逻辑精度无误;同时打印日志,方便开发者逐行核对资金流动。

1.  安全与扩展提示:

    -   生产环境务必替换 Mock 喂价为去中心化 Oracle,并增加资金费率、指数价格、溢价指数、多币种抵押、减仓/强平分级、保险池、Rebalance 等模块。
    -   维持保证金、清算奖励、最大杠杆等参数建议通过 Timelock + DAO 投票治理,防止管理员瞬间改规则。
    -   可引入 Pyth、Band、RedStone 等多源价格,配合链下 Keeper 自动触发清算,降低清算延迟风险。

至此,一套“能跑、能赚、能爆”的迷你永续协议已就绪。基于此基础,我们也可以继续进行功能的迭代。
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
木西
木西
0x5D5C...2dD7
江湖只有他的大名,没有他的介绍。