流程
- 建立 WorldCup 合约(已完成)
- 发行 WorldCupToken(已完成)
- 统计玩家下注的历史,计算每个人分配多少(由 subgraph 链下统计)
- 管理员分配奖励(一个合约)
- 用户领取奖励
分配奖励分析
技术选型
使用 merkle tree 方式,对当期所有玩家进行统一设置,然后各自去 claim。merkleRoot 是一个 hash 值,根节点hash确定后,叶子节点和通向根节点路径中的 hash 值就都确定了,从而可以完成快速验证功能,能够满足我们的奖励方法需求。
实现思路
1 管理员分配每个玩家 token 数量,生成 merkleRoot 写入合约
- 需要从 subgraph 请求 Play 玩家下注历史数据
- 然后在本地(前端或脚本),按照空投策略(比如参与权重 1,猜中权重 2),生成 merkleRoot
- 调用 token 奖励合约设置 merkleRoot 并发送事件,在 subgraph 计算每个玩家可以分配的 token 数量
2 玩家领取 token 奖励,需要将叶子信息和证明信息传递给合约,合约校验通过后,执行奖励发放
- 需要从 subgraph 请求所有用户的奖励数据,生成 merkleRoot 以生成证明
- 然后从 subgraph 请求自己能够获取的 token 数量
- 调用奖励合约,领取奖励
具体逻辑
1 管理员调用 distribute(步骤 7),这个方法的核心参数是 merkleRoot,是由所有玩家的“地址+奖励数量”作为叶子结点生成的。为了得到这些叶子结点,我们需要向 subgraph 请求玩家的原始数据(步骤 8),然后根据奖励分配规则,在前端本地计算每个人分配的数量,进而生成 merkleRoot(步骤 9),设置到合约中。
2 存储 merkleRoot 后发出事件(步骤 10),subgraph 内部收到事件后,会再重复计算一次玩家奖励,并将计算结果存储在 subgraph 库中(reward list)
3 玩家发起领奖时(步骤 11),点击 ClaimReward,此时需要的参数为:玩家地址、奖励数量、证明,用于在合约内部验证 merkleRoot。这些数据在上一步已存储在 subgraph 中,所以玩家发起请求获取奖励列表(步骤 12),在本地计算证明 proof,然后传递给合约。
4 合约接收到玩家领奖请求时,会将当前玩家当成一个叶子节点,进而与已经设置好的 merkleRoot 进行验证。如果验证成功,则向玩家转账奖励,反之合约revert。
代码实现
分配奖励合约
编写运行脚本
import { ethers } from "hardhat";
async function main() {
// FFTToken address
let token = '0x8fe664FA864C61054D2dec3dEB54b204a427d8A8'
const Distributor = await ethers.getContractFactory("WorldCupDistributor");
const distributor = await Distributor.deploy(token);
await distributor.deployed();
console.log(`new distributor: ${distributor.address}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
部署验证合约
npx hardhat run scripts/deployDistributor.ts --network goerli
# 0xF19233dFE30219F4D6200c02826B80e4347EF8BF
npx hardhat verify 0xF19233dFE30219F4D6200c02826B80e4347EF8BF 0x4c305227E762634CB7d3d9291e42b423eD45f1AD --network goerli
subgraph 链下监听
首先像上一章那样初始化配置 subgraph 项目,将 WorldCup 和 WorldCupDistributor 合约部署进项目中,以对合约进行监听,然后修改配置文件 startBlock。接着在 schema 文件中重新创建我们需要的实体对象:
# 玩家 Player
type PlayRecord @entity {
id: ID!
index: BigInt! # uint256 第几期
player: Bytes! # address
selectCountry: BigInt! # uint256 选择的队伍
time: BigInt!
block: BigInt!
}
type NeedToHandle @entity {
id: ID!
list: [PlayRecord!]!
}
# 球队 winner
type FinializeHistory @entity {
id: ID!
result: BigInt!
}
# 玩家奖励详情(分配后)
type PlayerDistribution @entity {
id: ID!
index: BigInt!
player: Bytes!
rewardAmt: BigInt! # 玩家应得奖金
weight: BigInt! # 权重
isClaimed: Boolean! # 是否已领取
}
# 每一期的奖励记录
type RewardHistory @entity {
id: ID!
index: BigInt!
rewardAmt: BigInt! # 当期奖池
settleBlockNumber: BigInt!
totalWeight: BigInt!
list: [PlayerDistribution!]!
}
# 当期奖池
type MerkleDistributor @entity {
id: ID!
index: BigInt!
totalAmt: BigInt!
settleBlockNumber: BigInt!
}
注意对应的 /generated/schema.ts 临时也要修改一下,直接 copy 就行,该文件虽然是自动根据实体生成的。
// THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
import {
TypedMap,
Entity,
Value,
ValueKind,
store,
Bytes,
BigInt,
BigDecimal
} from "@graphprotocol/graph-ts";
export class PlayRecord extends Entity {
constructor(id: string) {
super();
this.set("id", Value.fromString(id));
}
save(): void {
let id = this.get("id");
assert(id != null, "Cannot save PlayRecord entity without an ID");
if (id) {
assert(
id.kind == ValueKind.STRING,
`Entities of type PlayRecord must have an ID of type String but the id '``{id.displayData()}' is of type ``{id.displayKind()}`
);
store.set("PlayRecord", id.toString(), this);
}
}
static load(id: string): PlayRecord | null {
return changetype<PlayRecord | null>(store.get("PlayRecord", id));
}
get id(): string {
let value = this.get("id");
return value!.toString();
}
set id(value: string) {
this.set("id", Value.fromString(value));
}
get index(): BigInt {
let value = this.get("index");
return value!.toBigInt();
}
set index(value: BigInt) {
this.set("index", Value.fromBigInt(value));
}
get player(): Bytes {
let value = this.get("player");
return value!.toBytes();
}
set player(value: Bytes) {
this.set("player", Value.fromBytes(value));
}
get selectCountry(): BigInt {
let value = this.get("selectCountry");
return value!.toBigInt();
}
set selectCountry(value: BigInt) {
this.set("selectCountry", Value.fromBigInt(value));
}
get time(): BigInt {
let value = this.get("time");
return value!.toBigInt();
}
set time(value: BigInt) {
this.set("time", Value.fromBigInt(value));
}
get block(): BigInt {
let value = this.get("block");
return value!.toBigInt();
}
set block(value: BigInt) {
this.set("block", Value.fromBigInt(value));
}
}
export class NeedToHandle extends Entity {
constructor(id: string) {
super();
this.set("id", Value.fromString(id));
}
save(): void {
let id = this.get("id");
assert(id != null, "Cannot save NeedToHandle entity without an ID");
if (id) {
assert(
id.kind == ValueKind.STRING,
`Entities of type NeedToHandle must have an ID of type String but the id '``{id.displayData()}' is of type ``{id.displayKind()}`
);
store.set("NeedToHandle", id.toString(), this);
}
}
static load(id: string): NeedToHandle | null {
return changetype<NeedToHandle | null>(store.get("NeedToHandle", id));
}
get id(): string {
let value = this.get("id");
return value!.toString();
}
set id(value: string) {
this.set("id", Value.fromString(value));
}
get list(): Array<string> {
let value = this.get("list");
return value!.toStringArray();
}
set list(value: Array<string>) {
this.set("list", Value.fromStringArray(value));
}
}
export class FinializeHistory extends Entity {
constructor(id: string) {
super();
this.set("id", Value.fromString(id));
}
save(): void {
let id = this.get("id");
assert(id != null, "Cannot save FinializeHistory entity without an ID");
if (id) {
assert(
id.kind == ValueKind.STRING,
`Entities of type FinializeHistory must have an ID of type String but the id '``{id.displayData()}' is of type ``{id.displayKind()}`
);
store.set("FinializeHistory", id.toString(), this);
}
}
static load(id: string): FinializeHistory | null {
return changetype<FinializeHistory | null>(
store.get("FinializeHistory", id)
);
}
get id(): string {
let value = this.get("id");
return value!.toString();
}
set id(value: string) {
this.set("id", Value.fromString(value));
}
get result(): BigInt {
let value = this.get("result");
return value!.toBigInt();
}
set result(value: BigInt) {
this.set("result", Value.fromBigInt(value));
}
}
export class PlayerDistribution extends Entity {
constructor(id: string) {
super();
this.set("id", Value.fromString(id));
}
save(): void {
let id = this.get("id");
assert(id != null, "Cannot save PlayerDistribution entity without an ID");
if (id) {
assert(
id.kind == ValueKind.STRING,
`Entities of type PlayerDistribution must have an ID of type String but the id '``{id.displayData()}' is of type ``{id.displayKind()}`
);
store.set("PlayerDistribution", id.toString(), this);
}
}
static load(id: string): PlayerDistribution | null {
return changetype<PlayerDistribution | null>(
store.get("PlayerDistribution", id)
);
}
get id(): string {
let value = this.get("id");
return value!.toString();
}
set id(value: string) {
this.set("id", Value.fromString(value));
}
get index(): BigInt {
let value = this.get("index");
return value!.toBigInt();
}
set index(value: BigInt) {
this.set("index", Value.fromBigInt(value));
}
get player(): Bytes {
let value = this.get("player");
return value!.toBytes();
}
set player(value: Bytes) {
this.set("player", Value.fromBytes(value));
}
get rewardAmt(): BigInt {
let value = this.get("rewardAmt");
return value!.toBigInt();
}
set rewardAmt(value: BigInt) {
this.set("rewardAmt", Value.fromBigInt(value));
}
get weight(): BigInt {
let value = this.get("weight");
return value!.toBigInt();
}
set weight(value: BigInt) {
this.set("weight", Value.fromBigInt(value));
}
get isClaimed(): boolean {
let value = this.get("isClaimed");
return value!.toBoolean();
}
set isClaimed(value: boolean) {
this.set("isClaimed", Value.fromBoolean(value));
}
}
export class RewardHistory extends Entity {
constructor(id: string) {
super();
this.set("id", Value.fromString(id));
}
save(): void {
let id = this.get("id");
assert(id != null, "Cannot save RewardHistory entity without an ID");
if (id) {
assert(
id.kind == ValueKind.STRING,
`Entities of type RewardHistory must have an ID of type String but the id '``{id.displayData()}' is of type ``{id.displayKind()}`
);
store.set("RewardHistory", id.toString(), this);
}
}
static load(id: string): RewardHistory | null {
return changetype<RewardHistory | null>(store.get("RewardHistory", id));
}
get id(): string {
let value = this.get("id");
return value!.toString();
}
set id(value: string) {
this.set("id", Value.fromString(value));
}
get index(): BigInt {
let value = this.get("index");
return value!.toBigInt();
}
set index(value: BigInt) {
this.set("index", Value.fromBigInt(value));
}
get rewardAmt(): BigInt {
let value = this.get("rewardAmt");
return value!.toBigInt();
}
set rewardAmt(value: BigInt) {
this.set("rewardAmt", Value.fromBigInt(value));
}
get settleBlockNumber(): BigInt {
let value = this.get("settleBlockNumber");
return value!.toBigInt();
}
set settleBlockNumber(value: BigInt) {
this.set("settleBlockNumber", Value.fromBigInt(value));
}
get totalWeight(): BigInt {
let value = this.get("totalWeight");
return value!.toBigInt();
}
set totalWeight(value: BigInt) {
this.set("totalWeight", Value.fromBigInt(value));
}
get list(): Array<string> {
let value = this.get("list");
return value!.toStringArray();
}
set list(value: Array<string>) {
this.set("list", Value.fromStringArray(value));
}
}
export class MerkleDistributor extends Entity {
constructor(id: string) {
super();
this.set("id", Value.fromString(id));
}
save(): void {
let id = this.get("id");
assert(id != null, "Cannot save MerkleDistributor entity without an ID");
if (id) {
assert(
id.kind == ValueKind.STRING,
`Entities of type MerkleDistributor must have an ID of type String but the id '``{id.displayData()}' is of type ``{id.displayKind()}`
);
store.set("MerkleDistributor", id.toString(), this);
}
}
static load(id: string): MerkleDistributor | null {
return changetype<MerkleDistributor | null>(
store.get("MerkleDistributor", id)
);
}
get id(): string {
let value = this.get("id");
return value!.toString();
}
set id(value: string) {
this.set("id", Value.fromString(value));
}
get index(): BigInt {
let value = this.get("index");
return value!.toBigInt();
}
set index(value: BigInt) {
this.set("index", Value.fromBigInt(value));
}
get totalAmt(): BigInt {
let value = this.get("totalAmt");
return value!.toBigInt();
}
set totalAmt(value: BigInt) {
this.set("totalAmt", Value.fromBigInt(value));
}
get settleBlockNumber(): BigInt {
let value = this.get("settleBlockNumber");
return value!.toBigInt();
}
set settleBlockNumber(value: BigInt) {
this.set("settleBlockNumber", Value.fromBigInt(value));
}
}
接着编写监听事件逻辑,主要包括玩家下注信息的记录,以及调用 distributeReward() 后发出的事件(对 MerkleRoot 的再次计算存储)
import { Address, BigInt, Bytes, TypedMap, ethereum, log, bigInt } from "@graphprotocol/graph-ts";
import {
WorldCup,
ClaimReward,
Finialize,
Play
} from "../generated/WorldCup/WorldCup"
import {
Claimed,
DistributeReward
} from "../generated/WorldCupDistributor/WorldCupDistributor"
import { PlayRecord, NeedToHandle, PlayerDistribution, MerkleDistributor, FinializeHistory, RewardHistory } from "../generated/schema"
let NO_HANDLE_ID = "noHandleId"
export function handlePlay(event: Play): void {
// 统计所有 play 事件(玩家下注),存储起来
// 1.创建 id
let id = event.params._player.toHex() + "#" + event.params._currRound.toString() + "#" + event.block.timestamp.toHex();
// 2.创建玩家下注记录
let entity = new PlayRecord(id);
// 3.塞数据
entity.index = BigInt.fromI32(event.params._currRound);
entity.player = event.params._player;
entity.selectCountry = BigInt.fromI32(event.params._country);
entity.time = event.block.timestamp;
entity.block = event.block.number;
// 4.保存
entity.save()
// 5.保存待处理的玩家下注记录
let noHandle = NeedToHandle.load(NO_HANDLE_ID);
if (!noHandle) {
noHandle = new NeedToHandle(NO_HANDLE_ID);
noHandle.list = [];
}
noHandle.list.push(id)
noHandle.save()
}
// 当期冠军球队记录
export function handleFinialize(event: Finialize): void {
let id = event.params._currRound.toString();
let entity = new FinializeHistory(id);
entity.result = event.params._country;
entity.save();
}
// 1 遍历本期所有的Play记录
// 2 计算每个玩家的权重
// 3 按照权重分配总奖励数
export function handleDistributeReward(event: DistributeReward): void {
let id = event.params.index.toString();
let rewardAmt = event.params.amount;
// 第几期
let index = event.params.index;
let settleBlockNumber = event.params.settleBlockNumber;
// 找到当期冠军球队是谁
let winCountry = FinializeHistory.load(id)
if (!winCountry) {
return;
}
// 保存当期 token 奖励信息
let merkleEntity = new MerkleDistributor(id);
merkleEntity.index = index;
merkleEntity.totalAmt = rewardAmt;
merkleEntity.settleBlockNumber = settleBlockNumber;
merkleEntity.save();
let startBlock = BigInt.fromI32(0);
let endBlock = settleBlockNumber;
if (index > BigInt.fromI32(0)) {
// 上一期的 id
let prevId = index.minus(BigInt.fromI32(1)).toString()
let prev = MerkleDistributor.load(prevId) as MerkleDistributor;
startBlock = prev.settleBlockNumber;
}
let totalWeight = BigInt.fromI32(0)
// 实发总奖励 由于精准度问题可能造成实际的奖励少了一点
let rewardActuallyAmt = BigInt.fromI32(0)
// 奖励历史
let rewardHistoryList: string[] = [];
let noHandle = NeedToHandle.load(NO_HANDLE_ID);
if (noHandle) {
// 玩家地址 => 奖励权重
let group = new TypedMap<Bytes, BigInt>();
// 当期所有玩家记录
let currentList = noHandle.list;
let newList: string[] = [];
log.warning("current list: ", currentList)
for (let i = 0; i < currentList.length; i++) {
// 玩家奖励权重 初始化默认参与奖励权重 1
let playerWeight = BigInt.fromI32(1)
// 玩家下注记录
let record = PlayRecord.load(currentList[i]) as PlayRecord;
log.warning("record.block:", [record.block.toString()])
log.warning("startBlock:", [startBlock.toString()])
log.warning("endBlock:", [endBlock.toString()])
if (record.block > startBlock && record.block <= endBlock) {
if (winCountry.result == record.selectCountry) {
// 猜对了就可以得到双倍奖励
playerWeight = playerWeight.times(BigInt.fromI32(2))
}
let prevWeight = group.get(record.player)
if (!prevWeight) {
// 不存在默认 0
prevWeight = BigInt.fromI32(0)
}
// 更新玩家奖励权重 可能下注多次 所以需要累加
group.set(record.player, prevWeight.plus(playerWeight));
// 更新总权重
totalWeight = totalWeight.plus(playerWeight);
log.warning("hello world totalWeight: ", [totalWeight.toString()])
} else {
// block 区间之外的 会添加到 newList 中
newList.push(currentList[i]);
}
}
// 按照权重给玩家分配奖励 存储到 PlayerDistribution(供最终调用)
for (let j = 0; j < group.entries.length; j++) {
let player = group.entries[j].key;
let weight = group.entries[j].value;
let id = player.toString() + "#" + index.toString()
log.warning("totalWeight:", [totalWeight.toString()])
// 计算奖励
let reward = rewardAmt.times(weight).div(totalWeight);
let playerDistribution = new PlayerDistribution(id);
playerDistribution.index = index;
playerDistribution.player = player;
playerDistribution.rewardAmt = reward;
playerDistribution.weight = weight;
playerDistribution.isClaimed = false;
playerDistribution.save();
rewardHistoryList.push(id);
rewardActuallyAmt = rewardActuallyAmt.plus(reward);
}
noHandle.list = newList;
noHandle.save();
}
// 存储本期奖励详情 供后续查看历史
let rewardHistory = new RewardHistory(id);
rewardHistory.index = index;
rewardHistory.rewardAmt = rewardAmt;
rewardHistory.settleBlockNumber = settleBlockNumber;
rewardHistory.totalWeight = totalWeight;
rewardHistory.list = rewardHistoryList;
}
export function handleClaimed(event: Claimed): void {
}
export function handleClaimReward(event: ClaimReward): void {
}
先在 node_modules 目录下载所需依赖,接着编写脚本 scripts/distributeReward.ts 对第0期的所有玩家,发放10000 * 10^18 个奖励,读取数据,生成merkleRoot
npm install --save cross-fetch
npm i apollo-boost graphql react-apollo -S
npm install merkletreejs
import { ApolloClient, gql, HttpLink, InMemoryCache } from 'apollo-boost';
import { fetch } from 'cross-fetch';
import { BigNumber } from 'bignumber.js'
import { MerkleTree } from 'merkletreejs'
import hre from 'hardhat'
import { any } from 'hardhat/internal/core/params/argumentTypes';
// const graphUrl = process.env.SUBGRAPH_API;
// const graphUrl = "http://localhost:8000/subgraphs/name/duke/worldcup"
const graphUrl = "https://api.thegraph.com/subgraphs/name/dukedaily/worldcup"
async function executeQuery(query: string, variables: any) {
const client = new ApolloClient({
link: new HttpLink({uri: graphUrl, fetch}),
cache: new InMemoryCache(),
});
return await client.query({
query: gql(query),
variables: variables,
});
}
function calculatePlayerReward() {
}
async function getPlayerRecords(index: number) {
const query = `{
playRecords(where: {
index: ${index}
}){
id
index
player
selectCountry
block
}
}`;
let data = await executeQuery(query, {})
return data['data']['playRecords']
}
async function getWinnerHistory(index: number) {
const query = `{
finializeHistory(id: ${index}) {
result
}
}`;
let data = await executeQuery(query, {})
return data['data']['finializeHistory']
}
async function getPlayerDistributions(index: number) {
const query = ` {
playerDistributions(
where : {
index: ${index}
}
) {
player
rewardAmt
weight
}
}
`;
let data = await executeQuery(query, {})
return data['data']['playerDistributions']
}
function getPlayerRewardList(totalReward: string, records: any, winner: number) {
// 遍历所有的records,计算每个人的奖励数量,返回一个数组,然后抛出来,后续使用进行merkel计算
let group = {}
let totalWeight: string = '0'
records.map((it: {
player(arg0: string, player: any): unknown; selectCountry: number;
}) => {
// 猜中奖励翻倍
// console.log('mapping it:', it);
// console.log('it.selectCountry:', it.selectCountry, 'winner:', winner);
let weight = (it.selectCountry === winner) ? 2 : 1
return { it, weight }
}).forEach((element: {
weight(weight: any): unknown; player: string | number;
}) => {
let value = group[element.player] || {
list: [],
weight: '0'
}
// console.log('current value:', value);
value.list.push(element.it)
value.weight = new BigNumber(value.weight).plus(element.weight).toFixed()
totalWeight = new BigNumber(totalWeight).plus(element.weight).toFixed()
group[element.it.player] = value
});
console.log('group', group)
console.log('totalWeight', totalWeight)
let playerDistributionList = []
let actuallyAmt = "0"
for (const player in group) {
const item = group[player];
// TODO dp是什么?
item.reward = new BigNumber(item.weight).multipliedBy(totalReward).div(totalWeight).dp(0, BigNumber.ROUND_DOWN).toFixed();
actuallyAmt = new BigNumber(actuallyAmt).plus(item.reward).toFixed()
// console.log('total reward: ', totalReward, 'item.weight:', item.weight);
console.log('reward:', item.reward.toString());
playerDistributionList.push({
player: player,
rewardAmt: item.reward
})
}
return { playerDistributionList, actuallyAmt };
}
function generateLeaf(index: number, player: string, rewardAmt: number) {
return hre.ethers.utils.keccak256(
hre.ethers.utils.solidityPack(
['uint256', 'address', 'uint256'],
[index, player, rewardAmt]
))
}
function generateMerkelTree(index: number, playerRewardList: any) {
// make leafs
let items = playerRewardList.map((it => {
console.log('it.rewardAmt:', it.rewardAmt);
return generateLeaf(index, it.player, it.rewardAmt);
}))
// create tree
const tree = new MerkleTree(items, hre.ethers.utils.keccak256, { sort: true })
return tree
}
export const oneEther = new BigNumber(Math.pow(10, 18))
export const createBigNumber18 = (v: any) => {
return new BigNumber(v).multipliedBy(oneEther).toFixed()
}
const CURRENT_ROUND = 0;
const TOTAL_REWARD = createBigNumber18(10000);
const currentPlayer = '0xe8191108261f3234f1c2aca52a0d5c11795aef9e';
async function main() {
// 查询 subgraph 获取玩家数据
const playRecords = await getPlayerRecords(CURRENT_ROUND)
const winner = await getWinnerHistory(CURRENT_ROUND)
console.log(`winner for round ``{CURRENT_ROUND} is : ``{winner['result']}`);
// 所有Player都会设置weight:1
// 如果猜中了,weight: 2
// 计算每个玩家的奖励
const {playerDistributionList, actuallyAmt} = getPlayerRewardList(TOTAL_REWARD, playRecords, winner['resuly'])
// 计算 MerkleRoot
const tree = generateMerkelTree(CURRENT_ROUND, playerDistributionList)
console.log('root:', tree.getHexRoot());
//TODO 此时应该调用合约将 merkleRoot 设置进去了
console.log('请手动发放奖励!')
console.log('准备生成领取奖励所需数据:', currentPlayer);
// 查询 subgraph 获取玩家奖励数据
const playerDistributions = await getPlayerDistributions(CURRENT_ROUND)
const newTree = generateMerkelTree(CURRENT_ROUND, playerDistributions)
console.log('newRoot:', newTree.getHexRoot());
const player = playerDistributions.filter(function (item: any) {
return item.player === currentPlayer.toLowerCase()
})[0]
console.log('player:', player);
const leaf = generateLeaf(CURRENT_ROUND, player.player, player.rewardAmt)
const proof = newTree.getHexProof(leaf)
console.log('proof:', proof);
// TODO 使用 proof 作为合约入参进行验证发放奖励
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
})
部署 subgraph
# 启动graphnode
docker-compose up
# 创建
npm run codegen
npm run build
npm run create-local
npm run deploy-local
# 分发奖励
npx hardhat run scripts/distributeReward.ts
标签:set,竞猜,get,value,Dapp,BigInt,let,空投,id
From: https://www.cnblogs.com/pandacode/p/17004030.html