55926 bc insight totalsupply overstates circulating vtho
Submitted on Oct 7th 2025 at 20:42:53 UTC by @spongebob for Attackathon | VeChain Hayabusa Upgrade
Report ID: #55926
Report Type: Blockchain/DLT
Report severity: Insight
Target: https://github.com/vechain/thor/compare/master...release/hayabusa
Impacts:
A bug in the respective layer 0/1/2 network code that results in unintended smart contract behavior with no concrete funds at direct risk
Description
energy.TotalSupply() returns the lazily grown base supply plus cumulative issuance and never subtracts burned amounts.
Relevant code:
https://github.com/vechain/thor/blob/3ad5e1805c778a070e27d0d0293f335a256c235e/builtin/energy/energy.go#L108-L134
func (e *Energy) TotalSupply() (*big.Int, error) {
initialSupply, err := e.getInitialSupply()
if err != nil {
return nil, err
}
// calc grown energy for total token supply
acc := state.Account{
Balance: initialSupply.Token,
Energy: initialSupply.Energy,
BlockTime: initialSupply.BlockTime,
}
// this is a virtual account, use account.CalcEnergy directly
stopTime, err := e.GetEnergyGrowthStopTime()
if err != nil {
return nil, err
}
grown := acc.CalcEnergy(e.blockTime, stopTime)
issued, err := e.getIssued()
if err != nil {
return nil, err
}
return grown.Add(grown, issued), nil
}Burns are only tracked by incrementing total.TotalSub inside Energy.Sub (used by gas spending and fees):
https://github.com/vechain/thor/blob/3ad5e1805c778a070e27d0d0293f335a256c235e/builtin/energy/energy.go#L180-L205
https://github.com/vechain/thor/blob/3ad5e1805c778a070e27d0d0293f335a256c235e/runtime/resolved_tx.go#L146-L212
The net burned amount is exposed via TotalBurned():
https://github.com/vechain/thor/blob/3ad5e1805c778a070e27d0d0293f335a256c235e/builtin/energy/energy.go#L136-L144
func (e *Energy) TotalBurned() (*big.Int, error) {
total, err := e.getTotalAddSub()
if err != nil {
return nil, err
}
burned := new(big.Int).Sub(total.TotalSub, total.TotalAdd)
return burned, nil
}Because issued is only increased (for example, block rewards call e.addIssued(reward)) and never decreased, TotalSupply() reports cumulative minted tokens rather than minted minus burned. After any net burn, sum(balanceOf) < totalSupply().
The public ERC‑20 wrapper simply forwards totalSupply() to the native value, so on-chain consumers expecting standard semantics receive the inflated figure unless they manually subtract TotalBurned():
https://github.com/vechain/thor/blob/3ad5e1805c778a070e27d0d0293f335a256c235e/builtin/gen/energy.sol#L25-L27
function totalSupply() public view returns(uint256) {
return EnergyNative(this).native_totalSupply();
}The native energy module misreports supply, so the network layer exposes incorrect contract data. While nothing is stolen immediately, downstream contracts can misbehave because of the incorrect figure; this is classified as MEDIUM severity.
Proof of Concept (reproduction)
Add the following test to energy_test.go and run.
Seed an account with energy
Create a state and seed an account with 1 000 VTHO:
st := state.New(muxdb.NewMem(), trie.Root{})
acc := thor.BytesToAddress([]byte("burn"))
if err := st.SetEnergy(acc, big.NewInt(1000), 0); err != nil {
t.Fatalf("failed to seed energy: %v", err)
}
p := params.New(thor.BytesToAddress([]byte("par")), st)
eng := New(thor.BytesToAddress([]byte("eng")), st, 0, p)
assert.NoError(t, eng.SetInitialSupply(big.NewInt(1), big.NewInt(1000)))Observe totalSupply() unchanged while TotalBurned() tracks burns
totalAfter, err := eng.TotalSupply()
assert.NoError(t, err)
assert.Equal(t, totalBefore, totalAfter)
assert.True(t, totalAfter.Cmp(balance) > 0)
burned, err := eng.TotalBurned()
assert.NoError(t, err)
assert.Equal(t, big.NewInt(600), burned)The test demonstrates that after burning 600 VTHO, TotalSupply() still reports 1 000 while TotalBurned() reports 600 and the account balance is 400, proving the supply overstatement.
Was this helpful?