Root + Impact
The protocol records the requested deposit amount before transferring tokens, but fee-on-transfer tokens (e.g., USDT with enabled fees) deduct fees during transfer, causing the contract to receive less than recorded, creating undercollateralization.
Description
-
The protocol allows users to deposit whitelisted ERC20 collateral tokens by recording the deposit amount in state, then calling transferFrom() to pull tokens from the user.
-
The accounting records the full requested amount before transfer, but fee-on-transfer tokens deduct a percentage during the transfer operation, resulting in the contract receiving fewer tokens than recorded in state.
# dsc_engine.vy lines 215-230
@internal
def _deposit_collateral(token_collateral_address: address, amount_collateral: uint256):
assert amount_collateral > 0, "DSCEngine_NeedsMoreThanZero"
assert self.token_address_to_price_feed[token_collateral_address] != empty(address),
"DSCEngine__TokenNotAllowed"
self.user_to_token_address_to_amount_deposited[msg.sender][
token_collateral_address
] += amount_collateral
log CollateralDeposited(msg.sender, amount_collateral)
success: bool = extcall IERC20(token_collateral_address).transferFrom(
msg.sender, self, amount_collateral
) # @> Transfer deducts fee, contract receives less
assert success, "DSCEngine_TransferFailed"
Risk
Likelihood:
-
Protocol whitelists a token with transfer fees (e.g., USDT enables 0.1% fee, or wrapped tokens with management fees), making every deposit create accounting mismatches.
-
Users deposit such tokens repeatedly over time, accumulating discrepancies between recorded collateral and actual token balance held by contract.
Impact:
-
Protocol records more collateral than actually held, allowing users to mint more DSC than their real collateral supports, creating systemic undercollateralization.
-
Withdrawal operations fail when actual balance is insufficient to cover recorded amounts, causing user funds to become locked or protocol insolvency during mass withdrawals.
Proof of Concept
user_balance_before = fee_token.balanceOf(user)
contract_balance_before = fee_token.balanceOf(dsce)
with boa.env.prank(user):
fee_token.approve(dsce, 100e18)
dsce.deposit_collateral(fee_token, 100e18)
user_balance_after = fee_token.balanceOf(user)
contract_balance_after = fee_token.balanceOf(dsce)
recorded_collateral = dsce.get_collateral_balance_of_user(user, fee_token)
Recommended Mitigation
# dsc_engine.vy
@internal
def _deposit_collateral(token_collateral_address: address, amount_collateral: uint256):
assert amount_collateral > 0, "DSCEngine_NeedsMoreThanZero"
assert self.token_address_to_price_feed[token_collateral_address] != empty(address),
"DSCEngine__TokenNotAllowed"
+ # Check balance before transfer
+ balance_before: uint256 = staticcall IERC20(token_collateral_address).balanceOf(self)
- self.user_to_token_address_to_amount_deposited[msg.sender][
- token_collateral_address
- ] += amount_collateral
- log CollateralDeposited(msg.sender, amount_collateral)
success: bool = extcall IERC20(token_collateral_address).transferFrom(
msg.sender, self, amount_collateral
)
assert success, "DSCEngine_TransferFailed"
+ # Check balance after transfer and record actual amount received
+ balance_after: uint256 = staticcall IERC20(token_collateral_address).balanceOf(self)
+ actual_amount: uint256 = balance_after - balance_before
+
+ # Record only what was actually received
+ self.user_to_token_address_to_amount_deposited[msg.sender][
+ token_collateral_address
+ ] += actual_amount
+ log CollateralDeposited(msg.sender, actual_amount)
Alternative: Document that fee-on-transfer tokens are not supported and should not be whitelisted.