Token vesting contracts are supposed to be boring. Lock some tokens, release them on a schedule, done. So when UNCX’s Smart Token Vesting contract lost close to $87.7K across five tokens, it was a reminder that “boring” contracts can still hide very sharp edges — especially once you start layering share-based accounting on top of a simple idea.
Here’s what happened, and why we think the root cause is more instructive than the dollar figure.
UNCX’s vesting contract tracks who deposited what using an internal “shares” system, similar in spirit to how a lending pool or an ERC-4626 vault tracks deposits. The idea is reasonable on paper: instead of tracking raw token amounts per user, you track a share of a shared pool, which makes certain operations (like handling rebasing or fee-on-transfer tokens) easier to reason about.
The problem is that this kind of system only works if every function that touches the shared pool updates its bookkeeping with the same assumptions. In this case, one function didn’t.
cancel() calculated how many shares to remove from the global pool using a position’s lifetime deposit amount. But release() had already reduced the global pool for that same position earlier, without updating the deposit record it left behind. The two functions were reading from different, inconsistent pictures of reality — and the gap between them is exactly where the attacker operated.
The attacker didn’t exploit one dramatic bug in one transaction. They built the exploit out of ordinary-looking actions, stacked in a specific order:
Phase 1 — plant the inconsistency. Create two vesting positions and fully release both of them. Each position’s “amount released” field is now up to date, but its “amount deposited” field — the one used elsewhere in the contract — is left stale, still reflecting the original deposit.
Phase 2 — exploit it, twice. Donate tokens directly to the contract to distort the internal share-to-token ratio. Then, for each of the two stale positions: top it up (which, because of the distorted ratio, adds almost no new shares) and immediately cancel it (which subtracts the position’s original deposit amount from the global share total — a number that had already been subtracted once during release). That’s the double deduction. Repeat it on the second position, and the global total gets ground down to nearly zero.
The payoff. Release a third position — one that was never touched before. Payouts are calculated as a ratio: your shares divided by the global total. With the global total artificially crushed, this single release paid out far more than the position was ever entitled to, funded by tokens that belonged to everyone else still parked in the contract.
No reentrancy, no flash loan tricks, no price oracle manipulation. Just a sequence of normal function calls in an order the contract’s author probably never tested.
A few things stand out to us as auditors:
Shared accounting means shared risk. The moment several users’ tokens sit behind one internal ledger, any inconsistency in that ledger becomes everyone’s problem, not just the user who triggered it. A bug that looks contained to one position can silently drain a pool.
Complexity is a cost, not a feature. The share system here wasn’t solving an exotic problem — it added a layer of indirection (deposits, releases, a dynamic ratio recalculated from the live token balance) that didn’t need to exist for a straightforward vesting schedule. Every extra moving part is another place for two functions to disagree about what the numbers mean. In our experience, the contracts that get exploited this way are rarely the ones doing something unusual — they’re the ones that made an ordinary feature unnecessarily intricate.
Invariant testing would have caught this. The simplest fix wasn’t a patch to one line of cancel() — it’s a test that runs random sequences of create/release/topUp/cancel/donate and checks, after every single step, that the global share total still equals the sum of what every active position is actually owed. That kind of property-based test tends to surface exactly this class of bug before it ever reaches mainnet.
As a stopgap, the team has reportedly set the contract’s flat fee to its maximum possible value. Since several core functions require the caller to pay at least that fee, this effectively blocks new vesting creation and top-ups — a blunt but fast way to freeze the attack surface while a real fix is prepared. It’s not a repair to the underlying accounting logic, just a way to buy time.
If your contract’s logic is complex enough that you can’t convince yourself — and ideally an independent reviewer — that every function agrees on what the internal state means, that’s usually a sign to simplify the design rather than add more tests on top of it. Complexity doesn’t make a contract more secure; it just moves the bug somewhere harder to find.
If you’re building something similar — vesting, staking, or any pool-based accounting — and want a second set of eyes before it goes live, that’s exactly the kind of review we do. Feel free to reach out.
PandaSecurity Team writes about real-world Web3 incidents so builders and users can learn from them before the next one happens.
Autheo’s THEO token lost 92% after attackers used a Hyperlane TrustedRelayerIsm misconfiguration to mint 19 million unbacked tokens on Base and extract roughly $1.76 million. Here’s how the attack worked and why single-key security models remain dangerous for cross-chain assets.
Uniswap v4 hooks explained: the PoolManager singleton, flash accounting, and what the Cork and Bunni exploits teach us about hook security. A PandaSecurity deep dive.
An attacker forced a Uniswap V4 vault into a zero-liquidity state, redeemed one minimal share unit, and withdrew roughly 14,838.47 DAI. Panda Security breaks down the transaction, vulnerable code, root cause, and practical fixes.
GoldPesa’s Uniswap v4 hook GPXHooks on Base rebalanced protocol-owned GPX/USDC liquidity through a shared PositionManager without checking that GPX/USDC currency deltas were zero inside the caller’s unlock. An attacker left an unsettled negative delta, triggered rebalance, and netted the hook’s burn credit, withdrawing about 115K USDC from the PoolManager. This note covers the contract’s role, v4 flash accounting, the root cause, and the transaction path.