Staking Triple: Restaking de tokens de Restaking en Solidity (Con Ejemplos)
Ahmed Castro
Posted on May 13, 2024
Ethereum permite maneras novedosas y creativas de generar intereses denominados en ETH. Esto porque es una blockchain Proof of Stake, que permite crear LSTs o tokens de staking líquido.
Los LSTs son solo el primer paso ya que podemos restakear estos tokens en proyectos de restaking como Eigen Layer que ofrecen seguridad financiera a oráculos, protocolos de DA, verificadores de ZK, entre otros. Esto no termina ahí, podemos re-restakear estos tokens por 3era vez para dar seguridad financiera a tokens o proyectos nuevos.
¿Qué es el Re-Restaking (o staking triple) y qué protocolos podemos contruir en él?
En este artículos vamos a re-restakear, o hacer un triple staking de tokens. Esto tiene muchos casos de uso así que vamos a explorar un par con ejemplos prácticos en Solidity, recreando escenarios reales forkeando Ethereum Mainnet. Además, al final de este artículo vamos a detallar cuáles son los posibles riesgos de su uso.
A continuación usaremos eETH de EtherFi como ejemplo pues ya tiene disponible el retiro de fondos (o withdrawal) pero perfectamente pudimos haber demostrado con otro protocolo como Renzo, Kepler, Puffer, etc...
Cómo Re-Restakear desde Soldity
Stakear y retirar las ganancias (withdraw) en EtherFi se hace mediante la función deposit()
y withdraw()
respectivamente. Pero toma en cuenta que las withdraws no son inmediatas porque el proceso de retirar un nodo validador de ethereum no es inmediato. Por eso existe un paso intermedio gestionado por el contrato de de WithdrawalRequestNFT
descrito a continuación:
- Cualquier usuario stakea ether llamando la función
deposit()
en el contrato de liquidez particular a cada protocolo. A cambio recibe su garantía de depósito que dependerá del protocolo que se use (eETH en Etherfi, ezETH en Renzo, etc...) a estos les llamamos LSTs o LSDs. - Cuando el usuario quiere salirse del protocolo puede devolver sus LSTs que serán quemados y recibirá de vuelta un NFT de solicitud de retiro, un NFT de Withdrawal Request.
- Una vez el retiro esté listo y finalizado, el usuario puede recibir su ether al entregar su NFT de solicitud de retiro.
A continuación un ejemplo de stake y withdraw desde en EtherFi desde un contrato en Solidity. Este ejemplo solo es ilustrativo, no tiene un caso de uso, en breve veremos un par de casos aplicados.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
interface ILiquidityPool {
function deposit() external payable returns (uint256);
function requestWithdraw(address _recipient, uint256 _amount) external returns (uint256);
function rebase(int128 _accruedRewards) external;
function getTotalEtherClaimOf(address _user) external view returns (uint256);
function amountForShare(uint256 _share) external view returns (uint256);
}
interface IWithdrawRequestNFT {
function getClaimableAmount(uint256 tokenId) external view returns (uint256);
function claimWithdraw(uint256 tokenId) external;
function finalizeRequests(uint256 requestId) external;
}
// ERC20 interface used to interact with the staking token, which is DAI on this tutorial
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
contract EtherFiStaker is ReentrancyGuard {
address payable LIQUIDITY_POOL;
address WITHDRAW_REQUEST_NFT;
address EETH_TOKEN;
mapping(address account => uint amount) public sharesByAccount;
constructor(address payable liquidityPool, address withdrawRequestNFT, address eETH) {
LIQUIDITY_POOL = liquidityPool;
WITHDRAW_REQUEST_NFT = withdrawRequestNFT;
EETH_TOKEN = eETH;
}
function stake() public payable nonReentrant() {
uint shares = ILiquidityPool(LIQUIDITY_POOL).deposit{value: msg.value}();
sharesByAccount[msg.sender] += shares;
}
function unstake(uint shares) public returns(uint requestId) {
require(shares <= sharesByAccount[msg.sender], "Not enough shares");
sharesByAccount[msg.sender] -= shares;
uint amount = ILiquidityPool(LIQUIDITY_POOL).amountForShare(shares);
IERC20(EETH_TOKEN).approve(LIQUIDITY_POOL, amount);
return ILiquidityPool(LIQUIDITY_POOL).requestWithdraw(msg.sender, amount);
}
}
Recuerda que tras bambalinas el manager o administrador se debe de encargar de dos tareas importantes.
- El manager se encarga de ejecutar la función
rebase()
para distribuir las recompensas. Conoce más sobre el rebase aquí. - El admin se encarga de aprobar los NFTs de retiro mediante la función
finalizeRequests()
Por supuesto que estos dos son puntos de centralización. Los riesgos de fallo se pueden atenuar con ayuda de gobernanza u otra forma de incentivos pero siempre es importante tener en cuenta esto.
A continuación los comandos para ejecutar un ejemplo de test donde Alice hace staking de 1 ETH en nuestro contrato de staking durante 100 días. Al retirarlo recibe más de 1 ETH de vuelta. Este comando forkeará mainnet en tu PC y replicará el mismo ambiente como si estuviéramos en producción.
Para ejecutar el código a continuación necesitarás instalar foundry con el comando curl -L https://foundry.paradigm.xyz | bash
.
git clone https://github.com/Turupawn/EtherFiStaker.git
cd EtherFiStaker/
forge test --fork-url https://eth.llamarpc.com -vv --match-contract EtherFiStaker
Ejemplo práctico #1: Presale de NFTs con Restaking
¿Cuáles proyectos pueden hacer uso del Restaking? Cualquier proyecto que guarda ether por un tiempo: presales, tesorerías de DAOs, piscinas de liquidez, etc...
A contuniación un ejemplo de un lanzamiento de NFTs donde todo lo recaudado es automáticamente stakeado en EtherFi. Cuando el equipo, o "team", decida retirar los fondos recibirá un NFT de solicitud de retiro.
Puedes usar el siguiente ejemplo para incorporarlo en tu venta de NFTs. Ten en cuenta que el siguiente código no está auditado y también considera los elevados costos de gas por minteo.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
interface ILiquidityPool {
function deposit() external payable returns (uint256);
function requestWithdraw(address _recipient, uint256 _amount) external returns (uint256);
function rebase(int128 _accruedRewards) external;
function getTotalEtherClaimOf(address _user) external view returns (uint256);
function amountForShare(uint256 _share) external view returns (uint256);
}
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
contract NFTRestaker is ERC721 {
string public baseTokenURI = "https://nftrestaker.xyz/";
uint256 public constant MAX_SUPPLY = 10000;
uint256 public price = 0.01 ether;
uint supply;
address public teamWallet;
// EtherFi Stuff
address payable LIQUIDITY_POOL;
address EETH_TOKEN;
constructor (address liquidityPool, address eETH) ERC721 ("NFT Restaker", "RE") {
teamWallet = msg.sender;
LIQUIDITY_POOL = payable(liquidityPool);
EETH_TOKEN = eETH;
}
function mint() public payable {
require(supply < MAX_SUPPLY, "Can't mint more than max supply");
require(msg.value == price, "Wrong amount of ETH sent");
supply += 1;
_mint( msg.sender, supply );
ILiquidityPool(LIQUIDITY_POOL).deposit{value: msg.value}();
}
function withdrawTeam() public payable returns(uint requestId) {
require(msg.sender == teamWallet, "Only withdrawal address can withdraw");
IERC20(EETH_TOKEN).approve(LIQUIDITY_POOL, IERC20(EETH_TOKEN).balanceOf(address(this)));
return ILiquidityPool(LIQUIDITY_POOL).requestWithdraw(msg.sender, IERC20(EETH_TOKEN).balanceOf(address(this)));
}
function _baseURI() internal view virtual override returns (string memory) {
return baseTokenURI;
}
}
A continuación los comandos para ejecutar un ejemplo de test donde Alice y Bob compran 100 NFTs cada uno a 0.01 ETH por unidad. El equipo debería recibir 2 ETH como ganancia, pero recibé más. Este comando forkeará mainnet en tu PC y replicará el mismo ambiente como si estuviéramos en producción.
git clone https://github.com/Turupawn/EtherFiStaker.git
cd EtherFiStaker/
forge test --fork-url https://eth.llamarpc.com -vv --match-contract NFTRestake
Ejemplo práctico #2: Token con triple staking
Imagina que lanzamos un token llamado 3xST
o "Triple Staking Token". Es un token ERC20 de utilidad (o no 😁) que obtiene ganancias denominadas en ETH, que podrían ser obtenidas mediante comisiones de transacción. Luego estas ganancias se reparten a diario a los holders del token que stakeen sus 3xST
, y mientras stakean, sus ganancias se ven potenciadas por el Restaking un LST.
A continuación un ejemplo funcional de contrato de Staking de un ERC20 que otorga regalos denominados en ETH que son potenciados mediante el Restaking. Ten en cuenta que el siguiente contrato es funcional pero no ha sido auditado ni está optimizado para ahorrar gas.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
interface IeETH {
function shares(address _user) external view returns (uint256);
}
interface ILiquidityPool {
function deposit() external payable returns (uint256);
function requestWithdraw(address _recipient, uint256 _amount) external returns (uint256);
function rebase(int128 _accruedRewards) external;
function getTotalEtherClaimOf(address _user) external view returns (uint256);
function amountForShare(uint256 _share) external view returns (uint256);
}
interface IWithdrawRequestNFT {
function getClaimableAmount(uint256 tokenId) external view returns (uint256);
function claimWithdraw(uint256 tokenId) external;
function finalizeRequests(uint256 requestId) external;
}
// ERC20 interface used to interact with the staking token, which is DAI on this tutorial
interface IERC20 {
function totalSupply() external view returns (uint256);
function balanceOf(address account) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
function allowance(address owner, address spender) external view returns (uint256);
function approve(address spender, uint256 value) external returns (bool);
function transferFrom(address from, address to, uint256 value) external returns (bool);
}
struct StakerData {
uint lastClaimTimestamp;
uint amount;
}
contract TripleStaker {
address payable LIQUIDITY_POOL;
address WITHDRAW_REQUEST_NFT;
IERC20 public eETH;
IERC20 public tripleStakingToken;
// Staking data
uint public totalDeposits;
uint public pendingClaim;
mapping(address => StakerData) public stakerData;
// Daily history
uint public lastDayCalculated;
uint public lastDayCalculatedTimestamp;
mapping(uint => uint) public dayTotalRewards;
mapping(uint => uint) public dayTotalDeposited;
// Launch state
uint public rewardGenesisTiemstamp;
// Mechanics
uint STAKING_CLOSE_PERIOD = 1 days;
uint public rewardRateDailyPercentage = 1000;
constructor(address tripleStakingTokenAddress,
address liquidityPool, address withdrawRequestNFT, address eETHAddress // Etherfi
) {
eETH = IERC20(eETHAddress);
tripleStakingToken = IERC20(tripleStakingTokenAddress);
// Initialize claim
rewardGenesisTiemstamp = lastDayCalculatedTimestamp = block.timestamp;
// Etherfi
LIQUIDITY_POOL = payable(liquidityPool);
WITHDRAW_REQUEST_NFT = withdrawRequestNFT;
}
// Modifiers
modifier updateReward() {
uint daysSinceLastDayRewardCalculation = (block.timestamp - lastDayCalculatedTimestamp)/(STAKING_CLOSE_PERIOD);
if(totalDeposits > 0 && daysSinceLastDayRewardCalculation > 0)
{
for(uint i=0; i<daysSinceLastDayRewardCalculation; i++)
{
uint currentContractTokenSupply = eETH.balanceOf(address(this)) - pendingClaim;
uint currentDayReward = ((currentContractTokenSupply * rewardRateDailyPercentage) / 10000);
dayTotalRewards[lastDayCalculated + i] = currentDayReward;
dayTotalDeposited[lastDayCalculated + i] = totalDeposits;
pendingClaim += currentDayReward;
}
lastDayCalculated = lastDayCalculated + daysSinceLastDayRewardCalculation;
lastDayCalculatedTimestamp = block.timestamp;
}
_;
}
// External functions
function stake3X(uint amount) external updateReward() {
require(amount > 0, "Amount must be greater than 0.");
totalDeposits += amount;
uint firstDayToClaim = (block.timestamp - rewardGenesisTiemstamp) / (STAKING_CLOSE_PERIOD);
stakerData[msg.sender].lastClaimTimestamp = firstDayToClaim;
stakerData[msg.sender].amount += amount;
tripleStakingToken.transferFrom(msg.sender, address(this), amount);
}
function withdraw(uint amount) external updateReward() {
require(amount > 0, "No amount sent.");
require(stakerData[msg.sender].amount > 0, "Sender has no deposits.");
require(stakerData[msg.sender].amount >= amount, "Sender has no enough Triple Staking Token deposited to match withdraw amount.");
totalDeposits -= amount;
stakerData[msg.sender].amount -= amount;
tripleStakingToken.transfer(msg.sender, amount);
}
function claim() public updateReward() returns(uint requestId) {
uint reward;
uint daysClaimed;
while(stakerData[msg.sender].lastClaimTimestamp + daysClaimed < lastDayCalculated)
{
if(dayTotalDeposited[stakerData[msg.sender].lastClaimTimestamp + daysClaimed] != 0)
{
reward += (dayTotalRewards[stakerData[msg.sender].lastClaimTimestamp + daysClaimed] * stakerData[msg.sender].amount)
/ dayTotalDeposited[stakerData[msg.sender].lastClaimTimestamp + daysClaimed];
}
daysClaimed += 1;
}
stakerData[msg.sender].lastClaimTimestamp += daysClaimed;
pendingClaim -= reward;
eETH.approve(LIQUIDITY_POOL, reward);
return ILiquidityPool(LIQUIDITY_POOL).requestWithdraw(msg.sender, reward);
}
function calculateClaim(address participant) public view returns(uint)
{
if(stakerData[participant].amount == 0)
{
return 0;
}
uint reward;
uint daysClaimed;
while(dayTotalDeposited[stakerData[participant].lastClaimTimestamp + daysClaimed] != 0)
{
reward += (dayTotalRewards[stakerData[participant].lastClaimTimestamp + daysClaimed] * stakerData[participant].amount)
/ dayTotalDeposited[stakerData[participant].lastClaimTimestamp + daysClaimed];
daysClaimed+=1;
}
uint daysSinceLastDayRewardCalculation = (block.timestamp - lastDayCalculatedTimestamp)/(STAKING_CLOSE_PERIOD);
uint pendingClaimAux = pendingClaim;
uint totalDepositsAux = totalDeposits;
for(uint i=0; i<daysSinceLastDayRewardCalculation; i++)
{
uint currentContractTokenSupply = eETH.balanceOf(address(this)) - pendingClaimAux;
uint currentDayReward = ((currentContractTokenSupply * rewardRateDailyPercentage) / 10000);
reward += (currentDayReward * stakerData[participant].amount)
/ totalDepositsAux;
pendingClaimAux += currentDayReward;
}
return reward;
}
function updateRewardFunction() public updateReward() {
}
// Etherfi
function stakeETH() public payable {
ILiquidityPool(LIQUIDITY_POOL).deposit{value: msg.value}();
}
}
A continuación los comandos para ejecutar un ejemplo de test donde Alice stakea 700 3xST
s y Bob stakea 300 3xST
durante 100 días. El protocolo reparte 1 ETH para recompensas durante este tiempo. La suma de las ganancias de Alice y Bob superan 1 ETH. Este comando forkeará mainnet en tu PC y replicará el mismo ambiente como si estuviéramos en producción.
git clone https://github.com/Turupawn/EtherFiStaker.git
cd EtherFiStaker/
forge test --fork-url https://eth.llamarpc.com -vv --match-contract TripleStaker
Toma en cuenta lo siguiente...
1. Posibles errores de smart contracts
Recordemos que al usar un proyecto de restaking estamos confiando en muchos contratos (e.g. EtherFi y Eigen Layer) que pueden contener bugs. Si estos proyectos son hackeados o comprometidos, todos los proyectos construidos encima de ellos serán afectados también.
2. Puntos de centralización del Restaking
El restaking no está implementado a nivel del protocolo de Ethereum. Esto significa que existen acciones (como el rebase o la finalización) que son controladas por actores de confianza. Aquí es donde entra en juego el diseño de gobernanza, los incentivos y otros mecanismos que ayuden a decentralizar.
3. El riesgo sistemático del Restaking
Entre más crezca el staking y restaking (y re-restaking) crece también el riesgo sistemático de centralización de validadores y la posibilidad del slashing masivo. Ambos son problemas sistemáticos para Ethereum que hoy todos debemos conocer. Hoy se están estudiando las diferentes soluciones, si te gusta la teoría de juegos, matemática combinada con mecánicas sociales, ¡te invito a participar de la discusión!
4. Claimeando Eigen Tokens y otros airdrops
Toma en cuenta que los contratos en este ejemplo no son capaces de ejecutar código arbitrario, es decir, si EtherFi y otro protocolo entrega un airdrop por su uso se debería agregar una manera de claimear un posible airdrop.
5. Sobre el código mostrado en este artículo
Todos los contratos mostrados en este artículo son funcionales e incluyen tests unitarios en foundry. Esto no significa que están debidamente auditados, fueron hechos con propósitos educativos. ¡Maneja tu riesgo!
Actualmente no existe mucha documentación sobre el triple staking, si deseas conocer más sobre el tema es directamente a través de los contratos de los LSTs, liquidity pools o o NFTs de withdrawal.
¡Gracias por leer este tutorial!
Sígueme en dev.to y en Youtube para todo lo relacionado al desarrollo en Blockchain en Español.
Posted on May 13, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 27, 2024