Automated token issuance protocols rely on bonding curves to enforce deterministic pricing functions $P(S)$ based on total token supply $S$. By computing buy and sell quotes algorithmically, bonding curves replace traditional order books and counterparty matching. However, because future prices are fully predictable from public state variables, bonding curves are exposed to severe mempool-based attacks, arithmetic overflow vulnerabilities, and reserve accounting flaws.
In public mempools, attackers can observe pending transactions and compute exact trade outcomes before block finalization. This allows malicious actors to front-run token launches, execute precision sandwich attacks, exploit timelock-free administrative updates, and drain virtual reserves.
This article examines six critical vulnerability classes in Solidity-based bonding curve contracts. We present vulnerable implementations, explain the underlying attack mechanics, and provide production-ready Solidity fixes.
1. Front-Running During Token Launch
The Vulnerability
A linear bonding curve starts at a base floor price $P(0)$ when supply is zero, allowing early buyers to purchase tokens at the lowest rate. When a project deploys and initializes a bonding curve contract, the activation transaction sits in the public mempool.
In the vulnerable contract below, the launch() and buy() functions lack authorization controls. Furthermore, calculating price when totalSupply is zero causes an immediate division-by-zero revert.
// SPDX-License-Identifier: MIT
// VULNERABLE: Lack of access control on launch and division by zero at zero supply
pragma solidity ^0.8.20;
contract BondingCurve {
uint256 public totalSupply;
uint256 public constant SLOPE = 1e12; // price = slope * supply
mapping(address => uint256) public balances;
function launch() external {
// Curve goes live here - price is at its minimum
totalSupply = 0;
}
function buy() external payable {
// BUG: When totalSupply is 0, price is 0.
// This causes a Division by Zero revert on msg.value / price.
uint256 price = SLOPE * totalSupply;
uint256 tokensBought = msg.value / price;
balances[msg.sender] += tokensBought;
totalSupply += tokensBought;
}
}
When launch() is broadcast, an attacker watching the mempool can submit a buy() transaction in the same block. Even if division-by-zero is avoided by adding a flat floor price, dividing deposited Ether by the current spot price (msg.value / price) is mathematically flawed. Spot price pricing assumes a flat rate across the entire trade volume, enabling an attacker to acquire a massive percentage of token supply at floor price in a single transaction.
Proper bonding curve pricing requires integrating the pricing function. For a linear curve $P(x) = m \cdot x + c$, purchasing $\Delta S$ tokens starting at supply $S$ costs:
$$Cost = \int_{S}^{S + \Delta S} (m \cdot x + c) \, dx = \frac{m}{2} \left((S + \Delta S)^2 - S^2\right) + c \cdot \Delta S$$
Solving for tokens outputted ($\Delta S$) yields the quadratic formula:
$$\Delta S = \sqrt{\left(S + \frac{c}{m}\right)^2 + \frac{2 \cdot Cost}{m}} - \left(S + \frac{c}{m}\right)$$
The Fix
To resolve launch front-running and pricing errors, we apply three core safeguards in BondingCurveSecure:
1. Minimum Floor Price (BASE_PRICE): Guarantees $P(0) > 0$ to eliminate division-by-zero risks.
2. Overflow-Safe Integral Pricing Math: Reformulate the quadratic solution by defining $k = S + \frac{c}{m}$ using supply scale dimensions ($10^{18}$). This avoids intermediate $10^{38}$ price scaling factors that cause uint256 arithmetic overflows at high token supplies.
3. Commit-Reveal Launch Window: Restrict launch participation to a whitelisted period managed via OpenZeppelin Ownable.
// SPDX-License-Identifier: MIT
// FIXED: Ownable governance, whitelist launch window, and overflow-safe integral math
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";
contract BondingCurveSecure is Ownable {
bytes32 public commitHash;
bool public launched;
uint256 public launchBlock;
uint256 public constant WHITELIST_BLOCKS = 50; // ~10 minutes window
uint256 public totalSupply;
uint256 public constant SLOPE = 1e12;
uint256 public constant BASE_PRICE = 0.001 ether; // Guarantees P(0) > 0
mapping(address => uint256) public balances;
mapping(address => bool) public whitelist;
event Launched(uint256 blockNumber);
event WhitelistUpdated(address indexed account, bool status);
constructor() Ownable(msg.sender) {}
function commitLaunch(bytes32 _hash) external onlyOwner {
commitHash = _hash;
}
function revealLaunch(bytes32 secret) external onlyOwner {
require(keccak256(abi.encodePacked(secret)) == commitHash, "bad reveal");
launched = true;
launchBlock = block.number;
emit Launched(block.number);
}
function setWhitelist(address account, bool status) external onlyOwner {
whitelist[account] = status;
emit WhitelistUpdated(account, status);
}
function buy(uint256 minTokensOut) external payable {
require(launched, "not launched");
if (block.number < launchBlock + WHITELIST_BLOCKS) {
require(whitelist[msg.sender], "whitelist only during launch phase");
}
// FIXED: Refactored quadratic formula to prevent uint256 overflow
// k = S + (BASE_PRICE * 1e18 / SLOPE)
uint256 baseOffset = (BASE_PRICE * 1e18) / SLOPE;
uint256 k = totalSupply + baseOffset;
uint256 y = (k * k) + ((2 * msg.value * 1e36) / SLOPE);
uint256 tokensBought = Math.sqrt(y) - k;
require(tokensBought >= minTokensOut, "slippage exceeded");
require(tokensBought > 0, "zero tokens bought");
balances[msg.sender] += tokensBought;
totalSupply += tokensBought;
}
}
2. No Slippage Protection on Curve Trades
The Vulnerability
Bonding curves adjust price dynamically as token supply expands and contracts. Without explicit slippage boundaries (minTokensOut / minEthOut) and deadline checks, transactions are vulnerable to sandwich attacks in public mempools.
Flawed implementations often evaluate exchange rates using static linear ratios like (ethIn * supply) / reserve. This locks the exchange rate permanently because purchases scale supply and reserve by equal proportions. Furthermore, using deprecated .transfer() calls caps gas at 2,300, causing transactions to revert when interacting with smart contract wallets or multisigs.
// SPDX-License-Identifier: MIT
// VULNERABLE: No slippage limits, linear ratio price lock, and deprecated transfer()
pragma solidity ^0.8.20;
contract BondingCurve {
uint256 public reserve;
uint256 public supply;
function calculateTokens(uint256 ethIn, uint256 _reserve, uint256 _supply) public pure returns (uint256) {
if (_reserve == 0) return ethIn;
// BUG: Linear ratio keeps supply/reserve ratio locked, freezing the price
return (ethIn * _supply) / _reserve;
}
function calculateEth(uint256 tokenAmount, uint256 _reserve, uint256 _supply) public pure returns (uint256) {
if (_supply == 0) return 0;
return (tokenAmount * _reserve) / _supply;
}
function buy() external payable {
uint256 tokensOut = calculateTokens(msg.value, reserve, supply);
supply += tokensOut;
reserve += msg.value;
}
function sell(uint256 tokenAmount) external {
uint256 ethOut = calculateEth(tokenAmount, reserve, supply);
supply -= tokenAmount;
reserve -= ethOut;
// BUG: transfer() is limited to 2300 gas and can lead to permanent DOS
payable(msg.sender).transfer(ethOut);
}
}
MEV bots exploit unmonitored trades by front-running purchases to drive up price, letting the target trade execute at high slippage, and back-running a sell transaction for profit.
The Fix
We upgrade the contract by inheriting OpenZeppelin's ERC20 and ReentrancyGuard. We enforce mathematical integration for buy and sell quotes, implement minTokensOut, minEthOut, and deadline controls, and replace .transfer() with .call{value: ...}(""). Local state variables for supply are removed in favor of totalSupply().
// SPDX-License-Identifier: MIT
// FIXED: ERC20 standard, integral pricing math, slippage protection, and safe ETH transfers
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";
contract BondingCurveSecure is ERC20, ReentrancyGuard {
uint256 public constant SLOPE = 1e12;
uint256 public constant BASE_PRICE = 0.001 ether;
constructor() ERC20("Curve Token", "CRV") {}
function calculateTokens(uint256 ethIn, uint256 _supply) public pure returns (uint256) {
uint256 baseOffset = (BASE_PRICE * 1e18) / SLOPE;
uint256 k = _supply + baseOffset;
uint256 y = (k * k) + ((2 * ethIn * 1e36) / SLOPE);
return Math.sqrt(y) - k;
}
function calculateEth(uint256 tokenAmount, uint256 _supply) public pure returns (uint256) {
require(_supply >= tokenAmount, "Insufficient supply");
// FIXED: Scaled term calculations to match Wei denominator (2 * 1e36)
uint256 term1 = SLOPE * (2 * _supply * tokenAmount - tokenAmount * tokenAmount);
uint256 term2 = 2 * BASE_PRICE * tokenAmount * 1e18;
return (term1 + term2) / (2 * 1e36);
}
function buy(
uint256 minTokensOut,
uint256 deadline
) external payable nonReentrant {
require(block.timestamp <= deadline, "transaction expired");
uint256 tokensOut = calculateTokens(msg.value, totalSupply());
require(tokensOut >= minTokensOut, "slippage limit exceeded");
_mint(msg.sender, tokensOut);
}
function sell(
uint256 tokenAmount,
uint256 minEthOut,
uint256 deadline
) external nonReentrant {
require(block.timestamp <= deadline, "transaction expired");
uint256 ethOut = calculateEth(tokenAmount, totalSupply());
require(ethOut >= minEthOut, "slippage limit exceeded");
_burn(msg.sender, tokenAmount);
(bool success, ) = payable(msg.sender).call{value: ethOut}("");
require(success, "ETH transfer failed");
}
}
3. Reserve Accumulation via Rounding Manipulation
The Vulnerability
Solidity integer division truncates fractional values toward zero. A common misconception is that truncation allows attackers to drain contract reserves.
In practice, integer truncation favors the contract. When a user buys tokens, truncation reduces output tokens slightly below exact mathematical curves. When selling, truncation reduces returned Ether. This leaves residual dust inside the reserve, causing reserve accumulation rather than reserve drain.
However, zero reserve values create severe division-by-zero vulnerabilities at launch, while microscopic trades subject users to complete value loss from dust rounding.
// SPDX-License-Identifier: MIT
// VULNERABLE: Division by zero when reserve is 0, and lack of dust limits
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
contract BondingCurve {
uint256 public reserve;
uint256 public supply;
constructor() ERC20("Curve Token", "CRV") {}
function buy() external payable {
// BUG: If reserve is 0 at launch, this reverts with a division by zero error
uint256 tokensOut = (msg.value * supply) / reserve;
supply += tokensOut;
reserve += msg.value;
_mint(msg.sender, tokensOut);
}
function sell(uint256 tokenAmount) external {
// BUG: Underflow risk and division by zero if supply becomes 0
uint256 ethOut = (tokenAmount * reserve) / supply;
supply -= tokenAmount;
reserve -= ethOut;
_burn(msg.sender, tokenAmount);
payable(msg.sender).transfer(ethOut);
}
}
If reserve starts at zero, initial buy() attempts trigger division-by-zero reverts, rendering the contract unusable.
The Fix
To secure reserve accounting and eliminate launch lockups:
1. Virtual Supply Seeding (VIRTUAL_SUPPLY): Seeds initial denominator states to guarantee non-zero pricing calculations.
2. Dust Mitigation Limits (MIN_TRADE_ETH): Enforces minimum deposit thresholds to prevent zero-value rounding output.
3. Overflow-Safe Integral Formula: Computes token returns using supply-offset quadratic math.
// SPDX-License-Identifier: MIT
// FIXED: Virtual supply seeding, trade dust protection, and ERC20 totalSupply integration
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";
contract BondingCurveSecure is ERC20, ReentrancyGuard {
uint256 public constant SLOPE = 1e12;
uint256 public constant BASE_PRICE = 0.001 ether;
uint256 public constant VIRTUAL_SUPPLY = 1000 * 1e18;
uint256 public constant MIN_TRADE_ETH = 0.001 ether;
constructor() ERC20("Curve Token", "CRV") {}
function buy(uint256 minTokensOut, uint256 deadline) external payable nonReentrant {
require(msg.value >= MIN_TRADE_ETH, "trade size below minimum threshold");
require(block.timestamp <= deadline, "transaction expired");
uint256 effectiveSupply = totalSupply() + VIRTUAL_SUPPLY;
uint256 baseOffset = (BASE_PRICE * 1e18) / SLOPE;
uint256 k = effectiveSupply + baseOffset;
uint256 y = (k * k) + ((2 * msg.value * 1e36) / SLOPE);
uint256 tokensOut = Math.sqrt(y) - k;
require(tokensOut >= minTokensOut, "slippage limit exceeded");
require(tokensOut > 0, "zero tokens output");
_mint(msg.sender, tokensOut);
}
function sell(
uint256 tokenAmount,
uint256 minEthOut,
uint256 deadline
) external nonReentrant {
require(tokenAmount > 0, "token amount must be positive");
require(block.timestamp <= deadline, "transaction expired");
uint256 effectiveSupply = totalSupply() + VIRTUAL_SUPPLY;
uint256 term1 = SLOPE * (2 * effectiveSupply * tokenAmount - tokenAmount * tokenAmount);
uint256 term2 = 2 * BASE_PRICE * tokenAmount * 1e18;
uint256 ethOut = (term1 + term2) / (2 * 1e36);
require(ethOut >= minEthOut, "slippage limit exceeded");
require(ethOut > 0, "zero ETH output");
_burn(msg.sender, tokenAmount);
(bool success, ) = payable(msg.sender).call{value: ethOut}("");
require(success, "ETH transfer failed");
}
}
4. Curve Parameter Front-Running (Admin Update)
The Vulnerability
Administrative functions that update pricing slopes or fee rates without notice create front-running opportunities for mempool searchers.
// SPDX-License-Identifier: MIT
// VULNERABLE: Instant parameter changes without timelocks and custom non-standard ownership
pragma solidity ^0.8.20;
contract BondingCurve {
uint256 public slope;
uint256 public intercept;
address public owner;
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
constructor(uint256 _slope, uint256 _intercept) {
slope = _slope;
intercept = _intercept;
owner = msg.sender;
}
function setParameters(uint256 newSlope, uint256 newIntercept)
external
onlyOwner
{
// BUG: Parameter change takes effect instantly, allowing MEV front-running
slope = newSlope;
intercept = newIntercept;
}
function price(uint256 supply) public view returns (uint256) {
return slope * supply + intercept;
}
}
When an admin submits a transaction to increase slope, an attacker can front-run the transaction by purchasing tokens at the old price. Once the parameter update executes, the attacker back-runs the transaction to sell tokens at the elevated price, extracting value from the contract.
The Fix
We resolve parameter front-running by enforcing a 24-hour timelock mechanism and incorporating OpenZeppelin's Ownable module. Parameter updates require a two-step queue and execution process.
// SPDX-License-Identifier: MIT
// FIXED: Timelocked parameter updates with OpenZeppelin Ownable
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/access/Ownable.sol";
contract BondingCurveSecure is Ownable {
uint256 public slope;
uint256 public intercept;
uint256 public constant TIMELOCK_DELAY = 24 hours;
struct PendingUpdate {
uint256 newSlope;
uint256 newIntercept;
uint256 eta;
}
PendingUpdate public pendingUpdate;
event ParameterUpdateQueued(uint256 newSlope, uint256 newIntercept, uint256 eta);
event ParameterUpdateExecuted(uint256 newSlope, uint256 newIntercept);
event ParameterUpdateCancelled();
constructor(uint256 _slope, uint256 _intercept) Ownable(msg.sender) {}
function queueParameterUpdate(uint256 newSlope, uint256 newIntercept)
external
onlyOwner
{
uint256 eta = block.timestamp + TIMELOCK_DELAY;
pendingUpdate = PendingUpdate(newSlope, newIntercept, eta);
emit ParameterUpdateQueued(newSlope, newIntercept, eta);
}
function executeParameterUpdate() external onlyOwner {
require(pendingUpdate.eta != 0, "no update queued");
require(block.timestamp >= pendingUpdate.eta, "timelock delay not elapsed");
slope = pendingUpdate.newSlope;
intercept = pendingUpdate.newIntercept;
emit ParameterUpdateExecuted(slope, intercept);
delete pendingUpdate;
}
function cancelParameterUpdate() external onlyOwner {
delete pendingUpdate;
emit ParameterUpdateCancelled();
}
function price(uint256 supply) public view returns (uint256) {
return slope * supply + intercept;
}
}
5. Virtual Reserve Manipulation & Reserve Drain Insolvency
The Vulnerability
In Bancor-style bonding curve pricing models, virtual reserves establish baseline liquidity prior to user deposits.
In the flawed contract below, token output is computed using an AMM swap equation. Unlike AMMs that swap existing pool tokens, bonding curves mint new tokens. Applying AMM pricing formulas to a minting mechanism collapses the pricing curve, forcing token prices back to initial ratios regardless of market demand.
Furthermore, naive implementations that calculate sale refunds using theoretical curve supply without tracking actual collateral (realReserve) create fatal insolvency bugs. When buyer A buys early and buyer B buys later, total supply increases. If A sells while supply is elevated, calculating sale refunds based on theoretical supply allows A to withdraw a higher ETH rate than deposited, stealing B's collateral. Later sellers encounter persistent transaction reverts (require(ethOut <= address(this).balance)), causing permanent Denial of Service (DoS) and frozen funds.
// SPDX-License-Identifier: MIT
// VULNERABLE: Flawed AMM formula, local supply tracking, and missing sell()
pragma solidity ^0.8.20;
contract BancorCurve {
uint256 public realReserve;
uint256 public virtualReserve = 1 ether;
uint256 public supply = 1_000_000 * 1e18; // 1M supply
function buy() external payable {
uint256 totalReserve = realReserve + virtualReserve;
// BUG: Applies AMM swap math to a minting mechanism, collapsing curve pricing
uint256 tokensOut = supply * (
(1e18 + (msg.value * 1e18) / totalReserve) - 1e18
) / 1e18;
realReserve += msg.value;
supply += tokensOut;
}
// BUG: Missing sell() function
}
The Fix
To secure virtual reserve contracts and eliminate insolvency DoS vulnerabilities:
1. Explicit State Tracking (realReserve): Track collateral deposits directly in state variables (realReserve) so reserve accounting remains synchronized.
2. Bancor Invariant Pricing Formula ($F = 50\%$): Compute purchase and sale returns using connector weight formulas ($F = 0.5$) with total reserve defined as $R_{total} = realReserve + VIRTUAL_RESERVE$.
3. High-Precision Sqrt Scaling: Scale intermediate values to $10^{36}$ prior to executing Math.sqrt to prevent precision loss.
4. Path-Independent Solvency Guarantee: Because sale refunds are derived directly from $realReserve + VIRTUAL_RESERVE$, refunds mathematically satisfy $ethOut \le realReserve$. This guarantees solvency and path-independent conservation without requiring naive revert caps.
For connector weight $F = 0.5$, the continuous formulas are:
$$PurchaseReturn = S_{eff} \cdot \left(\sqrt{1 + \frac{\Delta R}{R_{total}}} - 1\right)$$
$$SaleReturn = R_{total} \cdot \left(\frac{2 \cdot S_{eff} \cdot \Delta S - \Delta S^2}{S_{eff}^2}\right)$$
// SPDX-License-Identifier: MIT
// FIXED: Path-independent Bancor formula with realReserve state tracking, high-precision math, and safe solvency guarantees
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
import "@openzeppelin/contracts/utils/math/Math.sol";
contract BancorCurveSecure is ERC20, ReentrancyGuard {
uint256 public realReserve;
uint256 public constant VIRTUAL_RESERVE = 10 ether;
uint256 public constant VIRTUAL_SUPPLY = 1_000_000 * 1e18;
uint256 public constant MAX_BUY_ETH = 0.5 ether;
constructor() ERC20("Bancor Curve Token", "BCT") {}
function calculatePurchaseReturn(
uint256 depositAmount,
uint256 currentSupply,
uint256 currentRealReserve
) public pure returns (uint256) {
uint256 totalReserve = currentRealReserve + VIRTUAL_RESERVE;
uint256 effectiveSupply = currentSupply + VIRTUAL_SUPPLY;
// Scale to 1e36 before square root to retain 1e18 precision after sqrt
uint256 ratio = 1e36 + (depositAmount * 1e36) / totalReserve;
return (effectiveSupply * (Math.sqrt(ratio) - 1e18)) / 1e18;
}
function calculateSaleReturn(
uint256 tokenAmount,
uint256 currentSupply,
uint256 currentRealReserve
) public pure returns (uint256) {
uint256 totalReserve = currentRealReserve + VIRTUAL_RESERVE;
uint256 effectiveSupply = currentSupply + VIRTUAL_SUPPLY;
require(effectiveSupply >= tokenAmount, "Token amount exceeds supply");
uint256 term = (2 * effectiveSupply * tokenAmount) - (tokenAmount * tokenAmount);
return (totalReserve * term) / (effectiveSupply * effectiveSupply);
}
function buy(uint256 minTokensOut, uint256 deadline) external payable nonReentrant {
require(msg.value <= MAX_BUY_ETH, "deposit exceeds transaction cap");
require(block.timestamp <= deadline, "transaction expired");
uint256 tokensOut = calculatePurchaseReturn(msg.value, totalSupply(), realReserve);
require(tokensOut >= minTokensOut, "slippage limit exceeded");
realReserve += msg.value;
_mint(msg.sender, tokensOut);
}
function sell(
uint256 tokenAmount,
uint256 minEthOut,
uint256 deadline
) external nonReentrant {
require(tokenAmount > 0, "token amount must be positive");
require(block.timestamp <= deadline, "transaction expired");
uint256 ethOut = calculateSaleReturn(tokenAmount, totalSupply(), realReserve);
require(ethOut >= minEthOut, "slippage limit exceeded");
require(ethOut <= realReserve, "Insufficient real reserve");
realReserve -= ethOut;
_burn(msg.sender, tokenAmount);
(bool success, ) = payable(msg.sender).call{value: ethOut}("");
require(success, "ETH transfer failed");
}
}
6. Reserve Extraction Flaws: Snapshot Mismatch & Time Drift
The Vulnerability
Governance frameworks often include administrative reserve withdrawal mechanisms to collect operational fees. However, improper accounting design introduces three flaws:
- Uninitialized Snapshot DoS:
epochReserveSnapshotdefaults to zero if not initialized in the constructor. Multiplying by zero yields a zero withdrawal cap, causing all extraction calls to revert. - Epoch Time Drift: Resetting epoch timers using
epochStart = block.timestampcauses withdrawal windows to shift forward whenever withdrawals are delayed. - Snapshot Mismatch: Failing to synchronize
epochReserveSnapshotwhen new reserves accumulate during an active epoch restricts governance withdrawals to deployment baseline values.
// SPDX-License-Identifier: MIT
// VULNERABLE: Uninitialized snapshot DoS, time-drifting epoch logic, and 2300 gas transfer limits
pragma solidity ^0.8.20;
contract BondingCurve {
uint256 public reserve;
address public owner;
uint256 public constant EPOCH_DURATION = 7 days;
uint256 public constant MAX_EPOCH_WITHDRAWAL_BPS = 500; // 5% of reserve per epoch
uint256 public epochStart;
uint256 public epochWithdrawn;
uint256 public epochReserveSnapshot; // BUG: Left uninitialized at 0
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
constructor() {
owner = msg.sender;
epochStart = block.timestamp;
}
function extractReserve(uint256 amount, address to) external onlyOwner {
// BUG: maxThisEpoch evaluates to 0 due to uninitialized epochReserveSnapshot
uint256 maxThisEpoch = (epochReserveSnapshot * MAX_EPOCH_WITHDRAWAL_BPS) / 10_000;
require(
epochWithdrawn + amount <= maxThisEpoch,
"epoch withdrawal limit exceeded"
);
require(amount <= reserve, "insufficient reserve");
reserve -= amount;
epochWithdrawn += amount;
// BUG: transfer() fails on smart contract recipients
payable(to).transfer(amount);
}
}
The Fix
In BondingCurveSecure, we fix these flaws by initializing snapshots in the constructor, advancing epochStart via fixed additions (epochStart += EPOCH_DURATION), syncing snapshots when new reserves enter, and using low-level .call transfers.
// SPDX-License-Identifier: MIT
// FIXED: Initialized reserve snapshot, drift-free epoch advancement, balance sync, and governance controls
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract BondingCurveSecure is ReentrancyGuard {
address public governance;
uint256 public constant EPOCH_DURATION = 7 days;
uint256 public constant MAX_EPOCH_WITHDRAWAL_BPS = 500; // 5% per epoch
uint256 public epochStart;
uint256 public epochWithdrawn;
uint256 public epochReserveSnapshot;
event ReserveExtracted(address indexed to, uint256 amount);
event GovernanceChanged(address indexed oldGov, address indexed newGov);
modifier onlyGovernance() {
require(msg.sender == governance, "not authorized: governance only");
_;
}
constructor(address _governance) payable {
require(_governance != address(0), "invalid governance address");
require(msg.value > 0, "must deploy with initial reserve");
governance = _governance;
epochStart = block.timestamp;
epochReserveSnapshot = msg.value; // FIXED: Initialize snapshot with initial balance
}
receive() external payable {}
function setGovernance(address _newGovernance) external onlyGovernance {
require(_newGovernance != address(0), "invalid governance address");
emit GovernanceChanged(governance, _newGovernance);
governance = _newGovernance;
}
function extractReserve(uint256 amount, address payable to) external onlyGovernance nonReentrant {
uint256 currentBalance = address(this).balance;
// FIXED: Advance epochStart without time drift
while (block.timestamp >= epochStart + EPOCH_DURATION) {
epochStart += EPOCH_DURATION;
epochWithdrawn = 0;
epochReserveSnapshot = currentBalance;
}
// FIXED: Sync snapshot to accumulated balance if no withdrawal occurred yet in this epoch
if (epochWithdrawn == 0 && currentBalance > epochReserveSnapshot) {
epochReserveSnapshot = currentBalance;
}
uint256 referenceBalance = epochReserveSnapshot;
uint256 maxThisEpoch = (referenceBalance * MAX_EPOCH_WITHDRAWAL_BPS) / 10_000;
require(
epochWithdrawn + amount <= maxThisEpoch,
"epoch withdrawal limit exceeded"
);
require(amount <= currentBalance, "insufficient reserve balance");
epochWithdrawn += amount;
(bool success, ) = to.call{value: amount}("");
require(success, "reserve extraction transfer failed");
emit ReserveExtracted(to, amount);
}
}
What ContractScan Detects
ContractScan detects bonding curve vulnerability patterns through automated static analysis, control-flow graph validation, and symbolic execution.
| Vulnerability Class | Detection Method | Severity |
|---|---|---|
| Front-running during launch | Flags un-guarded launch methods lacking commit-reveal or whitelist controls. | High |
| Missing slippage protection | Identifies trade execution functions missing output parameter bounds or deadlines. | High |
| Integer rounding & overflow | Flags un-scaled quadratic multiplications prone to uint256 arithmetic overflows. |
High |
| Curve parameter front-running | Detects instant administrative parameter setters missing timelock delays. | High |
| Virtual reserve insolvency | Identifies theoretical pricing curves un-synchronized with actual collateral state (realReserve). |
Critical |
| Unrestricted reserve extraction | Flags withdrawal methods prone to snapshot un-initialization or epoch time-drift errors. | High |
Analyze your smart contracts prior to deployment at contract-scanner.raccoonworld.xyz.
Pre-Deployment Security Checklist
Verify these safeguards before deploying bonding curve contracts:
- [ ] Access Controls: Are administrative and extraction functions restricted by standard access control modules (
Ownable/AccessControl)? - [ ] Slippage Protection: Do trade functions validate
minTokensOut,minEthOut, and transactiondeadlinebounds? - [ ] Mathematical Safety: Are quadratic pricing calculations refactored into supply-offset forms to prevent
uint256overflow? - [ ] Timelocks: Are administrative pricing parameter changes locked behind multi-step timelock delays?
- [ ] Safe Transfers: Does the contract transfer Ether using low-level
.call{value: ...}("")calls instead of.transfer()? - [ ] Reserve Conservation: Is collateral balance explicitly tracked via state variables (
realReserve) to maintain path-independent solvency? - [ ] Multi-signature Governance: Is contract governance assigned to a multi-signature wallet or decentralized DAO contract?
Frequently Asked Questions
What causes division-by-zero vulnerabilities in bonding curves?
Division-by-zero errors occur when contract formulas divide deposit values by spot prices or reserve states that evaluate to zero at contract launch. Securing initial launch states requires defining non-zero floor prices (BASE_PRICE) or seeding virtual supply variables.
Why does integer truncation in Solidity favor contract reserves?
Solidity integer division truncates decimal values down to the nearest integer. When calculating token outputs or Ether refunds, truncation yields slightly less value than theoretical curves, retaining tiny remainder dust inside contract reserves.
How do timelocks eliminate parameter update front-running?
Timelocks separate parameter updates into queue and execution phases separated by a time delay. This delay provides market participants notice of upcoming fee or slope changes, allowing them to adjust positions before modifications take effect.
Why should developers replace .transfer() with .call{value: ...}("")?
The standard .transfer() method forwards a fixed gas stipend of 2,300 gas. If the recipient address is a smart contract wallet or multisig requiring more than 2,300 gas to process incoming transfers, the transaction reverts. Low-level .call combined with ReentrancyGuard eliminates gas cap restrictions safely.
Related Posts
- Token Price Manipulation: Low Liquidity and Spot Price Attacks
- AMM and DEX Security: Uniswap Price Manipulation
Disclaimer
This article is provided for educational and audit reference purposes only and does not constitute financial or legal advice. Smart contract security requires comprehensive independent testing and formal audits prior to mainnet deployment.