StakeUtils Library
Functionality related to the Staking operations.

Understanding The Liquid Staking Logic

There are 4 main mechanisms that keeps a Liquid Staking Pool alive:
  1. 1.
    Staking Pool's maintainer controls the current Node Operators.
  2. 2.
    Node Operators moving staked funds between different subchains in a trustless way to create validators, delegators and to pay the stakers yield.
  3. 3.
    Price being updated with an oracle to represent rewards that has been created by the validators.
  4. 4.
    Stakers being able claim their rewards without any waiting period needed while maintaining the minimum slippage.
We will be going through related functionalities, step-by-step explaining all of the components.

Maintaining A Staking Pool

What does the term Maintainer stands for?

Every ID that is attained to a Node Operator has a maintainer.

Planets are also inherently Node Operators, so every planet maintainer can also act as their own Node Operator without needing any extra permissions or implementation changes.

Maintainers handle the daily pool operations, they can be a smart contract or an automatized script. However their keys have full access and control over the Staking Pool, so it should be covered accordingly.
Maintainers can get a fee up to MAX_MAINTAINER_FEE, this fee can be set for planet.fee or operator.fee.
CONTROLLERs control the maintainer address for the given Id.

Allowing a Node Operator to Operate for Staking Pool

Portal.activateOperator(
uint256 _id,
uint256 _activeId
);
Whenever an operator is activated for a staking pool, it sets an activationExpiration date, which means that the operator pays debt by burning gAvax tokens and collects the fees from their validators.
While this implementation allows any two different ids to co-operate, with multiple interactions at any given time, there can only be "1" activeOperator who can also claimSurplus to create new validators.
modifier beforeActivationExpiration(
DataStoreUtils.DataStore storage _DATASTORE,
uint256 _poolId,
uint256 _claimerId
) {
require(
_DATASTORE.readUintForId(
_poolId,
bytes32(keccak256(abi.encodePacked(_claimerId, "activationExpiration")))
) > block.timestamp,
"StakeUtils: operatorId activationExpiration has past"
);
_;
}

Excluding a Node Operator from your Staking Pool

In most cases, Staking Pools have multiple Node Operators, with only 1 of them being the activeOperator, which is the last activated one amongst them. However, in some extreme cases, the Staking Pool Operator can request to exclude a Node Operator from operating Validators for them. For Example, A planet might wish to keep all of the fees.
It is easy:
Portal.activateOperator(
uint256 _id,
uint256 _activeId
);
This function gives a Node Operator 15 days before starting to penalizing them. It is expected that a given Node Operator should return the all funds before this deadline.

Node Operator Daily Operations

What is a pBank?

pBank is the only address on the P subchain that interacts with tokens that is claimed by the operator as a surplus.
This logic makes the operator-planet's interactions more reliable and transparent when used by an oracle to detect the token flow between the different subchains.

Set Your pBank:

Portal.setOperatorPBank(
uint256 _id,
bytes pBank
);

ClaimSurplus to Create new Validators

When users stake Avax into the given Staking Pool, they generate a Surplus that can be moved to the P-subchain to create new Validators.
Current unclaimedFees are not allowed to be claimed as surplus.
Portal.claimSurplus(
uint256 planetId
);
Note that, Only the activeOperator can claim the surplus. operators can not claim fees if: expired OR deactivated.

ClaimFee that is distributed by the Oracle

Portal.claimFee(
uint256 planetId,
uint256 claimerId
);
Anyone can call this function, but it sends AVAX to the maintainer of a given claimerId.Pa

PayDebt to peg the staking Derivative

An Operator is expected to pay for the DEBT of a staking pool, but anyone can call this function.
// maintainer should send funds.
Portal.paydebt(
uint256 planetId,
uint256 claimerId
);
msg.value-debt is put to surplus, this can be used to increase surplus without minting new tokens! This is useful to claim fees when there is not enough surplus.

Secure Oracle, updates the price and distributes the fees

Only the Oracle can report a new price, however the price is not purely calculated by it. The balance on the P subchain is estimated by it, including the unrealized staking rewards. The Oracle has a pessimistic approach to make sure the price will not decrease by much even in the case of loss of funds.
simply put the new price is found by (pBALANCE + surplus - fees) / totalSupply)
function reportOracle(
...
uint256 _poolId,
uint256[] calldata _opIds,
uint256[] calldata _pBalanceIncreases
) external returns (uint256 price) {
require(msg.sender == self.ORACLE, "StakeUtils: msg.sender NOT oracle");
require(
_isOracleActive(_DATASTORE, _poolId),
"StakeUtils: Oracle is NOT active"
);
// distribute fees
(uint256 totalPBalanceIncrease, uint256 totalFees) = _distributeFees(
self,
_DATASTORE,
_poolId,
_opIds,
_pBalanceIncreases
);
uint256 newPBalance = _DATASTORE.readUintForId(_poolId, "pBalance") +
totalPBalanceIncrease;
_DATASTORE.writeUintForId(_poolId, "pBalance", newPBalance);
uint256 unclaimed = _DATASTORE.readUintForId(_poolId, "unclaimedFees") +
totalFees;
_DATASTORE.writeUintForId(_poolId, "unclaimedFees", unclaimed);
// deduct unclaimed fees from surplus
price =
((newPBalance +
_DATASTORE.readUintForId(_poolId, "surplus") -
unclaimed) * gAVAX_DENOMINATOR) /
(getgAVAX(self).totalSupply(_poolId));
_sanityCheck(self, _DATASTORE, _poolId, price);
_setPricePerShare(self, price, _poolId);
_DATASTORE.writeUintForId(
_poolId,
"oracleUpdateTimeStamp",
block.timestamp
);
emit OracleUpdate(_poolId, price, newPBalance, totalFees);
}
In order to prevent attacks from a malicious Oracle there are boundaries to price & fee updates. The Oracle cannot increase the price more than a given Max: PERIOD_PRICE_INCREASE_LIMIT.
The price should increase but it should not increase by more than PERIOD_PRICE_INCREASE_LIMIT with the factor of how many days since oracleUpdateTimeStamp has past.
/// @notice Oracle is active for the first 30 min for a day
uint256 constant ORACLE_PERIOD = 1 days;
uint256 constant ORACLE_ACTIVE_PERIOD = 30 minutes;
uint256 constant DEACTIVATION_PERIOD = 15 days;
function _sanityCheck(
....
uint256 _id,
uint256 _newPrice
){
uint256 periodsSinceUpdate = (block.timestamp + ORACLE_ACTIVE_PERIOD - _DATASTORE.readUintForId(
_id,
"oracleUpdateTimeStamp"
)) / ORACLE_PERIOD;
uint256 curPrice = oraclePrice(self, _id);
uint256 maxPrice = curPrice +
((curPrice * self.PERIOD_PRICE_INCREASE_LIMIT * periodsSinceUpdate) /
self.FEE_DENOMINATOR);
require(
_newPrice <= maxPrice && _newPrice >= curPrice,
"StakeUtils: price did NOT met"
);
}
Copy link
On this page
Understanding The Liquid Staking Logic
Maintaining A Staking Pool
What does the term Maintainer stands for?
Node Operator Daily Operations
What is a pBank?
ClaimSurplus to Create new Validators
ClaimFee that is distributed by the Oracle
PayDebt to peg the staking Derivative
Secure Oracle, updates the price and distributes the fees