Prepared by:
HALBORN
Last Updated 06/26/2024
Date of Engagement by: May 22nd, 2024 - June 11th, 2024
93% of all REPORTED Findings have been addressed
All findings
14
Critical
0
High
0
Medium
0
Low
2
Informational
12
Taurus Labs
engaged Halborn
to conduct a security assessment on their smart contracts beginning on May 22nd, 2024 and ending on June 11th, 2024. The security assessment was scoped to the smart contracts provided in the GitHub repository merkle-contract, commit hashes and further details can be found in the Scope section of this report.
The team at Halborn assigned two full-time security engineers to check the security of the modules. The security engineers are blockchain and smart-contract security experts with advanced penetration testing and smart-contract hacking skills, and deep knowledge of multiple blockchain protocols.
The purpose of this assessment is to:
Ensure that module functions operate as intended
Identify potential security issues with the modules
In summary, Halborn identified some improvements to reduce the likelihood and impact of risks, which were mostly addressed by the Taurus Labs team
. The main ones were the following:
Rectify the cancellation amount calculation when canceling a vesting plan.
Validate user-provided inputs during the vesting plan creation.
Allocate a distinct error code for each error type in the esmkl_token module.
Halborn
performed a combination of manual review of the code and automated security testing to balance efficiency, timeliness, practicality, and accuracy in regard to the scope of the smart contract assessment. While manual testing is recommended to uncover flaws in logic, process, and implementation; automated testing techniques help enhance coverage of smart contracts and can quickly identify items that do not follow security best practices. The following phases and associated tools were used throughout the term of the assessment:
Research into the architecture, purpose, and use of the platform.
Manual code read and walkthrough.
Manual Assessment of use and safety of critical Move
variables and functions in scope that could lead to arithmetic related vulnerabilities.
Cross module call controls.
Architecture related logical controls.
Test coverage review (aptos move test
).
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
0
High
0
Medium
0
Low
2
Informational
12
Security analysis | Risk level | Remediation Date |
---|---|---|
Miscalculation of cancel amount when canceling a vesting plan | Low | Solved - 06/20/2024 |
Missing input validation could result in an insolvent vesting plan | Low | Solved - 06/20/2024 |
Identical error codes assigned to different errors within the same module | Informational | Solved - 06/03/2024 |
Maintaining the transfer reference could potentially enable vemkl transfers | Informational | Acknowledged |
Canceling a vesting plan after its end date will be processed as a claim instead | Informational | Solved - 06/03/2024 |
Potential resource borrowing might go unused | Informational | Solved - 06/20/2024 |
Boolean equality | Informational | Solved - 06/20/2024 |
Presence of typos | Informational | Solved - 06/20/2024 |
Employ constants to mitigate unnecessary recomputation | Informational | Acknowledged |
Unnecessary use of mutable references | Informational | Solved - 06/20/2024 |
Usage of legacy Coin standard | Informational | Acknowledged |
Unused import statement | Informational | Solved - 06/20/2024 |
Missing view decorator for read-only functions | Informational | Future Release |
Redundant check increases gas usage | Informational | Solved - 06/20/2024 |
// Low
Upon cancellation of a vesting plan, the claimed asset containing the claimable amount is returned, along with the cancel amount designed to cover the remaining vesting period. This cancel amount is derived by subtracting the claimed amount from the total amount allocated within the vesting plan. The claimed amount is determined prior to the subtraction, being incremented with the output of get_claimable
, which is computed through the following process:
public fun get_claimable(_vesting_plan: &VestingPlan): u64 {
if (_vesting_plan.paused) {
return 0
};
let now = timestamp::now_seconds();
_vesting_plan.initial_amount + safe_mul_div(
_vesting_plan.total_amount,
min(now, _vesting_plan.end_at_sec) - min(now, _vesting_plan.start_at_sec),
(_vesting_plan.end_at_sec - _vesting_plan.start_at_sec)
) - _vesting_plan.claimed_amount
}
This illustrates that the claimed amount will fluctuate within the range from (_vesting_plan.initial_amount - _vesting_plan.claimed_amount)
to (_vesting_plan.initial_amount + _vesting_plan.total_amount - _vesting_plan.claimed_amount)
, wherein the claimed amount is initially set to zero upon the creation of the vesting plan.
Subsequently, it is incremented by the output of get_claimable
during either a claim
or cancel
operation when calling claim_internal
. Consequently, if_vesting_plan.initial_amount
is greater than zero, the cancel amount will consistently be incorrect, holding an amount that is lesser by _vesting_plan.initial_amount
, potentially leading to underflow issues because _vesting_plan.claimed_amount
can hold up to _vesting_plan.total_amount + _vesting_plan.initial_amount
.
Consequently, two distinct scenarios emerge:
Certain users may find themselves unable to cancel their vesting plans, thereby being compelled to continue with the vesting schedule against their intentions.
External modules relying on the cancel amount to refund users their vested tokens may inadvertently refund less than the requisite amount, potentially result in financial loss for the affected users.
The cancel
function does not incorporate the vesting plan's initial amount when calculating the cancel amount:
public fun cancel(_vesting_plan: VestingPlan, _claim_cap: ClaimCapability, _admin_cap: AdminCapability): (FungibleAsset, u64) {
assert!(_vesting_plan.paused == false, E_PAUSED);
assert!(_vesting_plan.uid == _claim_cap.uid && _vesting_plan.uid == _admin_cap.uid, E_NOT_AUTHORIZED);
let claimable = get_claimable(&_vesting_plan);
let claimed_asset = fungible_asset::zero(claimable_fa_store::get_metadata_by_uid(&_vesting_plan.claimable_fa_store_claim_cap));
if (claimable > 0) {
fungible_asset::merge(&mut claimed_asset, claim_internal(&mut _vesting_plan, claimable));
};
let cancel_amount = _vesting_plan.total_amount - _vesting_plan.claimed_amount;
(claimed_asset, cancel_amount)
}
The claim_internal
function increments the vesting plan's claimed_amount
with the provided amount:
fun claim_internal(_vesting_plan: &mut VestingPlan, _amount: u64): FungibleAsset {
_vesting_plan.claimed_amount = _vesting_plan.claimed_amount + _amount;
claimable_fa_store::claim_funding_store(&_vesting_plan.claimable_fa_store_claim_cap, _amount)
}
Consider changing the cancel_amount
calculation as follows:
let cancel_amount = _vesting_plan.initial_amount + _vesting_plan.total_amount - _vesting_plan.claimed_amount;
SOLVED: The Taurus Labs team solved the issue by deducting the initial amount from the total amount within the get_claimable
function:
_vesting_plan.initial_amount + safe_mul_div(
_vesting_plan.total_amount - _vesting_plan.initial_amount,
min(now, _vesting_plan.end_at_sec) - min(now, _vesting_plan.start_at_sec),
(_vesting_plan.end_at_sec - _vesting_plan.start_at_sec)
) - _vesting_plan.claimed_amount
// Low
During the creation of a vesting plan, the create
function, which can be called by anyone, currently lacks validation for the accuracy of the following arguments: _start_at_sec
, _end_at_sec
, _initial_amount
and _total_amount
.
This oversight presents integrity issues, potentially leading to the following scenarios:
Creation of a vesting plan allowing the user to claim more than the total amount if the provided initial amount exceeds the total amount.
Creation of a vesting plan with a start time in the past or one that has already expired.
Creation of a vesting plan with start_at_sec
equaling end_at_sec
, resulting in a division by zero error when calling get_claimable
and related functions (such as cancel
and claim
).
Creation of a vesting plan where 'start_at_sec' is greater than end_at_sec
, causing an underflow error when calling get_claimable
and related functions with the current timestamp exceeding the vesting plan's start time.
The create
function does not validate the user-provided arguments:
public fun create(
_user_address: address,
_start_at_sec: u64,
_end_at_sec: u64,
_initial_amount: u64,
_total_amount: u64,
_claimable_fa_store_claim_cap: claimable_fa_store::ClaimCapability
): (VestingPlan, ClaimCapability, AdminCapability)
acquires VestingConfig {
let config = borrow_global_mut<VestingConfig>(@merkle);
let uid = config.next_uid;
let vesting_plan = VestingPlan {
uid,
user: _user_address,
start_at_sec: _start_at_sec,
end_at_sec: _end_at_sec,
initial_amount: _initial_amount,
total_amount: _total_amount,
claimed_amount: 0,
paused: false,
claimable_fa_store_claim_cap: _claimable_fa_store_claim_cap
};
config.next_uid = config.next_uid + 1;
(vesting_plan, ClaimCapability { uid }, AdminCapability { uid })
}
Consider validating user-provided inputs by ensuring the following criteria:
The initial amount is less than the total amount.
The current timestamp is before the start time, and the end time is between the start time and a valid upper limit.
SOLVED: The Taurus Labs team implemented the recommended checks.
// Informational
In the esmkl_token
module, it was noticed that both E_NOT_AUTHORIZED
and E_TOO_SMALL_VEST_AMOUNT
errors are assigned the same value, which is 0
. This situation can detrimentally impact user experience and complicate the process of identifying the specific cause for a transaction failure.
The esmkl_token
module exposes two different errors that share the same error code:
/// When signer is not owner of module
const E_NOT_AUTHORIZED: u64 = 0;
/// When vest too small amount
const E_TOO_SMALL_VEST_AMOUNT: u64 = 0;
Consider allocating a distinct error code for each error type to enhance clarity and facilitate precise error identification.
SOLVED: The Taurus Labs team solved the issue by assigning the E_TOO_SMALL_VEST_AMOUNT
error the value 1
.
// Informational
During the locking process, a validation check determines if a user is eligible for a new vemkl
token by assessing the maximum number of tokens they hold. Subsequently, when the mint_vemkl
function is called, a token is minted, transferred to the user, and then rendered non-transferrable by invoking object::disable_ungated_transfer
with the corresponding transfer reference. This step is intended to "soul bound" the token, as per the accompanying comment. However, since the transfer reference is stored within the VoteEscrowedMKL
under the user, If the staking module introduces a new API that provides access to either the VoteEscrowedMKL
or the transfer reference contained within it, it creates an avenue for the token owner to call object::enable_ungated_transfer
and transfer their tokens using this reference.
The issue arises when vemkl
tokens are transferred, as the UserVoteEscrowedMKL
lists for both participating users fail to update. Consequently, when the new user attempts to unlock, the operation succeeds as removing a non-existent element from the vector doesn't trigger an error, returning an empty vector instead. However, the vemkl
address isn't removed from the initial owner's UserVoteEscrowedMKL
list. This discrepancy leads to the following consequences:
The original vemkl
owner accumulates deprecated elements in their UserVoteEscrowedMKL
list, potentially rendering them unable to lock and acquire new vemkl
tokens.
The new owner initiating the unlock could surpass the VoteEscrowedMKLConfig.max_num_vemkl
limit, as their list doesn't accurately reflect the owned vemkl
tokens.
This scenario also exposes the possibility for malicious users to exploit the system by creating multiple accounts, acquiring the maximum allowed vemkl
tokens for each account, and then transferring all tokens to their primary accounts. This manipulation results in surpassing the upper bound limit for allowed vemkl
tokens, granting a disproportionately strong voting power that could significantly impact the protocol's future.
The mint_vemkl
function stores the transfer reference for the created vemkl token under the VoteEscrowedMKL
resource:
let vemkl = VoteEscrowedMKL {
lock_time,
unlock_time: _unlock_time,
mutator_ref,
burn_ref,
transfer_ref,
mkl_token: mkl_store,
mkl_delete_ref,
esmkl_token: esmkl_store, // object address (FungibleStore)
esmkl_delete_ref,
};
Consider removing the transfer reference from the VoteEscrowedMKL
resource to prevent vemkl
tokens from being transferred.
ACKNOWLEDGED: The Taurus Labs team acknowledged this issue but opted to retain the TransferRef in the user's VoteEscrowedMKL
resource for potential future extensibility.
// Informational
After creating a vesting plan, users have the option to either claim or cancel them. If a user attempts to call cancel
before the vesting plan's end date, the current claimable amount will be distributed, and the cancel amount, intended to cover the remaining vesting period, will be returned. This cancel amount can then be utilized, for instance, in esmkl_token::cancel
to refund any unused esmkl tokens.
However, it's worth noting that the cancel
function can still be invoked even after the vesting plan's conclusion. In such cases, the logic mirrors that of the claim
function, albeit with an additional AdminCapability
. This introduces an ambiguous behavior where a user can effectively claim their tokens through the cancel
function, despite the vesting plan not being canceled in reality.
The cancel
function enables users to cancel their vesting plans upon conclusion, resulting in a claim instead.
public fun cancel(_vesting_plan: VestingPlan, _claim_cap: ClaimCapability, _admin_cap: AdminCapability): (FungibleAsset, u64) {
assert!(_vesting_plan.paused == false, E_PAUSED);
assert!(_vesting_plan.uid == _claim_cap.uid && _vesting_plan.uid == _admin_cap.uid, E_NOT_AUTHORIZED);
let claimable = get_claimable(&_vesting_plan);
let claimed_asset = fungible_asset::zero(claimable_fa_store::get_metadata_by_uid(&_vesting_plan.claimable_fa_store_claim_cap));
if (claimable > 0) {
fungible_asset::merge(&mut claimed_asset, claim_internal(&mut _vesting_plan, claimable));
};
let cancel_amount = _vesting_plan.total_amount - _vesting_plan.claimed_amount;
(claimed_asset, cancel_amount)
}
Consider implementing a check to ensure that the vesting plan's end date has not been reached before allowing cancellation. This requirement will compel users to claim their plans through the claim
function, as intended.
SOLVED: The Taurus Labs team revised the logic to ensure that a vesting plan cancellation is permissible only when the cancellation amount exceeds zero.
// Informational
In the pmkl_token::get_user_season_claimable
function, there's a risk that the borrowed RewardInfo
resource might not be utilized if the subsequent if clause is triggered. This could lead to unnecessary gas consumption.
The get_user_season_claimable
function borrows the RewardInfo resource too early, possibly leading to its underutilization.
let reward_info = borrow_global<RewardInfo>(@merkle);
if (!table::contains(&pmkl_info.season_pmkl, _season_number)) {
return 0
};
Consider borrowing the RewardInfo
resource just after the if-clause to ensure its use in the intended context.
if (!table::contains(&pmkl_info.season_pmkl, _season_number)) {
return 0
};
let reward_info = borrow_global<RewardInfo>(@merkle);
SOLVED: The Taurus Labs team implemented the recommended solution.
// Informational
In the vesting
module, both the claim
and cancel
functions include assertions that compare a boolean expression to false
, leading to unnecessary gas consumption.
The claim
function includes an assertion that compares a boolean expression to false
:
public fun claim(_vesting_plan: &mut VestingPlan, _claim_cap: &ClaimCapability): FungibleAsset {
assert!(_vesting_plan.paused == false, E_PAUSED);
The cancel
function includes an assertion that compares a boolean expression to false
:
public fun cancel(_vesting_plan: VestingPlan, _claim_cap: ClaimCapability, _admin_cap: AdminCapability): (FungibleAsset, u64) {
assert!(_vesting_plan.paused == false, E_PAUSED);
Consider implementing the necessary adjustments by adhering to the following principle:
assert!(x == true) -> assert!(x)
assert!(x == false) -> assert!(!x)
SOLVED: The Taurus Labs team implemented the recommended adjustments.
// Informational
Within the modules in scope, several typos were identified.
In the staking
module, the following typos were identified:
const E_INVALIAD_EPOCH_START_AT: u64 = 5;
string::utf8(b""), // decsription
In the pre_tge_reward
module, the following typo was identified:
let seasoun_end_sec = mkl_token::mkl_tge_at() - (DAY_SECONDS * 14 * (tge_season - 3));
Consider implementing the following changes:
E_INVALIAD_EPOCH_START_AT -> E_INVALID_EPOCH_START_AT
decsription -> description
seasoun -> season
SOLVED: The Taurus Labs team implemented the recommended changes.
// Informational
In the get_unlock_amount
function within the mkl_token
module, there is an unnecessary computation of one month's duration in seconds as DAY_SECONDS * 365 / 12
each time the function is called.
The get_unlock_amount
function recalculates the duration of one month in seconds every time it is invoked:
let idx = elapsed_time / (DAY_SECONDS * 365 / 12);
Consider adding a new constant named MONTH_SECONDS
within the mkl_token
module, with a value of 2628000
, calculated as DAY_SECONDS * 365 / 12
, to avoid redundant computation.
ACKNOWLEDGED: The Taurus Labs team acknowledged this issue but decided that using an additional const is an unnecessary use of gas.
// Informational
Borrowing a resource should ideally be reserved for cases where modification is necessary. However, in the modules in scope, several unnecessary mutable borrows were identified.
In the claimable_fa_store
module, the following unnecessary mutable borrows were identified:
let claimable_fa_store = borrow_global_mut<ClaimableFaStore>(resource_account);
let claimable_fa_store = borrow_global_mut<ClaimableFaStore>(_claim_cap.resource_account);
let claimable_fa_store = borrow_global_mut<ClaimableFaStore>(_claim_cap.resource_account);
In the mkl_token
module, the following unnecessary mutable borrow was identified:
let pool_store = borrow_global_mut<PoolStore<PoolType>>(@merkle);
In the pmkl_token
module, the following unnecessary mutable borrow was identified:
let reward_info = borrow_global_mut<RewardInfo>(@merkle);
In the staking
module, the following unnecessary mutable borrows were identified:
let config = borrow_global_mut<VoteEscrowedMKLConfig>(@merkle);
let ve_powers = borrow_global_mut<VoteEscrowedPowers>(@merkle);
let user_vemkl = borrow_global_mut<UserVoteEscrowedMKL>(_user_address);
In the protocol_reward
module, the following unnecessary mutable borrow was identified:
let user_reward_info = borrow_global_mut<UserRewardInfo<AssetType>>(_user_address);
Consider using borrow_global
instead.
SOLVED: The Taurus Labs team implemented the recommended changes.
// Informational
Following AIP-63, the Aptos team is transitioning from the Coin
standard to the Fungible Asset
(FA) standard. It's advisable to adopt the FA standard from the outset to circumvent potential migration complexities post-contract deployment.
The protocol_reward
module uses the legacy Coin
standard:
use aptos_framework::coin::{Self, Coin};
struct Reward<phantom AssetType> has store {
registered_at: u64,
registered_reward_amount: u64,
coin: Coin<AssetType>
}
let reward = coin::extract(&mut reward.coin, reward_amount);
coin: coin::withdraw<AssetType>(_admin, _amount)
aptos_account::deposit_coins(address_of(_admin), coin::extract_all(&mut reward.coin));
coin::merge(&mut reward.coin, coin::withdraw<AssetType>(_admin, _amount));
Consider using the Fungible Asset (FA) standard instead of the legacy Coin standard.
ACKNOWLEDGED: The Taurus Labs team acknowledged this issue but decided against fixing it considering their plan to distribute rewards in zUSDC
, which is a USD Coin bridged to Aptos
by LayerZero
.
// Informational
In the pmkl_token
module, an unused import was identified.
The merkle::claimable_fa_store
module was imported but never used during the pmkl_token
module's unit tests.
#[test_only]
use merkle::claimable_fa_store;
Consider removing the unused import statement.
SOLVED: The Taurus Labs team removed the unused import statement.
// Informational
#[view]
decorated functions cannot alter storage; they only read data, providing a safe way to access information without risking state modification. Utilizing the #[view]
macro to safeguard read-only functions ensures that any attempt to modify storage inadvertently is prevented, thereby minimizing potential security vulnerabilities. The following read-only functions lack the #[view]
decorator:
pmkl_token::get_current_season_info
pmkl_token::get_season_user_pmkl
pmkl_token::get_season_info
pmkl_token::get_user_season_claimable
staking::get_epoch_user_vote_power
mkl_token::get_metadata
mkl_token::get_unlock_amount
mkl_token::get_allocation
esmkl_token::get_metadata
protocol_reward::user_reward_amount
Consider using the #[view]
decorator for the mentioned functions.
PENDING: The Taurus Labs team appreciated the recommendation and is open to implementing it in the future.
// Informational
In the pmkl_token
module, the get_season_user_pmkl
function unnecessarily contains an assertion that always holds true.
Initially, the function verifies if the provided season number has an entry in the season season_pmkl
, returning a default SeasonUserPMKLInfoView
resource if not.
After this check, it redundantly verifies again if the season_pmkl
table contains an entry for the season number, which is always ensured by the previous return statement.
The get_season_user_pmkl
function unnecessarily contains an assertion that always holds true.
if (!table::contains(&season_pmkl_info.season_pmkl, _season_number)) {
return SeasonUserPMKLInfoView {
season_number: _season_number,
total_supply: 0,
user_balance: 0
}
};
assert!(table::contains(&season_pmkl_info.season_pmkl, _season_number), E_INVALID_SEASON_NUMBER);
Consider removing the redundant check.
SOLVED: The Taurus Labs team removed the redundant check.
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