Overview
This audit covers MNTD, a reward and utility token used within the Mint.io casino ecosystem on Base. MNTD can be distributed as casino rewards, used by applications for progression and utility, or held and traded as an ERC-20 token.
The review was conducted over a 1.5-week period and included a comprehensive analysis of the relevant smart contracts, covering the fixed token issuance and launch distribution flows, as well as two independent staking products designed for different user groups.
During the assessment, we did not identify any critical or high-severity issues. We identified one medium-severity finding that could allow newly staked tokens to bypass the configured unstaking cooldown and be withdrawn earlier than intended. In addition, we identified one low-severity finding and three informational issues.
All identified issues were either remediated or acknowledged by the development team and subsequently verified by our auditors.
Following the remediation phase, we conclude that the protocol's overall security posture and code quality have improved as a result of this audit.
Scope
The analyzed resources are located on:
https://github.com/mintdotio/mint-contracts/tree/38de223937dcf2776b859cbad1a965d9d6ee1128
The issues described in this report were fixed in the following commit:
https://github.com/mintdotio/mint-contracts/tree/a81fffe8afc147aefa64adaed434abfaa3d15771
Summary
Weaknesses
This section contains the list of discovered weaknesses.
MINT1-1 | NEWLY STAKED FUNDS CAN BE UNSTAKED IMMEDIATELY
Severity:
Status:
Fixed
Path:
src/flexible-staking/FlexibleStaking.sol#L594
Description:
This issue assumes that unstakingCooldown is greater than zero.
FlexibleStaking.unstakeAllUnlocked() is used to withdraw all currently unlocked stake after the configured unstakingCooldown. When a cooldown is enabled, the function creates an unstake request with amount set to 0:
function unstakeAllUnlocked() external nonReentrant {
...
if (unstakingCooldown > 0) {
...
_createUnstakeRequest(msg.sender, /* amount */ 0);
return;
}
...
}
The request is therefore stored as:
unstakeRequestOf[account] = UnstakeRequest({
amount: 0,
executableAt: executableAt
});
The issue is that storing amount = 0 causes the request to represent the user's entire balance at execution time, rather than the balance that existed when the request was created.
As a result, a user can make a new stake after creating the unstake request and then withdraw that newly staked amount as soon as the original cooldown expires, without waiting for the full unstakingCooldown.
For example:
- At time
T, Alice callsunstakeAllUnlocked()to unstake her existingXtokens:unstakeRequestOf[Alice].amount = 0unstakeRequestOf[Alice].executableAt = T + unstakingCooldown
- At
T + unstakingCooldown - 1, Alice stakes an additionalYtokens. - At
T + unstakingCooldown, a keeper callsexecuteUnstake()for Alice. Because the request storesamount = 0, the function withdraws all of Alice's current balance(X + Y)instead of only theXtokens that were eligible when the request was created. Therefore, the newly stakedYtokens can bypass the configuredunstakingCooldownand be withdrawn earlier than intended.
Remediation:
Consider modifying the function unstakeAllUnlocked() as follows:
function unstakeAllUnlocked() external nonReentrant {
...
if (unstakingCooldown > 0) {
...
uint256 unlockedBalance = _unlockedBalance(msg.sender, SMNTD_TOKEN.balanceOf(msg.sender));
if (unlockedBalance == 0) revert InsufficientUnlockedStake(0);
-- _createUnstakeRequest(msg.sender, 0);
++ _createUnstakeRequest(msg.sender, unlockedBalance);
return;
}
...
}
MINT1-4 | A PENDING WITHDRAWAL REQUEST HAS NO COST, ALLOWING ACCOUNTS TO PRE-ARM THE COOLDOWN AND EXIT INSTANTLY
Severity:
Status:
Acknowledged
Path:
src/flexible-staking/FlexibleStaking.sol#L672-L679, src/flexible-staking/FlexibleStaking.sol#L611-L621
Description:
When the cooldown is enabled, creating an unstake request only records the request. It does not transfer, burn, or reserve any of the account's stake:
function _createUnstakeRequest(address account, uint256 amount) private {
uint256 executableAt = block.timestamp + unstakingCooldown;
unstakeRequestOf[account] = UnstakeRequest({
amount: amount,
executableAt: executableAt
});
emit UnstakeRequested(account, amount, executableAt);
}
This is intentional. Specification 10.2 states that creating a request must not reserve value and that rewards continue accruing based on the existing stake weight during the cooldown period.
However, this also means that a pending request has no cost or restriction. The stake continues earning rewards at full weight, cancelUnstakeRequest() is free, and staking and compounding remain available. Once the request matures, it does not expire and can be executed at any time.
As a result, an account can call unstakeAllUnlocked() immediately after its first deposit and simply leave the request pending. After one cooldown period, the account has a permanently matured withdrawal request that can be executed at any time without incurring any cost.
Therefore, the cooldown only affects accounts that have not created a request in advance. It does not prevent an account from pre-arming the cooldown and later exiting immediately. Similarly, an account can stake enough to reach a higher reward tier, wait for the pre-existing request to mature, and then exit immediately after receiving the benefit.
Remediation:
Consider excluding the requested amount from the account's reward weight while the request is pending and restore it on cancellation, so that keeping a request open has a real cost.
Alternatively, give the request an execution window, so that it can only be executed between executableAt and executableAt + window and must be recreated afterwards.
MINT1-2 | DELAYED WITHDRAWAL REQUEST BEHAVIOR IS INCONSISTENTLY DOCUMENTED
Severity:
Status:
Fixed
Path:
docs/SPECIFICATION.md?plain=1#L339
Description:
The specification defines different behaviors for delayed withdrawal request creation.
Section 6 and section 8.3 require rewards to be settled and compounded and the account to be synchronized to the latest reward curve.
Stake, compound, withdrawal request, and withdrawal execution MUST settle the account under its old weight before compounding or changing balance, then calculate its final weight using the latest curve and store the latest version.
Immediate withdrawal, delayed request creation, and delayed execution MUST settle and compound before continuing. Request creation stores no withdrawal yet, but synchronizes the account to the latest curve.
However, section 10.2 states that request creation should not modify rewards, sMNTD, or weight. Section 15 similarly requires delayed requests to be non-mutating, with compounding deferred until execution.
When
unstakingCooldown > 0, request creation MUST NOT update global reward, settle the account, compound, mint or burn sMNTD, change weight, change liabilities, or reserve value.Flexible tests MUST cover:
- delayed request non-mutation and execution-time compounding;
The implementation follows sections 6 and 8.3. When the cooldown is enabled, unstake settles and compounds the account before creating the request:
function unstake(
uint256 amount
) external nonReentrant {
_requireWithdrawalsUnfrozen(msg.sender);
if (amount == 0) revert AmountZero();
if (unstakingCooldown > 0) {
_updateReward(msg.sender);
_compoundSettled(msg.sender);
uint256 liveBalance = SMNTD_TOKEN.balanceOf(msg.sender);
uint256 unlockedBalance = _unlockedBalance(msg.sender, liveBalance);
if (amount > unlockedBalance) revert InsufficientUnlockedStake(unlockedBalance);
_createUnstakeRequest(msg.sender, amount);
return;
}
_clearUnstakeRequest(msg.sender);
_settleCompoundAndWithdraw(msg.sender, amount, false);
}
Integrations that follow sections 10.2 or 15 may represent rewards, sMNTD balances, curve versions, or reward weights differently from the on-chain state during the cooldown.
Remediation:
- Update sections 10.2 and 15 to match the implemented request-time settlement, compounding, and curve synchronization behavior.
- Clarify that request creation does not burn sMNTD, transfer MNTD, reduce liabilities, or reserve withdrawal value.
MINT1-3 | USERREWARDPERWEIGHTPAID UPDATE CAN BE MOVED INTO THE IF BLOCK TO SAVE GAS
Severity:
Status:
Fixed
Path:
src/flexible-staking/FlexibleStaking.sol#L904
Description:
The FlexibleStaking._updateReward() function currently updates userRewardPerWeightPaid[account] regardless of whether the user's reward state has changed:
function _updateReward(
address account
) private {
_updateGlobalReward();
uint256 current = rewardPerWeightStored;
uint256 delta = current - userRewardPerWeightPaid[account];
if (delta > 0) {
storedRewardsOf[account] += Math.mulDiv(
rewardWeightOf[account],
delta,
REWARD_PRECISION
);
}
userRewardPerWeightPaid[account] = current;
}
When delta == 0, current is already equal to userRewardPerWeightPaid[account], making the storage write redundant.
Remediation:
Consider moving the update inside the if block to avoid the unnecessary storage operation:
function _updateReward(
address account
) private {
_updateGlobalReward();
uint256 current = rewardPerWeightStored;
uint256 delta = current - userRewardPerWeightPaid[account];
if (delta > 0) {
storedRewardsOf[account] += Math.mulDiv(
rewardWeightOf[account],
delta,
REWARD_PRECISION
);
+ userRewardPerWeightPaid[account] = current;
}
- userRewardPerWeightPaid[account] = current;
}
This preserves the existing behavior while avoiding a redundant storage write when delta == 0.
MINT1-5 | MATURED POSITIONS CONTINUE EARNING REWARDS AFTER UNLOCK
Severity:
Status:
Acknowledged
Path:
src/locked-staking/LockedStaking.sol
Description:
A position's lsMNTD weight is only burned when it is withdrawn. It remains in totalRewardWeight() after unlockTime, allowing a position with no remaining lock commitment to continue earning rewards.
For example, if Alice and Bob each stake 1,000 for 100 days and the reward schedule lasts 200 days:
- Days 0–100: rewards are split 50/50.
- At day 100: both positions mature, but both still have weight.
- If Alice calls
withdrawExpired(bob), Bob's weight is removed. - Alice then receives 100% of the rewards for days 100–200.
Thus, matured positions dilute still-locked positions, while the timing of
withdrawExpired()determines when that dilution ends.
Remediation:
Ensure matured positions are removed from the reward-weight denominator at unlockTime, rather than relying on the keeper to call withdrawExpired().