Algo Ssstablecoinsss

AI First Flight #2
Beginner FriendlyDeFi
EXP
View results
Submission Details
Impact: medium
Likelihood: medium
Invalid

deposit_collateral credits the requested amount instead of the amount received, over-crediting fee-on-transfer collateral

Description

_deposit_collateral (src/dsc_engine.vy:214-230) increases the user's internal collateral balance by the requested amount_collateral and only afterwards pulls the tokens:

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"

The engine never measures how many tokens actually arrived. For a fee-on-transfer collateral the contract receives less than amount_collateral, yet it records the full amount_collateral. The internal accounting then overstates the protocol's real holdings, and the user can mint DSC against the difference, which is not backed by tokens the protocol holds.

The README states the protocol is meant to be forked and used with "any basket of assets they like, and the code would work the same." Fee-on-transfer tokens are a common asset class, so this case is part of the intended design surface and the code does not hold for it.

Risk

Likelihood: Medium. The named tokens WETH and WBTC are not fee-on-transfer, so this does not trigger with the as-deployed pair, and the README Compatibilities line names only WETH and WBTC. Reachability rests on the stated design goal in the same README: the project "is meant to be such that someone could fork this codebase, swap out WETH & WBTC for any basket of assets they like, and the code would work the same." Fee-on-transfer tokens are a common asset class, and the code does not work the same for them, so this is a defect against the protocol's own stated intent.

Impact: Medium. On every deposit of a fee-on-transfer collateral the recorded collateral exceeds the tokens actually held by the fee amount, and DSC can be minted against that phantom collateral. The shortfall is permanent and accumulates across deposits, eroding the over-collateralization buffer. With a high enough fee or enough volume the real backing can fall below the DSC supply, leaving later redeemers unable to withdraw. The proof below demonstrates the over-crediting itself (100 recorded versus 98 held on a 2 percent fee token), which is the root cause; it does not by itself drain the 2 percent PoC position.

Proof of Concept

Self contained moccasin/titanoboa test. A 2 percent fee-on-transfer token is defined inline. Save as tests/poc_fee_on_transfer.py and run uv run mox test tests/poc_fee_on_transfer.py -s.

import boa
from src import dsc_engine, decentralized_stable_coin
from src.mocks import MockV3Aggregator
# A normal 18-decimal token (the second, healthy collateral slot the engine needs).
PLAIN_ERC20_SRC = """
# pragma version ^0.4.0
balanceOf: public(HashMap[address, uint256])
allowance: public(HashMap[address, HashMap[address, uint256]])
totalSupply: public(uint256)
decimals: public(uint8)
@deploy
def __init__():
self.decimals = 18
@external
def mint(to: address, amount: uint256):
self.balanceOf[to] += amount
self.totalSupply += amount
@external
def approve(spender: address, amount: uint256) -> bool:
self.allowance[msg.sender][spender] = amount
return True
@external
def transfer(to: address, amount: uint256) -> bool:
self.balanceOf[msg.sender] -= amount
self.balanceOf[to] += amount
return True
@external
def transferFrom(owner: address, to: address, amount: uint256) -> bool:
self.allowance[owner][msg.sender] -= amount
self.balanceOf[owner] -= amount
self.balanceOf[to] += amount
return True
"""
# Same shape, but charges a 2% fee on every transfer (burned from supply).
FOT_ERC20_SRC = """
# pragma version ^0.4.0
balanceOf: public(HashMap[address, uint256])
allowance: public(HashMap[address, HashMap[address, uint256]])
totalSupply: public(uint256)
decimals: public(uint8)
FEE_BPS: constant(uint256) = 200 # 2%
@deploy
def __init__():
self.decimals = 18
@external
def mint(to: address, amount: uint256):
self.balanceOf[to] += amount
self.totalSupply += amount
@external
def approve(spender: address, amount: uint256) -> bool:
self.allowance[msg.sender][spender] = amount
return True
@internal
def _xfer(frm: address, to: address, amount: uint256):
fee: uint256 = amount * FEE_BPS // 10000
self.balanceOf[frm] -= amount
self.balanceOf[to] += amount - fee
self.totalSupply -= fee
@external
def transfer(to: address, amount: uint256) -> bool:
self._xfer(msg.sender, to, amount)
return True
@external
def transferFrom(owner: address, to: address, amount: uint256) -> bool:
self.allowance[owner][msg.sender] -= amount
self._xfer(owner, to, amount)
return True
"""
def test_fee_on_transfer_overmint():
user = boa.env.generate_address()
fot = boa.loads(FOT_ERC20_SRC) # fee-on-transfer collateral, $1
weth = boa.loads(PLAIN_ERC20_SRC) # second collateral slot
fot_usd = MockV3Aggregator.deploy(8, 1 * 10**8) # $1.00
eth_usd = MockV3Aggregator.deploy(8, 2_000 * 10**8)
dsc = decentralized_stable_coin.deploy()
engine = dsc_engine.deploy(
[fot.address, weth.address], [fot_usd.address, eth_usd.address], dsc.address
)
dsc.set_minter(engine.address, True)
dsc.transfer_ownership(engine.address)
deposit = 100 * 10**18
fot.mint(user, deposit)
with boa.env.prank(user):
fot.approve(engine.address, deposit)
engine.deposit_collateral(fot.address, deposit)
credited = engine.get_collateral_balance_of_user(user, fot.address)
really_held = fot.balanceOf(engine.address)
print(f"Engine credited the user: {credited}")
print(f"Engine actually received: {really_held}")
print(f"Phantom (unbacked) collateral: {credited - really_held}")
# The engine records 100 tokens but holds only 98: 2 tokens of phantom backing.
assert credited == deposit
assert really_held == 98 * 10**18
assert credited > really_held
# The user can mint DSC against the inflated credit (50% threshold => $50),
# while the protocol only holds $98 of real collateral for it. Mint succeeds.
with boa.env.prank(user):
engine.mint_dsc(50 * 10**18)
assert dsc.balanceOf(user) == 50 * 10**18
# Recorded collateral value backing the debt is overstated vs reality.
recorded_value = engine.get_usd_value(fot.address, credited) # $100
real_value = engine.get_usd_value(fot.address, really_held) # $98
assert recorded_value > real_value
print(f"Recorded collateral value: ${recorded_value/10**18:.2f}; real: ${real_value/10**18:.2f}")

Output (test passes):

Engine credited the user: 100000000000000000000
Engine actually received: 98000000000000000000
Phantom (unbacked) collateral: 2000000000000000000
Recorded collateral value: $100.00; real: $98.00
1 passed

Recommended Mitigation

Credit the amount actually received by measuring the contract's balance before and after the transfer, and update the accounting from that delta:

@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"
balance_before: uint256 = staticcall IERC20(token_collateral_address).balanceOf(self)
success: bool = extcall IERC20(token_collateral_address).transferFrom(
msg.sender, self, amount_collateral
)
assert success, "DSCEngine_TransferFailed"
received: uint256 = staticcall IERC20(token_collateral_address).balanceOf(self) - balance_before
self.user_to_token_address_to_amount_deposited[msg.sender][token_collateral_address] += received
log CollateralDeposited(msg.sender, received)

Alternatively, document that only standard ERC20s without transfer fees or rebasing are supported and enforce that with an allowlist.

Updates

Lead Judging Commences

ai-first-flight-judge Lead Judge about 4 hours ago
Submission Judgement Published
Invalidated
Reason: Incorrect statement

Support

FAQs

Can't find an answer? Chat with us on Discord, Twitter or Linkedin.

Give us feedback!