Introduction
Utonoma is a social network that rewards you with cryptocurrencies for every like your content receives, provided that if more than 50% of the community’s votes are dislikes, the content will be removed. This incentivizes users to keep creating higher-quality content, ideally avoiding the use of offensive promotion, as moderation of non-explicit content is carried out democratically and decentralized.
Every time content on Utonoma is rewarded with cryptocurrencies, they are generated immediately and deposited into the author’s address, similar to how Bitcoin rewards a miner for their work. Of course, this constitutes a devaluation of previously created coins, similar to what would happen if a normal economy were flooded with new coins.
To prevent this, Utonoma’s smart contracts have a tool that allows adjusting the emission level of new coins based on the number of users on the platform. With a higher number of users, fewer coins are issued per like that wants to be exchanged for cryptocurrencies. This requires a reliable user counting algorithm.
In this post, we break down this tool step by step to understand how it works, along with the unit tests that validate its operation.
Let’s get into the code
Note: Utonoma is interested in knowing the number of monthly active users (MAU), but you can adapt this algorithm to track the number of weekly, daily, yearly users, etc.
First, we need to keep track of our “zero hour,” which is the time from which intervals will be counted. Remember that the EVM uses “epoch unix timestamps” to keep time records in blocks on the chain. You can use this unix timestamp converter to normal date format: https://www.unixtimestamp.com/.
In our case, we want the zero hour to be the moment the smart contract is created. We create a smart contract called Time.sol to store this value as follows:
abstract contract Time {
uint256 internal _startTimeOfTheNetwork;
constructor() {
_startTimeOfTheNetwork = block.timestamp;
}
/// @notice gets the time in which the contract was deployed
function startTimeOfTheNetwork() public view returns (uint256) {
return _startTimeOfTheNetwork;
}
}
We can inherit from the Time.sol contract whenever we want to check the “zero hour” from other contracts.
Next, we need a kind of user database that contains the last time they interacted with the smart contract, so that when we count them, we don’t double count them. We create a mapping that maps all possible user addresses with a structure containing user information, as follows:
struct UserProfile{
uint256 latestInteraction;
bytes32 userMetadataHash;
bytes15 userName;
uint64 strikes;
}
mapping(address account => UserProfile) internal _users;
Utonoma is interested in knowing more about its users, such as their nickname and metadata they want to add. In your case, you may only be tracking the time of the last interaction and could map it to a uint256 containing this value instead of mapping to a structure.
We add an array containing the number of active users, each element of the array corresponds to a time period.
uint256[] private _MAU;
Next comes the method that handles all the magic; this should be called whenever we want to record a possible user interaction with the contract. Let’s see it in full and then break it down to understand it better:
function _logUserInteraction(
uint256 currentTime,
uint256 startTimeOfNetwork
) internal {
uint256 startTimeMinusCurrent = currentTime - startTimeOfNetwork;
uint256 elapsedMonths = startTimeMinusCurrent / 30 days;
//If there are no users during the whole period then fill with 0 in the report
if(elapsedMonths > _MAU.length) {
uint256 monthsWithNoInteraction = elapsedMonths - _MAU.length;
for(uint i = 0; i < monthsWithNoInteraction; i++) {
_MAU.push(0);
}
}
address account = msg.sender;
uint256 latestUserInteraction = _users[account].latestInteraction;
bool shouldCountAsNewInteraction;
//If the interaction is the first of a new opening period.
if(elapsedMonths + 1 > _MAU.length) {
_MAU.push(1);
}
//if it is the first interaction of the user with the platform
else if(latestUserInteraction == 0) {shouldCountAsNewInteraction = true;}
//If the previous interaction was before the beginning of the new period
else {
uint startTimeOfNewPeriod = startTimeOfNetwork + (30 days * _MAU.length);
if(startTimeOfNewPeriod < latestUserInteraction) {
shouldCountAsNewInteraction = true;
}
}
if(shouldCountAsNewInteraction) {
_MAU[_MAU.length - 1]++;
}
_users[account].latestInteraction = currentTime;
}
Let’s start with the function definition. It’s important to mark it as internal since we only want it to be called by us so that nobody can alter or manipulate our counting. It also takes two parameters: “currentTime” and “startTimeOfNetwork.” These two values are essential for the method to work and are passed as parameters because it makes unit testing easier, and allows us to simulate different scenarios in our tests without having to wait for times to pass or do unusual things like setting up our blockchain to manipulate time. This way allows for the validation of the method in seconds.
The following is a block designed so that if there are no user interactions with the smart contract, the corresponding periods are filled with zeros.
Next, we evaluate three conditions to count our active users per period:
- It’s the first interaction of an opening period. For example, if our “zero time” is January 1, 2024, at 00:00 hours, our first period will count from this moment until January 30, 2024, at 00:00 hours. If a user interacts with the smart contract on February 2, 2024, at 15:30 hours and the array that holds the user count has only one element (one period) when it should have 2, it’s because we are in the second evaluation period, which means a new counting period is opening, and the interaction should be counted regardless of any other condition.
- It’s the first time the user interacts with the smart contract. We need to evaluate if latestUserInteraction, where the time of the last interaction is stored, is zero or already has a value written previously. If the first case is true, we count the interaction.
- Finally, we need to evaluate if the user’s last interaction was before the start of the current counting period. For example, let’s say the first period starts on January 1, 2024, at 00:00 and ends on the 30th of the same month at the same time (30 days later). If a user interacted on January 29, 2024 (during the first period) and, when calling the method to count users, we find ourselves on February 2, 2024 (during the second period), it means that the interaction should be counted as new. Let’s say, later, the user interacts again on February 4 of the same year (during the second period), in this case, the interaction should not be counted because it’s the second of the same period.
The method ends by recording the time of the user’s last interaction for future calculations.
Note: The way the current time is obtained to then pass it as an argument to the counting method comes from block.timestamp. Remember that this value can be manipulated by miners in ranges of up to 15 minutes. In our case, we estimate that these types of manipulations do not significantly affect our count, but study yours to see if it’s similar. For more information, you can visit this link: https://medium.com/coinmonks/smart-contract-security-block-timestamp-manipulation-baec1b95c921.
Finally, you can see the unit tests that were conducted for this method in our GitHub repository, specifically the tests called logUserInteractionForASingleUser, logUserInteractionForTwoUsers, and logUserInteractionForThreeUsers, which were performed in Remix IDE using the Solidity Unit Testing plugin: https://github.com/AstroSamus/utonoma/blob/master/tests/Users_test.sol.
Thanks for reading.
