smart contract แจก stake reward เขียนแบบไหนได้บ้างนะ ? (Part 1)

Nattawat Songsom
6 min readSep 13, 2023

--

มาไล่ทีละบรรทัดกัน

โอเค มาเริ่มจากทวน scenario ของ contract stake reward กันก่อน

  1. user A stake token B เข้าไปใน contract
  2. contract ทำการคำนวณรางวัล ให้กับ user ทุกๆวินาที ที่ stake
  3. user A ถอน stake ออกมา โดยจะได้รับ 2 อย่างคือ
  • token B ที่ไป stake เอาไว้
  • reward เป็น token C

โอเค แล้วถ้ามีคนมา stake หลายๆคนพร้อมกันละ reward จะแบ่งกันยังไง ?

คำตอบคือ แบ่งตามอัตราส่วนที่คนๆนั้น stake เอาไว้ เมื่อเทียบกับ กอง stake ทั้งหมด ณ เวลานั้นๆ

เช่น ถ้า scenario เป็นดังนี้

  1. นาย smile เริ่ม stake ที่วินาทีที่ 7 เป็นจำนวน 100 token S แล้วทำการ unstake ที่วินาทีที่ 14
  2. นาย bear ทำการ stake ที่วินาทีที่ 9 เป็นจำนวน 100 token S แล้วทำการ unstake ที่วินาทีที่ 18

ตามรูป

คำถามคือ

ตอนที่ นาย smile และนาย bear unstake ทั้งคู่จะได้รับ reward token เท่าไหร่ ?

ถ้า contract จะทำการให้ reward token จำนวน R เหรียญ ซึ่ง นาย smile และ นาย bear จะต้องนำไปแบ่งกันเอง

โอเค

ที่วินาทีที่ 8 นาย smile จะได้รับ reward token จำนวน R ไปเต็มๆ เพราะ stake อยู่คนเดียว

ที่วินาทีที่ 9 นาย smile จะได้รับ reward token จำนวน R ไปเต็มๆ เพราะ stake อยู่คนเดียว

ที่วินาทีที่ 10 นาย smile จะต้องแบ่ง reward token กับนาย bear

โดยจำนวน stake token ของนาย smile คิดเป็นอัตราส่วน 100/200 = 0.5

ดังนั้น นาย smile จะได้รับ 0.5 R เหรียญ

ที่วินาที 11–14 จะคำนวณเหมือนวินาทีที่ 10

ดังนั้นนาย smile จะได้รับ reward token จำนวนทั้งหมด 4.5 R ตามรูป

ส่วน reward ของนาย bear ก็จะคำนวณในแบบเดียวกัน

โอเค เราสามารถสรุปเป็นสูตรได้ดังรูป

โอเค มาลองเขียน code version ตามสูตรนี้กัน

** disclaimer อย่าเอา code ด้านล่างไปใช้นะครับ code ด้านล่างเป็นการ draft code ตามสูตรเฉยๆ (ยังไม่ได้ test) และที่สำคัญคือ ตอนนี้การ staking มี code ที่เป็น standard แล้ว https://solidity-by-example.org/defi/staking-rewards/ … ถ้าจะทำ staking แนะนำให้ไปท่านั้นครับ

โอเค มาเริ่มกัน

เริ่มจากกำหนดเหรียญ stake และ เหรียญ reward

จากนั้นกำหนด reward per period โดยจะกำหนดเป็น 1 เหรียญต่อ block

ต่อมาเมื่อมีการ stake เข้ามา

จะต้องทำการ update balance ของ user และ update totalSupply

และเพื่อให้เราสามารถเข้าถึงค่านี้ได้ในภายหลัง (เนื่องจาก mapping วนลูป key ไม่ได้) จึงต้องเก็บ array ของ timestamp ด้วย

โอเค พอมีคน stake เข้ามา เราจะทำการดึงเหรียญคนนั้นมาเก็บไว้

จากนั้นทำการ update balance ของ user และ update totalSupply

และเราจะจำ user ไว้ใน array เพื่อใช้ใน step ต่อไปด้วย

โอเค step ต่อไปคือการ update balance ของ user และ update totalSupply แม้ในเวลาที่ไม่ได้มีคน stake เข้ามาใหม่

เช่นจาก case นี้

ในช่วงวินาทีที่ 8–13 ไม่มีคนมา interact กับ contract เลย

จนในวินาทีที่ 14 มีคนมา unstake

คำถามคือ เราจะ update balance ของ user และ update totalSupply ในช่วงที่ไม่มีคนมา interact กับ contract เลยยังไง ?

จริงๆวิธีนึงที่ทำได้คือทำ cronjob มาจาก offchain เพื่อยิงมา update ทุกๆ block

แต่เราจะลองใช้วิธีที่ update ในตอนที่มีคน unstake กัน (เพราะจะคำนวณ reward ตอน unstake และค่า balance, totalSupply ใช้ในการคำนวณ reward)

โดย code ในการ fill เพื่อ update balance ของ user และ update totalSupply ให้กับ user ทุกคนจะเป็นประมาณนี้

เริ่มจากการหาก่อนว่ามีช่วง block ไหนบ้างที่เราต้อง fill

โดยค่า lastUpdated คือ timestamp ล่าสุดที่มีการ fill ให้ user ครบทุกคนแล้วนั่นเอง

โอเค จากนั้นในทุกๆ block เราจะ fill update balance ของ user ให้กับ user ทุกคนที่ยัง stake อยู่ โดยจะนำค่า balance ก่อนหน้ามาใช้ (เพราะค่าเหมือนกันหมด)

เช่นจากลูกศรสีน้ำเงินในรูป

จะได้ code ประมาณนี้

** Disclaimer: เป็น code draft ที่ assume ว่า block.timestamp ห่างกัน 15 วินาทีตลอด จริงๆต้องรับมือ edge case ด้วย

และการ update totalsupply จะใช้ logic เดียวกัน

และสุดท้าย เมื่อ user ทำการ unstake เพื่อ claim reward เราจะต้อง

  1. fill update block ที่ไม่มีการ interact กับ contract
  2. คำนวณ reward ของ user
  3. เคลียร์ประวัติการ stake ของ user เพื่อไม่ให้มีการ claim ซ้ำ

4. โอนเหรียญที่ user คือให้ user

5. โอนเหรียญ reward ให้ user

6. ลบ user ออกจาก list ที่ต้องทำการ update ค่าในข้อที่ 1

โดยสูตรการคำนวณ reward จะแปลงเป็น code ได้เป็น

โอเค เราจะเห็นได้ว่าถ้าจะ implement ตามสูตรนี้จริงๆ ค่าแก๊สจะเยอะมากๆ เพราะมีการวนลูปใน function อัพเดทค่าเยอะมาก ซึ่ง ณ จุดนึง ค่าแก๊สที่ต้องใช้อาจจะเยอะกว่าที่ Ethereum อนุญาติให้จ่ายด้วยซ้ำ

โอเค part ต่อไปเราจะไปดู code standard ที่เค้าใช้กัน ซึ่งลด complexity ของการคำนวณ reward จาก O(n) เป็น O(1) เลย

โอเค ขอแปะ code ของ part นี้เต็มๆไว้หน่อย ยังไม่ได้กดรันเลย จริงๆ part นี้ลองดูเฉยๆ ว่าท่าที่เค้าไม่ใช้กัน เขียน code แล้วจะเป็นยังไง lol

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

contract SimpleStaking {
uint256 public REWARD_RATE = 1**1e18;
uint256 public SECONDS_PER_BLOCK = 15;
mapping(uint256 => mapping(address => uint256)) public balanceAt;
mapping(uint256 => uint256) public totalSupplyAt;
uint256[] public timestamps;
address[] public userAddresses;
uint256 lastUpdated;

IERC20 public immutable stakingToken;
IERC20 public immutable rewardsToken;

constructor(address _stakingToken, address _rewardToken) {
stakingToken = IERC20(_stakingToken);
rewardsToken = IERC20(_rewardToken);
}

function stake(uint256 _amount) external {
require(_amount > 0, "amount = 0");
stakingToken.transferFrom(msg.sender, address(this), _amount);
addStakeAmount(msg.sender, _amount);
registerUser(msg.sender);
}

function registerUser(address user) public {
bool isFound = false;
for (uint256 i = 0; i < userAddresses.length; i++) {
if (userAddresses[i] == user) {
isFound = true;
break;
}
}
if (!isFound) {
userAddresses.push(user);
}
}

function unregisterUser(address user) public {
for (uint256 i = 0; i < userAddresses.length; i++) {
if (userAddresses[i] == user) {
userAddresses[i] = address(0);
}
}
}

function addStakeAmount(address user, uint256 _amount) internal {
balanceAt[block.timestamp][user] += _amount;
totalSupplyAt[block.timestamp] += _amount;
timestamps.push(block.timestamp);
if(lastUpdated == 0) {
lastUpdated = block.timestamp;
}
}

function fillEmptySlotStakeAmount() public {
uint256 currentTimeStamp = block.timestamp;
uint256 blockCountSinceLastUpdate = (lastUpdated - currentTimeStamp) /
SECONDS_PER_BLOCK;
for (uint256 j = 0; j < blockCountSinceLastUpdate; j++) {
uint256 thatBlockTimestamp = timestamps[lastUpdated] +
(j * SECONDS_PER_BLOCK);
timestamps.push(thatBlockTimestamp);
for (
uint256 userAddresseIndex = 0;
userAddresseIndex < userAddresses.length;
userAddresseIndex++
) {
address user = userAddresses[userAddresseIndex];
if (user == address(0)) {
continue;
}
if (balanceAt[thatBlockTimestamp][user] != 0) {
balanceAt[thatBlockTimestamp][
user
] = balanceAt[thatBlockTimestamp-SECONDS_PER_BLOCK][user];
}
if(totalSupplyAt[thatBlockTimestamp] != 0) {
totalSupplyAt[thatBlockTimestamp] = totalSupplyAt[thatBlockTimestamp - SECONDS_PER_BLOCK];
}
}
}
lastUpdated = currentTimeStamp;
}

function getRewards(address user) public view returns (uint256) {
uint256 reward = 0;
for (uint256 i = 0; i < timestamps.length; i++) {
uint256 t = timestamps[i];
reward += (REWARD_RATE * balanceAt[t][user]) / totalSupplyAt[t];
}
return reward;
}

function unstake() external {
address user = msg.sender;
fillEmptySlotStakeAmount();
uint256 rewardOfUser = getRewards(user);
uint256 stakeAmountOfUser = 0;
for (uint256 i = 0; i < timestamps.length; i++) {
uint256 t = timestamps[i];
uint256 balanceOfUser = balanceAt[t][user];
stakeAmountOfUser += balanceOfUser;
totalSupplyAt[t] -= balanceOfUser;
balanceAt[t][user] = 0;
}
stakingToken.transfer(msg.sender, stakeAmountOfUser);
rewardsToken.transfer(msg.sender, rewardOfUser);
unregisterUser(user);
}
}

interface IERC20 {
function totalSupply() external view returns (uint256);

function balanceOf(address account) external view returns (uint256);

function transfer(address recipient, uint256 amount)
external
returns (bool);

function allowance(address owner, address spender)
external
view
returns (uint256);

function approve(address spender, uint256 amount) external returns (bool);

function transferFrom(
address sender,
address recipient,
uint256 amount
) external returns (bool);

event Transfer(address indexed from, address indexed to, uint256 value);
event Approval(
address indexed owner,
address indexed spender,
uint256 value
);
}

Referrences

--

--

No responses yet