Silent Failure for Non-standard ERC20 Transfers in deposit()/withdraw()
Severity: Insight
Context: AlchemistTokenVault.sol#L28 && L41
Summary:
AlchemistTokenVault.deposit() and withdraw() call the raw ERC‑20 transferFrom/transfer and drop the boolean return value. For tokens that signal failure by returning false (USDT-style implementations), the functions still emit Deposited/Withdrawn even though no funds move, so the vault state diverges from reality.
Vulnerability Detail:
For some old token like USDT, transfer/transferFrom return a bool to express success/fail.
If the balance is insufficient, the credit limit is insufficient, the address is on the blacklist, etc.
This is safe only for tokens that revert on failure. Many legacy or heavily permissioned tokens (e.g., USDT) follow the original ERC‑20 convention: they return false when a transfer fails—due to insufficient balance, allowance, a freeze/blacklist flag, etc.—and do not revert. In those cases the vault still emits Deposited/Withdrawn, updates whatever internal bookkeeping comes afterward, but no funds actually move. As a result:
Deposits that should fail appear to succeed, leaving the vault with less collateral than it believes.
Withdrawals silently fail, so fee recipients or liquidators never receive the tokens they are owed, yet the system records the payout.
Imapct: Because the protocol continues operating on bogus accounting and cannot deliver funds to participants, this constitutes a high‑severity silent failure.
// Move funds to depositor while transfers work faultyToken.transfer(depositor, AMOUNT);
// Deploy a fresh vault that manages the faulty token vm.prank(owner); AlchemistTokenVault faultyVault = newAlchemistTokenVault(address(faultyToken), alchemist, owner);
// Allow the vault to pull funds from depositor vm.prank(depositor); faultyToken.approve(address(faultyVault), AMOUNT);
// Flip the token into "non-standard" mode (returns false on transfer) faultyToken.setFailTransfers(true);
// Deposit proceeds without reverting, but funds never move (silent failure) vm.prank(depositor); faultyVault.deposit(AMOUNT);
assertEq(faultyToken.balanceOf(address(faultyVault)), 0, "Vault should still be empty"); assertEq(faultyToken.balanceOf(depositor), AMOUNT, "Depositor should retain tokens");
// Downstream withdraw also appears successful while transferring nothing. address recipient = address(99); uint256 preRecipient = faultyToken.balanceOf(recipient);
_mytSharesDeposited didn’t get updated after _forceRepay && _doLiquidation called
Severity: High
Context: AlchemistV3.sol#L738 && L791
Summary:
In AlchemistV3.sol, the _forceRepay and _doLiquidation transfer MYT shares out of the contract (to the transmuter, liquidator, or protocol fee vault) but never decrement _mytSharesDeposited. Consequently, the internal ledger continues to assume those shares are still held. This drifts collateral accounting—global collateralization metrics are overstated, so liquidations can under-react—and deposit-cap enforcement rejects new deposits even after MYT has actually left the vault.
Vulnerability Detail:
Within _forceRepay and _doLiquidation, the contract transfers MYT shares to external recipients(typically the transmuter, liquidator, or protocol fee vault), but _mytSharesDeposited is never reduced by the amount that actually left the contract. Subsequent logic such as _getTotalUnderlyingValue(), calculateLiquidation, and depositCap checks rely on _mytSharesDeposited as the canonical measure of MYT held by AlchemistV3.
Because the counter is not updated after these transfers, every invocation of _forceRepay or _doLiquidation causes the internal ledger to drift further from the real balance. This bug triggers on every liquidation or forced repayment, independent of the caller, so it is both easy to reach and repeatedly exploitable.
Impact:
Collateral accounting drift – Global metrics (e.g., collateralization ratios) are overstated, enabling inaccurate positions to avoid or delay liquidation, thereby increasing insolvency risk.
DepositCap blockage – The contract continues to believe the cap is saturated even after MYT shares have been removed, so new deposits are rejected, blocking legitimate users and impairing protocol operations.
// Lower the MYT share value sharply so the position falls below the liquidation threshold. uint256 initialSupply = IERC20(address(mockStrategyYieldToken)).totalSupply(); IMockYieldToken(mockStrategyYieldToken).updateMockTokenSupply(initialSupply * 2);
if (depositReverted) { assertLt(alchemist.getTotalDeposited(), alchemist.depositCap(), "deposit still blocked pre-fix"); } else { assertEq(alchemist.getTotalDeposited(), totalDepositedBeforeRetry + available, "deposit should increase balance after fix"); assertLe(alchemist.getTotalDeposited(), alchemist.depositCap(), "deposit cap exceeded after fix"); } }
Recommendation:
In both _forceRepay and _doLiquidation, subtract the exact MYT amount that leaves the contract (principal and any fees) from _mytSharesDeposited, so the internal ledger stays aligned with the actual balance.
uint256 protocolFeePaid = 0; if (account.collateralBalance > protocolFeeTotal) { account.collateralBalance -= protocolFeeTotal; // Transfer the protocol fee to the protocol fee receiver TokenUtils.safeTransfer(myt, protocolFeeReceiver, protocolFeeTotal); protocolFeePaid = protocolFeeTotal; }
uint256 mytOut = protocolFeePaid; if (creditToYield > 0) { // Transfer the repaid tokens from the account to the transmuter. TokenUtils.safeTransfer(myt, address(transmuter), creditToYield); mytOut += creditToYield; }
// Handle outsourced fee from vault if (outsourcedFee > 0) ....... ....... }
Vechain
Double effective-stake decrement freezes unstake permanently after validator exit
Severity: Critical
Context: Stargate.sol#L266-#L283, #L568
Summary:
Calling requestDelegationExit records a first effective-stake decrement, util validator status changed to EXITED, USER try to call unstake , and enters the EXITED/PENDING branch and applies a second decrement for the same period, causing an underflow (panic 0x11) before any transfers.
Vulnerability Detail:
First decrement: requestDelegationExit calls _updatePeriodEffectiveStakefor the next period (Stargate.sol #L568).
Second decrement: unstake in the currentValidatorStatus == EXITED || status == PENDING branch calls _updatePeriodEffectiveStake again Stargate.sol (#L266-#L283), leading to underflow when the checkpointed value is already zero.
Impact:
Once triggered, every unstake attempt reverts; delegations cannot be changed, so the user’s staked VET remains locked in the staking contract. Funds are not stolen but are permanently frozen.
Affected flow: stake + delegate → requestDelegationExit (first decrement) → validator becomes EXITED → any unstake reverts on second decrement.
add a status judge before call _updatePeriodEffectiveStake in unstake.
1 2 3 4 5 6 7
bool shouldDecrement = (delegation.status == DelegationStatus.PENDING) || (currentValidatorStatus == VALIDATOR_STATUS_EXITED && delegation.status == DelegationStatus.NONE); // or some other scenery can do decreament if (shouldDecrement) { _updatePeriodEffectiveStake(..., false); } else { // status == EXITED has been decreased in requestDelegationExit, skip }