前言本文基于openzeppelinV5,solidity0.8.20,chainlink实现一套可编译、可部署、可清算的迷你永续合约:先发行一枚ERC-20保证金代币(USDC);再部署一个可手动更新的ChainlinkMock喂价器;最后上线一个20倍杠杆、5%
本文基于openzeppelinV5 ,solidity 0.8.20,chainlink实现一套可编译、可部署、可清算的迷你永续合约:
- 先发行一枚 ERC-20 保证金代币(USDC);
- 再部署一个可手动更新的 Chainlink Mock 喂价器;
- 最后上线一个 20 倍杠杆、5 % 维持保证金的永续交易核心,支持多空、盈亏结算、强制清算、角色权限控制。
整套合约仅 200 行,却浓缩了真实永续协议必备的四件套:仓位结构、杠杆检查、价格源、清算引擎。配合 Hardhat 的 deploy 脚本与 Mocha 测试用例,读者可在 10 分钟内跑完“开仓→涨价盈利→降价亏损→大幅爆仓”全生命周期,直观理解链上永续的数学与风险边界。永续合约
概念
永续合约(Perpetual Contract,简称“永续”)是一种没有到期日、可长期持有的衍生品合约,本质上是永不交割的期货 。它通过资金费率(Funding Rate)机制把合约价格锚定现货价格,让交易者无需实物交割就能进行高杠杆多空双向交易。
核心特征
- 无到期日
传统期货每周/每季度交割,永续永不交割,想持有多久就多久。- 资金费率(Funding)
每 1~8 小时多空双方互相支付一次利息:
- 合约价 > 现货价 → 多头付空头,鼓励做空压价;
- 合约价 < 现货价 → 空头付多头,鼓励做多抬价。
把溢价/折价“掰”回现货价,实现软锚定。- 高杠杆
主流平台支持 1~100 倍杠杆,保证金亏损触及维持保证金即触发强制平仓(清算) 。
永续合约 = “永久持仓的杠杆押注” ,用资金费率代替交割,把合约价格永远“拧”回现货,是目前加密世界成交量最大的衍生品。
// 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);
}
}
// 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;
}
}
// 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"; // <── 只需这一行
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 <= 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 < p.entryPrice ? p.entryPrice - price : price - p.entryPrice;
pnlValue = (p.size * delta) / p.entryPrice;
profit = (p.isLong && price >= p.entryPrice) || (!p.isLong && price <= 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 <= 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 <= 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;
}
}
特别说明
实现清算时要保证参数单位的一致性
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 自动触发清算,降低清算延迟风险。
至此,一套“能跑、能赚、能爆”的迷你永续协议已就绪。基于此基础,我们也可以继续进行功能的迭代。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!