Solidity 101: Solidity in the Wild
In the last piece, we reviewed the background of Solidity and how it is used. We then ran through a code review of a contract in captivity. With some basics under our belt, it is time to venture out into the wild and consider a code review in a real-world situation.
Identifying the target
First, we need to find the Solidity contract code we wish to review. At the risk of oversimplification, let's step through how we might do that. The target for today will be to review the Kapital DAO staking contract for $KAP-ETH LP tokens to earn $KAP tokens as a reward.
Step 1: Validate the website
It is always tempting to enter the website's name into a search engine and pick a highly-ranked result. The risk of this approach is minimal for a low-impact activity such as searching for a product review or reading a news website; however, if you intend to interact with a Smart Contract, you should be willing to do a little more homework given the authorization that will be granted over your wallet. If you come to any website and it looks similar but feels different to your expectations in any way, then spend the time validating it before interacting with the Smart Contract.
In this case, we know that the Kapital DAO website can be found at https://www.kapital.gg/. Once we land, we can see and select staking from the menu on the right-hand side. This will take us to https://staking.kapital.gg/, which we can see is a subdomain of the known good domain kapital.gg so we bookmark the page to help flag it as the known good address for the future.

Step 2: Find the Smart Contract
All good web3 websites will endeavor to provide information transparently so you can make good decisions. The Kapital DAO staking site is no exception, and a link to the GitBook documentation site is found squarely in the middle of the page, 'Staking Doc' takes you to https://docs.kapital.gg/tokenomics/staking

On this page, under the Documentation section, we see the Smart Contracts listed at the time of writing:
- Kapital DAO Token - [0x9625cE7753ace1fa1865A47aAe2c5C2Ce4418569]
- Kapital DAO Vesting - [0xF4ff2F51d721Cc62201D81dab4B5EEcB3d692a99]
- Kapital DAO Staking - [0xccde05524864009a3976acacd32f6728f08a7b4a]
Now we have a path to find the code for review; more importantly, we have a contract address to validate against when we press 'stake now'.
The image below shows you what you might expect to see in MetaMask - other wallets are also available - to allow you to check the contract you are interacting with is the one you have reviewed. Of course, we are not going to interact with this contract until we have reviewed it!

Piercing the veil
When we find Solidity Smart Contracts out in the wild, we can check if the source code is available and ensure that it ties out to the bytecode - the machine-readable version of the Solidity that we will review created by the compiler - that has been deployed. As highlighted below, the [Etherscan link] tells us the source code is verified. The contract below is not a Proxy contract. We can be sure that once we are comfortable with the code we review, we can go ahead and interact with the Smart Contract at this address.
A Proxy contract is one that delegates function calls to another contract for execution. Proxy contracts have valid use cases, such as in-place upgradability when a bug is found in a DeFi protocol; however, they carry additional risks as you may not be interacting with the code you thought you were. Identification of Proxy contracts and other security tips will form a later article in the series.

The full code
Below is the full code for the Staking.sol contract we will be interacting with when staking. It is a little more complicated than the VestingWallet that we reviewed, and it is one of nineteen files used in the deployment. Not to worry! We will eat the elephant in the same manner as before - one mouthful at a time.
// SPDX-License-Identifier: MIT pragma solidity 0.8.9; import "./interfaces/IGovernance.sol"; import "./interfaces/IGovernanceRegistry.sol"; import "./interfaces/IRewardsLocker.sol"; import "./interfaces/IStaking.sol"; import "./interfaces/IVotingWeightSource.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/utils/math/SafeCast.sol"; import "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; /** * @title Kapital DAO Staking Pool * @author Playground Labs * @custom:security-contact security@playgroundlabs.io * @notice Staking pool contract for KAP-ETH Uniswap v2 LP tokens. The word * "rewards" refers to an amount of KAP given to a user in return for the * user's agreement to lock LP tokens in the staking pool. */ contract Staking is IStaking, IVotingWeightSource, AccessControlEnumerable { using SafeCast for uint256; uint256 public constant MIN_LOCK = 4 weeks; // minimum staking lock uint256 public constant MAX_LOCK = 52 weeks; // maximum staking lock uint256 public constant CUMULATIVE_MULTIPLIER = 1e12; // to reduce integer division error bytes32 public constant TEAM_MULTISIG = keccak256("TEAM_MULTISIG"); using SafeERC20 for IERC20; IERC20 public immutable asset; // staked token, KAP or KAP-ETH LP IGovernanceRegistry public immutable governanceRegistry; // used to query the latest governance address IRewardsLocker public immutable rewardsLocker; // claimed rewards locked here for 52 weeks before withdrawal uint256 public cumulative; // cumulative rewards per wight, multiplied by {CUMULATIVE_MULTIPLIER} uint256 public totalWeight; // total staking weight in pool uint256 public syncdTo; // timestamp at which {cumulative} is valid uint256 public totalBoostRewards; // track total claimed boost rewards, for security monitoring bool public boostOn = true; // boosting can be turned off by governance or team multisig Emission public emission; // controls rewards emission rate mapping(address => Deposit[]) public deposits; mapping(address => uint256) public totalStaked; // voting weight mapping(address => uint256) public lastStaked; // to securely report voting weight constructor( address _asset, address _governanceRegistry, address _rewardsLocker, address _teamMultisig ) { require(_asset != address(0), "Staking: Zero address"); require(_governanceRegistry != address(0), "Staking: Zero address"); require(_rewardsLocker != address(0), "Staking: Zero address"); require(_teamMultisig != address(0), "Staking: Zero address"); asset = IERC20(_asset); governanceRegistry = IGovernanceRegistry(_governanceRegistry); rewardsLocker = IRewardsLocker(_rewardsLocker); _grantRole(TEAM_MULTISIG, _teamMultisig); } /** * @notice Updates {cumulative} and {syncdTo} based on {emission} */ function _sync() internal { if (block.timestamp > syncdTo) { uint256 expiration = emission.expiration; if (syncdTo < expiration && totalWeight > 0) { uint256 timeElapsed = block.timestamp < expiration ? block.timestamp - syncdTo : expiration - syncdTo; cumulative += (emission.rate * timeElapsed * CUMULATIVE_MULTIPLIER) / totalWeight; } syncdTo = block.timestamp; emit Sync(msg.sender, cumulative); } } modifier syncd() { _sync(); _; } /** * @notice Creates a deposit with the specified amount and lock period * @param amount The token amount to stake in units of wei * @param lock The time in seconds to lock the tokens for * @dev Requires token allowance from staker */ function stake(uint256 amount, uint256 lock) external syncd { require(amount > 0, "Staking: Zero amount"); require(MIN_LOCK <= lock && lock <= MAX_LOCK, "Staking: Lock"); require(amount <= type(uint112).max, "Staking: Overflow"); deposits[msg.sender].push( Deposit({ amount: uint112(amount), start: block.timestamp.toUint64(), end: (block.timestamp + lock).toUint64(), collected: false, cumulative: cumulative }) ); totalWeight += amount * lock; totalStaked[msg.sender] += amount; lastStaked[msg.sender] = block.timestamp; emit Stake(msg.sender, deposits[msg.sender].length - 1, amount, lock); asset.safeTransferFrom(msg.sender, address(this), amount); // no LP tokens are lost during transfer, expected amount always received } /** * @notice Collects the deposit amount and claims rewards * @param depositId The deposit array index to collect from */ function unstake(uint256 depositId) external syncd { Deposit storage deposit = deposits[msg.sender][depositId]; uint256 amount = deposit.amount; uint256 end = deposit.end; require(!deposit.collected, "Staking: Already collected"); require(block.timestamp >= end, "Staking: Early unstake"); totalWeight -= amount * (end - deposit.start); totalStaked[msg.sender] -= amount; claimRewards(depositId, 0); // must claim before updating `deposit.collected`, see {claimRewards} deposit.collected = true; emit Unstake(msg.sender, depositId, amount); asset.safeTransfer(msg.sender, amount); } /** * @notice Claims rewards and restakes if boosting * @param depositId The deposit array index to claim from * @param extension The time in seconds to extend the lock period */ function claimRewards(uint256 depositId, uint256 extension) public syncd { Deposit storage deposit = deposits[msg.sender][depositId]; uint256 amount = deposit.amount; uint256 end = deposit.end; uint256 lock = end - deposit.start; uint256 weight = amount * lock; uint256 cumulativeDifference = cumulative - deposit.cumulative; uint256 rewards = (weight * cumulativeDifference) / CUMULATIVE_MULTIPLIER; require(!deposit.collected, "Staking: Already collected"); // rewards stop accumulating after principal is collected if (boostOn && extension > 0) { uint256 boostRewards = _boost(deposit, amount, end, lock, weight, extension, rewards); rewards += boostRewards; emit Extend(msg.sender, depositId, extension, boostRewards); } deposit.cumulative = cumulative; emit ClaimRewards(msg.sender, depositId, extension, rewards); if (rewards > 0) { rewardsLocker.createLockAgreement(msg.sender, rewards); } } /** * @notice Calculates boost rewards and updates state */ function _boost( Deposit storage deposit, uint256 amount, uint256 end, uint256 lock, uint256 weight, uint256 extension, uint256 rewards ) internal returns (uint256 boostRewards) { require(block.timestamp < end, "Staking: Remaining"); uint256 remaining = end - block.timestamp; uint256 maxExtension = MAX_LOCK - remaining; boostRewards = (rewards * remaining * extension) / (lock * maxExtension); uint256 newStart = block.timestamp; uint256 newEnd = end + extension; uint256 newLock = newEnd - newStart; uint256 newWeight = amount * newLock; require(MIN_LOCK <= newLock && newLock <= MAX_LOCK, "Staking: New lock"); deposit.start = newStart.toUint64(); deposit.end = newEnd.toUint64(); totalWeight -= weight; totalWeight += newWeight; totalBoostRewards += boostRewards; } modifier onlyAdmin() { require( msg.sender == governanceRegistry.governance() || hasRole(TEAM_MULTISIG, msg.sender), "Staking: Only admin" ); _; } /** * @notice Sets a new rate and expiration for {emission} * @param rate The new kap per second reward * @param expiration The new timestamp after which rewards stop */ function updateEmission(uint256 rate, uint256 expiration) external onlyAdmin syncd { require(block.timestamp < expiration, "Staking: Invalid expiration"); emission.rate = rate.toUint128(); emission.expiration = expiration.toUint128(); emit UpdateEmission(msg.sender, rate, expiration); } /** * @notice Permanently turns off boosting */ function turnOffBoost() external onlyAdmin { require(boostOn, "Staking: Already off"); boostOn = false; emit TurnOffBoost(msg.sender); } /** * @notice Reports voting weight * @param voter Staker to report voting weight for */ function votingWeight(address voter) external view returns (uint256) { uint256 votingPeriod = IGovernance(governanceRegistry.governance()).votingPeriod(); uint256 timeElapsed = block.timestamp - lastStaked[voter]; return timeElapsed > votingPeriod ? totalStaked[voter] : 0; } /** * @notice Front-end getter for staker deposits * @param staker Staker to get deposits for */ function getDeposits(address staker) external view returns (Deposit[] memory) { return deposits[staker]; } }
Version & Imports
pragma solidity 0.8.9; import "./interfaces/IGovernance.sol"; import "./interfaces/IGovernanceRegistry.sol"; import "./interfaces/IRewardsLocker.sol"; import "./interfaces/IStaking.sol"; import "./interfaces/IVotingWeightSource.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; import "@openzeppelin/contracts/utils/math/SafeCast.sol"; import "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; contract Staking is IStaking, IVotingWeightSource, AccessControlEnumerable { .... }
The opening line defines the compiler version this code is expecting to use. In this case, it is not a range but a single explicit version, 0.8.9. We then move on to a host of import statements. The first five are Interfaces from the Kapital DAO staking project. The last four are all OpenZeppelin (OZ) contracts from the publicly deployed package @openzeppelin. As ever, trust but verify, ensure the spelling agrees with your expectations for the correct package and the code used ties out to a golden source, in this case, the [OZ documentation page], which links you to the relevant [GitHub].
We can infer a few things from this list of OZ contracts, firstly, the contract is going to be interacting with ERC20 tokens hence the inclusion of IERC20.sol and SafeERC20.sol. SafeCast.sol is a helper utility library from OZ that allows contracts to translate data between primitive types without risking buffer overflow related errors. Finally, we see that AccessControlEnumerable.sol is imported. From this, we can deduce that different roles will be defined to allow for granular access control over who can do what.
Interfaces
A quick convention point, any file named "I[NAME]" will be expected to be an Interface definition for the object [NAME]. An Interface defines a set of methods, data structures, and events but does not include implementation details for any methods defined. This robust design pattern allows other contracts to know how to interact with a deployed Smart Contract that implements a given Interface - via the keyword is - without being restrictive on the exact code used. In this way, a fresh deployment could change a method implementation without any other components needing to change as long as the Interface is adhered to. Think of it as a defined set of operating instructions; as long as you follow them, you can expect the software to work without caring about the specifics of code executed inside the virtual machine.
Below can be found the project interfaces that the Staking.sol contract is committing to adhere to. As a reminder, the keyword is flags the inheritance of the other contracts listed after the keyword. We will address each in turn, starting with IGovernance.
IGovernance
The interface defines a single function votingPeriod() which is callable only by other contracts (external) and does not modify the contract state (view), returning an unsigned - positive only - integer from 0 to 2^256-1. It also defines a data structure Proposal using the struct keyword. A data structure is a handy encapsulation of other data items grouped to make logical sense. In this case, each instance of a Proposal will have six primitive data items, as shown below. This grouping allows for a collection (Array) or a mapping of Proposals with the data encapsulated inside. The rest of the Interface code defines four events. An event is a message that can be published to communicate with the world outside the Smart Contract. These messages are often used for logging activity and allow the front end of the dApp to be notified once an event has occurred.
interface IGovernance { function votingPeriod() external view returns (uint256); // used when reporting voting weight to prevent double-voting struct Proposal { bytes32 paramsHash; // hash of proposal data uint56 time; // proposal timestamp uint96 yays; // votes for proposal uint96 nays; // votes against proposal bool executed; // to make sure a proposal is only executed once bool vetoed; // vetoed proposal cannot be executed or voted on } event Propose( address indexed proposer, uint256 indexed proposalId, address[] targets, uint256[] values, bytes[] data ); event Vote( address indexed voter, uint256 indexed proposalId, bool yay, uint256 votingWeight ); event Execute(address indexed executor, uint256 indexed proposalId); event Veto(uint256 indexed proposalId); }
IGovernanceRegistry
This interface defines a single method governance() designed to let other contracts (external) query the address where the governance contract for an object implementing IGovernanceRegistry can be found.
interface IGovernanceRegistry { function governance() external view returns (address); }
IRewardsLocker
The IRewardsLocker interface defines in the abstract form how to create lock agreements when a user claims $KAP staking rewards. There is a single method createLockAgreement() which takes the wallet address of the beneficiary and the integer amount of rewards to be locked. Next, we see a data structure that defines the lock-up agreement, which includes a timestamp for when the staking rewards are claimable, the number of rewards that can be claimed, and a Boolean to stop double-claims. Interesting points to keep in mind the developers have used uint64 and uint96 in place of uint256 for the numerical values saying 192 and 160 bytes in memory, respectively. This is an efficient step as they will have sized these variables based on the data to be stored to ensure sufficient storage space. As resources in the shared virtual machine are scarce and efficient allocation is controlled by the cost of gas - paid in ETH - steps to optimize make sense. However, you should still spend a moment thinking if this is a sensible optimization. For example, for the timestamp, a uint64 can hold a value in nanoseconds representing the year 2554, so there is undoubtedly sufficient headroom. Finally, there are three more *events* which will allow external parties to track the progress of the contract execution.
interface IRewardsLocker { function createLockAgreement(address beneficiary, uint256 amount) external; /** * @dev Data structure describing a lock agreement created after a user * claims KAP staking rewards */ struct LockAgreement { uint64 availableTimestamp; // after `availableTimestamp`, `amount` KAP is made available for withdrawal uint96 amount; // amount of KAP promised to the beneficiary bool collected; // used to prohibit double-collection } event CreateLockAgreement(address indexed beneficiary, uint256 amount); event CollectRewards(address indexed beneficiary, uint256 lockAgreementId); event TransferKap(address to, uint256 amount); }
IStaking
The IStaking interface defines a series of events and two data structures. It's great when the development team leaves thoughtful commentary in their code - as designated by // or a block encompassed by /* */ - which the compiler ignores but gives a code reviewer guidance as to the intent. In this case, the comments are verbose, and there is little more to add. The events give us a steer as to how we might expect the staking to work. One item that stands out as interesting is the mention of boosting rewards in exchange for an extension of the lock-up period, as indicated by the Extend event. That is a feature you do not always see, so worth keeping an eye out for the implementation later.
interface IStaking { struct Emission { uint128 rate; // KAP rewards per second emitted by the staking pool uint128 expiration; // rewards are no longer accumulated after this time } struct Deposit { uint112 amount; // token amount given to the staking pool uint64 start; // time of lock period start uint64 end; // time of lock period end bool collected; // becomes true after principal is collected uint256 cumulative; // {cumulative} at time of deposit or last claim } event Sync(address indexed by, uint256 cumulative); event Stake(address indexed staker, uint256 depositId, uint256 amount, uint256 lock); event Unstake(address indexed staker, uint256 depositId, uint256 amount); event Extend(address indexed staker, uint256 depositId, uint256 extension, uint256 boostRewards); event ClaimRewards(address indexed staker, uint256 depositId, uint256 extension, uint256 rewards); event UpdateEmission(address indexed updater, uint256 rate, uint256 expiration); event TurnOffBoost(address indexed by); }
IVotingWeightSource
This final interface looks to return the voting weight of a given wallet address as a 'read-only' interaction that does not change the contract state (view keyword). The weight is returned as a positive integer - uint256 - so we should watch for the denominator used to ensure the math is good.
interface IVotingWeightSource { function votingWeight(address voter) external view returns (uint256); }
State Variables
With the interfaces and contract declaration considered, the next step is to consider the state variables for the contract. These should give us an idea of what is important enough to the contract for it to be stored in the big EVM computer in the sky. There is a little more going on here; however, the developer has left helpful comments. It is always a good sign to find code that is well signposted, although we should always assume a stance of "trust but verify" to ensure the comments align with the functionality.
using SafeCast for uint256; uint256 public constant MIN_LOCK = 4 weeks; // minimum staking lock uint256 public constant MAX_LOCK = 52 weeks; // maximum staking lock uint256 public constant CUMULATIVE_MULTIPLIER = 1e12; // to reduce integer division error bytes32 public constant TEAM_MULTISIG = keccak256("TEAM_MULTISIG"); using SafeERC20 for IERC20; IERC20 public immutable asset; // staked token, KAP or KAP-ETH LP IGovernanceRegistry public immutable governanceRegistry; // used to query the latest governance address IRewardsLocker public immutable rewardsLocker; // claimed rewards locked here for 52 weeks before withdrawal uint256 public cumulative; // cumulative rewards per wight, multiplied by {CUMULATIVE_MULTIPLIER} uint256 public totalWeight; // total staking weight in pool uint256 public syncdTo; // timestamp at which {cumulative} is valid uint256 public totalBoostRewards; // track total claimed boost rewards, for security monitoring bool public boostOn = true; // boosting can be turned off by governance or team multisig Emission public emission; // controls rewards emission rate mapping(address => Deposit[]) public deposits; mapping(address => uint256) public totalStaked; // voting weight mapping(address => uint256) public lastStaked; // to securely report voting weight
The first new keyword to digest is using, which hints to the compiler that an external Library should be used when interacting with a primitive or object type. In this case, the OZ SafeCast library is being used for uint256 primitives, and the OZ SafeERC20 library is being used for any object that implements the IERC20 interface. You can always use an external Library by calling it by its full name each time, although that would inherently make your code more verbose with no technical benefit.
We then find several public constant variables. These are hard coded in the Solidity source code, meaning that to change them, you would have to edit and then compile them to fresh bytecode before deployment. As such, values here are expected not to be changed easily/often. Using variable names in upper case characters for a constant variable is the convention and good practice. Here we see the minimum and maximum staking lock periods defined, a helper constant, and an indication that the controlling team wallet is intended to be a multi-signature (Multisig) address, which is a reassuring sign for security. A deep discussion of Multisig (vs. MPC) is out-of-scope to this document; however, the premise is simple, it allows for the movement of funds/calls to Smart Contracts to require multiple signatures instead of just one increasing the burden on an attacker as they need to compromise multiple keys.
Next, we have three immutable items that behave like constant variables once set in the constructor method. All three are objects where the explicit type of the object is not defined. Instead, an interface the object will adhere to is used as the type. Again, this allows for flexibility and the ability to enhance specific pieces of the code in a modular fashion as long as any new object obeys the specification defined by the relevant interface. Per the comment left by the development team, this same staking contract code could be used for $KAP-ETH LP, $KAP, or any other asset that implements IERC20. Still, due to the immutable nature of the variable, you can be sure of what will be staked based on the arguments used in the constructor.
Finally, we have a batch of mutable state variables that the contract operations will update. By convention, the development team has grouped the different variables by mutability (constant/immutable/mutable). Once again, this is not necessary, yet it is desirable, and thoughtful code layout implies energy has been put into smart contract risk minimization. The most interesting variable declarations are the emission of type Emission and the mapping of addresses to an array - a list - of Deposit objects - both object types are data structures defined in the IStaking interface that this contract implements due to the is keyword. Each Emission data structure has two uint128 attributes - an unsigned integer of half the maximum value of a uint256 used to save memory space when the maximum value is bounded at a lower number by the use case - representing the rate $KAP rewards are emitted per second to those staked in the pool and the timestamp for when emissions will no longer be accrued. Each Deposit data structure has five attributes representing the deposit in the form of an amount, the start of the lock period, the end of the lock period selected, a cumulative counter for staking rewards paid when the Deposit was last touched, and a Boolean representing when the principal staked has been returned to the user.
Data structures are a handy way to group related data items together in a nested manner. Consider the Deposit object. It is of course, possible to keep a set of five arrays to store the same data set; however, by encapsulating the data attributes nested inside an object of type Deposit, it becomes a lot easier to pass the data around. A list of Deposit objects is then tracked back to the address that made the deposit using the mapping - effectively an address book.
Constructor
Having familiarised ourselves with the state variables for the contract, the next step is to see what the constructor is doing. Remember, the constructor is a special method that can only be called once when the contract is deployed. It typically initializes the state variables and is the only part of the code that can set the immutable state variables.
constructor( address _asset, address _governanceRegistry, address _rewardsLocker, address _teamMultisig ) { require(_asset != address(0), "Staking: Zero address"); require(_governanceRegistry != address(0), "Staking: Zero address"); require(_rewardsLocker != address(0), "Staking: Zero address"); require(_teamMultisig != address(0), "Staking: Zero address"); asset = IERC20(_asset); governanceRegistry = IGovernanceRegistry(_governanceRegistry); rewardsLocker = IRewardsLocker(_rewardsLocker); _grantRole(TEAM_MULTISIG, _teamMultisig); }
The constructor here is simple. It is taking in an address for the asset to be staked using the deployed contract, an address of a deployed contract that implements the IGovernanceRegistry interface, an address of a deployed contract that implements the IRewardsLocker interface and the address of the Team Multi-Signature wallet.
The constructor checks that each argument is a real address rather than the zero address using the require() method. The advantage of require is that it fails fast based on the logical check, reverts the contract state - rolls back any alteration that might have been made - and returns any unspent gas to the user. As seen here, require allows the contract to pass back a short error message. With those prechecks done, the constructor assigns the internal state to these variables passed in. The use of a named interface bracketing a variable such as IERC20(_asset) is known as casting. Casting is telling the compiler that it is safe to assume the variable will adhere to the given interface - the compiler will check if the casting specified is hypothetically possible to help identify errors before they are encountered at runtime.
One callout to make is the _grantRole() method. This is inherited as the Staking.sol contract is AccessControlEnumerable. The method is internal, so only the contract can trigger it rather than an external user. It allows granular roles to be set up for access control to specific methods using a modifier.
Modifiers
A modifier is a keyword that can be applied to a method call to trigger code to run ahead of entering the method. Two typical use cases are seen in this contract:
- Access control: onlyAdmin()
- Synchronization: syncd()
modifier onlyAdmin() { require( msg.sender == governanceRegistry.governance() || hasRole(TEAM_MULTISIG, msg.sender), "Staking: Only admin" ); _; } modifier syncd() { _sync(); _; } /** * @notice Updates {cumulative} and {syncdTo} based on {emission} */ function _sync() internal { if (block.timestamp > syncdTo) { uint256 expiration = emission.expiration; if (syncdTo < expiration && totalWeight > 0) { uint256 timeElapsed = block.timestamp < expiration ? block.timestamp - syncdTo : expiration - syncdTo; cumulative += (emission.rate * timeElapsed * CUMULATIVE_MULTIPLIER) / totalWeight; } syncdTo = block.timestamp; emit Sync(msg.sender, cumulative); } }
When a method uses the onlyAdmin modifier, the above snippet will be run that checks if msg.sender - the special variable representing the address that interacted with the contract - is equal to the governance registry specified for this deployment in the constructor or if it is the address of the team multisig. We can see the logical or inside the require() method as signified by the ||. If neither condition is satisfied, the method will fail to execute due to the onlyAdmin modifier. In this manner, it is possible to lock down access to sensitive methods and protect the deployed code from bad actors.
The syncd() modifier is a little more subtle. Unlike a centralized server deployment, Smart Contracts do not have a scheduler which makes sense as the cost of resources to execute - gas - is paid live based on the current demand for network resources. As such, it is hard to find a satisfactory method to pre-fund and guarantee execution at a given scheduled time when it may be preferable to wait rather than execute until resource demand has died down, leading to lower gas costs. The internal method _sync() is designed to refresh the number of reward tokens emitted, bringing it up-to-date to the current timestamp. As this code is triggered via the modifier syncd() it results in the user paying the gas cost to update the contract state in place of an automated scheduler. The process of syncing is simple once we decompose the syntax:
- First, the code checks if the contract has already been updated to the current block - if (block.timestamp > syncdTo) - to avoid carrying out duplicate work.
- If not, then the code looks up the timestamp representing the expiration of emissions as a uint256 - a value in seconds from a defined epoch for those interested: https://www.epochconverter.com/ - which is the same format as the block.timestamp the EVM provides.
- Next, another if statement to check to see if there is anything to update, defined as the time of the last sync (syncdTo) occurring before the expiration and that there are assets staked (totalWeight > 0).
- Now, calculate how many rewards have been emitted between the last _sync() and the current time before adding them to the cumulative variable. This is handled with another if() then {} else {}; however, the code is written in short-hand, making it quite dense if you are not sure what you are looking at. The shorthand is setting the variable timeElapsed based on an expression if(condition)? [code if true] : [code if false] or perhaps easier to show this written out in long form for comparison as it is something you will see often.
uint256 timeElapsed; if (block.timestamp < expiration) { timeElapsed = block.timestamp - syncdTo; } else { timeElapsed = expiration - syncdTo; }
This gives you a timeElapsed - in seconds - the difference between the earlier of current block time/expiration and the last _sync(). This is then multiplied by the emissions rate and divided by the totalWeight. The scalar CUMULATIVE_MULTIPLIER allows for integer division as solidity does not support floating point math natively. The shorthand notation cumulative += [calc] adds the [calc] result to itself and is the equivalent of cumulative = cumulative + [calc].
- Finally, the code updates syncdTo to the latest time and emits a Sync() event, allowing other parties - for example a dApp front-end - to subscribe to monitor how often the contract is synchronized.
Business Logic
With the structure examined, it is time to dive into the 'core' of the contract, often known as the business logic.
Stake
The stake() method allows a user to deposit a defined amount of tokens for a set time period, the lock time. The tokens to be staked are those defined as the asset via the constructor when this staking contract was deployed. This method is only callable from outside the contract itself - marked external - and uses the syncd modifier to ensure the contract is synchronized to the latest block when any user uses the stake() method.
/** * @notice Creates a deposit with the specified amount and lock period * @param amount The token amount to stake in units of wei * @param lock The time in seconds to lock the tokens for * @dev Requires token allowance from staker */ function stake(uint256 amount, uint256 lock) external syncd { require(amount > 0, "Staking: Zero amount"); require(MIN_LOCK <= lock && lock <= MAX_LOCK, "Staking: Lock"); require(amount <= type(uint112).max, "Staking: Overflow"); deposits[msg.sender].push( Deposit({ amount: uint112(amount), start: block.timestamp.toUint64(), end: (block.timestamp + lock).toUint64(), collected: false, cumulative: cumulative }) ); totalWeight += amount * lock; totalStaked[msg.sender] += amount; lastStaked[msg.sender] = block.timestamp; emit Stake(msg.sender, deposits[msg.sender].length - 1, amount, lock); asset.safeTransferFrom(msg.sender, address(this), amount); // no LP tokens are lost during transfer, expected amount always received }
Initially, the method runs through three require() checks to ensure the user is trying to stake a non-zero amount of tokens, that the lock period is between the minimum/maximum lock parameters (4/52 weeks, respectively), and that the amount supplied is less than the maximum value of a uint112 (2^112 -1) which helps check that the value supplied is relevantly sized. If any of these checks fail, the execution is rolled back with a short error message.
Next, the method initializes a Deposit object by naming each struct attribute and supplying values. With the data encapsulated in as a Deposit, the object is inserted into the deposits mapping using msg.sender - the address of the user's wallet interacting with the contract - as the key. The mapping allows you to look up a value given a key via the mapping[key] syntax. Specifically, if you remember, the deposits variable is a mapping of address to an array (list) of Deposit objects. Here, the code uses the method push to insert the new Deposit at the end of the array. To avoid doubt, names are case-sensitive, and convention dictates variables and methods will use names in camelCase - start with lowercase and capitalize the start of each word - and Object definitions (struct/event/contract) naming will use PascalCase - like camelCase but staring with uppercase.
With the deposit stored, it calculates the weight of all staked tokens adding the amount multiplied by the time locked to the cumulative variable totalWeight. It then adds the amount to the cumulative variable for the user stored in the totalStaked mapping, updates the time of their most recent stake in the lastStaked mapping and emits a Stake event of the user's wallet, the number of deposits the user currently has, the amount staked and the time it is locked. Again, the front end can subscribe to these messages to track state changes. Of course, this is web3, so any user could build their own front-end to consume these events should they wish to do it differently...web3 = choices.
Finally, the method now attempts to move the tokens with the state updates saved. In terms of sequencing, you should always look for this pattern of state updates before moving assets to prevent opening an attack vector for a reentrancy attack; other protections against such an attack include using the OZ RenetrancyGuard() modifier. If you remember the constructor() method, the asset variable is cast - an assertion to the compiler to promise the variable will be of a given type - to be an IERC20 token. The interface IERC20 does not natively implement the safeTransferFrom() method; however, in the state variable section, we noticed that the contract is 'using SafeERC20 for IERC20', which means all IERC20 variables will make use of the SafeERC20 wrapper automatically and as a result, this method will be available. The benefit of using safeTransferFrom() is that if it fails, the contract will revert, undoing all the actions previously taken, and ensuring the state remains consistent. The composability of Smart Contracts and the ability to revert the entire chain upon an error, making the entire chain atomic - either all completes, or nothing completes - highlights the power of the solution when layering complexity into any given operation.
Unstake
Another key method is unstake() which allows us to return our tokens to self-custody. In many ways, this is a mirror of the process to stake the tokens with a few extra error checks and the ability to claim rewards owed, so hopefully, it will start to feel familiar.
/** * @notice Collects the deposit amount and claims rewards * @param depositId The deposit array index to collect from */ function unstake(uint256 depositId) external syncd { Deposit storage deposit = deposits[msg.sender][depositId]; uint256 amount = deposit.amount; uint256 end = deposit.end; require(!deposit.collected, "Staking: Already collected"); require(block.timestamp >= end, "Staking: Early unstake"); totalWeight -= amount * (end - deposit.start); totalStaked[msg.sender] -= amount; claimRewards(depositId, 0); // must claim before updating `deposit.collected`, see {claimRewards} deposit.collected = true; emit Unstake(msg.sender, depositId, amount); asset.safeTransfer(msg.sender, amount); }
The unstake() method takes an integer offset representing which of the user's deposits to pull out of the staking contract. The deposits mapping is accessed based on the user's wallet - msg.sender - to get the array of Deposit objects which is then selected based on the offset supplied to the method. Then, two checks are implemented with the fast fail require() method to ensure the deposit has not been collected and that the time on the blockchain is equal to or in excess of the endpoint chosen when the deposit was locked up in the staking contract.
Assuming these pass the total weight if removed from the cumulative total variable totalWeight using the shorthand -= to subtract the result of the right-hand side of the expression from the left-hand side and setting the result to the left-hand variable. When the voting weight was added upon staking tokens, the lock period was supplied to the method; however, here we have to calculate it (end - deposit.start). The amount being removed is then removed for the total held at a user level. Then method calls the claimRewards() method. As the code comment suggests, rewards must be claimed before the initial tokens deposited are marked as having been collected via deposit.collected = true; otherwise, the user cannot claim the rewards.
One more nuance worth calling out in this method: the storage keyword used when the Deposit variable deposit is declared. This tells the EVM to use the instance of the deposit from long-term storage by reference - which persists in the contract state rather than being held in memory only whilst the method is resolving - allowing the code to update attributes of the Deposit directly. By contrast, where you see a variable declared and set equal to an attribute of the deposit, for example, uint256 amount = deposit.amount;, then the information is copied by value. This means the newly declared variable amount is no longer linked to the deposit attribute, and updates would not be reflected on the deposit.
ClaimRewards
Why do we stake our assets? To be able to claimRewards()! Again, the code pulls out the rewards based on a specific deposit - if you stake multiple times, you will need to claim each separately - uses the attributes to calculate the rewards owed. The require() blocks claiming rewards once a deposit has been removed from staking. As well as the base rewards, users can extend their lock-up period to earn a boosted reward. If enabled (boostOn) and the user has requested it, then the method will call the internal _boost() method, which can reset the lock period.
The reward boost is a strong incentive for users to lock for the longest period they feel comfortable and then regularly re-lock to extend back to that maximum period, i.e. encourage a stickier userbase staking. To use it, the user has to request an extension before their prior staking has been completed - the opening require() statement - and they earn additional boostRewards equal to:
For those who subscribe to the cult of minimax - minimizing maximum regret - the focus should be on reducing the denominator by re-locking regularly; however, this must be balanced against the cost of gas, which will change the cost/benefit analysis. With the boosts calculated, the rest of the code focuses on updating the deposit attributes and the cumulative variables stored in the contract state. Two events are emitted to pass information to interested subscribers, one to signal that a user extended the lock period for their deposit, and the other to show that rewards have been claimed. Finally, if the user is due a non-zero amount of rewards, a lock agreement is created, which locks the rewards for 52 weeks before the user can withdraw the $KAP token rewards.
/** * @notice Claims rewards and restakes if boosting * @param depositId The deposit array index to claim from * @param extension The time in seconds to extend the lock period */ function claimRewards(uint256 depositId, uint256 extension) public syncd { Deposit storage deposit = deposits[msg.sender][depositId]; uint256 amount = deposit.amount; uint256 end = deposit.end; uint256 lock = end - deposit.start; uint256 weight = amount * lock; uint256 cumulativeDifference = cumulative - deposit.cumulative; uint256 rewards = (weight * cumulativeDifference) / CUMULATIVE_MULTIPLIER; require(!deposit.collected, "Staking: Already collected"); // rewards stop accumulating after principal is collected if (boostOn && extension > 0) { uint256 boostRewards = _boost(deposit, amount, end, lock, weight, extension, rewards); rewards += boostRewards; emit Extend(msg.sender, depositId, extension, boostRewards); } deposit.cumulative = cumulative; emit ClaimRewards(msg.sender, depositId, extension, rewards); if (rewards > 0) { rewardsLocker.createLockAgreement(msg.sender, rewards); } } /** * @notice Calculates boost rewards and updates state */ function _boost( Deposit storage deposit, uint256 amount, uint256 end, uint256 lock, uint256 weight, uint256 extension, uint256 rewards ) internal returns (uint256 boostRewards) { require(block.timestamp < end, "Staking: Remaining"); uint256 remaining = end - block.timestamp; uint256 maxExtension = MAX_LOCK - remaining; boostRewards = (rewards * remaining * extension) / (lock * maxExtension); uint256 newStart = block.timestamp; uint256 newEnd = end + extension; uint256 newLock = newEnd - newStart; uint256 newWeight = amount * newLock; require(MIN_LOCK <= newLock && newLock <= MAX_LOCK, "Staking: New lock"); deposit.start = newStart.toUint64(); deposit.end = newEnd.toUint64(); totalWeight -= weight; totalWeight += newWeight; totalBoostRewards += boostRewards; }
RewardsLocker
So far, we have relied solely on the interface IRewardsLocker as that is all that the Staking contract needed to know to operate; however, given it stands between us and collecting our rewards we should check the implementation. We know that the rewardsLocker is an immutable variable that can only be set in the constructor at the point of contract deployment. Heading back to the code tab on etherscan and scrolling down to the bottom, we can find the constructor arguments that were used on deployment.
Clicking on the address for _rewardsLocker will take you to the etherscan page for the deployed RewardsLocker, where you can again select the contract tab to find the Solidity code that has been used. The code is well laid out, with plenty of comments, so digesting it is a good takeaway exercise. Here are a few quick callouts that might help:
- The contract defines roles for the LOCK_CREATOR and KAP_SAVER, which are set for the staking pool and the team multisig wallet, respectively. Through the use of the onlyRole() modifier methods are locked down in this contract. For example, only the defined staking pool can use the RewardsLocker to create lock agreements for the user's KAP reward tokens. Similarly, a fail-safe allows the team multisig to withdraw KAP tokens locked for vesting in case of an unforeseen issue.
- The governanceRegistry and kapToken are immutable variables set to a permanent value in the constructor so the user can be sure the contract specified for the $KAP token and the governance will not change during their 52-week vesting schedule.
- When the staking pool calls createLockAgreement() the contract tracks the total rewards locked at a user and contract level in case there is a decision by the DAO to reflect the weight of claimed but locked rewards in future votes.
- Each user wallet is mapped to an array of LockAgreement data structures, allowing the contract to handle multiple reward claims per user, each addressable and claimable separately.
- When claiming the rewards via collectRewards() the state is updated before the transfer occurs to avoid a reentrancy attack.
- Each method that can change state emits an event unique to that method to give more granular information about what happened during execution.
- The collectRewards() method is passed the specific index of a LockAgreement for the user to try to claim. Assuming 52 weeks have passed from the initial claim, the rewards are paid out using SafeERC20.safeTransfer(). We discussed a similar method to this method earlier in the article (.safeTransferFrom); however, it looked a little different as the staking contract had the line 'using SafeERC20 for IERC20;' at the top. If this contract used the same short-hand, this transfer would have been written as 'kapToken.safeTransfer(msg.sender, amount);'. Functionally the same, just a good opportunity to highlight the difference.
// SPDX-License-Identifier: MIT pragma solidity 0.8.9; import "./interfaces/IRewardsLocker.sol"; import "./interfaces/IVotingWeightSource.sol"; import "./interfaces/IGovernanceRegistry.sol"; import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import "@openzeppelin/contracts/utils/math/SafeCast.sol"; import "@openzeppelin/contracts/access/AccessControlEnumerable.sol"; import "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol"; /** * @title Kapital DAO Rewards Locker * @author Playground Labs * @custom:security-contact security@playgroundlabs.io * @notice KAP rewards claimed from a staking pool are locked here for 52 weeks * before being made available for withdrawal */ contract RewardsLocker is IRewardsLocker, IVotingWeightSource, AccessControlEnumerable { bytes32 public constant KAP_SAVER = keccak256("KAP_SAVER"); // role to call {transferKAP} bytes32 public constant LOCK_CREATOR = keccak256("LOCK_CREATOR"); // role to call {createLockAgreement} IGovernanceRegistry public immutable governanceRegistry; // used to query the latest governance address IERC20 public immutable kapToken; // the rewards token mapping(address => LockAgreement[]) public lockAgreements; mapping(address => uint256) public votingWeight; // the DAO may vote to include {RewardsLocker} as a voting weight source uint256 public totalVotingWeight; // sum over {votingWeight}, for security monitoring constructor( address _stakingPool, address _governanceRegistry, address _kapToken, address _teamMultisig ) { require(_stakingPool != address(0), "RewardsLocker: Zero address"); require(_governanceRegistry != address(0), "RewardsLocker: Zero address"); require(_kapToken != address(0), "RewardsLocker: Zero address"); require(_teamMultisig != address(0), "RewardsLocker: Zero address"); governanceRegistry = IGovernanceRegistry(_governanceRegistry); kapToken = IERC20(_kapToken); _grantRole(LOCK_CREATOR, _stakingPool); _grantRole(KAP_SAVER, _teamMultisig); } /** * @notice Called by role {LOCK_CREATOR} when KAP rewards are claimed * @param beneficiary Address which is permitted to collect the KAP * @param amount Number of KAP tokens promised in the lock agreement */ function createLockAgreement(address beneficiary, uint256 amount) external onlyRole(LOCK_CREATOR) { votingWeight[beneficiary] += amount; totalVotingWeight += amount; lockAgreements[beneficiary].push( LockAgreement({ availableTimestamp: SafeCast.toUint64( block.timestamp + (52 weeks) ), amount: SafeCast.toUint96(amount), collected: false }) ); emit CreateLockAgreement(beneficiary, amount); } /** * @notice Called by the beneficiary of a lock agreement * @param lockAgreementId Index in `lockAgreements[beneficiary]` */ function collectRewards(uint256 lockAgreementId) external { require(lockAgreementId < lockAgreements[msg.sender].length, "RewardsLocker: Invalid Id"); LockAgreement storage lockAgreement = lockAgreements[msg.sender][lockAgreementId]; uint256 amount = lockAgreement.amount; require( block.timestamp >= lockAgreement.availableTimestamp, "RewardsLocker: Too early" ); // make sure beneficiary waits 52 weeks before collecting rewards require(!lockAgreement.collected, "RewardsLocker: Already collected"); // prohibit double-collection lockAgreement.collected = true; votingWeight[msg.sender] -= amount; totalVotingWeight -= amount; emit CollectRewards(msg.sender, lockAgreementId); SafeERC20.safeTransfer(kapToken, msg.sender, amount); } /** * @dev Used in emergency to save KAP rewards from vulnerability * @param to Address of recipient of saved KAP rewards * @param amount Amount of KAP rewards to save */ function transferKap(address to, uint256 amount) external { bool senderIsGovernance = (msg.sender == governanceRegistry.governance()); bool authorized = senderIsGovernance || hasRole(KAP_SAVER, msg.sender); require(authorized, "RewardsLocker: Access denied"); require(amount > 0, "RewardsLocker: Invalid amount"); emit TransferKap(to, amount); SafeERC20.safeTransfer(kapToken, to, amount); } /** * @notice Used on the front-end * @param user Owner of {LockAgreement}s * @return {LockAgreement}s associated with `user` */ function getLockAgreements( address user ) external view returns (LockAgreement[] memory) { return lockAgreements[user]; } }
Final Tidy-up
There are four more methods to cover from the Staking.sol contract to ensure a complete review, but do not worry; we have left the easy ones for last.
/** * @notice Sets a new rate and expiration for {emission} * @param rate The new kap per second reward * @param expiration The new timestamp after which rewards stop */ function updateEmission(uint256 rate, uint256 expiration) external onlyAdmin syncd { require(block.timestamp < expiration, "Staking: Invalid expiration"); emission.rate = rate.toUint128(); emission.expiration = expiration.toUint128(); emit UpdateEmission(msg.sender, rate, expiration); } /** * @notice Permanently turns off boosting */ function turnOffBoost() external onlyAdmin { require(boostOn, "Staking: Already off"); boostOn = false; emit TurnOffBoost(msg.sender); } /** * @notice Reports voting weight * @param voter Staker to report voting weight for */ function votingWeight(address voter) external view returns (uint256) { uint256 votingPeriod = IGovernance(governanceRegistry.governance()).votingPeriod(); uint256 timeElapsed = block.timestamp - lastStaked[voter]; return timeElapsed > votingPeriod ? totalStaked[voter] : 0; } /** * @notice Front-end getter for staker deposits * @param staker Staker to get deposits for */ function getDeposits(address staker) external view returns (Deposit[] memory) { return deposits[staker]; }
The updateEmission() method allows only the KAP team's multisig wallet - thanks to the onlyAdmin modifier - to update the emissions for the staking contract. The arguments supplied to the method are the number of KAP tokens emitted per second to the pool, allocated to users based on their staked weight, and the timestamp for when the emissions will stop. When the team modifies this configuration, an event is emitted to ensure transparency for all observers that the update was made; there is no hiding in web3. The turnOffBoost() method is also locked down to the team multisig and can only ever be called once to turn off the boost functionality by changing the state variable boostOn to false.
The votingWeight() method is a read-only method called from outside the contract - external view keywords - to determine whether a user had staked tokens before a vote started. If the user had staked tokens before the vote began, then the number of tokens staked is returned; otherwise, zero. Finally, there is a getDeposits() method, which is again a read-only method called from outside the contract to return the list of deposits staked by the supplied address; useful given many other methods require a user to specify the deposit they wish to claim or unstake.
Summary
Easy as that, right?
You have now digested a live staking contract out in the wild - congratulations!
Not just a regular one either, but one that incorporates boost mechanics to use rewards to incentivize longer lock periods. We have covered a lot of ground and mentioned an abundance of useful concepts.
Using this knowledge, you will be able to find the code behind and unpick a lot of contracts or, at the very least, the thrust of what they are going to do with your assets. Once again, web3 is all about choices, and we hope these more technical pieces will help you make good choices before you sign that next transaction.