Horizon Finance Rug Pull

Reported loss $25K
BNB Chain
Rugpull

What happened

Horizon Finance (HRZN) is a BNB Smart Chain token whose source header pitches a DeFi protocol powering a Web 3.0 Horizon marketplace with only 50,000 tokens, deployed on 2021-03-11 by 0xcea43e91…d1abeC. Its HorizonFinance contract is a hand-written token, not a standard BEP-20: every transfer is checked against an owner-controlled whitelist and, for everyone else, against sale limits that are zero by default and cannot be changed, so a transfer from any holder the owner has not whitelisted reverts unless it is sent to the owner.

De.Fi's REKT database lists the contract deployer as adding initial liquidity (Tx 1 0x8ff1…322c) and later removing it (Tx 2 0xec15…6746), with 25,287 USD lost.

How it happened

  1. The constructor mints the full supply to the deployer, who becomes the only owner: balanceOf[msg.sender] = totalSupply; and owner = msg.sender;. owner is private and the contract has no way to hand it over or renounce it.
  2. transfer forwards to transferFrom, so every movement of HRZN passes through ensure(_from, _to, _value) before the balance changes.
  3. ensure lets a move through only for the privileged: if(_from == owner || _to == owner || _from == tradeAddress||canSale[_from]){ return true; }. That covers transfers from the owner, transfers to the owner, transfers from tradeAddress and transfers from canSale addresses. Everything else must satisfy require(condition(_from, _value));.
  4. condition starts with if(_saleNum == 0 && _minSale == 0 && _maxSale == 0) return false;. Those three variables are declared private and no function in the contract ever assigns them, so they stay zero and condition always returns false. Any transfer that is not covered by step 3 reverts, which includes every transfer from an ordinary holder to anyone other than the owner.
  5. A router sale calls transferFrom(holder, pair, amount), so _from is the holder and _to is the pair; unless the pair is the owner, the sale reverts. A buy is a transfer out of the pair, so it passes only if the pair is the owner's tradeAddress or canSale. The code lets only the owner set both: function transferownership(address addr) public returns(bool) { require(msg.sender == owner); tradeAddress = addr; and approveAndCall(address spender, uint256 addedValue), which sets canSale[spender]=true. Which of these the owner set for the pair was not checked.
  6. The owner passes as _from and as _to, so adding the initial liquidity and receiving the tokens back when the pool is removed are never blocked. A holder can likewise always send tokens to the owner, because _to == owner passes ensure.
  7. approveAndCall is a hidden mint: if(addedValue > 0) {balanceOf[spender] = addedValue*(10**uint256(decimals));} overwrites a balance without changing totalSupply.
  8. What happened, per De.Fi's REKT database: the deployer added initial liquidity and later removed it. The transactions were not opened, so the code above shows what the contract allows, not which of these controls were used.

Protocol details

Classification Other
Protocol Type Exit Scam/Rugpull
Affected asset / contract HRZN
Protocol links Website @Horizon2021

Build your security review skills

Work through hands-on labs covering real exploit mechanics, review techniques, and defensive patterns.