Bonding curves are mathematically elegant. Given a supply S and a price function P(S), every trade is fully deterministic — no order book, no counterparty discovery, no bid-ask spread. Protocols from Pump.fun to early Bancor to countless governance token launches rely on this property.
That determinism is also the attack surface.
When every price is predictable from on-chain state, attackers can compute exactly what a pending transaction will do to the curve before it confirms. They can front-run launches at the floor price, sandwich ordinary trades with precision, exploit parameter updates, and drain reserves through uncapped owner functions. None of these attacks require zero-days — only the ability to read the mempool and bid higher gas.
This post covers six vulnerability classes in bonding curve implementations, with vulnerable Solidity, attack mechanics, and the fixes that close each one.
1. Front-Running During Token Launch
The vulnerability
A linear bonding curve starts at price P(0) — a small initial value that gives early buyers a low entry price. The launch transaction is visible in the mempool before it confirms.
// VULNERABLE: launch is a public mempool event with no front-run protection
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 {
uint256 price = SLOPE * totalSupply;
uint256 tokensBought = msg.value / price;
balances[msg.sender] += tokensBought;
totalSupply += tokensBought;
}
}
The attacker submits buy() with higher gas before launch() confirms:
- Attacker buys at totalSupply = 0 — the floor price P(0)
- Legitimate buyers execute at higher supply and higher price
- Attacker sells immediately at the elevated price
On a 10,000 ETH launch, the attacker captures 20–30% of first-block price appreciation at the expense of legitimate buyers.
The fix
Use a commit-reveal scheme or restrict initial purchases to a whitelist for a fixed window.
// FIXED: commit-reveal launch — attacker cannot predict the activation block
contract BondingCurveSecure {
bytes32 public commitHash;
bool public launched;
uint256 public launchBlock;
uint256 public constant WHITELIST_BLOCKS = 50; // ~10 min
mapping(address => bool) public whitelist;
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;
}
function buy(uint256 minTokensOut) external payable {
require(launched, "not launched");
if (block.number < launchBlock + WHITELIST_BLOCKS) {
require(whitelist[msg.sender], "whitelist only");
}
// ... rest of buy logic with slippage check
}
}
2. No Slippage Protection on Curve Trades
The vulnerability
Without a minTokensOut parameter, a bonding curve buy() offers no protection against MEV bots inserting trades between submission and confirmation.
// VULNERABLE: no minTokensOut, no deadline — sandwich-able
contract BondingCurve {
uint256 public reserve;
uint256 public supply;
function buy() external payable {
uint256 tokensOut = calculateTokens(msg.value, reserve, supply);
supply += tokensOut;
reserve += msg.value;
_mint(msg.sender, tokensOut);
}
function sell(uint256 tokenAmount) external {
uint256 ethOut = calculateEth(tokenAmount, reserve, supply);
supply -= tokenAmount;
reserve -= ethOut;
_burn(msg.sender, tokenAmount);
payable(msg.sender).transfer(ethOut);
}
}
A sandwich attack in three steps:
- Front-run: Bot buys tokens, pushing price up ~15%
- Victim: User's
buy()executes at the inflated price, receiving 15% fewer tokens - Back-run: Bot sells at the elevated price, keeping the spread
The bot earns the spread; the victim gets far fewer tokens than quoted. Without a deadline, a stale transaction can sit in the mempool for hours and execute at a wildly different price.
The fix
Add minTokensOut and deadline to every buy and sell function — the same pattern Uniswap V2 established.
// FIXED: slippage and deadline protection on all trade functions
contract BondingCurveSecure {
uint256 public reserve;
uint256 public supply;
function buy(
uint256 minTokensOut,
uint256 deadline
) external payable {
require(block.timestamp <= deadline, "expired");
uint256 tokensOut = calculateTokens(msg.value, reserve, supply);
require(tokensOut >= minTokensOut, "slippage exceeded");
supply += tokensOut;
reserve += msg.value;
_mint(msg.sender, tokensOut);
}
function sell(
uint256 tokenAmount,
uint256 minEthOut,
uint256 deadline
) external {
require(block.timestamp <= deadline, "expired");
uint256 ethOut = calculateEth(tokenAmount, reserve, supply);
require(ethOut >= minEthOut, "slippage exceeded");
supply -= tokenAmount;
reserve -= ethOut;
_burn(msg.sender, tokenAmount);
payable(msg.sender).transfer(ethOut);
}
}
Front-ends should default minTokensOut to 99% of the quoted amount and deadline to now plus five minutes.
3. Reserve Drain via Rounding Manipulation
The vulnerability
Solidity integer division always truncates. A buy computes tokensOut = ethIn * supply / reserve and a sell computes ethOut = tokenIn * reserve / supply — both round down. The per-trade error is a fraction of a wei, but an attacker contract can execute millions of round trips in a single block.
// VULNERABLE: rounding loss accumulates in reserve, drainable at scale
contract BondingCurve {
uint256 public reserve;
uint256 public supply;
function buy() external payable {
// Integer division truncates — tiny loss on every call
uint256 tokensOut = (msg.value * supply) / reserve;
supply += tokensOut;
reserve += msg.value;
_mint(msg.sender, tokensOut);
}
function sell(uint256 tokenAmount) external {
// Symmetric truncation — each round trip leaks reserve
uint256 ethOut = (tokenAmount * reserve) / supply;
supply -= tokenAmount;
reserve -= ethOut;
_burn(msg.sender, tokenAmount);
payable(msg.sender).transfer(ethOut);
}
}
Each 1-wei buy followed by an immediate sell extracts a tiny positive difference from the reserve. At 1 million iterations — achievable in a single block from a contract — a 100 ETH reserve can leak several ETH.
The fix
Enforce a minimum trade size and verify the invariant: a full round trip must never return more than the input.
// FIXED: minimum trade size + round-trip invariant check
contract BondingCurveSecure {
uint256 public reserve;
uint256 public supply;
uint256 public constant MIN_TRADE_ETH = 0.001 ether; // dust guard
function buy(uint256 minTokensOut, uint256 deadline) external payable {
require(msg.value >= MIN_TRADE_ETH, "below minimum trade size");
require(block.timestamp <= deadline, "expired");
uint256 tokensOut = (msg.value * supply) / reserve;
require(tokensOut >= minTokensOut, "slippage exceeded");
require(tokensOut > 0, "zero tokens out");
supply += tokensOut;
reserve += msg.value;
_mint(msg.sender, tokensOut);
}
function sell(
uint256 tokenAmount,
uint256 minEthOut,
uint256 deadline
) external {
require(tokenAmount > 0, "zero amount");
require(block.timestamp <= deadline, "expired");
// Sell rounds DOWN — caller receives <= fair value, reserve protected
uint256 ethOut = (tokenAmount * reserve) / supply;
require(ethOut >= minEthOut, "slippage exceeded");
supply -= tokenAmount;
reserve -= ethOut;
_burn(msg.sender, tokenAmount);
payable(msg.sender).transfer(ethOut);
}
}
Add a fuzz test verifying sell(buy(x)) <= x across a range of inputs.
4. Curve Parameter Front-Running (Admin Update)
The vulnerability
Many bonding curves let an admin adjust the slope, intercept, or reserve ratio. That parameter update is visible in the mempool, letting an attacker front-run with a favorable trade and back-run once the parameters change.
// VULNERABLE: instant parameter update — front-runnable
contract BondingCurve {
uint256 public slope;
uint256 public intercept;
address public owner;
constructor(uint256 _slope, uint256 _intercept) {
slope = _slope;
intercept = _intercept;
owner = msg.sender;
}
// Owner calls this to rebalance — visible in mempool before confirmation
function setParameters(uint256 newSlope, uint256 newIntercept)
external
onlyOwner
{
slope = newSlope;
intercept = newIntercept;
}
function price(uint256 supply) public view returns (uint256) {
return slope * supply + intercept;
}
}
Suppose the owner increases slope to correct undervaluation. An attacker sees the pending setParameters() call and executes:
- Front-run: Buy at the old lower price
- Parameter update: Slope increases, curve shifts up
- Back-run: Sell at the new higher price
The attacker extracts the price delta the admin intended to correct — the larger the change, the larger the profit.
The fix
Apply a timelock to all parameter changes. A 24-hour delay makes the attack window costly to exploit.
// FIXED: timelocked parameter update — no instant front-run opportunity
contract BondingCurveSecure {
uint256 public slope;
uint256 public intercept;
address public owner;
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);
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(block.timestamp >= pendingUpdate.eta, "timelock active");
slope = pendingUpdate.slope;
intercept = pendingUpdate.intercept;
delete pendingUpdate;
emit ParameterUpdateExecuted(slope, intercept);
}
}
5. Virtual Reserve Manipulation
The vulnerability
Bancor-style curves seed a virtual reserve — protocol-owned ETH that sets the floor price before real liquidity enters. If it is too small relative to total token supply, a single large buy pushes the price arbitrarily high, inflating cost for every subsequent buyer.
// VULNERABLE: virtual reserve too small — one buyer distorts price for everyone
contract BancorCurve {
uint256 public realReserve;
uint256 public virtualReserve = 1 ether; // only 1 ETH virtual backing
uint256 public supply = 1_000_000 * 1e18; // 1M tokens total supply
function buy() external payable {
uint256 totalReserve = realReserve + virtualReserve;
// Bancor formula — simplified
uint256 tokensOut = supply * (
(1e18 + (msg.value * 1e18) / totalReserve) - 1e18
) / 1e18;
realReserve += msg.value;
supply -= tokensOut;
_mint(msg.sender, tokensOut);
}
}
With a 1 ETH virtual reserve and 1 million tokens, a whale buying 10 ETH pushes the price roughly 10x. Every subsequent buyer pays 10x the intended launch price, while the whale sells to newcomers at a 10x premium.
The fix
Size the virtual reserve relative to the expected raise target and cap per-transaction purchases.
// FIXED: virtual reserve sized to support expected liquidity; per-tx cap
contract BancorCurveSecure {
uint256 public realReserve;
// 10% of expected 100 ETH raise target
uint256 public virtualReserve = 10 ether;
uint256 public supply = 1_000_000 * 1e18;
// 1% of virtual reserve per tx — limits price impact
uint256 public constant MAX_BUY_ETH = 0.1 ether;
function buy(uint256 minTokensOut, uint256 deadline) external payable {
require(msg.value <= MAX_BUY_ETH, "exceeds per-tx cap");
require(block.timestamp <= deadline, "expired");
uint256 totalReserve = realReserve + virtualReserve;
uint256 tokensOut = _bancorFormula(msg.value, totalReserve, supply);
require(tokensOut >= minTokensOut, "slippage exceeded");
realReserve += msg.value;
supply -= tokensOut;
_mint(msg.sender, tokensOut);
}
}
6. Rug Pull via Reserve Extraction Function
The vulnerability
Many bonding curve contracts include an admin function to withdraw fees or recover ETH. With no withdrawal cap and no timelock, the owner can drain the entire reserve in a single transaction, leaving token holders with worthless assets.
// VULNERABLE: owner can drain 100% of reserve in one call
contract BondingCurve {
uint256 public reserve;
address public owner;
modifier onlyOwner() {
require(msg.sender == owner, "not owner");
_;
}
function extractReserve(uint256 amount) external onlyOwner {
require(amount <= reserve, "insufficient reserve");
reserve -= amount;
payable(owner).transfer(amount); // uncapped, no timelock
}
}
The attack: owner calls extractReserve(reserve). All backing ETH is gone; token holders' sells revert. The only recourse is legal, not technical.
The fix
Apply two controls: a per-epoch withdrawal cap and governance ownership instead of a single EOA.
// FIXED: capped, timelocked, governance-controlled reserve extraction
contract BondingCurveSecure {
uint256 public reserve;
address public governance; // multisig or DAO
uint256 public constant EPOCH_DURATION = 7 days;
uint256 public constant MAX_EPOCH_WITHDRAWAL_BPS = 500; // 5% per epoch
uint256 public epochStart;
uint256 public epochWithdrawn;
event ReserveExtracted(address indexed to, uint256 amount);
modifier onlyGovernance() {
require(msg.sender == governance, "not governance");
_;
}
function extractReserve(uint256 amount, address to) external onlyGovernance {
if (block.timestamp >= epochStart + EPOCH_DURATION) {
epochStart = block.timestamp;
epochWithdrawn = 0; // reset epoch
}
uint256 maxThisEpoch = (reserve * MAX_EPOCH_WITHDRAWAL_BPS) / 10_000;
require(
epochWithdrawn + amount <= maxThisEpoch,
"epoch withdrawal cap exceeded"
);
reserve -= amount;
epochWithdrawn += amount;
payable(to).transfer(amount);
emit ReserveExtracted(to, amount);
}
}
Replacing onlyOwner with a multisig or DAO requires multiple signers and prevents unilateral extraction.
What ContractScan Detects
ContractScan detects all six vulnerability classes using static analysis, data flow tracing, and semantic pattern matching.
| Vulnerability | Detection Method | Severity |
|---|---|---|
| Front-running during launch | Detects public launch functions with no commit-reveal or access control on initial buy calls | High |
| No slippage protection | Flags buy/sell functions missing minTokensOut and deadline parameters |
High |
| Reserve drain via rounding | Identifies curves where sell returns can exceed buy inputs on repeated micro-trades | Medium |
| Curve parameter front-running | Detects onlyOwner parameter setters applied without timelock or delay mechanism |
High |
| Virtual reserve manipulation | Flags Bancor-style curves where virtual reserve is disproportionately small vs token supply | Medium |
| Rug pull reserve extraction | Detects unrestricted transfer/send calls from reserve balance gated only by onlyOwner |
Critical |
Scan your bonding curve contract at contractscan.io before deployment.
Related Posts
- Token Price Manipulation: Low Liquidity and Spot Price Attacks
- AMM and DEX Security: Uniswap Price Manipulation
Important Notes
This post is for informational and educational purposes only. It does not constitute financial, legal, or investment advice. The security analysis provided is based on available data and automated tools, which may not capture all potential vulnerabilities. Always conduct a professional audit before deploying smart contracts.