Decimal-Exponent Bomb in Gonka: Halting Consensus via Pre-Ante Rescaling and authz.MsgExec

How an unbounded int32 exponent in Gonka's custom Decimal wire type turns shopspring/decimal rescaling into a pre-ante CPU/RAM bomb—and how wrapping it in authz.MsgExec bypasses CheckTx to trigger a post-commit block of death.

On July 23, 2026, we disclosed a critical consensus-liveness vulnerability in the Gonka network (productscience/inference and its Cosmos SDK fork github.com/gonka-ai/cosmos-sdk v0.53.3-ps17-observability), earning a $20,000 bug bounty. Instead of using Cosmos SDK’s fixed-point LegacyDec, Gonka defines a custom protobuf message Decimal { int64 value; int32 exponent } and converts it directly into shopspring/decimal (v1.4.0) with no exponent bound. When MsgValidation.ValidateBasic() checks whether value_decimal lies in [0, 1] via GreaterThan(shopspring.NewFromInt(1)), shopspring/decimal aligns the exponents by materializing 10^|exponent| as a heap-allocated math/big.Int.

Because the active gonka-ai/cosmos-sdk fork runs validateBasicTxMsgs inside baseapp.runTx before the AnteHandler in every execution mode, that big.Int.Exp allocation is completely unmetered and runs prior to signature verification or fee deduction. What makes this bug particularly interesting is how an attacker can weaponize it across trust boundaries—moving from crashing a single RPC node in CheckTx, to gossiping valid in-range negative exponents across the mempool, to wrapping exponent = -2147483648 inside cosmos.authz.v1beta1.MsgExec so the transaction sails through CheckTx in microseconds and OOM-kills every validator post-commit in FinalizeBlock before the authz grant check is even reached.


The Primitive: Unbounded Wire Exponents in Pre-Ante ValidateBasic()

To represent fractional inference-validation scores on the wire, Gonka defines a two-field protobuf struct in decimal.proto and a helper method ToDecimal() that constructs a shopspring/decimal.Decimal (value * 10^exponent):

// productscience/inference proto/inference/inference/decimal.proto
message Decimal {
  option (gogoproto.equal) = true;
  int64 value = 1;
  int32 exponent = 2;
}
// productscience/inference x/inference/types/decimal.go
func (d *Decimal) ToDecimal() decimal.Decimal {
	return decimal.New(d.Value, d.Exponent)
}

When a node receives a MsgValidation message, MsgValidation.ValidateBasic() verifies that ValueDecimal falls inside [0, 1]:

// productscience/inference x/inference/types/message_validation.go (ValidateBasic)
	if msg.ValueDecimal != nil {
		decimalValue := msg.ValueDecimal.ToDecimal()
		// value in [0,1]
		if decimalValue.IsNegative() || decimalValue.GreaterThan(shopspring.NewFromInt(1)) {
			return errorsmod.Wrap(sdkerrors.ErrInvalidRequest, "value must be in [0,1]")
		}
	}

Locally, this looks harmless: decimal.New only stores big.NewInt(d.Value) and d.Exponent in a struct (O(1)), and IsNegative() only checks the sign of the significand.

The trap is decimalValue.GreaterThan(shopspring.NewFromInt(1)). In github.com/shopspring/decimal@v1.4.0, GreaterThan calls Cmp → RescalePair → rescale. Unlike fixed-point libraries, shopspring/decimal has no magnitude fast-path when two positive numbers have different exponents—it rescales the larger exponent down to the smaller exponent by computing 10^diff as a math/big.Int:

// github.com/shopspring/decimal@v1.4.0/decimal.go:499-503 (rescale)
expScale := new(big.Int).Exp(tenInt, big.NewInt(int64(diff)), nil)
if exp > d.exp {
	value = value.Quo(value, expScale)
} else if exp < d.exp {
	value = value.Mul(value, expScale)
}

Worse still, in the active gonka-ai/cosmos-sdk v0.53.3-ps17-observability fork, baseapp.runTx runs validateBasicTxMsgs at the very start of transaction processing—before installing a GasMeter and before executing the AnteHandler—in every mode (CheckTx, ReCheckTx, Simulate, and FinalizeBlock):

// gonka-ai/cosmos-sdk@v0.53.3-ps17-observability baseapp/baseapp.go:900-903 (runTx)
msgs := tx.GetMsgs()
if err := validateBasicTxMsgs(msgs); err != nil {
	return sdk.GasInfo{}, nil, nil, err
}

This immediately gives a keyless remote attacker an unauthenticated crash primitive (Tier A): broadcasting a ~300-byte MsgValidation with ValueDecimal = { Value: 1, Exponent: -2147483648 } and a garbage signature forces rescale to compute 10^2147483648 during CheckTx before the signature is ever checked, pinning CPU and allocating multiple gigabytes of Karatsuba temporaries until the RPC or sentry node OOMs.


Pivot 1: Passing CheckTx with an In-Range Negative Exponent

Crashing a directly reachable RPC node in CheckTx does not halt the blockchain: if the node hangs or dies inside CheckTx, the transaction is never admitted to the CometBFT mempool and never gossips to sentried validators.

How do we get a poison MsgValidation past CheckTx so CometBFT gossips it across the entire network and an honest proposer packs it into a block?

Look closely at the math in ValidateBasic() when the exponent is negative:

  • Suppose we pass ValueDecimal = { Value: 1, Exponent: -E } with E = 10,000,000.
  • To compare 1 * 10^-10000000 against 1 * 10^0, RescalePair still computes new(big.Int).Exp(10, 10000000, nil)—burning ~1.37 seconds of CPU and 35.7 MB of heap allocations on a single transaction.
  • Once that rescale finishes, what is the mathematical value of 10^-10000000? It is 0.000...001, which lies strictly inside [0, 1].
  • Therefore, GreaterThan(1) returns false, and ValidateBasic() returns nil (valid)!

Two protocol features in Gonka turn this accepted CheckTx validation into a free, self-propagating consensus stall (Tier A′):

  1. Zero fees (NetworkDutyFeeBypassDecorator): MsgValidation is classified as a fee-exempt network duty (x/inference/ante/ante_fee.go:116-126), so an ordinary participant with nonzero ValidationWeight in the current epoch pays zero gas fees to broadcast thousands of them (and live mainnet/testnet run with max_gas = "-1").
  2. Bypassable duplicate filter (revalidation = true): During CheckTx, ValidationEarlyRejectDecorator checks whether the participant already validated msg.InferenceId—but explicitly skips that check whenever msg.Revalidation == true:
// productscience/inference x/inference/ante (ValidationEarlyRejectDecorator)
	// Duplicate detection is only relevant for non-revalidation flows.
	if !msg.Revalidation {
		egv, found := d.inferenceKeeper.GetEpochGroupValidations(ctx, msg.Creator, inference.EpochId)
		if found && slices.Contains(egv.ValidatedInferences, msg.InferenceId) {
			return inferencetypes.ErrDuplicateValidation
		}
	}

Every MsgValidation with exponent = -10,000,000 and revalidation = true clears CheckTx (code 0), gossips peer-to-peer to every validator, and is packed by the next honest proposer (which runs NoOpMempool by default, decoding and packing transactions in PrepareProposal without re-running ValidateBasic()). When the block commits and enters FinalizeBlock, every validator simultaneously re-pays 10^10000000 in validateBasicTxMsgs plus a second GreaterThan rescale and .String() allocation inside the message handler (msg_server_validation.go:134-144). A few hundred ~300-byte transactions in a single block stall consensus network-wide and leave a permanent replay tarpit in the chain history.


Pivot 2: Deferring a Single-Tx “Block of Death” via authz.MsgExec

Tier A′ is a sustained network-wide stall, but it has two constraints: the attacker must hold an active participant key with nonzero ValidationWeight, and each transaction must use a moderate exponent (-10^7) so that CheckTx actually finishes and gossips it.

Can we do worse? Can an attacker with no participant key, no authz grant, and no malicious validator land a single exponent = -2147483648 transaction inside a committed block to guarantee an immediate, unrecoverable OOM crash across all validators?

To pull that off, we need CheckTx to skip MsgValidation.ValidateBasic() while FinalizeBlock still runs it. That exact validation asymmetry exists when MsgValidation is wrapped inside cosmos.authz.v1beta1.MsgExec (Tier A″):

1. Why the MsgExec Wrapper Survives CheckTx in Microseconds

First, in gonka-ai/cosmos-sdk v0.53.3-ps17-observability, *authz.MsgExec does not implement sdk.HasValidateBasic (x/authz/msgs.go exposes only GetMessages()). Consequently, validateBasicTxMsgs in CheckTx skips MsgExec without inspecting its inner messages.

Second, both of Gonka’s custom inference AnteHandler decorators explicitly unwrap one layer of authz.MsgExec:

// productscience/inference x/inference/ante/ante_fee.go (isNetworkDuty)
func isNetworkDuty(msg sdk.Msg, ik *inferencemodulekeeper.Keeper) bool {
	if execMsg, ok := msg.(*authztypes.MsgExec); ok {
		// ... unwraps one MsgExec level and checks isExemptMessageType(unwrapped)

When an attacker uses any ordinary signable account as the outer MsgExec.grantee and wraps an inner MsgValidation{ creator: <public_weighted_participant>, inference_id: <public_inference_id>, revalidation: true, value_decimal: { value: 1, exponent: -2147483648 } }:

  • NetworkDutyFeeBypassDecorator unwraps MsgExec, sees MsgValidation, and waives transaction fees.
  • ValidationEarlyRejectDecorator unwraps MsgExec and verifies only that the inner creator string (a public on-chain address copied by the attacker) has ValidationWeight for inference_id, skipping duplicate detection because revalidation == true.
  • Neither decorator calls ToDecimal(), and neither checks whether <public_weighted_participant> ever granted authz permission to the attacker!

2. Why FinalizeBlock Detonates Before Checking the authz Grant

The wrapped transaction clears CheckTx in microseconds with only the attacker’s outer signature, gossips across the mempool, and is committed into a block by the next honest proposer. During FinalizeBlock, baseapp.runMsgs dispatches the committed MsgExec to authz.Keeper.Exec:

// gonka-ai/cosmos-sdk@v0.53.3-ps17-observability x/authz/keeper/msg_server.go:95-141
func (k Keeper) Exec(goCtx context.Context, msg *authz.MsgExec) (*authz.MsgExecResponse, error) {
	// ...
	msgs, err := msg.GetMessages()
	// ...
	if err := validateMsgs(msgs); err != nil {   // <-- Line 116: runs inner ValidateBasic() FIRST
		return nil, err
	}
	results, err := k.DispatchActions(ctx, grantee, msgs) // <-- Line 120: grant lookup is HERE
	// ...
}

func validateMsgs(msgs []sdk.Msg) error {
	for i, msg := range msgs {
		m, ok := msg.(sdk.HasValidateBasic)
		if !ok { continue }
		if err := m.ValidateBasic(); err != nil { // <-- *MsgValidation detonates 10^2147483648
			return errorsmod.Wrapf(err, "msg %d", i)
		}
	}
	return nil
}

Look at the ordering in authz.Keeper.Exec: validateMsgs(msgs) runs at line 116, while k.DispatchActions(ctx, grantee, msgs)—which queries the authz store and returns authz.ErrNoAuthorizationFound (x/authz/keeper/keeper.go:121-130)—is not called until line 120.

┌──────────────────────────────────────────────────────────────────────────────────┐
│           TIER A'' (authz.MsgExec) DEFERRED DETONATION ACROSS STAGES             │
├──────────────────────────────────────────────────────────────────────────────────┤
│                                                                                  │
│  Tx: authz.MsgExec {                                                             │
│    grantee: <any_attacker_account>,              // outer tx signer (no grant)   │
│    msgs: [ MsgValidation {                                                       │
│      creator:       <public_participant_addr>,   // copied from chain state      │
│      inference_id:  <public_inference_id>,       // copied from chain state      │
│      revalidation:  true,                        // skips duplicate filter       │
│      value_decimal: { value: 1, exponent: -2147483648 }                          │
│    } ]                                                                           │
│  }                                                                               │
│         │                                                                        │
│         ▼                                                                        │
│  ┌────────────────────────────────────────────────────────────────────────────┐  │
│  │ 1. CheckTx: Survives in <1 ms                                              │  │
│  │  • validateBasicTxMsgs : MsgExec is NOT HasValidateBasic -> skipped        │  │
│  │  • Ante decorators     : unwrap MsgExec -> 0 fee, valid public creator/id  │  │
│  │  • Result              : code 0 -> CometBFT mempool gossip -> block commit │  │
│  └─────────────────────────────────────┬──────────────────────────────────────┘  │
│                                        ▼                                         │
│  ┌────────────────────────────────────────────────────────────────────────────┐  │
│  │ 2. FinalizeBlock: Post-Commit Detonation in authz.Keeper.Exec              │  │
│  │  • msg_server.go:116 : validateMsgs(msgs) calls inner ValidateBasic()      │  │
│  │  • decimal.go:499    : computes new(big.Int).Exp(10, 2147483648, nil)      │  │
│  │  • msg_server.go:120 : DispatchActions (grant check) is NEVER REACHED      │  │
│  │  • Result            : every validator OOMs on committed block (crash loop)│  │
│  └────────────────────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────────────────────┘

Because CometBFT persists the committed block to disk before FinalizeBlock runs, every validator OOM-crashes before ErrNoAuthorizationFound can be returned—and crashes again at the exact same block height every time it restarts.


Empirical Cost Curve and Verification

To verify both the shopspring/decimal v1.4.0 rescale curve and the authz.Keeper.Exec execution ordering without disrupting live networks, we ran standalone micro-benchmarks and a production test suite (//go:build poc) against the pinned gonka-ai/cosmos-sdk v0.53.3-ps17-observability fork:

Exponent (-E) Wall-Clock Time Heap Allocations ValidateBasic() Return
-100,000 1.06 ms 0.31 MB nil (in [0, 1])
-1,000,000 28.0 ms 2.8 MB nil (in [0, 1])
-5,000,000 350.9 ms 17.4 MB nil (in [0, 1])
-10,000,000 1.06 s 26.3 MB nil (in [0, 1])
-50,000,000 18.3 s 155 MB nil (in [0, 1])
-100,000,000 65.0 s 331 MB nil (in [0, 1])
-200,000,000 133.0 s 405 MB nil (in [0, 1])
-400,000,000 > 9.8 min (killed) ~0.5+ GB Hangs / OOM

Running the test suite directly against the production MsgValidation.ValidateBasic() and authz.Keeper.Exec implementations on Linux confirms both the cost curve and the pre-grant detonation:

--- PASS: TestPoC_Sink_CostCurve      (real MsgValidation.ValidateBasic)
    exp=-1000000    time=33.9ms   alloc=3.6 MB   err=<nil>
    exp=-3000000    time=179.1ms  alloc=10.2 MB  err=<nil>
    exp=-10000000   time=1.373s   alloc=35.7 MB  err=<nil>
--- PASS: TestPoC_Sink_UnboundedHang  (real MsgValidation.ValidateBasic)
    ValidateBasic did NOT return within 20s for exponent=-200000000
--- PASS: TestPoC_Authz_MsgExec_SkipsCheckTxValidation
    MsgExec is NOT HasValidateBasic -> skipped by validateBasicTxMsgs in CheckTx
--- PASS: TestPoC_Authz_Exec_DetonatesBeforeGrant  (real authz.Keeper.Exec)
    Exec stuck in validateMsgs (msg_server.go:116) after 15s — inner
    ValidateBasic detonated BEFORE DispatchActions/grant (:120); no grant/funds/key
ok  github.com/productscience/inference/x/inference/types  36.675s

Remediation and Engineering Takeaways

No operational configuration change (firewalling RPCs, revoking authz grants, or lowering max_bytes / max_gas) stops a self-propagating Tier A′ or Tier A″ payload once it enters P2P gossip. Fixing the vulnerability requires enforcing a strict exponent bound at the Decimal chokepoint before any conversion to shopspring/decimal:

// Recommended guard in productscience/inference x/inference/types/decimal.go
const (
	maxDecExp = 18
	minDecExp = -18
)

func (d *Decimal) Validate() error {
	if d == nil {
		return nil
	}
	if d.Exponent > maxDecExp || d.Exponent < minDecExp {
		return fmt.Errorf("decimal exponent %d out of range [%d,%d]", d.Exponent, minDecExp, maxDecExp)
	}
	return nil
}

Calling d.Validate() at the top of MsgValidation.ValidateBasic() and hardening ToDecimal() / ToLegacyDec() closes MsgValidation across all execution modes—including the authz.MsgExec wrapper path—as well as sibling Decimal sinks in MsgUpdateParams.ValidateBasic() (validateStabilityZoneBound) and MsgRegisterModel.ValidationThreshold.

Three broader engineering lessons stand out from this finding:

  1. Never expose an unbounded exponent to an arbitrary-precision decimal library: Unlike fixed-scale decimals (LegacyDec), shopspring/decimal constructs (value, exp) in O(1) time but materializes 10^|exp_1 - exp_2| in memory on the first comparison or .String() call. Any wire format carrying an exponent must clamp it to a tight range ([-18, 18]) before conversion.
  2. Pre-ante ValidateBasic() and Go recover() are not protected by the Cosmos GasMeter: Pure Go big.Int arithmetic consumes zero SDK gas, and a Go runtime OOM (fatal error: runtime: out of memory) cannot be caught by baseapp.runTx’s recover().
  3. Wrapper messages like authz.MsgExec must not defer expensive inner validation past CheckTx or prior to authorization checks: When custom AnteHandler decorators unwrap MsgExec during CheckTx to grant fee exemptions, skipping inner ValidateBasic() until authz.Keeper.Exec—and running validateMsgs before checking whether a grant exists—turns any inner validation bug into an unauthenticated post-commit block of death.