Decent.xyz Crescendo Bonding Curve Reentrancy Drain

On 2026-02-08 12:06:47 UTC (block 24,411,960), tx 0x7b3878969c2f44dae5e47d7c03616d5f17dfc46ea59ea75f135c468709a59ce7 on Ethereum drained four Decent.xyz “Crescendo” ERC1155 bonding curve contracts of nearly all their ETH reserves via reentrancy through the native ETH refund path in buy() and the payout path in sell(). The attacker EOA 0x66d3832bd138b94b7329c0c2806145ab665ed717 called run(bytes,uint256) (0x194e10ce) on attacker contract 0x0a28036a285d309f3b5345a5703fd357dadd15fb, which flash-loaned 5 WETH from Aave V3 and exploited all four curves in a single transaction. Total loss: 6.73 ETH ($14,290 at $2,123/ETH).

Attack vector: reentrancy via ETH refund transfer in buy() and ETH payout in sell(). Both functions send ETH to msg.sender via low-level CALL before updating the contract’s internal pricing state (_prices[id]), violating the checks-effects-interactions pattern. The attacker’s contract implements a receive() fallback that re-enters buy() or sell() upon receiving ETH, purchasing multiple tokens at the same stale price or selling them all at the same inflated price.

The vulnerable contracts are all unverified, non-proxy ERC1155 bonding curves deployed by the Decent.xyz music NFT platform using its “Crescendo” instant-liquidity model. They were recovered via TAC decompilation. Each contract is a standalone ERC1155 token with a built-in bonding curve for buy/sell pricing. Four instances were attacked:

ContractNameSymbolPre-Attack BalancePre-Attack SupplyDrained
0x02411c115f63e19fa2c4c42f7b99734036d6e758Right my WrongRIGHT0.4833 ETH8 tokens0.4820 ETH
0xae4c2ce08f5e6a749e08d616520f0eaf6efb9a4bFORMULA ONEF11.2140 ETH14 tokens1.1975 ETH
0x8b289f9d918d628c1a38978cca15093cf9336646JungleJNGL1.6562 ETH22 tokens1.6481 ETH
0xbdd00b230901713b1686cd90faa34d7e1e69debcAttatchment TheoryTHRY3.4123 ETH31 tokens3.4070 ETH

Vulnerable function buy(uint256) (0xd96a094a) — recovered from TAC decompilation, corrected to match on-chain trace ordering where the CALL opcode at TAC block 0xd9fB0x44e executes before any SSTORE updating balances or pricing at block 0x193dB0xe03B0x44e:

function buy(uint256 id) public payable {
    require(saleIsActive, "Sale must be active to buy");
    require(id == 0, "currently only one edition");

    uint256 price = calculateCurvedMintReturn(1, id); // returns _prices[id]
    require(msg.value >= price, "Insufficient funds");

    // INTERACTION — external call BEFORE any state update (reentrancy vector)
    if (msg.value > price) {
        (bool success, ) = payable(msg.sender).call{value: msg.value - price}("");
        require(success, "Failed to send ether");
    }

    // EFFECTS — all state updates happen AFTER the external call
    _balances[msg.sender][id] += 1;
    _totalSupply[id] += 1;

    if (_totalSupply[id] < 30) {
        _prices[id] += 0.005 ether;
    } else {
        _prices[id] += 0.015 ether;
    }

    emit TransferSingle(msg.sender, address(0), msg.sender, id, 1);
    _doSafeTransferAcceptanceCheck(msg.sender, address(0), msg.sender, id, 1, "");
}

Vulnerable function sell(uint256) (0xe4849b32) — the CALL sending ETH proceeds at TAC block 0x108aB0x483 executes before the SSTORE updating both _totalSupply[id] and _prices[id]. Note: The recovered Solidity approximation (contract.sol) places both _totalSupply[id] and _prices[id] decrements before the ETH send, but TAC analysis confirms the actual bytecode performs the CALL first, with only _balances[msg.sender][id] decremented before the CALL:

function sell(uint256 id) public {
    require(saleIsActive, "Sale must be active to sell");
    require(balanceOf(msg.sender, id) > 0, "must own nft to sell");

    uint256 payout = calculateCurvedBurnReturn(1, id); // _prices[id] * 17 / 20

    _balances[msg.sender][id] -= 1;

    emit TransferSingle(msg.sender, msg.sender, address(0), id, 1);

    // INTERACTION — sends ETH BEFORE updating _totalSupply[id] and _prices[id] (reentrancy vector)
    (bool success, ) = payable(msg.sender).call{value: payout}("");
    require(success, "Failed to send ether");

    // EFFECTS — supply and pricing state updates happen AFTER the external call
    _totalSupply[id] -= 1;
    if (_totalSupply[id] < 30) {
        _prices[id] -= 0.005 ether;
    } else {
        _prices[id] -= 0.015 ether;
    }
}

The flaw: both buy() and sell() send ETH to msg.sender before updating _prices[id]. The buy() function computes the purchase price as _prices[id] (via calculateCurvedMintReturn), then sends the refund (msg.value - price) to the caller before incrementing _prices[id]. The attacker deliberately sends _prices[id] + 1 wei to trigger a 1 wei refund, which enters the attacker’s receive() fallback. Since _prices[id] has not changed, calculateCurvedMintReturn returns the same value on every recursive call. The attacker buys N tokens all at the same stale price. After the recursion unwinds, _prices[id] gets incremented N times, making it far higher than the original value. The attacker then calls sell(), where the same reentrancy pattern allows selling M tokens all at the inflated _prices[id] * 17 / 20 (via calculateCurvedBurnReturn) before _prices[id] is decremented. The deepest sell in each round reverts (contract runs out of ETH), but the attacker’s receive() absorbs this via a low-level call. The net effect: each buy costs _prices[id] while each sell yields (inflated_price * 17/20), systematically draining the contract’s ETH reserves.

On-chain evidence confirms this: the CurvedMint events (topic 0x25ba9095...) on RIGHT show connectorBalance = 90000000000000000 (0.09 ETH) for all 14 recursive mints in the first buy round (logs 3–29), proving _prices[id] was not updated between re-entrant calls. Similarly, CurvedBurn events (topic 0x68294b5f...) show a constant connectorBalance = 136000000000000000 (0.136 ETH) across all 12 recursive sells in the first sell round, proving the sell payout was identically stale.

Call flow: EOA 0x66d3832bAttacker.run(bytes,uint256) (0x194e10ce) → AavePool.flashLoanSimple(address,address,uint256,bytes,uint16) (0x42b0b77c) borrowing 5 WETH → [Aave callback] Attacker.executeOperation(address,uint256,uint256,address,bytes) (0x1b11d0ff) → WETH.withdraw(uint256) (0x2e1a7d4d) unwrapping 5 WETH to ETH. Then for each bonding curve, a repeated cycle: AttackerBondingCurve.calculateCurvedMintReturn(uint256,uint256) (0xa3111abd) [price check] → BondingCurve.buy(uint256) (0xd96a094a) with ETH → [refund CALL to Attacker.receive()] → re-enter buy() recursively (14–24 levels deep per round) → AttackerBondingCurve.balanceOf(address,uint256) (0x00fdd58e) → BondingCurve.sell(uint256) (0xe4849b32) → [payout CALL to Attacker.receive()] → re-enter sell() recursively (5–21 levels deep per round). Contract3 (JNGL at 0x8b289f9d) uses mint(address) (0x6a627842) instead of buy(uint256) but has the identical reentrancy pattern. After draining all four curves, the attacker wraps 5.0025 ETH back to WETH via WETH.deposit() (0xd0e30db0), approves Aave to pull repayment via WETH.approve(address,uint256) (0x095ea7b3), and sends a 0.03 ETH builder tip to 0x4838b106fce9647bdf1e7877bf73ce8b0bad5f97 (the block’s feeRecipient).

Financial impact: ~6.73 ETH drained from the four bonding curve contracts, leaving dust-level remainders (0.001–0.017 ETH). After the Aave flash loan fee (0.0025 ETH), builder tip (0.03 ETH), and gas costs (0.007 ETH), the net attacker profit is approximately 6.70 ETH ($14,222 at $2,123/ETH as measured from the Chainlink ETH/USD oracle at block 24,411,960). The attacker contract 0x0a28036a285d309f3b5345a5703fd357dadd15fb held 6.7021 ETH post-attack, confirming the full drain minus flash loan and tip costs was retained as profit. The 5 ETH flash loan served as working capital to fund the buy operations; it was returned to Aave from the proceeds before the attacker kept the remainder. All four contracts had existing token holders (totalSupply of 8, 14, 22, and 31 tokens respectively) whose NFTs lost nearly all redemption value as the backing ETH reserves were drained. The owner address 0x645A6bE9f7daFd70F9479262ba9f336836925c4A (which doubles as the dividend payouts recipient hardcoded in all four contracts) also lost any accumulated fee residuals.

Evidence: the refund CALL in buy() sends exactly 1 wei (hex 0x1) because the attacker deliberately sends _prices[id] + 1 wei as msg.value, producing a 1 wei refund that triggers the attacker’s receive() function for reentrancy. The trace shows 102 total buy-side mint events and 69 burn events across all four contracts in a single transaction. The Aave FlashLoan event on pool 0x87870bca3f3fd6335c3f4ce8392d69350b4fa4e2 confirms 5 WETH borrowed and 5.0025 WETH repaid. Post-attack balances: RIGHT 0.0013 ETH, F1 0.0165 ETH, JNGL 0.0081 ETH, THRY 0.0053 ETH.


Validation

Overall result: PASS

Validated on 2026-02-11 by automated three-stage pipeline.

Stage 1: Logical Challenger — PASS (with corrections applied)

  • All function selectors verified against on-chain bytecode (11 selectors match).
  • Event counts confirmed: 102 CurvedMint events, 69 CurvedBurn events, 171 TransferSingle events.
  • Financial figures verified: pre/post-attack balances match on-chain state, net profit ~6.70 ETH confirmed from attacker contract post-attack balance (6,702,100,000,000,000,000 wei).
  • Corrections applied during validation:
    • calculateCurvedMintReturn returns _prices[id] (not _prices[id] * 17/20 as originally stated in code comments); calculateCurvedBurnReturn returns _prices[id] * 17/20. Confirmed via TAC function analysis (0xbae vs 0x11fd).
    • sell() execution order corrected: TAC confirms _balances decremented before CALL, but _totalSupply and _prices decremented after CALL (the recovered contract.sol incorrectly showed _totalSupply before the CALL).
    • Buy reentrancy mechanism clarified: attacker sends _prices[id] + 1 wei, the 1 wei refund triggers receive() (not onERC1155Received). The _doSafeTransferAcceptanceCheck callback occurs after the refund and state updates, and the attacker’s onERC1155Received just returns the selector without reentering.
    • Deepest sell in each round reverts with OutOfFunds (contract’s ETH exhausted); the attacker absorbs this via low-level call to prevent revert cascading.

Stage 2: On-Chain Verifier — PASS

  • TX metadata: block 24,411,960, from 0x66d3832b..., to 0x0a28036a..., selector 0x194e10ce. All match.
  • Receipt: status 0x1 (success), 349 log entries, gas used 4,693,037.
  • Call trace structure matches report description: EOA → AttackerContract → AavePool.flashLoanSimple → executeOperation → WETH.withdraw → 8 attack rounds (2 per curve) → WETH.deposit → WETH.approve → builder tip.
  • All four bonding curve addresses confirmed in trace.
  • Flash loan: 5 WETH borrowed, 5.0025 WETH repaid (0.05% fee).
  • Builder tip: 0.03 ETH to 0x4838b106....
  • Pre-attack balances verified: RIGHT 0.4833375 ETH, F1 1.214 ETH, JNGL 1.65625 ETH, THRY 3.412325 ETH.
  • Post-attack balances verified: RIGHT 0.0013375 ETH, F1 0.0165 ETH, JNGL 0.00815 ETH, THRY 0.005325 ETH.

Stage 3: Foundry PoC — PASS

  • PoC location: poc/decent-crescendo-reentrancy/test/Exploit.t.sol
  • Fork block: 24,411,959 (one block before the attack)
  • Test result: PASS (gas: 5,898,833)
  • PoC profit: 6.7321 ETH (within 1% of actual 6.7021 ETH)
  • All four curves drained to < 0.02 ETH remainder, matching on-chain post-attack balances:
    • RIGHT: 0.0013375 ETH (matches on-chain)
    • F1: 0.0165 ETH (matches on-chain)
    • JNGL: 0.00815 ETH (matches on-chain)
    • THRY: 0.005325 ETH (matches on-chain)
  • Attack mechanism: flash loan 5 WETH → unwrap → 8 attack rounds using receive() reentrancy for both buy (via 1 wei refund) and sell (via payout), with low-level calls to absorb deepest-sell reverts → repay flash loan.
  • Total drained: 6.7346 ETH across all four curves.