Protocol Components
Milkomeda Liquid Staking protocol consists of three upgradeable smart contracts, deployed through an ERC1967Proxy. The core logic resides primarily in the Liquid Staking contract. This contract features a set of public functions that can be invoked by either DApps or individual users. Additionally, it contains an exclusive function designed for depositing rewards, which is solely callable via the Milkomeda Bridge.
Contract | Description |
---|---|
LiquidStaking | The core staking contract |
StakedMADA | A ERC20 like token used to represent stakers share of the pool |
Pillage | A utility contract to withdraw rewards from smart contract account unable to withdraw their rewards |
You can find the audit on the Liquid Staking smart contracts here.
Contracts
LiquidStaking
Variables
The Staking Smart Contract keeps the count of the total mADA available in total and for each user. There are at least three variables that track the movements of mADA native tokens:
userDeposit
: a mapping of the amount of mADA the user has as balance (includes deposits + claimed rewards)totalDeposited
: the total amount ofuserDeposit
totalRewards
: the total unclaimed rewards deposited up until this point by the Milkomeda DAO
Functions
stake
Every time the
stake
method is executed, a specific amount of shares is minted in favor of the caller, based on the value sent. This is due to a double accounting system shared by the LiquidStaking and the stMADA Token smart contracts.In order to calculate the number of shares to be minted, the LiquidStaking contract keeps track of the total deposited in mADA while the Staked mADA Token keeps track of the percentage ownership of each user in relation to the total mADA deposited.
function stake() external returns (uint256);
The stake
function with emit the Staked
event
event Staked(
address indexed account,
uint256 indexed milkAdaDeposited,
uint256 indexed sharesReceived
);
Sending mADA to the liquid staking contract will indirectly call the stake function for the value sent
fallback() external payable {
stake();
}
unstake
The unstake with exchange stMADA for mADA.
When the method
unstake
is executed, thewithdrawRewards
is called internally on behalf of the user and the additional rewards are deposited to them (needed to calculate the number of shares to burn)
function unstake(uint256 _amount) external returns (uint256, uint256);
The unstake
function with trigger the Unstaked
event.
event Unstaked(
address indexed account,
uint256 indexed sharesToWithdraw,
uint256 indexed milkAdaAmountToWithdraw
);
withdrawRewards
This function converts the unclaimed rewards of the caller to user stMADA balance. All externally owned accounts (EOA) have the assumption that they are capable of withdrawing rewards. For smart contract accounts, see claim rewards
Since rewards are accumulated on each distribution, users do not need to call this function every epoch
function withdrawRewards() external returns (uint256);
removeRewardsOnBehalf
This is an access controlled function that can only be called by the Milkomeda DAO to claim rewards for a smart contract that cannot claim its own rewards.
See below claim rewards
Claiming rewards
Rewards can be claimed on every epoch (unlike Cardano where there is a wait period of multiple epochs before the first reward). Both dApps and EOAs also accrue stmADA and can claim the staking rewards of any token they've accrued.
However, to avoid staking rewards accumulating forever in dApps that will never be able to claim the rewards because they didn't implement that functionality, the Milkomeda DAO has a feature where it can claim the rewards on behalf of a smart contract that it judges unable to ever claim the rewards.
Because the Milkomeda DAO has no way to know that every smart contract that can hold stMADA has implemented method to claim rewards, the smart contract should explicitly implement a method called ableToWithdrawRewards
that returns true
or else it might have its rewards claimed by the Milkomeda DAO.
A smart contract that intends to hold stMADA, should:
- Ensure the contract has a way of calling with
withdrawRewards
function of the liquid staking contract
function withdrawRewards() external returns (uint256 _rewardsInmADA);
- Ensure the contract has a
ableToWithdrawRewards
that returns true (a-la EIP-165). If the contract does not have aableToWithdrawRewards
function, your dApp will forfeit any staking rewards accrued by the stMADA held in the smart contract
function ableToWithdrawRewards() external pure returns (bool) {
return true;
}
Optionally, stake
and unstake
functions can be implemented to convert to/from mADA
Additionally, note that the default behavior when sending MADA to the liquid staking contract is to stake the tokens. For example, you can implement the stake function wrapper as seen below
function stake(uint256 _amountToStake) external {
payable(stakingSmartContractAddress).transfer(_amountToStake);
}
Context on ableToWithdrawRewards
There were at 2 ways of checking whether or not a contract could claim rewards :
- A static call following EIP-165. This costs 3,698 every time Milkomeda DAO attempts to withdraw rewards on behalf of a particular account. The downsides are that it requires work on the smart contract implementer and, if they forget to add the
ableToWithdrawRewards
function, they can't add it later without upgrading their contract - Maintaining a map of whether or not a particular contract has claimed rewards in the past so that once a contract claims rewards once, the DAO can no longer claim on their behalf. Other than censorship concerns, this would also cost 22,257 gas to initially add an entry into the map (higher initial cost for lower read cost)
Due to both the censorship concerns and the higher initial cost, Milkomeda implemented the first option (1).
Dead Shares
To mitigate against inflation attacks, we stake on initialization of the Liquid Staking contract, creating dead shares. In order to create dead shares, a value (msg.value
> 0) must be sent when initializating the staking contract. And this value is used to create the first stake in the pool, which is needed to protect against inflation attacks. Creating dead shares doesn't entirely solve this problem, but it reduces the profit that could be made from it .This dead shares is minted to the deployer - it's expected this shares and stake will never be withdrawn, unstaked or redeemed
StakedMADA (stMADA)
stMADA is an ERC20-like token - though it's not a rebase token, it still doesn't fully comply with the ERC20 standard. Modifications have been made to some of its functions, such as transfer
, transferFrom
and balanceOf
. On top of that, the mint
and burn
methods have been protected by roles: only the Staking Smart Contract can call those methods. While it could have been possible to inherit the ERC20 contract from Open Zeppelin, it was more than necessary to only comply with the interface (IERC20) since several methods have custom implementations.
The contract only emits a Tranfer event when an explicit transfer occurs between holders. From the ERC20 standard the Transfer amount or value in the transfer signature represents the amount of the token sent. Though in the StakedMADA contract, while it still represents the amount being sent, it's not the amount of tokens, but the amount of mADA. A new event (TransferShares) has been added. This event has the same signature and can be seen as replicating the Tranfer event in the ERC20 standard; all that differs is the name.
The main reason for implementing customized complying methods is that there are two account systems within the token: one that is shares-based and another one that represents the equivalence one to one with the amount of mADA deposited. After staking an X amount of mADA, an Y amount of shares are minted for the user. However, he will only know about the total amount of mADA deposited, which will be represented by the stMADA Token that he will see in his wallet balance.
Let's see how the methods are affected:
balanceOf
Returns back the amount of mADA deposited by the user in the LiquidStaking smart contract. The token stMADA does not keep the count of the total mADA deposited. Hence, when the method
balanceOf
is consulted, internally an inter-contract call is executed. The Staking Smart Contract responds to this query and returns the amount of mADA deposited by a particular user. When the balance is shown, it's portrayed as stMADA Token.
function balanceOf(
address _account
) public view override returns (uint256) {
bytes memory returndata = _staticcall(
abi.encodeWithSignature("balanceOf(address)", _account),
stakingSCProxy
);
return abi.decode(returndata, (uint256));
}
totalSupply
Another inter-contract call is necessary to grab the total of mADA deposited, which is stored in the LiquidStaking smart contract. In other words,
totalSupply
does not return the total number of minted shares but rather the total amount of mADA deposited by all users.
function totalSupply() public view override returns (uint256) {
bytes memory returndata = _staticcall(
abi.encodeWithSignature("totalDeposited()"),
stakingSCProxy
);
return abi.decode(returndata, (uint256));
}
transfer
andtransferFrom
All methods that involve the movements of funds that are executed have as input a specific amount of mADA Token. After all, the user is only aware of the amount of mADA deposited represented by stMADA Token.
When the methods
transfer
andtransferFrom
are called, the amount of mADA received is converted to shares to make the necessary updates.Since there is a double accounting system, aside from updating the number of shares one particular account has after the transfer of mADA, it's also required to update the balances of mADA deposited in the LiquidStaking smart contract. For that reason, these two methods (
transfer
andtransferFrom
) have side effects on the other smart contract (the Liquid Staking contract). The side effect simply decreases the count of mADA deposited in the sender and adds that exact amount to the count of the receiver.
function sideEffectTransferMilkAda(...) … {
...
userDeposit[_from] -= _milkAdaAmount;
userDeposit[_to] += _milkAdaAmount;
}
mint
andburn
These two methods can only be called by the LiquidStaking contract. In the same fashion as the two methods described above, before calling
mint
orburn
the input of mADA amount is converted into shares. The only place wheremint
is called is within thestake
method of the LiquidStaking smart contract. Inversely,burn
is only called when the user wants tounstake
the total mADA deposited.increaseAllowance
,decreaseAllowance
,approve
andallowance
All these methods are performed with the input of mADA amount, not the shares. The user through
balanceOf
would be able to know the amount of mADA deposited and not the number of minted shares. Hence, it makes sense to work these methods with his mADA balance.
Pillage
Contracts that do not implement the ableToWithdrawRewards
function, as explained here, signal that they forfeit their staking rewards, and these rewards could be pillaged by the Milkomeda DAO.
When an account is Pillaged, its accumulated rewards are removed and the value is automatically staked on behalf of the pillager (Milkomeda DAO).
function pillage(address _account, bytes32 _transactionId) external {
// Ensure the msg.sender is a validator
_assertValidator(msg.sender);
// Add the validator vote, encoding the the data to be executed.
// The staking smart contract has a function removeRewardsOnBehalf
// that removes the rewards from an account that cannot withdraw rewards
// and adds it as a stake for the pillager.
Transaction memory transaction = _addTransaction(
_transactionId,
abi.encodeWithSignature("removeRewardsOnBehalf(address)", _account),
stakingSCProxy
);
Status status = _confirmTransaction(_transactionId, transaction);
if (status == Status.SUCCESS) {
emit Pillaged(_account);
}
}
Example:
- Smart contract
A
doesn't implement theableToWithdrawRewards
function. - Smart contract
A
holds 5 stmADA and has accrued staking rewards of 3 mADA. - Smart contract
A
can then be pillaged by the Milkomeda DAO and its rewards of 3 mADA are withdrawn, and staked on behalf of the pillager. - Now the Milkomeda DAO has a stake balance of 3 mADA, and smart contract
A
has a stake balance of 5 mADA, but reward value of 0.