DexLyn Disclosed Report

Permanent value loss when splitting a veNFT (silent deflation)

Company
Created date
Nov 01 2025

Target

https://github.com/hackenproof-public/tokenomics_contract

Vulnerability Details

yo;

what the code actually does

1) split pre‑decrements global supply by the full original lock

in split, after loading value = locked.amount, the contract executes:

(https://github.com/hackenproof-public/tokenomics_contract/blob/main/sources/voting_escrow.move#L647)

// reset supply, deposit_for_internal increase it
voting_escrow.supply = voting_escrow.supply - value;

this reduces the global “locked DXLYN” counter by the entire old position before new children are created

2) per‑child amounts are floored (u64 integer division)

  • for each weight in split_weights, the child’s amount is computed as:

(https://github.com/hackenproof-public/tokenomics_contract/blob/main/sources/voting_escrow.move#L669)

_value_internal = value * weight / total_weight;

this is integer division -> floors each piece

3) supply is re‑incremented child‑by‑child by that floored amount

inside deposit_for_internal:

let supply_before = voting_escrow.supply;
voting_escrow.supply = supply_before + value;   // value is _value_internal for splits
...
table::upsert(&mut voting_escrow.locked, token, locked); // writes floored amount
check_point_internal(...); // updates bias/slope using the floored new amount

there’s no final reconciliation of the missing remainder after the loop

4) tokens backing a lock actually move only on deposit/withdraw (not on split)

during a split, deposit_for_internal skips token transfers when type == SPLIT_TYPE:

if (value > 0 && type != MERGE_TYPE && type != SPLIT_TYPE) {
    ... transfer from user ...
}

so on splits, only accounting changes (supply & locked.amount), not balances, the “stranded remainder” stays inside the VotingEscrow object account and is not associated to any token anymore, so the user cannot withdraw it later

5) withdraw returns exactly the recorded locked.amount

withdraw moves out value = locked.amount and then burns the NFT, if split floor rounding made the new locks sum to less than the original, the difference is unrecoverable by the user

6) global voting power is tracked via check_point_internal

voting power (bias/slope) is recomputed from the new floored amounts, because each child uses the floored value, the sum of slopes/bias after the split is strictly less than before when any remainder exists, that propagates into total_supply(...) via point history

7)

  • split is an entry function, any veNFT holder can call it; there is no admin gate only a check that the token isn’t voted at that moment

impact:

1) funds loss for the caller (unrecoverable “stranded” tokens)

because withdraw sends exactly locked.amount, any remainder shaved by split rounding is not withdrawable afterwards (no code path re‑credits it)

2) system‑wide governance & emissions distortion

  • point history / slopes: check_point_internal calculates u_new from the floored amounts and updates per‑epoch slope changes; the aggregate bias/slope that feed total_supply(t) become smaller than before for the same time horizon

  • global “supply” metric: VotingEscrow.supply is lowered by the remainder and persists, (you can observe supply vs. actual primary_fungible_store::balance via the test helper get_voting_escrow_state, which returns both numbers)

  • downstream modules (voting, bribes, fee/emission distribution) typically rely on ve bias/slope and per‑token balance_of/total_supply(t); those are also deflated after split because the new children are recorded with less (or even zero) amount.

3) DoS‑like / griefing externalities

if emissions or incentive weights are calibrated to ve totals, attackers can artificially shrink the reference totals (even to near‑zero), skewing per‑unit rates and analytics—even if they don’t profit directly,

exploit:

tx:

1) minimal dust case (lose 1):

  • create_lock(user, 100, T_end) -> receive NFT A (amount=100)
  • split(user, A, [1,2])
  • Pre‑decrement: supply -= 100
  • Children: B=33, C=66 (floors)
  • re‑increment: supply += 33, then supply += 66 -> net -1
  • withdraw later: B gives 33, C gives 66; the remaining 1 is gone from accounting forever

2) catastrophic case (lose all):

  • create_lock(user, 100, T_end) -> NFT A
  • split(user, A, [1,1,1,...] 101 times)
  • each child gets floor(100/101) = 0
  • net supply drop: -100 (all “lost”) after the loop
  • withdraw later: all children withdraw 0. Funds are stranded

Validation steps

poc:

1) worst‑case one‑shot full burn (100% of lock “lost”)

this is more severe than dust: because child amounts use floor(value * weight / total_weight), if you choose many small weights such that total_weight > value, then each value * weight / total_weight can be 0, causing all children to receive 0 and the entire original value to disappear from recorded locks

example: value = 100, choose split_weights as 101 entries of 1 each

  • total_weight = 101
  • for every child: floor(100 * 1 / 101) = 0
  • sum of children = 0
  • supply change: pre‑decrement -100, re‑increment +0 -> -100
  • result: original NFT is burned; 101 new NFTs with 0 amount are minted; user’s withdrawable DXLYN from this lock becomes 0; the 100 tokens are stranded in the escrow account balance with no function to recover them (no “sweep”/reconcile)
  • code sites: weight loop and floor calculation in split , supply math in split + deposit_for_internal, withdraw semantics

Attachments

hidden
CommentsReport History
Comments on this report are hidden
Details
Statedisclosed
Severity
Medium
Bounty$75
Visibilitypartially
VulnerabilityOther
Participants (4)
company admin
author
company admin