https://github.com/hackenproof-public/tokenomics_contract
yo;
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)
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 moment1) 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,
tx:
1) minimal dust case (lose 1):
create_lock(user, 100, T_end) -> receive NFT A (amount=100)split(user, A, [1,2])supply -= 100Children: B=33, C=66 (floors)supply += 33, then supply += 66 -> net -12) catastrophic case (lose all):
create_lock(user, 100, T_end) -> NFT Asplit(user, A, [1,1,1,...] 101 times)floor(100/101) = 0-100 (all “lost”) after the loop1) 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 = 101floor(100 * 1 / 101) = 0children = 0split + deposit_for_internal, withdraw semantics