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.
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.
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
PLAIN_ERC20_SRC = """
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
"""
FOT_ERC20_SRC = """
balanceOf: public(HashMap[address, uint256])
allowance: public(HashMap[address, HashMap[address, uint256]])
totalSupply: public(uint256)
decimals: public(uint8)
FEE_BPS: constant(uint256) = 200
@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)
weth = boa.loads(PLAIN_ERC20_SRC)
fot_usd = MockV3Aggregator.deploy(8, 1 * 10**8)
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}")
assert credited == deposit
assert really_held == 98 * 10**18
assert credited > really_held
with boa.env.prank(user):
engine.mint_dsc(50 * 10**18)
assert dsc.balanceOf(user) == 50 * 10**18
recorded_value = engine.get_usd_value(fot.address, credited)
real_value = engine.get_usd_value(fot.address, really_held)
assert recorded_value > real_value
print(f"Recorded collateral value: ${recorded_value/10**18:.2f}; real: ${real_value/10**18:.2f}")
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.