Guardian's review of Bonding, Routing and Graduation for Alt Fun, published July 2026. The report records 36 findings across 2 review rounds, including 1 critical and 1 high.
- Published
- Rounds
- Main Review, Round 3 - Remediation Review 2
- Chains
- Hyperliquid
- Sector
- Token launches
- 1 Critical
- 1 High
- 3 Medium
- 10 Low
- 21 Informational
Findings 36
Main Review
35 findings-
R1-C-01 Critical Dust Pre-Seed Allows Full Liquidity Drain Logical Error L O C A T I O N src/Bonding.sol:1226-1231 R E P O Round 1 - Main Review Resolved
Description
Bonding.finalizeGraduationassumes the post-graduation Uniswap V2 pair can always be either seeded directly or repaired through the rebalance path in_seedUniswapV2Direct. However, that assumption is false when the pair already exists in a dust-synced state with nonzero reserves but zero LP supply. Since anyone can create the(launchedToken, LT)pair and both token addresses are known in advance, an attacker can pre-create the pair, transfer 1 wei of each asset into it, and callsync, causing the pair to end up with reserves(1,1)whiletotalSupply==0.From that point onward,
_seedRebalancingdoes not meaningfully correct the pool because_pairRebalancecomputesexpectedOut==0against such tiny reserves and returns without executing a swap, causing the code to fall through to_routerDepositAndDispose, which results in liquidity being added at the attacker-controlled 1:1 ratio rather than the intended cached graduation ratio.Attack Steps:
Attacker creates the (launchedToken, LT) pair Transfers 1 wei of each token directly to the pair and calls
syncThis updates the reserves and sets the ratio to 1:1finalizeGraduationis called and_routerDepositAndDisposeadds liquidity at 1:1 ratio rather than the actual curve ratio. Attacker sells previously acquired launched tokens at artificially manipulated 1:1 ratioAs a result, the graduated pool opens at an artificial 1:1 ratio instead of the curve-close ratio, allowing previously accumulated launched tokens to be sold into a severely mispriced market and enabling an attacker to drain almost all LT liquidity immediately after graduation.
Recommendation
Treat pairs with
totalSupply()==0as pristine regardless of synced dust reserves. Additionally, consider requiring the post-rebalance reserve ratio to be validated against the cached curve-close ratio before adding liquidity.Resolution
Alt Fun Team - Resolved in
alt-fun#1137. -
R1-H-01 High Tiny LP seed skews graduation price Logical Error L O C A T I O N src/Bonding.sol:1293-1306 R E P O Round 1 - Main Review Resolved
Description
finalizeGraduationassumes that a pre-existing V2 pair with nonzero reserves can be safely repaired by_seedRebalancingbefore liquidity is deposited. This is false when an attacker creates the pair and mints a tiny amount of LP at a hostile ratio. The attacker can seed the pair with amounts just above Hyperswap V2 minimum liquidity requirements. This gives them only dust LP, but records the attacker-controlled reserves in the pair. During graduation,_seedUniswapV2Directsees nonzero reserves and enters_seedRebalancing. The rebalance/deposit flow then adds liquidity against the manipulated reserve ratio instead of guaranteeing the cached curve-close ratio. The router only pulls the ratio-matched subset, and leftover launched tokens are burned. As a result, the graduated pool can open at an incorrect price with substantially less liquidity than intended.Recommendation
Update
_pairRebalanceso it does not skip the rebalance when the computed swap output rounds down to zero. In that case, calculate the minimum input required to receive 1 wei of output and execute that swap if it fits within the rebalance budget. This prevents dust LP preseeds from bypassing the swap-based repair path due to integer rounding. For cases where a swap is impossible, handle dust-only preseeds with a tightly boundeddonate + sync + mintfallback so finalization can still progress without opening the pool at the attacker-controlled ratio.Resolution
Alt Fun Team - Resolved in
alt-fun#1195. -
R1-L-01 Low Graduation not locked during sells Unexpected Behavior L O C A T I O N src/Bonding.sol:619-634 R E P O Round 1 - Main Review Resolved
Description
A token can become graduatable without a trade when the LT perp value increases, as graduation uses the current LT exchange rate and the curve's stored LT reserve. However, this state is not locked in through the sell path as it is through the buy path. The sell path does not check
canGraduatebefore allowing a sale to execute. A token holder can therefore sell after the token has become graduatable but before anyone callstriggerGraduation, which can push the token back below the graduation threshold. Consequently, graduation becomes race-dependent, and a token that was already eligible to graduate can miss that opportunity to graduate if a sell lands first.Recommendation
Check
canGraduatebefore executing sells, and reject curve sells once the token is already graduatable. Alternatively, document this behavior as intended.Resolution
Alt Fun Team - Resolved in
alt-fun#1191. -
R1-L-02 Low Low LT price can block graduation Unexpected Behavior L O C A T I O N contracts/UniswapV2Pair.sol:74 R E P O Round 1 - Main Review Resolved
Description
During graduation, the protocol withdraws the real LT raised by the curve and later deposits it into the HyperSwap V2 pair. The internal bonding curve stores reserves as
uint256, but the final HyperSwap V2 pair stores reserves asuint112. This creates a mismatch when the LT exchange rate becomes extremely small.This can become problematic if the supported leverage LT suffers a severe price decline or liquidation during the curve phase. Even if the LT exchange rate does not fall to zero because some assets remain idle on HyperEVM, the exchange rate can become low enough that the same graduation value is represented by a very large number of LT units. The bonding curve can continue accounting for those large LT-unit reserves, but the final V2 pair cannot store reserves above
uint112.max. IfltFromPairexceeds that limit, phase 2 finalization can revert, leaving the token stuck inGraduatingstate after curve trading has already been frozen.Recommendation
Be aware that extremely low LT exchange rates can prevent HyperSwap V2 finalization, document this limitation clearly, and avoid launching pairs near this reserve limit. If an LT is liquidated or impaired, consider pausing mints or alternatively making
canGraduaterefuse entering graduation until finalization can safely fit within the V2uint112reserve limit.Resolution
Alt Fun Team - Resolved in
alt-fun#1189. -
R1-L-03 Low Accrued LT fees skew exchange rate Trust Assumptions L O C A T I O N src/LeveragedToken.sol:178 R E P O Round 1 - Main Review Resolved
Description
Alt Fun relies on the BounceTech LT
exchangeRatefor launch pricing and graduation checks. However, this is a view function and does not apply accrued streaming fees. Those fees are only applied when_checkpointruns during state changing LT operations such as mint or redeem. During launch, Alt Fun computes the curve's virtual LT reserve fromexchangeRate, then the mandatory seed buy callsmint, which will checkpoint the LT and reduce the rate immediately after the curve is initialized. The same issue applies to graduation.canGraduatecan return true using a pre-checkpoint rate, allowing the token to enterGraduatingphase even if the post-checkpoint reserve value would be below the threshold. As a result, launch pricing and graduation eligibility can happen based on overstated LT values.Recommendation
Document that launch pricing and graduation checks may use a stale, pre-checkpoint LT exchange rate. Monitor
lastCheckpointfor every supported LT, and warn users in the frontend whenblock.timestamp - lastCheckpointexceeds a safe threshold. Consider exposing a fee-adjusted exchange rate view function.Resolution
Alt Fun Team - Resolved in
alt-fun#1188. -
R1-L-04 Low LT redeploy freezes exchangeRate Logical Error L O C A T I O N src/Bonding.sol:124 R E P O Round 1 - Main Review Resolved
Description
Factory.redeployLt(oldLT)retires an LT and spins up a new one at a different address. The old LT contract isn't destroyed, just removed from the registry (ltExists[oldLT] = false).mint,redeem, andexchangeRateon the LT don't check the registry, so the old LT still works after redeploy. The problem is exchangeRate goes flat. After redeploy, keepers stop opening Hypercore positions on the old LT, sohyperliquidValueandspotAssetValuestay at 0. OnlybaseAssetBalance - debtis left in totalValue.So exchangeRate is stuck at whatever it was at redeploy time and doesn't track the underlying perp anymore. Tokens referencing the old LT keep trading but the LT side becomes a stable vault. The leveraged part of the product silently disappears.
canGraduateusesrealLtRaised * exchangeRate>=graduationThresholdUsd. With exchangeRate frozen, the USD trigger only fires by raising more LT.Tokens counting on LT appreciation to graduate just sit on the curve.
The precondition here is that
Factory.redeployLtreverts withStillHasMarginunless the operator first closes the old LT's margin position, so this only fires if redeployment happens while Alt Fun curves still reference the old LT; i.e. the operator empties the margin but Alt Fun tokens have not yet unwound their LT holdings.Recommendation
Consider clearly documenting this behavior so that users are fully aware of such mechanism.
Resolution
Alt Fun Team - Resolved in
alt-fun#1193. -
R1-L-05 Low Stale getAmountOut quotes for graduated tokens Logical Error L O C A T I O N src/Router.sol:61 R E P O Round 1 - Main Review Acknowledged
Description
Router.graduate()pulls LT out of the curve pair withpair.transferAsset()but never callspair.swap()to update_pool.assetReserve. So after phase 1 the pair's actual LT balance is 0 butpair.getReserves()still reports the pre-graduation value (virtualLtReserve + ltFromPair).function graduate(address token, uint256 amount) external onlyRole(BONDING_ROLE) { address asset = assetTokenFor(token); address pairAddr = factory.getPair(token, asset); if (pairAddr == address(0)) revert PairNotFound(); IPair(pairAddr).transferAsset(msg.sender, amount); } Router. getAmountOut and Router.pre viewBuy read pair.g etReserves() and don'tche cklifecycle:(uint256 reserveToken, uint256 reserveAsset) = pair.getReserves(); uint256 k = pair.k();For graduated tokens these return prices on inflated reserves. The pair has no LT left to back any quote.
Recommendation
Gate the view functions on lifecycle:
function getAmountOut(address token, bool isBuy, uint256 amountIn) public view returns (uint256) { if (bonding.isGraduating(token) || bonding.isGraduated(token)) revert TokenGraduated();3...4 }Resolution
Alt Fun Team - Acknowledged.
-
R1-L-06 Low Events can emit misleading amounts Unexpected Behavior L O C A T I O N src/Zap.sol:259-263 R E P O Round 1 - Main Review Resolved
Description
buyInternalemitsBuyandReferredevent with the user-suppliedusdcAmount, even when_executeBuyconsumes only a small portion and refunds the rest. This happens on capped curve buys near graduation whenpreviewLtUntilGraduationlimits how much LT the curve still needs, so Zap may convert only the minimum required USDC, execute the capped buy, then return unused USDC and refunded fee portion to the trader.emit Buy(tokenAddress, msg.sender, usdcAmount, tokensOut); if (referrer != address(0) && referrer != msg.sender) { emit Referred(tokenAddress, msg.sender, referrer, usdcAmount); }A trader can submit a large buy when remaining graduation capacity is tiny, receive almost all USDC back in the same transaction, and function emits inflated
BuyandReferredamounts, which may be unexpected for external listeners that treat these event amounts as executed trade volume.Recommendation
Document that the mentioned fields represent the submitted gross amount, not the executed or non-refunded trade amount, or consider emitting the actual gross amount spent.
Resolution
Alt Fun Team - Resolved in
alt-fun#1187. -
R1-L-07 Low
MIN_USDC_AMOUNTcan desync from LT floor Configuration L O C A T I O N src/Zap.sol:44 R E P O Round 1 - Main Review ResolvedDescription
ZaphardcodesMIN_USDC_AMOUNT = 10e6and uses it as a pre-flight floor on buys and sells so users seeBelowMinAmount()instead of LT's undecodable0x05eb05ac(BelowMinTransactionSize). The matching value on the LT side isGlobalStorage.minTransactionSize, which is owner-settable and capped at100e6($100) by_MAX_MIN_TRANSACTION_SIZE_UNSCALED = 100. Today both are10e6on mainnet, but they can legitimately raise their floor up to 10 Zap's constant without any on-chain notification to Alt Fun.If that ever happens, trades passing Zap's check but below the new LT floor revert with the raw selector, only fix is a Zap UUPS upgrade.
Recommendation
Consider reading
minTransactionSizelive fromIBounceGlobalStorageinstead of hardcoding, or add anonlyOwnersetter forMIN_USDC_AMOUNT.Resolution
Alt Fun Team - Resolved in
alt-fun#1186. -
R1-L-08 Low Graduation event overstates LP tokens Unexpected Behavior L O C A T I O N src/Bonding.sol:963 R E P O Round 1 - Main Review Resolved
Description
During phase-1 graduation,
tokensForLPandltFromPairare stored as the intended amounts for LP seeding. During phase- 2 finalization, an empty V2 pair receives exactly those stored amounts. However, if the V2 pair was already minted with non-empty reserves, finalization enters the rebalance path, may swap part of the stored amounts, and then callsaddLiquidity, which only pulls the optimal balanced subset of the remaining balances. However,TokenGraduatedis emitted with the cached phase-1tokensForLPvalue rather than the actual token amount deposited by the router. In an imbalanced preseed scenario, the actual token amount deposited and locked can be lower or higher thantokensForLP. Off-chain listeners that treattokensInLPas the actual locked token amount may overstate post-graduation liquidity and token backing.Recommendation
Document that
tokensInLPrepresents the cached phase-1 target amount, not the actual token amount deposited into the V2 LP, or consider emitting the actual token amount locked.Resolution
Alt Fun Team - Resolved in
alt-fun#1185. -
R1-L-09 Low Floor-bump buys strand LT dust Logical Error L O C A T I O N src/Zap.sol:336-370 R E P O Round 1 - Main Review Acknowledged
Description
Near graduation, a cap-binding buy may require less LT than BounceTech's minimum mint size. To avoid bricking tiny closing buys,
Zapintentionally mints the minimum USDC amount and refunds unused LT directly to the buyer.However, that refunded LT can be below the LT redeem floor, so the buyer cannot independently redeem it back to USDC. Even when the buyer sends the exact remaining USDC needed, decimal conversion between 6-decimal USDC and 18-decimal LT can leave a small LT remainder.
Therefore, the user entered a USDC-to-token flow but exits with unwanted leveraged-token dust exposure. Recovering the value may require acquiring more LT to cross the redeem floor and later paying the LT redemption fee.
Recommendation
Do not blindly remove the floor-bump, because it is needed to let tiny closing buys graduate. Instead, add an explicit
acceptLtDustoption or surface the expected LT refund before execution.If
acceptLtDust==falseand the expected refund is below the redeem floor, revert with a clear custom error before minting.Resolution
Alt Fun Team - Acknowledged.
-
R1-L-10 Low
getAmountOutoverstates buys near sellout Logical Error L O C A T I O N src/Router.sol:45-67 R E P O Round 1 - Main Review ResolvedDescription
Router.getAmountOut(buy path) returns the raw curve output with no cap:if (isBuy) { uint256 newReserveAsset = reserveAsset + amountIn; uint256 newReserveToken = k / newReserveAsset; return reserveToken - newReserveToken; // uncapped }The real buy (
_computeBuy, used bybuy()andpreviewBuy()) clamps the output to the pair's actual token balance and back-solves the input:uint256 realBalance = pair.tokenBalance(); if (tokensOut > realBalance) { tokensOut = realBalance; uint256 cappedReserveToken = reserveToken - tokensOut; uint256 cappedReserveAsset = (k + cappedReserveToken - 1) / cappedReserveToken; amountInUsed = cappedReserveAsset - reserveAsset; }reserveTokenis virtual (runs to the full 1B supply) and stays ~250M above the real sellable balance for the whole curve, so near sellout the two functions disagree.Recommendation
Cap
getAmountOut's buy branch attokenBalance()(or delegate to_computeBuy) so the quote matches execution, or document thatgetAmountOutis the raw curve quote andpreviewBuyis the one to use for buys.Resolution
Alt Fun Team - Resolved in
alt-fun#1184. -
R1-I-01 Informational Inconsistent
balanceOfvstokenBalance()Best Practices L O C A T I O N src/Bonding.sol:1009 R E P O Round 1 - Main Review ResolvedDescription
In
Bonding._prepareGraduationLiquidity, the unsold-token snapshot reads the pair's launched-token balance directly via the ERC-20 interface:1 unsoldBurned = IERC20(tokenAddress).balanceOf(pairAddr);Every other call site in
Bonding.solthat needs the same value uses the dedicated helper exposed byPair:// Pair.sol function tokenBalance() external view returns (uint256) { return IERC20(launchedToken).balanceOf(address(this)); }Using
IPair(pairAddr).tokenBalance()here would match the convention used incanGraduate,_previewLtUntilGraduation, and the rest of the file.Recommendation
Consider replacing the direct ERC-20 call with the
Pairhelper for consistency:1 unsoldBurned = IPair(pairAddr).tokenBalance();Resolution
Alt Fun Team - Resolved in
alt-fun#1183. -
R1-I-02 Informational
Usdconstants are actually USDC-denominated Documentation L O C A T I O N src/interfaces/IBounceLeveragedToken.sol:28 R E P O Round 1 - Main Review ResolvedDescription
Throughout
Bonding.sol, several constants and storage slots are named with aUsdsuffix (e.g.VIRTUAL_LIQUIDITY_USD,graduationThresholdUsd, and the localvalueUsdincanGraduate). The naming implies these values are denominated in U.S. dollars. In reality, the protocol has no USD price oracle. TheexchangeRate()of the BounceTech LT is defined as:// LeveragedToken.sol return totalAssets().scaleFrom(_baseAsset().decimals()).div(totalSupply());totalAssets()is the LT contract's raw USDC balance, and the result is USDC-per-LT scaled to 18 decimals. All Alt Fun arithmetic that compares against*Usdconstants is therefore comparing USDC amounts (18-dp scaled), not USD amounts./// @notice USD per LT unit, 18-dp. function exchangeRate() external view returns (uint256);Recommendation
Consider clearly documenting that all denominations are in USDC.
Resolution
Alt Fun Team - Resolved in
alt-fun#1182. -
R1-I-03 Informational Seed Fee Reduces Curve Floor Validation L O C A T I O N src/Zap.sol:223 R E P O Round 1 - Main Review Resolved
Description
Zap.createTokenenforces the mandatory seed minimum against gross USDC input, butZap._executeBuydeducts the buy fee before minting LT and sending value into the bonding curve.For example, at a 75 bps buy fee, an exact
20 USDCseed passes the seed floor but only19.85 USDCreaches the LT/curve.Therefore, launches can satisfy the advertised seed floor while providing slightly less net curve liquidity than the anti-snipe design implies.
Recommendation
If the intended floor is net curve liquidity, require
seedUsdcAmount - fee>=MIN_SEED_USDCor exempt the mandatory seed buy from protocol fees. If the intended floor is gross user spend, update docs and UI copy to say the seed floor is before fees.Resolution
Alt Fun Team - Resolved in
alt-fun#1178. -
R1-I-04 Informational Dust Fee Rounding Under-accrues Rounding L O C A T I O N src/Zap.sol:379 R E P O Round 1 - Main Review Resolved
Description
For cap-binding buys,
Zap._executeBuycomputes:effectiveBaseSpent = (amountInUsed * baseToConvert) / ltMinted; actualFee = (usdcAmount * buyFeeBps_ * effectiveBaseSpent) / (BPS_DENOM * netUsdc);The intermediate
effectiveBaseSpentdivision rounds down before the fee multiplication. The sell path has the same rounding direction at the direct fee calculation:1 fee = (grossUsdc * sellFeeBps) / BPS_DENOM;Because the fee is owed to the protocol and creator, trader favorable rounding returns dust that should instead accrue to
FeeVaultunder a protocol-favorable rounding policy.Recommendation
Use a single full-precision expression or
Math.mulDivand round trade fees up in favor of the protocol and creator. For cap-binding buys, cap the rounded fee atfeeOnGrossso refunds cannot underflow and full-size buys never charge more than the fee already withheld.For sells, compute the fee with upward rounding, for example
Math.mulDiv(grossUsdc, sellFeeBps, BPS_DENOM,Math.Rounding.Ceil). If keeping the current behavior, document the trader-favorable dust rounding as intentional.Resolution
Alt Fun Team - Resolved in
alt-fun#1180. -
R1-I-05 Informational Zero LP Lock Can Be Rewritten Validation L O C A T I O N src/LPLock.sol:76 R E P O Round 1 - Main Review Resolved
Description
LPLock.recordLockuseslocks[token].amount!= 0as the only one-shot guard. A zero-amount lock storeslpPairandlockedAt, but leavesamountequal to zero, so a later allowlistedrecordLockcall for the same token can replace the recorded pair and amount instead of reverting as already locked.Recommendation
Reject
amount==0inLPLock.recordLock.Resolution
Alt Fun Team - Resolved in
alt-fun#1179. -
R1-I-06 Informational Trader-Favorable Rounding in Curve Quote Math Rounding L O C A T I O N src/Router.sol:136 R E P O Round 1 - Main Review Acknowledged
Description
Router._computeBuyandRouter._computeSellboth derive output amounts using expressions of the formreserve - (k/ newReserve). Because Solidity integer division rounds down, both(k / newReserveAsset)in the buy path and(k /newReserveToken)in the sell path round down. As a result, the surrounding subtraction rounds up, meaningtokensOutandassetOutare each rounded up in the trader's favor by up to 1 unit in the normal path.Noting that this does not appear to violate the pair's intended invariant, because
Pair.swapexplicitly tolerates a 1-unit rounding shortfall by checking(newTokenReserve + 1) * (newAssetReserve + 1)>=kinstead of enforcing the strict product directly.Recommendation
If stricter accounting is preferred, round the post-trade reserve up so the resulting output value rounds down instead of up.
Resolution
Alt Fun Team - Acknowledged.
-
R1-I-07 Informational Zap stores an unused UniswapV2 router Best Practices L O C A T I O N src/Zap.sol:70 R E P O Round 1 - Main Review Resolved
Description
Zap takes
uniswapV2Routerin initialize, requires it non-zero, stores it, and has a getter for it. Nothing ever calls it.1 \$.uniswapV2Router = IUniswapV2Router02(uniswapV2Router_);Post-graduation swaps don't use the router.
_swapOnUniswapV2pulls the pair frombonding.graduatedPair(...)and callspair.swapdirectly, because HyperSwap's router has no canonical swap functions (see the comment on_swapOnUniswapV2). So the stored router never gets touched.The storage comment claims it's a meaningful immutable ("migrating to a different HyperSwap fork requires a UUPS upgrade"), but nothing reads it on-chain, so that's not true. It just forces deployers to pass a live address that does nothing and burns an SSTORE.
Bonding's
uniswapV2Routeris a different story, it's actually used in_routerDepositAndDisposeforaddLiquidity. This is only about Zap's copy.Recommendation
Drop the field, the initialize param, the zero-check, and the getter.
Resolution
Alt Fun Team - Resolved in
alt-fun#1177. -
R1-I-08 Informational Hardcoded 0.3% V2 fee desyncs from HyperSwap Logical Error L O C A T I O N src/Zap.sol:526 R E P O Round 1 - Main Review Resolved
Description
Both swap paths that hit the HyperSwap V2 pair compute the output with a hardcoded 997/1000 fee numerator.
Zap._swapOnUniswapV2 :uint256 amountInWithFee = amountIn * 997; amountOut = (amountInWithFee * reserveOut) / (reserveIn * 1000 + amountInWithFee);and
Bonding._pairRebalance:uint256 amountInWithFee = s * 997; uint256 expectedOut = (amountInWithFee * p.reserveOut) / (p.reserveIn * 1000 + amountInWithFee);Neither reads the fee from the pair. The output is then handed straight to
pair.swap, whose K-check uses the pair's real fee. The 0.3% assumption holds only as long as HyperSwap's fee stays at the canonical Uniswap V2 value.Recommendation
Don't hardcode the fee. Read it from the pair and derive the numerator at call time, or quote through the pair's own fee-aware view if exposed. Or consider documenting the hard dependency on HyperSwap keeping the 0.3% fee.
Resolution
Alt Fun Team - Resolved in
alt-fun#1181. -
R1-I-09 Informational Documentation drift Documentation L O C A T I O N Global R E P O Round 1 - Main Review Resolved
Description
Opening market cap: docs say approximately
$4,000; current source pins it toBonding.VIRTUAL_LIQUIDITY_USD =3000e18, so it is approximately$3,000. Graduation threshold: docs say$12,000/$12K; currentBonding.graduationThresholdUsd()is9000e18, so it is$9,000 .Graduation market cap: docs say approximately
$16,000; current launch-rate value isVIRTUAL_LIQUIDITY_USD +graduationThresholdUsd = $3,000 + $9,000, so approximately$12,000. Buy / sell fee: docs say0.5% per side; currentZap.buyFeeBps()andZap.sellFeeBps()are75, so fees are0.75% per side. Fee split: docs say0.4% protocol /0.1% creator; currentZap.creatorFeeBps()is3333, so the effective split is approximately0.50% protocol /0.25% creator.Recommendation
Update the documentation to match the deployed contracts
Resolution
Alt Fun Team - Resolved in
alt-fun#1176. -
R1-I-10 Informational Seed Buy Can Be Unwound Documentation L O C A T I O N src/Zap.sol:46 R E P O Round 1 - Main Review Resolved
Description
Zap.createTokenrequires a creator seed buy of at leastMIN_SEED_USDC. Comments and docs describe this seed as absorbing the cheap bottom of the curve before public trading opens.However, the launch-delay gate only blocks buys. The creator can sell the seed tokens back into the curve before public trading opens.
Recommendation
Document this behavior, or block creator sells until public trading opens if the seed is meant to remain in the curve.
Resolution
Alt Fun Team - Resolved in
alt-fun#1175. -
R2-M-01 Medium Tiny Minted Preseeds Still Cause Graduation Skew Logical Error L O C A T I O N src/Bonding.sol:1389-1400 R E P O Round 2 - Main Remediation Review Resolved
Description
The remediation of H-01 substantially reduces the original attack and prevents the large skew previously observed at graduation. However, a small residual profit opportunity remains in the mint-preseed path of
finalizeGraduation.In the minted-preseed branch,
finalizeGraduationenters_seedRebalancingand only falls back to_seedDirectMintwhen no rebalance swap can execute or when the quoted output is zero.However, if the
expectedOut==1in_pairRebalance, the code treats the rebalance as good enough even when the swap is still too coarse to bring the pool close to the cached curve-close ratio.In the replayed fuzzing reproducer, the hostile preseed is
141,638 TOKEN / 9 LT, the rebalance swap returns only 1 LT, and the pool remains meaningfully LT-richer than intended. The subsequent router deposit then adds liquidity at that post-swap ratio, preserving the skew instead of restoring the cached graduation ratio.While the resulting profit is far smaller than in the original issue, this still gives the attacker a better-than-fair post-graduation exit.
Recommendation
Consider checking the reserve ratios even after the rebalance and only allow some expected amount of skew (e.g. 0.5%) and fallback into the
_seedDirectMintpath otherwise.Alternatively, widen the
_seedDirectMintfallback requirements preemptively for tiny seeds/low reserves situations without any after-rebalance ratio checks.Resolution
Alt Fun Team - Resolved in
alt-fun#1208. -
R2-I-01 Informational Unused Zap import Superfluous Code L O C A T I O N Zap.sol:14 R E P O Round 2 - Main Remediation Review Resolved
Description
Zap.solimportsIBounceGlobalStorageatsrc/Zap.sol:14, butZapnever references that identifier after the import. Forge lint reports the sameunused-importnote for this line. This has no direct runtime fund-loss impact, but it leaves a stale BounceTech dependency in the user-facing router source and can mislead reviewers or integrators into thinkingZapreads BounceTech global storage directlyRecommendation
Remove the unused
IBounceGlobalStorageimport fromZap.sol. IfZapis intended to validate BounceTech global storage directly in a future version, wire that dependency explicitly and document the behavior instead of leaving the stale import.Resolution
Alt Fun Team - Resolved in
alt-fun#1202. -
R2-I-02 Informational Zap Bonding rotation lacks runbook Documentation L O C A T I O N Zap.sol:585 R E P O Round 2 - Main Remediation Review Resolved
Description
Zap.initializealready sets the Bonding dependency during deployment, butZap.setBondingcan replace that dependency later atsrc/Zap.sol:585. The setter only validates that the new Bonding recognizes this Zap as a router; it does not verify that tokens launched through the previous Bonding exist in the new Bonding state. Once the pointer is changed,Zap.buyandZap.sellresolve token ownership and lifecycle through the new Bonding, so old tokens that only exist in the previous Bonding are treated as non-trading and user flows revert.The documentation describes the safer hot-swap direction as adding a new Zap to the Bonding allowlist, flipping the frontend, and removing the old Zap while the old Zap can continue serving the old Bonding state. There is no NatSpec or runbook explaining when reusing one Zap across a fresh Bonding proxy is safe, how old tokens remain tradable, or whether the function is only a leftover deployment hook.
Recommendation
If Bonding rotation is not required after initialization, remove
setBondingand rely on deploying a new Zap for a new Bonding instance. If it is required, add explicit NatSpec and an operational runbook stating that the old Zap must remain available for tokens stored in the old Bonding, or define and enforce a state-migration/empty-state precondition before changing the pointer.Resolution
Alt Fun Team - Resolved in
alt-fun#1207. -
R2-I-03 Informational Stale
MIN_SEED_USDCFloor Docs Documentation L O C A T I O N src/Zap.sol:43 R E P O Round 2 - Main Remediation Review ResolvedDescription
Zap.createTokenenforcesminSeedUsdc(), not the bareMIN_SEED_USDCconstant.minSeedUsdc()returnsmax(MIN_SEED_USDC, grossed-up live mint floor), so the real floor exceeds$20whenever BounceTech'sminTransactionSize()grossed up for the buy fee is larger. Two comments still call$20the floor:MIN_SEED_USDCnatspec ("Mandatory seed-buy floor ...$20", rewritten in this diff but still omitting the mint-floor raise).BelowMinSeednatspec ("your launch seed must be at least $20").Recommendation
Update both comments to state the enforced floor is
minSeedUsdc()=max(MIN_SEED_USDC, live mint floor grossed upfor the buy fee), and point them atminSeedUsdc()like the call site already does.Resolution
Alt Fun Team - Resolved in
alt-fun#1205. -
R2-I-04 Informational GlobalStorage rotation affects trades Documentation L O C A T I O N src/Bonding.sol:799-801 R E P O Round 2 - Main Remediation Review Resolved
Description
setBounceGlobalStorageis documented as affecting future launches only, but live Zap trade checks read the currentGlobalStoragethrough Bonding.function minUsdcAmount() public view returns (uint256) { return _s().bonding.bounceGlobalStorage().minTransactionSize(); }As a result, rotating BounceTech
GlobalStoragecan immediately changeminUsdcAmountfor already launched tokens, affecting buys, sells, and seed-size calculations, which is not in accordance with thesetBounceGlobalStorageNatSpec stating that the change affects future launches only.Recommendation
Update the NatSpec to state that
GlobalStoragerotation also affects the live minimum USDC trade amount.Resolution
Alt Fun Team - Resolved in
alt-fun#1203. -
R2-I-05 Informational
Router.initializemissing factory zero-check Informational L O C A T I O N src/Router.sol:31-37 R E P O Round 2 - Main Remediation Review ResolvedDescription
Router.initializestores the factory dependency with only a bare assignment and no validation:function initialize(address factory_) external initializer { __AccessControl_init(); _grantRole(DEFAULT_ADMIN_ROLE, msg.sender); factory = Factory(factory_); // no zero-address check }The contract already declares
error ZeroAddress();atRouter.sol, but it is never reverted anywhere in the contract, the author clearly intended a guard here that was never wired up.Recommendation
Add the guard using the already-declared error, mirroring the other initializers.
Resolution
Alt Fun Team - Resolved in
alt-fun#1204. -
R2-I-06 Informational FeeVault rotation fragments claims Documentation L O C A T I O N Zap.sol:596-608 R E P O Round 2 - Main Remediation Review Resolved
Description
Zap._accrueFeeforwards each trade fee to the currently configured FeeVault and records the creator/protocol claim there.Zap.setFeeVaultcan later replace that configured vault atsrc/Zap.sol:600, but the setter only checks that the new vault allowlists Zap as a depositor. Existing creator and protocol balances are not migrated, because those balances are stored inside the old FeeVault instance. After such a rotation, new fees accrue in the new vault while old claimable balances remain in the previous vault.This does not by itself burn funds: creators and the protocol can still claim from the old vault if it remains available. The issue is that the docs describe a single pooled FeeVault claim surface and router/Zap swapability with balances untouched, but they do not explain that replacing the FeeVault itself creates multiple claim locations or requires an explicit migration/dual-claim runbook.
Recommendation
If
FeeVaultreplacement is not needed, removesetFeeVaultand rotate Zaps/depositors within the same vault. If it is needed, document the operational runbook: keep the old vault discoverable and claimable, expose both old and new claim balances in the UI/API, or provide a migration path before redirecting future accruals.Resolution
Alt Fun Team - Resolved in
alt-fun#1206. -
R2-I-07 Informational Renounced ownership can break admin paths Informational L O C A T I O N Global R E P O Round 2 - Main Remediation Review Acknowledged
Description
Ownable contracts inherit
renounceOwnershipwhile still relying on owner for live administration and recovery. IfrenounceOwnershipis called accidentally during a mistaken ownership operation, allonlyOwnerfunctions become unavailable, including configuration updates and upgrades. In Bonding, this can also affect graduation finalization because LT residue is swept to the current owner.Recommendation
Consider overriding
renounceOwnershipin ownable contracts to prevent accidental ownership renouncement.Resolution
Alt Fun Team - Acknowledged.
-
R2-I-08 Informational Single-step creator transfer Informational L O C A T I O N src/Bonding.sol:727-737 R E P O Round 2 - Main Remediation Review Acknowledged
Description
transferCreatorimmediately replaces the creator without requiring the new address to accept. Future creator fees are credited to the creator address recorded at accrual time, andFeeVault.claimlater pays only that credited address. If the creator is changed to a typo, inaccessible wallet, or contract that cannot call claim, future creator fees and creator-level control can become inaccessible.Recommendation
Consider using a two-step creator transfer where the new creator must accept before the creator address is updated.
Resolution
Alt Fun Team - Acknowledged.
-
R2-I-09 Informational Factory router can be frozen wrong Validation L O C A T I O N src/Factory.sol:62-70 R E P O Round 2 - Main Remediation Review Resolved
Description
Factory only checks that the router is non-zero before storing it, then permanently freezes the value after the first pair is created. New pairs store the factory router as their immutable authorized router.
If the factory is configured with a router that does not match the router used by Bonding, or with a router pointing to a different factory, the first successful pair creation can lock the system into an inconsistent configuration.
Recommendation
In
setRouter, validate that the provided router points back to the factory before the first pair is created.1 if (address(Router(router_).factory()) != address(this)) revert InvalidRouter();Resolution
Alt Fun Team - Resolved in
alt-fun#1210. -
R2-I-10 Informational
FeeVault.setFeeToredirects accrued fees Best Practices L O C A T I O N src/FeeVault.sol:179-187 R E P O Round 2 - Main Remediation Review ResolvedDescription
FeeVaultaccrues protocol fees into a single pooledprotocolBalance, andclaimProtocol()pays the currentfeeToat claim time:function claimProtocol() external nonReentrant returns (uint256 amount) { amount = \$.protocolBalance; if (amount == 0) revert NothingToClaim(); \$.protocolBalance = 0; address feeTo_ = \$.feeTo; // <- read live, at claim time \$.usdc.safeTransfer(feeTo_, amount);7...8 }setFeeTo(FeeVault.sol:179) replacesfeeTowith only a non-zero check and emitsFeeToUpdated; it does not flush or migrate the outstandingprotocolBalancefirst. As a result, if protocol fees accrue whilefeeTo==Aand the owner later callssetFeeTo(B)before anyone callsclaimProtocol, the entire accumulatedprotocolBalance, including the portion earned whileAwas the recipient, is paid toB. The same applies tosweepDonations(), which also pays the livefeeTo.Recommendation
Either (a) document on
setFeeTothat rotation redirects the entire outstandingprotocolBalance(and donations) to the new recipient and thatclaimProtocol()should be called before rotating; or (b) auto-settle the pendingprotocolBalanceto the oldfeeToinsidesetFeeTobefore updating it, mirroring the rotation-safe, per-address handling of creator balances.Resolution
Alt Fun Team - Resolved in
alt-fun#1209. -
R3-M-01 Medium Pre-Seed LP Subsidy via Protocol LT Correction Logical Error L O C A T I O N Bonding.sol R E P O Round 3 - Remediation Review 2 Resolved
Description
Bonding._seedRebalancingchecks whether both sides of a pre-existing V2 pair reserve are belowDIRECT_MINT_PRESEED_BPS(50 bps = 0.5%) of the graduation targets. If both pass, the code falls into_seedDirectMint, which transfers a portion of the protocol's own LT budget to the pre-seeded pair and callspair.sync()before the graduation mint, equalising the reserve ratio.However, the correction transfer itself grants the attacker indirect access to protocol LT. An attacker who preseeds with
preseedToken = 0.5%of tokensForLPandpreseedLT = 1 weipasses both sides of the band check. The corrected pair's LT side is then funded with approximately0.5%of ltFromPairfrom the protocol's budget. The attacker's pre-existing LP therefore represents roughly 0.5% of the post-graduation pool, backed in part by LT they never contributed.At graduation the attacker holds ~49 bps of total LP. Redeeming it yields approximately 45 LT ($45 at 1 USDC/LT). Their real cost is the USDC paid to acquire the preseed tokens on the bonding curve, which is recovered in full at LP redemption since the pool opens at the correct ratio. Net gain: ~45 LT per graduation event, funded entirely by the protocol's correction budget.
Recommendation
Consider reducing
DIRECT_MINT_PRESEED_BPSsubstantially.The originally suggested 0.5% tolerance was appropriate for an after-rebalance deviation check, not for a preemptive direct-mint cutoff. If this path is retained, the threshold should be small enough that any attacker LP minted before graduation remains economically negligible.
Resolution
Alt Fun Team - Resolved in
alt-fun#1214. -
R3-M-02 Medium Hostile Preseed Can Consume Swap Budget Logical Error L O C A T I O N src/Bonding.sol:1346-1349 R E P O Round 3 - Remediation Review 2 Acknowledged
Description
The remediation of the previous mint-preseed issue addresses the tiny-seed / coarse-swap case that was reproduced in
test_replay_PRESEED. In that scenario, the new preemptive_seedDirectMintbranch prevents the router from depositing at a meaningfully skewed post-swap ratio.However, because Uniswap is permissionless, attackers can choose arbitrary TOKEN/LT amounts and any reserve ratios when preseeding. As a result, they can construct a preseed that is outside the new small-seed direct-mint band, but still so far from the cached curve-close ratio that
_pairRebalanceis unable to correct it with the protocol's available inventory. In these cases, the rebalance can consume essentially the full swap budget while still leaving the pool materially off-curve.In the replayed reproducer (
test_replay_10), the hostile preseed is approximately 177,514 TOKEN / 983,483 LT, while the cached graduation target is approximately 131,689,568 TOKEN / 554.659 LT. This makes the pool extremely LT-rich relative to the intended curve-close ratio._pairRebalancethen consumes essentially the full TOKEN-side swap budget (~130.37M TOKEN) and successfully executes, but the resulting pool is still materially off-curve. Because the rebalance returned true,finalizeGraduationproceeds into_routerDepositAndDispose, which deposits at that still-skewed post-swap ratio rather than restoring the cached graduation ratio.Recommendation
Consider validating whether the projected rebalance can actually bring the pool sufficiently close to the cached curve-close ratio before executing it. If the projected post-rebalance state would still remain materially off-curve, the contract should avoid proceeding into
_routerDepositAndDisposeat that skewed ratio.One possible mitigation is to introduce a dedicated error for unrecoverable hostile preseed states, where neither the rebalance path nor the fallback path can restore an acceptable ratio with the protocol's available inventory. In such cases, the transaction can revert and require manual intervention by the team to resolve graduation safely.
However, this does introduce a liveness / DoS tradeoff, so the acceptable approach depends on protocol priorities: if price integrity at graduation is more important than unconditional liveness, a fail-closed design with explicit operator intervention may be preferable to allowing graduation to complete at a materially attacker-favorable ratio. If liveness is the top priority, consider evaluating both the rebalance path and the fallback path, and selecting the outcome that remains closest to the cached curve-close ratio in order to minimize attacker profit as much as possible.
Resolution
Alt Fun Team - Acknowledged.
Round 3 - Remediation Review 2
1 finding-
R3-I-01 Informational Factory <-> Router Circular Import Best Practices L O C A T I O N packages/contracts/src/Factory.sol:7 Resolved
Description
Factory.solimports the concreteRoutercontract to call.factory()inside the newRouterFactoryMismatchguard, whileRouter.solalready importsFactory. This creates a mutual import cycle between the two contracts.Recommendation
Introduce a minimal
IRouterinterface inFactory.solexposing onlyfactory() external view returns (address), and use that instead of the import.Resolution
Alt Fun Team - Resolved in
alt-fun#1216.
No findings match.
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
