Regeneration Credit
Core Contracts

Examined the core contracts converting ecological impact into digital credits redeemable for tokens.
report id
0612
category
Token & RWA
language
Solidity
final report
October 4, 2025
lines of code
3286
Content

Download audit report

1

Executive Summary

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 TypeSecurity Review
Initial ReportSeptember 18, 2025
Final ReportOctober 04, 2025
Initial Commit05ce1f8e4b
Final Commit6111f9efe5
Documentation AssessmentHigh
Test Suite AssessmentHigh
Total findings
18
Fixed
12
Acknowledged
3
Mitigated
3
Unresolved
0

Findings by severity

  • Critical0
  • High4
  • Medium3
  • Low1
  • Informational9
  • Best Practices1

2

Audited Files

#ContractLoCCommentsRatioBlankTotal
1DeveloperPool.sol6471110%26161
2InspectorPool.sol6470109%25159
3RegeneratorRules.sol27426295%108638
4ActivistPool.sol6470109%26160
5InspectionRules.sol26022486%103587
6RegenerationCredit.sol56103183%33192
7DeveloperRules.sol183276150%99558
8ValidationRules.sol11911395%57289
9CommunityRules.sol19219099%71453
10RegenerationIndexRules.sol1134741%23183
11InspectorRules.sol148208140%75431
12ContributorPool.sol6470109%26160
13ActivistRules.sol122165135%64351
14InvitationRules.sol1069286%39237
15ResearcherRules.sol233239102%104576
16RegeneratorPool.sol6571109%26162
17ResearcherPool.sol6470109%25159
18RegenerationCreditImpact.sol5789156%27173
19SupporterRules.sol12111090%54285
20VoteRules.sol756384%25163
21ContributorRules.sol202275136%99576
22PoolTypes.sol67116%114
23SupporterTypes.sol1821116%241
24ContributorTypes.sol353188%571
25IndexTypes.sol141392%330
26ResearcherTypes.sol5662110%7125
27ActivistTypes.sol1315115%230
28ValidationTypes.sol11436%116
29DeveloperTypes.sol362672%668
30CommunityTypes.sol3460176%498
31InspectorTypes.sol2122104%346
32RegeneratorTypes.sol2730111%461
33InspectionTypes.sol332369%359
34Callable.sol2138181%1069
35Poolable.sol6997140%32198
36Invitable.sol1327207%444
37Blockable.sol4682178%24152
38IActivistPool.sol830375%543
39IInspectionRules.sol620333%329
40IContributorRules.sol1140363%859
41IInspectorRules.sol1561406%1288
42IRegenerationIndexRules.sol414350%119
43IValidationRules.sol1019190%433
44ICommunityRules.sol2079395%15114
45IDeveloperPool.sol935388%650
46IDeveloperRules.sol1140363%859
47IInspectorPool.sol830375%543
48IResearcherRules.sol1245375%966
49IRegeneratorPool.sol936400%651
50IVoteRules.sol411275%116
51IRegenerationCredit.sol1218150%939
52IResearcherPool.sol935388%650
53IContributorPool.sol935388%650
54IRegeneratorRules.sol1956294%1287
55IActivistRules.sol1143390%862
Total32864083124.3%13708733

4

System Overview

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:

  • Actor Rules Contract: users register, submit work (contributions, inspections, offsets), or withdraw rewards.
  • Rules Pool Contract: Rules contracts add/remove pool levels or trigger withdrawals on behalf of actors.
  • Rules CommunityRules: ensures proportionality, invitation validity, penalties, and role assignment system-wide.

4.1 Actors and Their Roles

  • Supporters: They buy RC tokens and burn them to generate Impact Certificates.
  • Regenerators: They are the primary value creators who register and regenerate land areas.
  • Inspectors: Validate regenerators’ projects with evidence (photos, counts, reports)
  • Activists: Drive protocol growth by inviting new Regenerators and Inspectors.
  • Researchers: Provide scientific methodologies and impact metrics
  • Contributors: Provide general contributions (education, documentation, awareness campaigns).
  • Developers: Provide technical infrastructure.

4.2 Structure of the Contracts and Interaction with Users

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:

  1. An Inspector must call the acceptInspection function to be assigned to the inspection.
  2. After visiting the area and completing the inspection, the Inspector calls the realizeInspection function to register the ecological data (number of trees and biodiversity).
  3. Each Inspector may inspect a given Regenerator only once.

Inspection Validation: In cases where an inspection is suspected to be invalid:

  1. Users with voting rights call the addInspectionValidation function to challenge the inspection.
  2. An inspection is invalidated only when the required minimum number of votes is reached.
  3. Once invalidated, the Inspector who submitted the inspection is penalized.

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:

  • i) Accepting inspections,
  • ii) Withdrawing rewards,
  • iii) Inviting new users,
  • iv) Voting.

Similar penalty mechanisms apply to other user roles, except for Supporters, who are exempted from such restrictions.

Flow diagram for the core calls
Fig. 2: Flow diagram for the core calls

5

Risk Rating Methodology

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:

  1. High: the issue is trivial to exploit and has no specific conditions that need to be met;
  2. Medium: the issue is moderately complex and may have some conditions that need to be met;
  3. Low: the issue is very complex and requires very specific conditions to be met.

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:

  1. High: the issue can cause significant damage, such as loss of funds or the protocol entering an unrecoverable state;
  2. Medium: the issue can cause moderate damage, such as impacts that only affect a small group of users or only a particular part of the protocol;
  3. Low: the issue can cause little to no damage, such as bugs that are easily recoverable or cause unexpected interactions that cause minor inconveniences.

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 / LikelihoodLowMediumHigh
HighMediumHighCritical
MediumLowMediumHigh
LowInfo / Best PracticesLowMedium
UndeterminedUndeterminedUndeterminedUndetermined

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.

  1. Informational findings do not pose any risk to the application, but they carry some information that the audit team intends to pass to the client formally;
  2. Best Practice findings are used when some piece of code does not conform with smart contract development best practices;
  3. Undetermined findings are used when we cannot predict the impact or likelihood of the issue.

6

Issues

6.1 Fraud Reporting Mechanism Fails to Protect Protocol Integrity

FileFix commitTypeStatus
RegeneratorRules.sol
CommunityRules.sol
PR #664HighFixed

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.

6.2 Inspectors Are Unfairly Penalized When Detecting Invalid Regenerators

FileFix commitTypeStatus
CommunityRules.sol
InspectionRules.sol
PR #662HighFixed

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:

  1. Abandon the inspection — The inspector does not submit, but is penalized for giving up, which may eventually lead to exclusion from the system;
  2. Submit with denunciation — The inspector calls the 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;
}
// ...
}

6.3 Inspectors Can Inflate Ecological Value to Profit from Token Sales

FileFix commitTypeStatus
RegenerationCreditImpact.solPR #663HighFixed

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]++;
}
// ..
}

6.4 Insufficient validation of coordinates can lead to double registration of a regeneration area

FileFix commitTypeStatus
RenegeratorRules.solPR #659HighMitigated

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");
}
}
}

6.5 Any type of user can accept invitation from denied users

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
// ..
}

6.6 Risk of Fake Denunciation Undermining User Reputation System

FileFix commitTypeStatus
CommunityRules.solPR #657MediumFixed

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);
}

6.7 Unexecuted Inspections may Block Regenerator Rewards

FileFix commitTypeStatus
InspectionRules.solPR #660MediumFixed

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"
);
// ..
}

6.8 The average level decreases when denied users are included in the vote power computation

FileFix commitTypeStatus
VoteRules.sol
ValidationRules.sol
PR #653LowFixed

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);
}

6.9 Accumulation of Residual Tokens in Pools

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.

6.10 Imbalanced Token Distribution in Inspector Pool Reduces Incentives

FileFix commitTypeStatus
RegeneratorPool.sol
InspectorPool.sol
PR #656InfoMitigated

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.

  • Regenerators register their areas in the system;
  • Inspectors physically audit those areas, reporting biodiversity and tree counts;

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:

  • The Inspector pool will be spread across a much larger group of users, reducing individual rewards;
  • Inspectors, who are crucial for validating Regenerators’ submissions, may lack sufficient incentives to remain active;

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

6.11 Inspectors are deactivated in their three give-ups

FileFix commitTypeStatus
InspectorRules.solPR #655InfoFixed

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;
}

6.12 Lack of Incentives for Inspection Invalidation

FileFix commitTypeStatus
InspectionRules.solPR #661InfoFixed

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;
}

6.13 Permanent Pool Misconfiguration Risk After Ownership Renouncement

FileFix commitTypeStatus
RegenerationCredit.solPR #654InfoMitigated

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;

6.14 Unallocated tokens in Eras and Epochs will be completely locked in the contract

FileFix commitTypeStatus
shared/Poolable.sol—InfoAcknowledged

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.

6.15 Unclaimed Rewards Across Multiple Eras May Become Unrecoverable

FileFix commitTypeStatus
contracts/*Pool.sol—InfoAcknowledged

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.

6.16 _canVoteRules function returns true when totalUsers is zero

FileFix commitTypeStatus
VoteRules.solPR #650InfoFixed

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;
}

6.17 canInvite function returns true when totalUsers is zero

FileFix commitTypeStatus
Invitable.solPR #651InfoFixed

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;
}

6.18 Event in _denyUseris never emitted

FileFix commitTypeStatus
ValidationRules.solPR #652Best PracticesFixed

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

7

Documentation Evaluation

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:

  • Technical whitepaper: A technical whitepaper is a comprehensive document describing the smart contract’s design and technical details. It includes information about the purpose of the contract, its architecture, its components, and how they interact with each other;
  • User manual: A user manual is a document that provides information about how to use the smart contract. It includes step-by-step instructions on how to perform various tasks and explains the different features and functionalities of the contract;
  • Code documentation: Code documentation is a document that provides details about the code of the smart contract. It includes information about the functions, variables, and classes used in the code, as well as explanations of how they work;
  • API documentation: API documentation is a document that provides information about the API (Application Programming Interface) of the smart contract. It includes details about the methods, parameters, and responses that can be used to interact with the contract;
  • Testing documentation: Testing documentation is a document that provides information about how the smart contract was tested. It includes details about the test cases that were used, the results of the tests, and any issues that were identified during testing;
  • Audit documentation: Audit documentation includes reports, notes, and other materials related to the security audit of the smart contract. This type of documentation is critical in ensuring that the smart contract is secure and free from vulnerabilities.

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.

8

Remarks & Test Suite

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.

Test suite
1268Tests passed0Failed

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.