Skip to content

Instantly share code, notes, and snippets.

@this-is-chainlight
Last active March 13, 2025 12:24
Show Gist options
  • Save this-is-chainlight/268e106904997e21ce664c1fdb0896b9 to your computer and use it in GitHub Desktop.
Save this-is-chainlight/268e106904997e21ce664c1fdb0896b9 to your computer and use it in GitHub Desktop.
Review RareSkills article: Coding a Solidity rebase token

This review is based on the rebasing design described in the article β€œCoding a Solidity rebase token”. It evaluates potential vulnerabilities and provides recommendations within the context of the rebasing mechanism.

πŸ” Key points we have looked:

  • Verify share and asset calculation formulas
  • Review slippage calculation logic during share issuance
  • Inspect mint, burn, and transfer logic for accurate share/asset handling
  • Handle dust rounding errors in _amountToShare() during burn and transfer

πŸ“‘ Table of Contents

  1. Core Issue
  2. Edge Case Issues
  3. Recommendations

Core Issue

Anyone can directly theft over-rebasing assets that have been inflated donated via burn().


Description

The current implementation is vulnerable to a small deposit attack, where shares may round down to zero:

"Since there is no 0 share check in burn(), attackers can exploit over-rebasing assets."
β€” Coding a Solidity rebase token

To mitigate zero-share rounding, the original formula is scaled:

sharesToCreate * 10,000 * address(this).balance >= slippageBp * msg.value * _totalShares

When slippageBp = 0, the user is fully exposed to rebasing volatility.


βš”οΈ Attack Scenario

Actors:

  • bob: Owner
  • alice: Attacker
  • charlie: Innocent User

Steps:

  1. Pool starts with 0 shares (no bootstrap).
  2. alice mints with 1 wei to preempt a small share.
  3. charlie mints 1 ETH with slippageBp = 0.
  4. alice frontruns and donates 100 ETH, causing charlie's share to round to 0.
  5. alice tries to burn() the pool, but charlie frontruns and burns first.
  6. Result: charlie steals 101 ETH despite holding 0 shares.

πŸ”¬ Proof of Concept (PoC)

function test_sx() public {
    vm.prank(alice);
    token.mint{value: 1 wei}(alice, 0);

    vm.prank(alice);
    address(token).call{value: 100 ether}("");

    vm.prank(charlie);
    token.mint{value: 1 ether}(charlie, 0);

    vm.prank(charlie);
    token.burn(charlie, 101 ether);
}

Explanation:

In the formula:

(amount * _totalShares) / address(this).balance

If the result is 0 due to integer division, but burn() lacks a zero-share check, then rebased assets can be stolen.


βœ… Recommendations

πŸ”’ Security Fixes

  • Revert burn() if _amountToShares() returns 0.

  • Apply a Virtual Offset in mint() to prevent early ratio distortion:

    VOFFSET = 1;
    sharesToCreate = msg.value * (_totalShares + VOFFSET) / (prevBalance + VOFFSET);

    This avoids division by zero and stabilizes minting when balance or _totalShares is near 0.

  • Do NOT apply offset to burn() – rebasing effects from donations should be preserved accurately in withdrawals.


πŸ”§ Patched Code

check below patch.sol


⚠️ Edge Case Issues

  1. Ensure slippageBp is within (0,10,000]
  2. Revert if msg.value == 0 during mint()
// SPDX-License-Identifier: (c) RareSkills 2025
pragma solidity 0.8.28;
import {IERC20Errors} from "@openzeppelin/contracts/interfaces/draft-IERC6093.sol";
import {IERC20} from "@openzeppelin/contracts/interfaces/IERC20.sol";
contract RebasingERC20_VOFFSET is IERC20Errors, IERC20 {
uint256 internal _totalShares;
mapping(address => uint256) public _shareBalance;
mapping(address owner => mapping(address spender => uint256 allowance)) public allowance;
uint256 private constant VOFFSET = 1;
receive() external payable {}
function mint(address to, uint256 slippageBp) external payable {
require(to != address(0), ERC20InvalidReceiver(to));
require(msg.value > 0);
uint256 sharesToCreate;
if (_totalShares == 0) {
sharesToCreate = msg.value;
} else {
uint256 prevBalance = address(this).balance - msg.value;
sharesToCreate = msg.value * (_totalShares + VOFFSET) / (prevBalance + VOFFSET);
require(sharesToCreate > 0);
}
_totalShares += sharesToCreate;
_shareBalance[to] += sharesToCreate;
require(sharesToCreate * 10_000 * address(this).balance >= slippageBp * msg.value * _totalShares, "slippage");
uint256 balance = sharesToCreate * address(this).balance / _totalShares;
emit Transfer(address(0), to, balance);
}
function burn(address from, uint256 amount) external {
_spendAllowanceOrBlock(from, msg.sender, amount);
uint256 shares = _amountToShares(amount);
require(shares > 0);
_shareBalance[from] -= shares;
_totalShares -= shares;
(bool ok,) = from.call{value: amount}("");
require(ok, ERC20InvalidReceiver(from));
emit Transfer(from, address(0), amount);
}
function _amountToShares(uint256 amount) public view returns (uint256) {
if (address(this).balance == 0) {
return 0;
}
return amount * _totalShares / address(this).balance;
}
function totalSupply() public view returns (uint256) {
return address(this).balance;
}
function balanceOf(address account) public view returns (uint256) {
if (_totalShares == 0) {
return 0;
}
return _shareBalance[account] * address(this).balance / _totalShares;
}
function transfer(address to, uint256 amount) external returns (bool) {
transferFrom(msg.sender, to, amount);
return true;
}
function transferFrom(address from, address to, uint256 amount) public returns (bool) {
require(to != address(0), ERC20InvalidReceiver(to));
_spendAllowanceOrBlock(from, msg.sender, amount);
uint256 shareTransfer = _amountToShares(amount);
_shareBalance[from] -= shareTransfer;
_shareBalance[to] += shareTransfer;
emit Transfer(from, to, amount);
return true;
}
function approve(address spender, uint256 amount) external returns (bool) {
allowance[msg.sender][spender] = amount;
emit Approval(msg.sender, spender, amount);
return true;
}
function _spendAllowanceOrBlock(address owner, address spender, uint256 amount) internal {
if (owner != msg.sender && allowance[owner][spender] != type(uint256).max) {
uint256 currentAllowance = allowance[owner][spender];
require(currentAllowance >= amount, ERC20InsufficientAllowance(spender, currentAllowance, amount));
allowance[owner][spender] = currentAllowance - amount;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment