Download audit report
This document presents the results of a security review conducted by Nethermind Security for Regeneration Credit protocol. It establishes a funding system that directly rewards those engaged in the restoration of ecosystems.
Basically, the Regeneration Credit enables regenerators to earn additional income by transforming measurable ecological impact into digital credits while contributing to the recovery of biodiversity and natural resources. These credits can be converted into tokens that can be exchanged in the open market.
The audit comprises 3286 lines of Solidity code. The audit was performed using (a) manual analysis of the codebase, and (b) automated analysis tools.
| Audit assessment | |
| Audit Type | Security Review |
| Initial Report | September 18, 2025 |
| Final Report | October 04, 2025 |
| Initial Commit | 05ce1f8e4b |
| Final Commit | 6111f9efe5 |
| Documentation Assessment | High |
| Test Suite Assessment | High |
Findings by severity
The Regeneration Credit Protocol is a modular set of smart contracts designed to issue, distribute, and retire a fixed supply of Regeneration Credit (RC) tokens as a reward for ecosystem regeneration. The system coordinates multiple user roles Regenerators, Inspectors, Activists, Researchers, Contributors, Developers, Supporters through a combination of Rules contracts, Pool contracts, and Shared modules.
In the Regeneration Credit Protocol, each class of participant (actor) is managed by a Rules contract that enforces role-specific constraints (e.g., registration, validation, eligibility). These Rules contracts are tightly coupled with corresponding Pool contracts, which handle reward distribution based on levels, inspections, or contributions. The Pools themselves inherit from a sharedPoolable abstraction that applies era/epoch logic and halving schedules. Finally, every actor (Supporter, Regenerator, Inspector, Activist, Researcher, Contributor, Developer) interacts with its own Rules contract as the primary entry point. The Rules contracts then forward state updates to the Pool contracts for reward accrual, while also interacting with CommunityRules for global registration, invitations, and denial mechanics.
This three-layer pattern is:
Figure 2 presents a flow diagram illustrating the core processes of the protocol and the interaction with the respective user roles.
Initialization: The protocol starts with the owner, who adds the pool contracts and transfers the predefined fixed amount of tokens to each pool.
User Onboarding: All users, with the exception of Supporters, must be invited to join the protocol. Invited users accept the invitation by calling the respective contract function to register themselves. For example, a Regenerator calls the addRegenerator function and subsequently the requestInspection function to register and request an inspection.
Inspection Workflow: Once an inspection request is opened by a Regenerator:
acceptInspection function to be assigned to the inspection.realizeInspection function to register the ecological data (number of trees and biodiversity).Inspection Validation: In cases where an inspection is suspected to be invalid:
addInspectionValidation function to challenge the inspection.Penalties and Restrictions: If an Inspector accumulates the maximum number of allowed penalties, their status is set to denied. A denied user cannot perform any further operations in the system, such as:
Similar penalty mechanisms apply to other user roles, except for Supporters, who are exempted from such restrictions.

The risk rating methodology used by Nethermind Security follows the principles established by the OWASP Foundation. The severity of each finding is determined by two factors: Likelihood and Impact.
Likelihood measures how likely the finding is to be uncovered and exploited by an attacker. This factor will be one of the following values:
When defining the likelihood of a finding, other factors are also considered. These can include but are not limited to motive, opportunity, exploit accessibility, ease of discovery, and ease of exploit.
Impact is a measure of the damage that may be caused if an attacker exploits the finding. This factor will be one of the following values:
When defining the impact of a finding, other factors are also considered. These can include but are not limited to data and state integrity, loss of availability, financial loss, and reputation damage. After defining the likelihood and impact of an issue, the severity is determined according to the table below.
| Impact / Likelihood | Low | Medium | High |
| High | Medium | High | Critical |
| Medium | Low | Medium | High |
| Low | Info / Best Practices | Low | Medium |
| Undetermined | Undetermined | Undetermined | Undetermined |
To address issues that do not fit a High, Medium or Low severity, Nethermind Security also uses three more finding severities: Informational, Best Practices, and Undetermined.
| File | Fix commit | Type | Status |
RegeneratorRules.solCommunityRules.sol | PR #664 | High | Fixed |
The system currently allows a user to become a regenerator only via invitation from an activist. There is no implementation to avoid that an area is used by another regenerator. While this mechanism restricts entry, it introduces vulnerabilities:
a - Self-Invitation Abuse: A user with an activist account can invite their own secondary accounts to join as regenerators, enabling them to register the same area multiple times.
b - Area Misappropriation: An activist can invite another account as regenerator but assign an area that belongs to a different regenerator, effectively diverting rewards to themselves across both activist and regenerator roles.
This can lead to duplicated or fraudulent area registrations. To mitigate the issue, the protocol introduces a denunciation mechanism that allows active users to flag irregularities.
However, the system does not effectively incentivize users to do so. For example, if an inspector invests time and money to visit an area and realizes they have already inspected it before for another regenerator, they may choose not to report the irregularity.
Recommendation
Reevaluate the denunciation mechanism to better incentivize users to report fraud and preserve the integrity of the protocol.
Resolution
PR #664. The problem pointed at this issue is that the system does not block or calculate the regenerators area on chain. The reason for that is that it is complicated to put on chain real world assets without a centralized solution, in this case an area being regenerated. It is not possible, as far as I know, to solve this problem only with the on chain registration function. So the solution is the validation mechanism, which was updated to solve this problem.
The attack pointed here is the "malicious activist" attack, where a user with this role tries to obtain rewards for fake or duplicated regenerators area. First, it is not that the user will make an instant profit out of it, to be successfull new regenerators and inspectors need to conclude 3 valid inspections each one. And to do this without being discovered will be a challenge. A basic protection mechanism is the on chain reputation of the user, so the attacker will risk loose his account, what can return rewards/profit if acting honestly, when registering fake or duplicated areas.
One implementation to reduce the problem was to set activist as a non voter user, to avoid conflict of interest and to reduce this user type power. So now the voting rights and the inspections operations are separated. Activist, regenerators and inspectors are the operation base. And developers, researches and contributors are the voting layer, users that will have the validation as their function to the system. Another implementation was the reduction to only 5 allowed inviterPenalties for the Activist. So now, if 5 invited users gets denied, the activist is blocked from sending new invitations. So the risk is higher for the attacker.
constructor(
uint8 inspectorProportionality,
uint8 activistProportionality,
uint8 researcherProportionality,
uint8 developerProportionality,
uint8 contributorProportionality
) {
// ..
userTypeSettings[CommunityTypes.UserType.ACTIVIST] = CommunityTypes.UserTypeSetting(
activistProportionality,
false,
true,
VOTER_INVITATION_DELAY_BLOCKS,
false
);Another implementation was the ValidationPool, a token reward mechanism for hunters and voters. This implementation added a mechanism were hunters, or the users that start the voting for another user, receives one level of this validation pool if the user gets denied. The intention is to be a small pool, to reward a bonus or a tip for this service. So users can open voting, make "campaign" to invalidate a particular malicious user and get rewarded for that.
/**
* @notice Allows users to attempt to vote to invalidate an user.
* @dev Votes to invalidate users with unwanted behavior.
*
* Requirements:
* - The caller must be a registered voter user (verified by VoteRules).
* - Caller level must be above average (verified by VoteRules.canVote implicitly).
* - Caller must have waited `timeBetweenVotes` since their last vote.
* - Caller must vote only once per user per era.
* - The target user must be registered and not already denied.
* - If the target user is a Regenerator, they must have fewer than 4 completed inspections to be eligible for invalidation.
*
* @param userAddress Invalidation user address.
* @param justification Invalidation justification (Max characters).
*/
function addUserValidation(address userAddress, string memory justification) external nonReentrant {
require(bytes(justification).length <= MAX_JUSTIFICATION_LENGTH, "Max characters");
require(voteRules.canVote(msg.sender), "Not a voter");
require(!communityRules.userTypeIs(CommunityTypes.UserType.UNDEFINED, userAddress), "User not registered");
require(
!communityRules.userTypeIs(CommunityTypes.UserType.SUPPORTER, userAddress),
"Supporter validation not allowed"
);
require(!communityRules.isDenied(userAddress), "User already denied");
require(_canBeValidated(userAddress), "Regenerator has reached validation immunity");
uint256 currentEra = _userCurrentEra(userAddress);
require(!validatorUsersValidations[msg.sender][userAddress][currentEra], "Already voted");
require(waitedTimeBetweenVotes(msg.sender), "Wait timeBetweenVotes");
if (hunterPools[msg.sender].currentEra == 0) {
hunterPools[msg.sender].currentEra = validationPool.currentContractEra();
}
if (userValidations[userAddress][currentEra] == 0) {
hunterVoter[userAddress][currentEra] = msg.sender;
}
validatorUsersValidations[msg.sender][userAddress][currentEra] = true;
validatorLastVoteAt[msg.sender] = block.number;
userValidations[userAddress][currentEra]++;
validationPoints[msg.sender]++;
uint256 validationsCount = userValidations[userAddress][currentEra];
uint256 _votesToInvalidate = votesToInvalidate();
if (validationsCount >= _votesToInvalidate) {
_denyUser(userAddress);
address hunter = hunterVoter[userAddress][currentEra];
totalValidationLevels[hunter]++;
validationPool.addLevel(hunter, userAddress);
}
emit UserValidation(msg.sender, userAddress, justification, currentEra);
}
/**
* @dev Allows a validator to initiate a withdrawal of Regeneration Credits
* for the malicious/fake users hunt service. Rewards will be based on their hunting level and current era.
* @notice Validators can claim tokens for their hunt and investigation service.
*/
function withdraw() external nonReentrant {
// Only registered voters can call this function.
require(communityRules.isVoter(msg.sender), "Pool only to voters");
require(validatorLastVoteAt[msg.sender] > 0, "Not eligible to withdraw");
Pool storage hunterPool = hunterPools[msg.sender];
uint256 currentEra = hunterPool.currentEra;
// Check if the validator is eligible to withdraw for the current era through DeveloperPool.
require(validationPool.canWithdraw(currentEra), "Not eligible to withdraw for this era");
// Increment the validator's era in their local pool data.
hunterPool.currentEra++;
// Call the DeveloperPool contract to perform the actual token withdrawal.
validationPool.withdraw(msg.sender, currentEra);
}The other voters, they do have the incentive to vote to keep the system clean and increase their past and future rewards by having a community without the fraudlent actors. But to improve their incentive, a validationPoints mechanism was added to all vote functions, and with 50 validationPoints, the user can exchange his points for one validationPool level and receive a token tip for this service.
/// @notice Validation points required for one pool level.
uint256 public constant POINTS_PER_LEVEL = 50;
/// @notice Tracks the accumulated, unspent validation points for each voter.
mapping(address => uint256) public validationPoints;
/// @notice Tracks the total number of validation levels a user has ever earned.
mapping(address => uint256) public totalValidationLevels;
/**
* @notice Grants a single validation point to a voter for a voting action.
* @dev This is a function intended to be called by the resources contracts after a validation vote.
* @param voter The address of the voter who is earning the point.
*/
function addValidationPoint(address voter) external mustBeAllowedCaller {
validationPoints[voter]++;
}
/**
* @notice Allows a voter to exchange their accumulated validation points for a single level.
* @dev This function implements a fixed exchange rate where a voter can trade a specific
* amount of points (POINTS_PER_LEVEL) for one level, which contributes to their
* standing and potential rewards in the Validation Pool.
*
* Requirements:
* - The caller (`msg.sender`) must be a registered voter (e.g., Researcher, Developer, Contributor).
* - The caller must have accumulated at least `POINTS_PER_LEVEL` points to be eligible for the exchange.
*/
function exchangePointsForLevel() external nonReentrant {
require(communityRules.isVoter(msg.sender), "Pool only to voters");
uint256 userPoints = validationPoints[msg.sender];
require(userPoints >= POINTS_PER_LEVEL, "Not enough points");
if (hunterPools[msg.sender].currentEra == 0) {
hunterPools[msg.sender].currentEra = validationPool.currentContractEra();
}
validationPoints[msg.sender] = userPoints - POINTS_PER_LEVEL;
totalValidationLevels[msg.sender]++;
validationPool.addPointsLevel(msg.sender);
}Also, some events had the indexed tag added to facilitate lookup.
| File | Fix commit | Type | Status |
CommunityRules.solInspectionRules.sol | PR #662 | High | Fixed |
The protocol provides mechanisms to invalidate inspections and to report other users or resources. However, when an inspector accepts an inspection and later realizes that the area belongs to a regenerator they have already audited (which is not allowed), the current design leaves the inspector with only two unfavorable outcomes:
InspectionRules.realizeInspection function to complete the inspection. Report the regenerator calling CommunityRules.addDelation indicating the regenerator’s address. The inspection is then subject to voting and invalidation, which also penalizes the inspector when the inspection is invalidated. If the inspector does not report the regenerator, they also may be penalized if users invalidate the inspection.Inspectors are punished in both cases, even when acting honestly.
Recommendation
Revise the invalidation and denunciation logic to ensure that inspectors acting correctly are not penalized.
Resolution
Fixed at PR #662. This problem was solved by introducing a check to only count as a realized inspection and increment the ecological counters if the regeneration score is positive. Also, was added a check to only add pool level and increment inspections count to both regenerators and inspectors if score is not zero.
We will encourage inspectors to realize the inspection passing 0 as value and with his description/delation of the problem faced on the report. So with this update, 0 score inspection will not afect the token ecological backing, will not increase level and inspections count, will not increase the inspector giveUp count and may not be penalized if well justified at the report.
This way, the inspector don't even need to create a delation, the the zero inspection with the justification for the invalid regenerator. No giveUps, no penalty, if validators agree with the justification.
Impact update:
/**
* @dev Allow a inspector realize a inspection and mark as INSPECTED.
* @notice Inspectors must evaluate the amount of trees and species of the regeneration area.
* How many trees, palm trees and other plants over 1m high and 3cm in diameter there is in the regenerating area? Justify your answer in the report.
* How many different species of those plants/trees were found? Each different species is equivalent to one unity and only trees and plants managed or planted by the regenerator should be counted. Justify your answer in the report.
* Zero score means invalid inspection.
* NOTE: If the inspector finds something suspicous about the inspected regenerator, such as invalid area, suspicious of fake account, or if the Regenerator is not
* findable, inspectors are encourage to realize passing 0 as values with his justification at the report to avoid being penalized.
* @param inspectionId The id of the inspection to be realized.
* @param proofPhotos The string of a photo with the regenerator or the string of a document with the proofPhoto with the regenerator and other area photos.
* @param justificationReport The justification and report of the result found.
* @param treesResult The number of trees, palm trees and other plants over 1m high and 3cm in diameter found in the regeneration area. Only plants managed or planted by the regenerator must be counted.
* @param biodiversityResult The number of different species of trees, palm trees and other plants over 1m high and 3cm in diameter found in the regeneration area. Only plants managed or planted by the regenerator must be counted.
*/
function realizeInspection(
uint64 inspectionId,
string memory proofPhotos,
string memory justificationReport,
uint32 treesResult,
uint32 biodiversityResult
) external nonReentrant {
// ...
// Only count inspections that have a positive impact towards the global metrics.
if (inspection.regenerationScore > 0) {
impactPerEra[inspection.inspectedAtEra].trees += treesResult;
impactPerEra[inspection.inspectedAtEra].biodiversity += biodiversityResult;
realizedInspectionsCount++;
impactPerEra[era].realizedInspections++;
}
// ...
}To solve the problem that inspections not carried out were still counting to the totalInspections, a new logic to only count as realized inspection was introduced. Now, a zero score inspection will not be counted as an inspection or a level for users.
This was implemented at the afterRealizeFunction for both inspectors and regenerators:
function afterRealizeInspection(
address addr,
uint32 score,
uint64 inspectionId
) external mustBeAllowedCaller mustBeContractCall(inspectionRulesAddress) nonReentrant returns (uint256) {
require(score <= MAX_SCORE, "Maximum score");
require(!processedInspections[inspectionId], "Inspection results already submitted");
// ...
if (score > 0) {
totalInspections = _incrementInspections(addr);
} else {
totalInspections = regenerators[addr].totalInspections;
}
// ...
}| File | Fix commit | Type | Status |
RegenerationCreditImpact.sol | PR #663 | High | Fixed |
The functions treesPerToken, carbonPerToken, and biodiversityPerToken compute the per-token ecological impact (trees, carbon, and biodiversity, respectively). Each of these functions ultimately depends on the number of trees recorded in the system:
function treesPerToken() external view returns (uint256) {
uint256 effectiveSupply = _getEffectiveSupply();
if (effectiveSupply == 0) return 0;
return (totalTreesImpact() * PRECISION_FACTOR) / effectiveSupply;
}
function carbonPerToken() external view returns (uint256) {
uint256 effectiveSupply = _getEffectiveSupply();
if (effectiveSupply == 0) return 0;
return (totalCarbonImpact() * PRECISION_FACTOR) / effectiveSupply;
}The values of totalTreesImpact and totalCarbonImpact are derived from inspectionsTreesImpact, which accumulates the number of trees reported across all inspections:
function totalTreesImpact() public view returns (uint256) {
if (inspectionRules.realizedInspectionsCount() == 0) return 0;
return
(inspectionRules.inspectionsTreesImpact() * regeneratorRules.totalImpactRegenerators()) /
inspectionRules.realizedInspectionsCount();
}
function totalCarbonImpact() public view returns (uint256) {
return totalTreesImpact() * CARBON_PER_TREE;
}Each inspection contributes to inspectionsTreesImpact, capped at four trees per square meter and a maximum inspected area of 500,000 sqm. If an inspection is later invalidated by voters, both inspectionsTreesImpact and realizedInspectionsCount are decremented accordingly.
The problem arises when a malicious user operates multiple accounts as inspectors or independent inspectors observe that the Regeneration Credit (RC) value is connected to the number of trees. Since inspectors can take on new inspections roughly every 24 hours, they can repeatedly submit manipulated inspections to artificially inflate the ecological value of the RC token and then sell tokens earned in previous eras (e.g., Era n-1, n-2, etc.).
This vulnerability allows attackers to artificially manipulate the ecological value per RC token, enabling unfair profit extraction and undermining the credibility and stability of the system.
Recommendation
Review the design of the per-token ecological impact calculation to ensure inspectors cannot manipulate token value. One possible mitigation is to base the computation solely on validated data from the previous era.
Resolution
A struct impactEra and a mapping from era to impactEra was introduced. Now the impact calculation is realized by era. A setEraImpact function was added at the request inspection function to add the past era impact to the global metrics. So now the impact will be increased at the first requestInspection of each era, summing only the past impact to avoid the manipulation issue. Fixed at PR #663.
/**
* @notice Tracks the inspection impact of an Era.
* @dev This struct is used to register the impact of all inspection of an Era.
* @param trees Trees count.
* @param biodiversity Biodiversity count.
* @param realizedInspections Era realizedInspections count.
*/
struct EraImpact {
uint256 trees;
uint256 biodiversity;
uint256 realizedInspections;
}
function realizeInspection(
uint64 inspectionId,
string memory proofPhotos,
string memory justificationReport,
uint32 treesResult,
uint32 biodiversityResult
) external nonReentrant {
// ..
if (inspection.regenerationScore > 0) {
uint256 era = inspection.inspectedAtEra;
impactPerEra[era].trees += treesResult;
impactPerEra[era].biodiversity += biodiversityResult;
impactPerEra[era].realizedInspections++;
}
// ..
}
function requestInspection() external nonReentrant {
// ..
// Update era impact.
_setEraImpact();
}
function _setEraImpact() private {
uint256 nextEraToSet = lastSettledEra + 1;
if (nextEraToSet < regeneratorRules.poolCurrentEra()) {
EraImpact storage eraImpact = impactPerEra[nextEraToSet];
inspectionsTreesImpact += eraImpact.trees;
inspectionsBiodiversityImpact += eraImpact.biodiversity;
realizedInspectionsCount += eraImpact.realizedInspections;
totalImpactRegenerators += regeneratorRules.newCertificationRegenerators(nextEraToSet);
// Update the lastSetlledEra to the era just settled.
lastSettledEra = nextEraToSet;
}
}To avoid variables conflict, the logic to calculate the impact regenerators, users that are on the certification process was changed and now a mapping registers how many new impact regenerators started at each era.
/// @notice The number of regenerators that have started the certification process on each era,
/// and have reached the minimum of one inspection.
mapping(uint256 => uint256) public newCertificationRegenerators;
function decrementInspections(address addr) external
// ..
if (totalInspections == 1) {
uint256 era = poolCurrentEra();
if (newCertificationRegenerators[era] > 0) {
newCertificationRegenerators[era]--;
}
impactRegenerators[addr] = false;
}
// ..
}
function _incrementInspections(address addr) private returns (uint256) {
// ..
if (!impactRegenerators[addr]) {
impactRegenerators[addr] = true;
uint256 era = poolCurrentEra();
newCertificationRegenerators[era]++;
}
// ..
}| File | Fix commit | Type | Status |
RenegeratorRules.sol | PR #659 | High | Mitigated |
When adding a regenerator the addRegeneratior function validates the provided coordinates via the _validateCoordinates function presented below:
function _validateCoordinates(Coordinates[] calldata _coords) private pure {
// Loop through each coordinate
for (uint256 i = 0; i < _coords.length; i++) {
// --- 1. Check for Duplicates ---
// Compare the current coordinate with all subsequent coordinates
for (uint256 j = i + 1; j < _coords.length; j++) {
// We use keccak256 to compare the structs efficiently
require(
keccak256(abi.encode(_coords[i])) != keccak256(abi.encode(_coords[j])), //@audit can represent same area ie 13.23 is the same as 13.230000.
"Duplicate coordinates are not allowed"
);
}
// ....
// ....
}
}In validating the coordinates structs the function does keccak256(abi.encode(_coords[i])) != keccak256(abi.encode(_coords[j])) operation. Now given this is a keccak256 hash operation then any variability in the coordinates data will not match and the check will pass. For example a latitude of 13.2 will produce a different hash from 13.2000 and in coordinates the value 13.2000 is basically the same area but with a higher precision.
Recommendation
Consider improving validation of provided coordinates. For example the difference between coordinates 12.3456 and 12.3457 is only 11 meters and this might represent the same regeneration area.
Resolution
Mitigated at PR #659. The problem about on-chain coordinates storage is that it is technically complicated to compare these points with a solidity function to, for example, block duplicate registration or polygon crossings areas. We could develop a way to check duplicate coordinates points, but if the user walks 1m on any direction than it is already a different point that could be stored instead. So the system needs to rely on the community to perform the cleaning actions to check the area. The challenge for a governance system is that it requires user interaction and participation. Hunters will need to review every single registration and voters will need to proactively vote for maintenance of the system.
The question about this function problem, is a mathematical problem that allows 13.2 and 13.2000 to be registered as if it was different points. The function was updated to solve this problem and block now the regenerator to add the same coordinates at registration, solving the mathematical problem. The issue abount the duplicated coordinates or crossing area polygons still exists and perhaps is unsolvable in solidity. We could have developed a way to store all coordinate points and then check at registration. It would be necessary 2 fors going from 3-10 coordinates to make this check, adding a good amount of gas to the function. Since even with this check it would still need the validation process, we decided to keep the function cleaner without the seenPoints logic. Here is the new validateCoordinates function:
function _validateCoordinates(Coordinates[] calldata _coords) private pure {
uint256 len = _coords.length;
int256 precision = 10 ** 6;
for (uint256 i = 0; i < len; i++) {
// --- 1. Convert and Validate Range ---
int256 lat_i = _stringCoordToInt(_coords[i].latitude);
int256 lon_i = _stringCoordToInt(_coords[i].longitude);
require(lat_i >= -90 * precision && lat_i <= 90 * precision, "Invalid latitude");
require(lon_i >= -180 * precision && lon_i <= 180 * precision, "Invalid longitude");
// --- 2. Check for Duplicates ---
for (uint256 j = i + 1; j < len; j++) {
int256 lat_j = _stringCoordToInt(_coords[j].latitude);
int256 lon_j = _stringCoordToInt(_coords[j].longitude);
require(lat_i != lat_j || lon_i != lon_j, "Duplicate coordinates are not allowed");
}
}
}| File | Fix commit | Type | Status |
ActivistRules.solDeveloperRoles.solContributorRules.solRegeneratorRules.solResearcherRules.sol | PR #658 | Medium | Fixed |
The system defines different user types: activist, researcher, developer, and contributor. Each of which requires an invitation to join the community. Only users without the denied status are permitted to send invitations.
However, after an invitation is sent and before the invitee completes their registration, the inviter may subsequently be blocked from performing any actions, including sending new invitations. Despite this, there is no verification at the time of registration to ensure that the inviter remains eligible.
This allows invitees to successfully register using invitations from inviters who have since been blocked, undermining the restriction mechanism and enabling indirect circumvention of access controls. This issue impacts all the rule contracts implemented for the mentioned user type above.
Recommendation
Validate the inviter’s status at the time of invitee registration to ensure that invitations from blocked users cannot be used. The addUser function in the CommunityRules contract is invoked by all user registration flows. For better modularity and to reduce the risk of errors, the necessary validation should be implemented directly within this function.
function addUser(address addr, CommunityTypes.UserType userType) external mustBeAllowedCaller {
require(addr != address(0), "User address cannot be zero");
require(users[addr] == CommunityTypes.UserType.UNDEFINED, "User already exists");
require(userType != CommunityTypes.UserType.UNDEFINED, "Invalid user type");
require(_registrationProportionalityAllowed(userType), "Proportionality invalid");
require(_invitedTypeOnRegister(addr, userType), "Invalid invitation");
// @audit insert the checking if the inviter is not denied
users[addr] = userType;
usersCount++;
userTypesCount[userType]++;
userTypesTotalCount[userType]++;
emit UserRegistered(addr, userType);
}Resolution
A require check was added to the addUser function at CommunityRules.sol to block registration of invited wallets by a denied inviter. Now, if a inviter gets denied, all invitations previously made from that user are not valid anymore.
To increase security, besides the isDenied check, another require was added at addUser function to block registration from inviters that have exceeded the max inviter penalties. It is the same issue, just a different penalty that should also block invitation. Fixed at PR #658
function addUser(address addr, CommunityTypes.UserType userType) external mustBeAllowedCaller {
// ..
require(!isDenied(invitations[addr].inviter), "Inviter denied"); // Inviter cannot be denied
require(inviterPenalties[invitations[addr].inviter] < MAX_INVITER_PENALTIES, "Inviter with too many penalties"); // Inviter cannot exceed penalties
// ..
}| File | Fix commit | Type | Status |
CommunityRules.sol | PR #657 | Medium | Fixed |
Currently, users can file a report alleging malicious behave on the protocol by other users. These denunciations are stored on the reported user’s profile and are visible to everyone, effectively marking the profile as “dirty” when misbehavior is recorded.
The addDelation function can be called by any user, except for supporters. As shown, each denunciation is stored in the delations mapping. Currently, there is no function available to update or change the status of a denunciation; once created, it is automatically assumed to be valid.
function addDelation(address addr, string memory title, string memory testimony) external {
require(
bytes(title).length <= MAX_TITLE_LENGTH && bytes(testimony).length <= MAX_TESTIMONY_LENGTH,
"Max characters reached"
);
require(hasWaitedRequiredTime(msg.sender), "Wait delay blocks");
require(users[msg.sender] != CommunityTypes.UserType.UNDEFINED, "Caller must be registered");
require(users[msg.sender] != CommunityTypes.UserType.SUPPORTER, "Not allowed to supporters");
require(users[addr] != CommunityTypes.UserType.UNDEFINED, "User must be registered");
require(addr != address(0), "Cannot delate zero address");
require(addr != msg.sender, "Self-denunciation not allowed");
lastDelationBlock[msg.sender] = block.number;
delationsCount++;
uint64 newDelationId = delationsCount;
CommunityTypes.Delation memory newDelation = CommunityTypes.Delation(
newDelationId,
msg.sender,
addr,
title,
testimony,
block.number
);
delations[addr].push(newDelation);
delationsById[newDelationId] = newDelation;
emit DelationAdded(msg.sender, addr, newDelationId);
}However, this mechanism introduces a critical risk: a malicious actor could deliberately create false denunciation to harm another user’s reputation. Over time, as fake reports accumulate alongside legitimate ones, the overall value and credibility of this feature may diminish.
Recommendation
Implement a validation layer for denunciations. One possible approach is a voting mechanism, where other users (or a designated group) can validate or invalidate each denunciation. This would reduce the risk of abuse, increase the accuracy of reputation tracking, and help maintain trust in the system over time.
Resolution
The problem was significantly reduced after the implementation of a check to only allow one delation per user. A user can still add a fake delation, riskying his own credibility, but only one. More than one delation per wallet per user is not allowed anymore.
The second mitigation action of this issue is a voting mechanism were users can vote true or false for a delation. With this logic, users can add a social proof and vote to unmask fake delations or support true delations. This way, even a fake delation with the intention to harm a users reputation can be largely voted as false, not compromising the user reputation.
With this change, the delation data structure was updated to split functions between mappings and leave the work to the front-end. One to store the ids and another with de relationship between delation id and its data. Fixed at PR #657
New mappings structure:
/// @dev Index of delation IDs for each reported user.
mapping(address => uint64[]) private _delationIdsForUser;
/// @dev mapping: delationId => voterAddress => hasVoted (bool)
mapping(uint64 => mapping(address => bool)) private _hasVotedOnDelation;New voteOnDelation function:
/**
* @notice Allows users to vote (thumbs up/down) on an existing delation.
* @dev This creates a social validation layer. Voters cannot be the informer or the reported user.
* @param _delationId The ID of the delation to vote on.
* @param _supportsDelation True for a "thumbs up" (agrees), false for "thumbs down" (disagrees).
*/
function voteOnDelation(uint64 _delationId, bool _supportsDelation) external {
// 1. Check if the delation exists by accessing it. It will revert if the ID is invalid.
CommunityTypes.Delation storage delation = delationsById[_delationId];
require(delation.id != 0, "Delation does not exist");
// 2. Check if the voter is eligible.
require(users[msg.sender] != CommunityTypes.UserType.UNDEFINED, "Caller must be registered");
require(users[msg.sender] != CommunityTypes.UserType.SUPPORTER, "Not allowed to supporters");
require(!isDenied(msg.sender), "User denied");
// 3. The informer and the reported user cannot vote on their own delation.
require(msg.sender != delation.informer, "Informer cannot vote");
require(msg.sender != delation.reported, "Reported user cannot vote");
// 4. Check to prevent double voting.
require(!_hasVotedOnDelation[_delationId][msg.sender], "Already voted");
// --- State Changes ---
// Mark that this user has now voted.
_hasVotedOnDelation[_delationId][msg.sender] = true;
// Increment the appropriate counter.
if (_supportsDelation) {
delation.thumbsUp++;
} else {
delation.thumbsDown++;
}
emit DelationVoted(_delationId, msg.sender, _supportsDelation, delation.thumbsUp, delation.thumbsDown);
}| File | Fix commit | Type | Status |
InspectionRules.sol | PR #660 | Medium | Fixed |
Inspectors accept inspection requests through the acceptInspection function, which updates the inspection status from InspectionStatus.OPEN to InspectionStatus.ACCEPTED and records the block number at which it was accepted:
function acceptInspection(uint64 inspectionId) external nonReentrant {//ok
// ...
require(inspection.status == InspectionStatus.OPEN, "Inspection must be OPEN");
// ...
inspection.status = InspectionStatus.ACCEPTED;
inspection.acceptedAt = block.number;
inspection.inspector = msg.sender;
// ...
}When the assigned inspector later calls realizeInspection to submit results, the contract checks that the inspection has not expired:
function realizeInspection(
uint64 inspectionId,
string memory proofPhotos,
string memory justificationReport,
uint32 treesResult,
uint32 biodiversityResult
) external nonReentrant {
// ...
require(inspection.status == InspectionStatus.ACCEPTED, "Accept before");
require(inspection.inspector == msg.sender, "Not your inspection");
require(!(block.number > inspection.acceptedAt + blocksToExpireAcceptedInspection), "Inspection Expired");
// ...
}As presented in the code above, once the block.number exceeds acceptedAt + blocksToExpireAcceptedInspection, the inspector can no longer submit results.
While the protocol penalizes inspectors for failing to deliver, it also indirectly penalizes regenerators. If an inspection is created near the end of an era and the assigned inspector does not complete it in time, the regenerator may be unable to create a new inspection and have it realized before the era ends. As a result, the regenerator’s rewards are delayed until the next era (around six months). In addition, each regenerator can request up to 11 inspections and earn rewards, and even those not carried out would still be counted within this limit.
Recommendation
This problem can be mitigated by allowing inspections with status ACCEPTED but already expired to be reassigned to another inspector. This can be achieved by adapting the acceptInspection function to include an additional condition that permits reassignment when an inspection has expired:
function acceptInspection(uint64 inspectionId) external nonReentrant {//ok
require(communityRules.userTypeIs(CommunityTypes.UserType.INSPECTOR, msg.sender), "Only inspectors");
require(inspectorRules.isInspectorValid(msg.sender), "Only 3 giveUps allowed");
Inspection storage inspection = inspections[inspectionId];
require(inspection.id >= 1, "Inspection do not exist");
require(alreadyHaveInspectionAccepted(), "Already accepted");
require(!inspectorInspected[msg.sender][inspection.regenerator], "Already inspected");
// @audit Adapt the restriction to also allow inspections with status ACCEPTED that have already expired.
require(inspection.status == InspectionStatus.OPEN, "Inspection must be OPEN");
require(acceptInspectionDelayBlocksPassed(inspection), "Wait delay blocks");
require(beforeAcceptHaveSecurityBlocksToVote(), "Wait until next era");
// ...
}Resolution
A new function isInspectionExpired() was introduced to check if an existing inspection was expired. Then, the require inspection.STATUS == OPEN was incremented with the logic to check and allow other inspectors to accept the same inspection again, solving the reported issue.
The problem that inspections not carried out were still counting to the totalInspections was fixed at issue number #2. Fixed at PR #660
/**
* @dev Checks if a previously accepted inspection has expired.
* @param inspection The inspection to check.
* @return bool True if the inspection is expired, false otherwise.
*/
function isInspectionExpired(Inspection memory inspection) public view returns (bool) {
return inspection.acceptedAt > 0 && (block.number > inspection.acceptedAt + blocksToExpireAcceptedInspection);
}
function acceptInspection(uint64 inspectionId) external nonReentrant {
// ..
require(
inspection.status == InspectionStatus.OPEN ||
(inspection.status == InspectionStatus.ACCEPTED && isInspectionExpired(inspection)),
"Inspection must be OPEN or EXPIRED"
);
// ..
}| File | Fix commit | Type | Status |
VoteRules.solValidationRules.sol | PR #653 | Low | Fixed |
The canVote function determines whether a user is eligible to vote based on a global user counter. However, the value in userTypesTotalCount includes both active and denied users. As a result, totalUsers is equal to userTypesTotalCount.
function canVote(address addr) public view returns (bool) {
require(communityRules.isVoter(addr), "Not a voter user");
CommunityTypes.UserType userType = communityRules.getUser(addr);
uint256 totalUsers = communityRules.userTypesTotalCount(userType);
// @audit The function should use only the number of active users to determine voting eligibility
return _canVoteRules(_totalLevels(userType), totalUsers, _totalUserLevels(addr, userType));
}Because the average level is calculated using this global counter, it becomes lower than intended, allowing users with fewer levels to pass the voting threshold.
function _canVoteRules(uint256 totalTypeLevels, uint256 totalUsers, uint256 userLevels) private pure returns (bool) {
// @audit The average is skewed when the global counter includes denied users
// Rule 2: Check if the user's level is strictly greater than the average
return userLevels * totalUsers > totalTypeLevels;
}In addition, there is an inconsistency with how voting power is computed. The votesToInvalidate function, which defines the threshold for invalidating an inspection, relies only on the count of active users:
function votesToInvalidate() public view returns (uint256) {
uint256 voters = communityRules.votersCount();
if (voters < VOTERS_THRESHOLD_LEVEL_1) {
return VOTES_TO_INVALIDATE_LEVEL_1;
}
if (voters < VOTERS_THRESHOLD_LEVEL_2) {
return VOTES_TO_INVALIDATE_LEVEL_2;
}
uint256 invalidationVotes = (voters * DYNAMIC_INVALIDATION_PERCENTAGE) / 100;
return invalidationVotes + 1;
}This discrepancy leads to inconsistent logic between eligibility to vote and the number of votes required to invalidate, although the practical severity of the issue is low since votesToInvalidate uses only active voters.
Recommendation
Update the canVote logic to rely exclusively on active users when computing voting power. This ensures consistency with the votesToInvalidate function and prevents unintended lowering of the voting threshold.
Resolution
A variable named 'activeLevelsCount' was added to each of the voters contract, adding a level when a resource is created and removing a level when the resource is invalidated. When the user is denied, all levels from that user is removed from the 'activeLevelsCount'. To solve this issue, it was necessary this implementation because if the function was simply updated to userTypesCount instead of userTypesTotalCount and resourcesCount instead of resourcesTotalCount it would lead to data incosistency because a user can be invalidated with past eras valid resources. For example a researcher that was denied at era 2 with valid researches at era 1. Now, the average to calculate if the user canVote, and the same was applied also to the canInvite function, is processed counting only valid users and their resources. A 'clean' average was implemented instead of the old 'dirty' average. Fixed at PR #653
New variable implementation:
/// @notice The sum of all active levels from valid reports by non-denied developers.
uint256 public totalActiveLevels;The totalActiveLevel is being incremented at the functions that add level to the user. For example, the addContribution function for contributors:
function addContribution(string memory description, string memory report) external nonReentrant {
// ..
contributionsCount++;
contributionsTotalCount++;
totalActiveLevels++;
// ..
}And the same variable is being decremented by one level when a resource is invalidated or is removing all levels when the user is denied:
function _invalidateContribution(Contribution memory contribution) private {
contributionsCount--;
contribution.valid = false;
contribution.invalidatedAt = block.number;
contributions[contribution.id] = contribution;
contributors[contribution.user].pool.level -= RESOURCE_LEVEL;
totalActiveLevels--;
contributorPool.removePoolLevels(contribution.user, false);
}function _denyContributor(address userAddress) private {
if (communityRules.isDenied(userAddress)) return; // Already denied, nothing to do
totalActiveLevels -= contributors[userAddress].pool.level;
communityRules.setToDenied(userAddress);
// ..
}And now the logic of the canVote and also canInvite are using this variable instead of the old dirty total count:
function canSendInvite(address addr) public view returns (bool) {
Developer memory developer = developers[addr];
// Return false if the address is not a registered developer (id is 0).
if (developer.id <= 0) return false;
// Calls the inherited `canInvite` function from `Invitable` to calculate eligibility.
// This depends on total reports count, total developer count, and the developer's pool level.
return canInvite(totalActiveLevels, communityRules.userTypesCount(USER_TYPE), developer.pool.level);
}| File | Fix commit | Type | Status |
RegeneratorPool.solActivistPool.solResearcherPool.solInspectorPool.sol | — | Info | Acknowledged |
After users withdraw from pools for a given era, small amounts of tokens often remain stuck. Over time, these residual tokens accumulate across multiple eras and epochs. In the current protocol design, any leftover tokens from past eras are effectively treated as lost and remain unusable.
Recommendation
Consider carrying over residual tokens to subsequent eras.
Resolution
The design is to keep the leftover as lost and remain unusable.
| File | Fix commit | Type | Status |
RegeneratorPool.solInspectorPool.sol | PR #656 | Info | Mitigated |
According to the white paper and the code, fixed amounts of tokens are allocated to each user type’s pool. While Researchers, Developers, Contributors, and Activists each receive 3.20% of the total supply, Regenerators and Inspectors receive significantly higher allocations of 60% and 14.40% respectively, as they are considered the core roles of the protocol.
However, this distribution introduces a structural imbalance. The number of Regenerators is expected to be substantially smaller than the number of Inspectors. Each Regenerator can request 11 inspections and one Inspector can only audit each Regenerator once. As a result:
Without multiple Inspectors available per Regenerator, the protocol risks failing to operate as intended.
Recommendation
Reevaluate the token distribution, particularly in the Inspector and Regenerator pools, to ensure adequate incentives that support sustainable participation and maintain protocol integrity.
Resolution
The reason the regenerator pool is much bigger is that they are the ecological foundation of the protocol. The onchain service of the inspector is much harder and costly then the simple requestInspection call for the regenerator. But the real service of the regenerators is on the ground, planting and managing the threes and the ecosystem of the registered area.
The act of realizing inspections is the foundation of the system, it will set the ecological base and growth speed. In a scenario with more inspections then inspectors interested on realizing then, the system growth will be limited by this operation. And at the scenario where more inspectors want participate, this will drive competition for accepting the inspections. The natural process is that overtime this balance will change. More inspectors, means more competition, what will reduce the token reward per inspector, what can discourage inspectors. When it happens, less active inspectors will participate so more inspections will be avaiable, what will decrease competition and increase token reward per inspector, what can bring back the interest of old or new inspectors, what can increase competition again and the cycle continues.
Even if it was a free or voluntary service, without the token earnings, there would probably have some people that would do it for free. They could do it by helping the system purpose/mission or to learn. Every test inspection I did over the last years testing the system I learned something new about agroforestry systems. I good path I see for the inspections is to attract people that want to learn about syntropic/agroforestry systems, people that want to become a farmer or students that can use this process to learn about ecology and reforestation. And earn some tokens in exchange for the service.
The proportionality was changed from 1:5 regenerator:inspector to 1:20. So now more inspectors vacancies are allowed.
To improve the pool balance, it was added 50 million tokens to the inspector pool. Also, the maximum amount of allowed inspections was reduced from 12 to 6. It is a trade between methodology precision to an easier operation. So the relation token/inspection will increase and inspectors will need to realize less of the work to get the same reward. PR #656
| File | Fix commit | Type | Status |
InspectorRules.sol | PR #655 | Info | Fixed |
The isInspectorValid function checks whether a given inspector address is still valid. It does this by comparing the number of giveUps associated with the inspector against the MAX_GIVEUPS. The function is stricter than intended, while the documentation states that an inspector can have three giveUps, the code allows only two before deactivation.
function isInspectorValid(address addr) public view returns (bool) {
return inspectors[addr].giveUps < MAX_GIVEUPS;
}Recommendation
Consider allowing inspectors keep active with up to three giveUps.
Resolution
The function isInspectorValid was updated to <= instead of only < to maintain the semantics of the text and allow 3 giveUps. Fixed at PR #655
function isInspectorValid(address addr) public view returns (bool) {
return inspectors[addr].giveUps <= MAX_GIVEUPS;
}| File | Fix commit | Type | Status |
InspectionRules.sol | PR #661 | Info | Fixed |
Inspectors submit their inspections by calling the realizeInspection function.
function realizeInspection(
uint64 inspectionId,
string memory proofPhotos,
string memory justificationReport,
uint32 treesResult,
uint32 biodiversityResult
) external nonReentrant {
// ...
uint256 maxTreesForThisArea = uint256(regenerator.totalArea) * MAX_TREES_PER_SQM;
require(treesResult <= maxTreesForThisArea, "Tree count exceeds density limit for this area");
require(biodiversityResult <= MAX_BIODIVERSITY_RESULT, "Max result limit");
// ...
// @audit treesResult and biodiversityResult contributes to compute the ecological token value
inspectionsTreesImpact += treesResult;
inspectionsBiodiversityImpact += biodiversityResult;
inspectorInspected[msg.sender][inspection.regenerator] = true;
realizedInspectionsCount++;
// ...
}Users with voting rights can then call the function addInspectionValidation to invalidate a specific inspection. Once the required number of votes is reached, the inspection is invalidated. This mechanism is essential for preserving the integrity of the protocol, since inspection results directly impact the ecological value of the token.
function addInspectionValidation(uint64 id, string memory justification) external nonReentrant {
require(!communityRules.isDenied(msg.sender), "User denied");
require(bytes(justification).length <= MAX_TEXT_LENGTH, "Max characters reached");
require(voteRules.canVote(msg.sender), "Not a voter");
// ...
inspection.validationsCount += 1;
uint256 _votesToInvalidate = validationRules.votesToInvalidate();
require(_votesToInvalidate >= 2, "Validation threshold cannot be less than 2");
if (inspection.validationsCount >= _votesToInvalidate) {
inspectionPenalized[id] = true;
_invalidateInspection(inspection);
//...
// ...
}However, as the community grows, both the number of votes required to invalidate an inspection and the overall number of inspections will increase. Currently, the protocol does not provide any incentives for users to participate in this invalidation process.
As a result, fraudulent inspections may remain as valid and continue to be used in the computation of the number of trees and biodiversity per token.
Recommendation
Introduce incentive mechanisms for users who vote to invalidate fraudulent inspections.
Resolution
We solved the incentive problem at issue #1 with the creation of the ValidationPool. At this issue, we will focus on the solution for the problem described of the community unlimited growth and validation difficulty.
The system relies on the invalidation mechanism to clean wrong/fake inspections. The problem was that the validation mechanism, as pointed here, would keep increasing its difficulty as community grows with more resources/users and more votes, potentially leading to break the validation system. So we need to set a limit to solve it.
To mitigate the problem, we set a cap with a maximum number of active regenerators, to intentionally put a break on the system growth to solve this unlimited difficulty problem. So when the system reaches this maximum point, new regenerators registration will only be allowed when the old ones conclude the inspections lifecycle.
To make the calculations, a new mapping and variable was added. Now the system is registering how many regenerators have conclude the certification process.
/// @notice The maximum number of active 'Regenerator' type users permitted in the system.
uint256 public constant MAX_ACTIVE_REGENERATORS = 500000;
/// @notice The total count of regenerators who have completed the certification process.
uint256 public totalCertifiedRegenerators;
/// @notice A mapping to track if a regenerator is a "certified regenerator", a user that has successfully
/// completed the maximum inspections number, concluding system participation.
mapping(address => bool) public certifiedRegenerators;function addRegenerator(
uint32 totalArea,
string memory name,
string memory proofPhoto,
string memory projectDescription,
Coordinates[] calldata _coordinates
) external {
// ..
require(isRegistrationAllowed(), "Wait for vacancy: Max regenerators limit");
// ..
} /**
* @notice Checks if new Regenerator registrations are allowed based on the dynamic count of active users.
* @dev The number of active users is calculated as the total number of created Regenerators
* minus the number of those who have completed their lifecycle.
* @return bool True if registration is allowed, false otherwise.
*/
function isRegistrationAllowed() public view returns (bool) {
return communityRules.userTypesCount(USER_TYPE) - totalCertifiedRegenerators < MAX_ACTIVE_REGENERATORS;
}
}The certification variable is changed at the incrementInspection and decrementInspection functions. It is interesting to have this on the system, now we have a *bool* to show at the regenerators profile if they have completed or not.
function decrementInspections(address addr) external mustBeAllowedCaller mustBeContractCall(inspectionRulesAddress) {
// ..
if (totalInspections == MAXIMUM_INSPECTIONS) {
totalCertifiedRegenerators--;
certifiedRegenerators[addr] = false;
}
regenerators[addr].totalInspections--;
}function _incrementInspections(address addr) private returns (uint256) {
// ..
if (regenerator.totalInspections == MAXIMUM_INSPECTIONS) {
certifiedRegenerators[addr] = true;
totalCertifiedRegenerators++;
emit RegeneratorCertified(addr);
}
return regenerator.totalInspections;
}A maximum number of required votes was also introduced to limit the percentage growth and create a mechanism that will drop the invalidation difficulty after reaching the point with 12k voters. Also, the timeBetweenVotes was reduced to approximately 2 hours. These changes were necessary to increase the number of users/resources the system will be able to process. Of course the mechanism will depend on the users participation on the governance mechanism to invalidate the inspections, at the end they need to call the functions. But now, with this cap mechanism and vote limit, it is mathematically possible for the validators to keep the integrity of the protocol.
uint32 private constant VOTES_TO_INVALIDATE_LEVEL_3 = 360;
/**
* @notice Get how many validations is necessary to invalidate a user or resource.
* @dev Calculates the required number of votes for invalidation based on the total number of registered voters in the system.
* Calculation is based on the `votersCount` which includes researchers, developers, and contributors.
* @return count Number of votes required for invalidation.
*/
function votesToInvalidate() public view returns (uint256) {
uint256 voters = communityRules.votersCount();
// Threshold 1: Very early stage, requires a fixed number of 2 votes.
if (voters < VOTERS_THRESHOLD_LEVEL_1) {
return VOTES_TO_INVALIDATE_LEVEL_1;
}
// Threshold 2: Early stage, requires a fixed number of 5 votes.
if (voters < VOTERS_THRESHOLD_LEVEL_2) {
return VOTES_TO_INVALIDATE_LEVEL_2;
}
// Threshold 3: Mature stage, calculates votes based on a percentage of active voters.
uint256 invalidationVotes = (voters * DYNAMIC_INVALIDATION_PERCENTAGE) / 100;
uint256 requiredVotes = invalidationVotes + 1;
// Threshold 4: Capped stage, returns a maximum votes level.
if (requiredVotes > VOTES_TO_INVALIDATE_LEVEL_3) {
return VOTES_TO_INVALIDATE_LEVEL_3;
}
return requiredVotes;
}| File | Fix commit | Type | Status |
RegenerationCredit.sol | PR #654 | Info | Mitigated |
Each pool is expected to be initialized with a fixed amount defined in TOTAL_POOL_TOKENS. The addContractPool function currently allows the owner to deposit any arbitrary _numTokens value. The protocol is designed in the white paper that the owner will renounce ownership once it is deployed and initializations are applied. There is no way to correct the pool balances. If a pool is initialized with an incorrect deposit amount, it will not function properly, and the error becomes permanent.
function addContractPool(address _fundAddress, uint256 _numTokens) external onlyOwner returns (bool) {
contractsPools[_fundAddress] = true;
// @audit: Missing validation for _numTokens
_transfer(msg.sender, _fundAddress, _numTokens);
totalLocked_ += _numTokens;
return true;
}Recommendation
Ensure that _numTokens matches the required TOTAL_POOL_TOKENS for the specific pool when creating a new pool.
Resolution
This problem will be mitigated with a manual review of the contracts settings and balance after mainnet deploy. The same applies to other configurations such as allowedCallers and calls interfaces. For the review, it was missing to set the interfaces public so it can be easily checked. All interfaces were set to public to help.
/// @notice The interface of the `CommunityRules` contract, used to interact with
/// community-wide rules, user types, and invitation data.
ICommunityRules public communityRules;
/// @notice The interface of the `ContributorPool` contract, responsible for managing
/// and distributing token rewards to contributors.
IContributorPool public contributorPool;
/// @notice The interface of the `ValidationRules` contract, which defines the rules
/// and processes for validating or invalidating contributions.
IValidationRules public validationRules;Eras and Epochs will be completely locked in the contract| File | Fix commit | Type | Status |
shared/Poolable.sol | — | Info | Acknowledged |
Tokens in a given pool for a given Epoch are calculated as :
function tokensPerEpoch(uint256 currentEpoch) public view returns (uint256) { //@audit there could be dust totalTokens remaining in the contract after certain epochs, rounding down.
return totalTokens / (2 ** currentEpoch);Subsequently, tokens per Era are calculated as :
function tokensPerEra(uint256 currentEpoch, uint256 halvingFactor) public view returns (uint256) {
return tokensPerEpoch(currentEpoch) / halvingFactor; //@audit-note after every epoch per era tokens halves.
}Therefore, each epoch and era have a predetermined amount of tokens based on the total tokens allocated to a given pool. Per era and per epoch tokens are not carried forward. As a result, in the event there was no token allocation in a given era or epoch then those tokens will be stuck in the contract forever given there is no mechanism to withdraw them.
Recommendation
Consider implementing a logic to remove unallocated tokens from the pool contracts.
Resolution
This is the design choice and if tokens are unallocated they should remain locked in the contract.
| File | Fix commit | Type | Status |
contracts/*Pool.sol | — | Info | Acknowledged |
After each era ends, users can withdraw their rewards at any time. The system assumes that users will withdraw rewards from the previous era once a new era begins. However, the current design allows users to accumulate rewards across multiple eras and withdraw them later.
_Missed Withdrawals Across Eras:_ A user may unintentionally skip withdrawals for some eras. If the user is later denied (e.g., an activist being penalized due to invitee misbehavior), they permanently lose access to all unclaimed rewards from previous eras.
On the other hand, if a user withdraws rewards after each era ends and is denied, they lose access only to the rewards from the current era.
Recommendation
Encourage users to withdraw rewards after each era ends to prevent future loss of their rewards.
Resolution
Design choice. Users wil be encouraged to withdraw the rewards after each era. We could have developed a mechanism to set a limit amount of blocks to impose the need for withdraw in, or a way to send these tokens to the validationPool but for simplicity the design was maintained.
| File | Fix commit | Type | Status |
VoteRules.sol | PR #650 | Info | Fixed |
The _canVoteRules function serves to check a user’s eligibility to vote. However, in the specific case where totalUsers==0, it always returns true. As a result, the intended edge case that should prevent voting is never triggered.
function _canVoteRules(uint256 totalTypeLevels, uint256 totalUsers, uint256 userLevels) private pure returns (bool) {
// @audit When totalUsers == 0, the function returns true
// Rule 1: Allow anyone to vote if the user type has few members.
if (totalUsers <= INITIAL_USER_COUNT_THRESHOLD) return true;
// @audit this edge case is never true
// Edge case: If there are no users, no one can vote.
if (totalUsers == 0) {
return false;
}
// Rule 2: Check if the user's level is strictly greater than the average
return userLevels * totalUsers > totalTypeLevels;
}However, _canVoteRules is only invoked by the canVote function. As shown in the code, it is impossible for totalUsers == 0, since verifying that addr is a voter and retrieving its userType ensures at least one user. The caller’s own addr contributes to the count. Therefore, the logic mistake does not add risks for the current implementation.
function canVote(address addr) public view returns (bool) {
require(communityRules.isVoter(addr), "Not a voter user");
CommunityTypes.UserType userType = communityRules.getUser(addr);
uint256 totalUsers = communityRules.userTypesTotalCount(userType);
return _canVoteRules(_totalLevels(userType), totalUsers, _totalUserLevels(addr, userType));
}Recommendation
For reuse of the private function in future improvements of the contract, consider only moving the edge case before the Rule 1 condition.
Resolution
The _canVoteRules function was reorganized to check the edge case before rule 1.
function _canVoteRules(uint256 totalTypeLevels, uint256 totalUsers, uint256 userLevels) private pure returns (bool) {
// Edge case: If there are no users, no one can vote.
if (totalUsers == 0) {
return false;
}
// Rule 1: Allow anyone to vote if the user type has few members.
if (totalUsers <= INITIAL_USER_COUNT_THRESHOLD) return true;
// Rule 2: Check if the user's level is strictly greater than the average
return userLevels * totalUsers > totalTypeLevels;
}canInvite function returns true when totalUsers is zero| File | Fix commit | Type | Status |
Invitable.sol | PR #651 | Info | Fixed |
The canInvite function serves to check a user’s eligibility to invite others. However, in the specific case where totalUsers == 0, it always returns true. As a result, the intended edge case that should prevent invites is never triggered.
function canInvite(uint256 totalLevels, uint256 totalUsers, uint256 userLevels) public pure returns (bool) {
// Rule 1: Allow anyone to invite if the system has few users.
if (totalUsers <= INITIAL_USER_COUNT_THRESHOLD) {
return true;
}
@audit this condition will never be satisfied
// If there are no users, no one can invite. This also prevents totalUsers from being zero in the calculation.
if (totalUsers == 0) {
return false;
}
// Rule 2: Check if the user's level is strictly greater than the average.
return userLevels * totalUsers > totalLevels;// @audit review it again
}As shown in the code, it is not possible for totalUsers == 0, since verifying that addr is an inviter and retrieving its userType ensures at least one user. The caller’s own addr contributes to the count. Therefore, the logic mistake does not add risks for the current implementation. The canSendInvite function is implemented in all roles contracts where users can send invites.
function canSendInvite(address addr) public view returns (bool) {
Activist memory activist = activists[addr];
// Return false if it is not an activist
if (activist.id <= 0) return false;// @audit it is impossible negative
// Calls the inherited `canInvite` function from `Invitable` to calculate eligibility.
// This depends on total approved invites, total activist count, and the activist's pool level.
return canInvite(approvedInvites, communityRules.userTypesTotalCount(USER_TYPE), activist.pool.level);
}Recommendation
For reuse of the private function in future improvements of the contract, consider only moving the edge case before the Rule 1 condition.
Resolution
The edge case was moved before the rule 1 at canInvite and canVote functions to check the zero value first.
function canInvite(uint256 totalLevels, uint256 totalUsers, uint256 userLevels) public pure returns (bool) {
// If there are no users, no one can invite. This also prevents totalUsers from being zero in the calculation.
if (totalUsers == 0) {
return false;
}
// Rule 1: Allow anyone to invite if the system has few users.
if (totalUsers <= INITIAL_USER_COUNT_THRESHOLD) {
return true;
}
// Rule 2: Check if the user's level is strictly greater than the average.
return userLevels * totalUsers > totalLevels;
}_denyUseris never emitted| File | Fix commit | Type | Status |
ValidationRules.sol | PR #652 | Best Practices | Fixed |
The _denyUser function sets a user to DENIED in CommunityRules and removes their levels from the respective pools. The function below shows that when the userType matches with a type, it returns empty and the remaining code of the function is not executed.
function _denyUser(address userAddress) private {
CommunityTypes.UserType userType = communityRules.getUser(userAddress);
// ..
communityRules.setToDenied(userAddress);
// ..
// Check for each user type and call their respective removePoolLevels function.
if (userType == CommunityTypes.UserType.REGENERATOR) return regeneratorRules.removePoolLevels(userAddress);
if (userType == CommunityTypes.UserType.INSPECTOR) return inspectorRules.removePoolLevels(userAddress, true);
if (userType == CommunityTypes.UserType.DEVELOPER) return developerRules.removePoolLevels(userAddress);
if (userType == CommunityTypes.UserType.RESEARCHER) return researcherRules.removePoolLevels(userAddress);
if (userType == CommunityTypes.UserType.CONTRIBUTOR) return contributorRules.removePoolLevels(userAddress);
if (userType == CommunityTypes.UserType.ACTIVIST) return activistRules.removePoolLevels(userAddress);
// @audit this event is never emitted
emit UserDenied(userAddress);
}However, this event is duplicated because in setToDenied function, an event is emitted with the denied user address.
Recommendation
Remove this event, the returns and replace the if for elseif.
Resolution
This event was removed since the communityRules.setToDenied() already emits a deny event. Fixed PR #652
Software documentation refers to the written or visual information that describes the functionality, architecture, design, and implementation of software. It provides a comprehensive overview of the software system and helps users, developers, and stakeholders understand how the software works, how to use it, and how to maintain it. Software documentation can take different forms, such as user manuals, system manuals, technical specifications, requirements documents, design documents, and code comments. Software documentation is critical in software development, enabling effective communication between developers, testers, users, and other stakeholders. It helps to ensure that everyone involved in the development process has a shared understanding of the software system and its functionality. Moreover, software documentation can improve software maintenance by providing a clear and complete understanding of the software system, making it easier for developers to maintain, modify, and update the software over time. Smart contracts can use various types of software documentation. Some of the most common types include:
These types of documentation are essential for smart contract development and maintenance. They help ensure that the contract is properly designed, implemented, and tested, and they provide a reference for developers who need to modify or maintain the contract in the future.
Sintrop provided well-structured and comprehensive documentation on the functionality of its smart contracts. In addition, the team shared further clarifications through our communication channels and made themselves available for sync calls. This greatly facilitated the review process and offered valuable insights that enhanced our technical understanding of the project.
| 1268Tests passed | 0Failed |
The complete test output is in the full report.
As auditors, we recommend that any changes or updates made to the audited codebase undergo a re-audit or security review to address potential vulnerabilities or risks introduced by the modifications. By conducting a re-audit or security review of the modified codebase, you can significantly enhance the overall security of your system and reduce the likelihood of exploitation. However, we do not possess the authority or right to impose obligations or restrictions on our clients regarding codebase updates, modifications, or subsequent audits. Accordingly, the decision to seek a re-audit or security review lies solely with you.
This report is based on the scope of materials and documentation provided by you to Nethermind in order that Nethermind could conduct the security review outlined in 1. Executive Summary and 2. Audited Files. The results set out in this report may not be complete nor inclusive of all vulnerabilities. Nethermind has provided the review and this report on an as-is, where-is, and as-available basis. You agree that your access and/or use, including but not limited to any associated services, products, protocols, platforms, content, and materials, will be at your sole risk. Blockchain technology remains under development and is subject to unknown risks and flaws. The review does not extend to the compiler layer, or any other areas beyond the programming language, or other programming aspects that could present security risks. This report does not indicate the endorsement of any particular project or team, nor guarantee its security. No third party should rely on this report in any way, including for the purpose of making any decisions to buy or sell a product, service or any other asset. To the fullest extent permitted by law, Nethermind disclaims any liability in connection with this report, its content, and any related services and products and your use thereof, including, without limitation, the implied warranties of merchantability, fitness for a particular purpose, and non-infringement. Nethermind does not warrant, endorse, guarantee, or assume responsibility for any product or service advertised or offered by a third party through the product, any open source or third-party software, code, libraries, materials, or information linked to, called by, referenced by or accessible through the report, its content, and the related services and products, any hyperlinked websites, any websites or mobile applications appearing on any advertising, and Nethermind will not be a party to or in any way be responsible for monitoring any transaction between you and any third-party providers of products or services. As with the purchase or use of a product or service through any medium or in any environment, you should use your best judgment and exercise caution where appropriate. FOR AVOIDANCE OF DOUBT, THE REPORT, ITS CONTENT, ACCESS, AND/OR USAGE THEREOF, INCLUDING ANY ASSOCIATED SERVICES OR MATERIALS, SHALL NOT BE CONSIDERED OR RELIED UPON AS ANY FORM OF FINANCIAL, INVESTMENT, TAX, LEGAL, REGULATORY, OR OTHER ADVICE.
The Leading Engineers of Blockchain Infrastructure