In this post
- Bitcoin’s scaling problem and L2s
- Cosmos SDK chains — how modules own state
- GOAT Network architecture
- How slashing works end-to-end
- The bug — formal proof
- Mathematical proof of token inflation
- Attack flow
- Proof of concept
- The fix
- Summary
Bitcoin’s scaling problem and L2s
Bitcoin’s base layer processes roughly 7 transactions per second. Every transaction competes for space in a 1 MB block produced every ~10 minutes. This ceiling is intentional: Bitcoin optimizes for finality and censorship resistance, not throughput. The trade-off works for high-value settlement, but it rules Bitcoin out as a platform for DeFi, smart contracts, or any application that needs sub-minute confirmation.
Layer 2 networks address this by separating execution from settlement. Transactions run on a secondary chain — faster and cheaper — while Bitcoin’s base layer remains the trust anchor. The hard problem for any Bitcoin L2 is how it inherits Bitcoin’s security. Projects answer this differently:
- State channels (Lightning) — bilateral off-chain accounting, settled on dispute.
- Merged-mined sidechains — miner participation as the security bridge.
- ZK rollups — batch many L2 transactions into a single ZK proof that Bitcoin can verify.
GOAT Network takes the ZK rollup approach. All transactions execute on the L2 network. Their validity is proven with zero-knowledge proofs. Bitcoin miners are the final arbiters: if a sequencer posts an invalid state root, anyone can submit a fraud proof and Bitcoin’s scripting layer enforces the correct outcome via BitVM2.
┌───────────────── Bitcoin L1 (settlement + dispute) ──────────────────┐
│ BitVM2 bridge ZK proof anchor Miner enforcement │
└──────────────────────────────┬───────────────────────────────────────┘
│ ZK proofs committed
┌──────────────────────────────▼───────────────────────────────────────┐
│ ZKM zkVM prover network (GPU, Groth16) │
└──────────────────────────────┬───────────────────────────────────────┘
│ batch validity
┌──────────────────────────────▼───────────────────────────────────────┐
│ GOAT L2 — Cosmos SDK chain (CometBFT consensus) │
│ x/staking · x/evidence · x/slashing · x/gov · x/locking ★ │
└──────────────────────────────────────────────────────────────────────┘
Cosmos SDK chains — how modules own state
The Cosmos SDK is a Go framework for building application-specific blockchains. Rather than deploying a smart contract on a shared chain, teams ship their own sovereign chain — with validators, consensus, tokenomics, and governance — assembled from composable modules. Each module owns a slice of the chain’s KV store and exposes message handlers, query endpoints, and genesis configuration.
CometBFT consensus engine
│
▼
ABCI application
├── x/staking — validator set, delegation, bonded tokens
├── x/evidence — routes misbehavior proofs to handlers
├── x/slashing — signing records, executes stake reductions
├── x/gov — on-chain proposals, the only path to change Params
└── x/locking ★ — GOAT custom: BTC collateral, SlashFraction params ← BUG
This matters for severity. Unlike a smart contract, Cosmos SDK module logic runs inside the chain’s state machine itself. A bug in module code runs as consensus-level code on every full node simultaneously. There is no EVM sandbox. Every node executes the same state transition, including the wrong one.
CometBFT achieves Byzantine fault-tolerant finality in ~1–6 seconds. Validators
participate by bonding stake. If a validator misbehaves, the slashing module burns
a fraction of their bonded tokens — deterministically enforced by every node
running the same Params.
GOAT Network architecture
GOAT Network is structured as a ZK rollup whose execution environment and validator coordination live on a Cosmos SDK chain, while dispute resolution and final settlement are anchored to Bitcoin via BitVM2.
Three distinct layers, one trust chain. Bitcoin provides the trust root. The ZKM proof layer provides cryptographic validity. The Cosmos SDK chain provides the execution environment and economic coordination. The vulnerability lives in the Cosmos SDK layer — specifically in how governance parameters are validated before they reach the execution environment.
The sequencer and the locking module
On GOAT Network, sequencers must lock BTC as collateral via x/locking. This
collateral secures two things: it makes misbehavior costly, and it provides
native BTC yield to operators through gas fees and GOAT token rewards. When a
sequencer locks BTC, a yBTC token is minted on L2 as a receipt. The validator
set is ranked by locked collateral. Governance — via x/gov — controls the
slashing parameters.
Two critical parameters live in x/locking/types/params.go:
type Params struct {
SlashFractionDoubleSign sdk.Dec // fraction burned on equivocation (double-sign)
SlashFractionDowntime sdk.Dec // fraction burned on liveness failure
// ...
}
Both are sdk.Dec — arbitrary-precision signed decimal. Typical values:
SlashFractionDoubleSign = 0.05 (5%), SlashFractionDowntime = 0.0001 (0.01%).
How slashing works end-to-end
When a validator double-signs — producing two conflicting vote messages for the same block height — CometBFT detects the conflict and the evidence is processed through the following path:
1. CometBFT gossip detects conflicting pre-commits at height H
│
▼
2. DuplicateVoteEvidence packaged → MsgSubmitEvidence submitted
│
▼
3. x/evidence module validates format, routes to handleEvidence
│
▼
4. Keeper reads SlashFractionDoubleSign from Params (KV store)
│
▼
5. slashAmount = validator.Tokens × SlashFractionDoubleSign
│
▼
6. validator.Tokens -= slashAmount (expected: tokens decrease)
totalSlashed += slashAmount
│
▼
7. Validator tombstoned (permanent jail for double-sign)
The keeper trusts that Params.Validate() enforced the invariant
SlashFractionDoubleSign ∈ (0, 1] before the value reached the KV store. This
trust is the assumption the bug breaks.
The bug — formal proof
Invariant definition
Let $\mathcal{P}$ be the set of valid Params values. The slashing module’s
safety invariant requires:
Params.Validate() is the sole enforcement gate for this invariant. It is called
at genesis and on every governance parameter-change proposal.
The defective guard
The actual code before the patch:
func (p Params) Validate() error {
// ...
if p.SlashFractionDoubleSign.IsZero() || p.SlashFractionDowntime.IsNegative() {
return fmt.Errorf("SlashFractionDoubleSign too low: %s",
p.SlashFractionDoubleSign.String())
}
// ...
}
The guard constructs a compound condition:
\[G(p) \equiv \bigl(p.\texttt{SFDS} = 0\bigr) \;\lor\; \bigl(p.\texttt{SFDt} < 0\bigr)\]where $\texttt{SFDS} = \texttt{SlashFractionDoubleSign}$ and $\texttt{SFDt} = \texttt{SlashFractionDowntime}$.
Proof of guard failure
Claim: There exists a value of $p.\texttt{SFDS}$ that violates
$\mathcal{I}_{\text{slash}}$ yet satisfies $\neg G(p)$, causing Validate() to
return nil.
Proof. Choose any $p$ such that:
\[p.\texttt{SFDS} = -\delta \quad \text{for some } \delta > 0, \qquad p.\texttt{SFDt} = \varepsilon \quad \text{for some } \varepsilon > 0\]Evaluate $G(p)$:
\(\bigl(p.\texttt{SFDS} = 0\bigr) = \bigl(-\delta = 0\bigr) = \textbf{false}\) \(\bigl(p.\texttt{SFDt} < 0\bigr) = \bigl(\varepsilon < 0\bigr) = \textbf{false}\)
\[\therefore\quad G(p) = \textbf{false} \lor \textbf{false} = \textbf{false}\]The condition is false, so Validate() does not return an error. Yet:
This violates $\mathcal{I}_{\text{slash}}$. $\blacksquare$
The correct guard should be:
\[G'(p) \equiv \bigl(p.\texttt{SFDS} \leq 0\bigr) \;\lor\; \bigl(p.\texttt{SFDS} > 1\bigr)\]evaluated with the right variable name on every sub-expression.
Mathematical proof of token inflation
Setup
Let $T \in \mathbb{Z}_{>0}$ be the validator’s bonded token balance. Let $f = p.\texttt{SlashFractionDoubleSign} \in \mathbb{Q}$. The keeper computes:
\[\texttt{slashAmount} = \left\lfloor T \cdot f \right\rfloor\] \[T' = T - \texttt{slashAmount} = T - \left\lfloor T \cdot f \right\rfloor\]Normal operation (f ∈ (0, 1])
For $f \in (0, 1]$:
\[T \cdot f > 0 \implies \left\lfloor T \cdot f \right\rfloor \geq 1\] \[\therefore\quad T' = T - \left\lfloor T \cdot f \right\rfloor \leq T - 1 < T\]The validator’s balance strictly decreases. $\checkmark$
Exploit case (f < 0)
For $f = -\delta$ where $\delta > 0$:
\[\texttt{slashAmount} = \left\lfloor T \cdot (-\delta) \right\rfloor = -\left\lceil T \cdot \delta \right\rceil\]Since $T > 0$ and $\delta > 0$, we have $T \cdot \delta > 0$, so $\left\lceil T \cdot \delta \right\rceil \geq 1$. Therefore:
\[\texttt{slashAmount} \leq -1 < 0\]Substituting into the token update:
\[T' = T - \texttt{slashAmount} = T - \bigl(-\left\lceil T\delta \right\rceil\bigr) = T + \left\lceil T\delta \right\rceil\] \[\boxed{T' = T + \left\lceil T \cdot \delta \right\rceil > T}\]The validator’s balance strictly increases. Each double-sign event mints $\left\lceil T \cdot \delta \right\rceil$ tokens from nothing.
Quantified impact
For a validator holding $T = 1{,}000{,}000$ tokens with $\delta = 0.05$:
\[\Delta T = \left\lceil 1{,}000{,}000 \times 0.05 \right\rceil = 50{,}000 \text{ tokens per slash event}\]After $n$ iterations (unjail, re-stake, double-sign again):
\[T_n = T_0 + n \cdot \left\lceil T_0 \cdot \delta \right\rceil \approx T_0 \cdot (1 + n\delta)\]The balance grows linearly in $n$ with slope $T_0 \cdot \delta$. The attacker
can extract real BTC by redeeming inflated yBTC through the BitVM2 bridge until
the reserve is drained or the discrepancy is detected on-chain.
Accounting invariant violation
The locking module maintains a total-slashed counter $S$. After $n$ events:
\[S_n = S_0 + n \cdot \texttt{slashAmount} = S_0 + n \cdot \bigl(-\left\lceil T\delta \right\rceil\bigr) < S_0\]The counter decreases, which is economically nonsensical — negative total slashing masks the inflation. On-chain accounting appears normal.
The broken invariant is:
\[\mathcal{I}_{\text{account}} : S_n \geq S_0 \;\text{ for all } n > 0\]With $f < 0$ this invariant is violated on every single evidence event.
Attack flow
Proof of concept
Run with:
go test ./x/locking/types/... -run TestNegativeDoubleSignBypass -v
func TestNegativeDoubleSignBypass(t *testing.T) {
// Construct params with a negative SlashFractionDoubleSign.
// This SHOULD be rejected by Validate(). It is not — that is the bug.
params := types.NewParams(
sdk.NewDecWithPrec(-5, 2), // SlashFractionDoubleSign = -0.05
sdk.NewDecWithPrec(1, 1000), // SlashFractionDowntime = normal
// remaining params: genesis defaults
)
err := params.Validate()
// Bug: err == nil. The negative value passes validation silently.
require.Error(t, err,
"expected Validate() to reject negative SlashFractionDoubleSign, got nil")
}
On the unpatched binary: the test fails (require.Error fires because err == nil).
On the patched binary: Validate() returns
"SlashFractionDoubleSign too low: -0.050000000000000000" and the test passes.
To confirm the arithmetic inversion in the keeper:
func TestInvertedSlash(t *testing.T) {
initialTokens := sdk.NewInt(1_000_000)
slashFactor := sdk.NewDecWithPrec(-5, 2) // -0.05
slashAmount := initialTokens.ToDec().Mul(slashFactor).TruncateInt()
newTokens := initialTokens.Sub(slashAmount)
// slashAmount = -50,000
// newTokens = 1,050,000 ← tokens increased
require.True(t, newTokens.GT(initialTokens),
"expected token inflation: got %s from %s", newTokens, initialTokens)
}
The fix
Separate the two fraction guards so each references only its own field:
func (p Params) Validate() error {
// ...
- if p.SlashFractionDoubleSign.IsZero() || p.SlashFractionDowntime.IsNegative() {
- return fmt.Errorf("SlashFractionDoubleSign too low: %s",
- p.SlashFractionDoubleSign.String())
- }
+ if p.SlashFractionDoubleSign.IsZero() || p.SlashFractionDoubleSign.IsNegative() {
+ return fmt.Errorf("SlashFractionDoubleSign too low: %s",
+ p.SlashFractionDoubleSign.String())
+ }
+ if p.SlashFractionDowntime.IsZero() || p.SlashFractionDowntime.IsNegative() {
+ return fmt.Errorf("SlashFractionDowntime too low: %s",
+ p.SlashFractionDowntime.String())
+ }
// ...
}
Why this is the correct fix
The patched guard enforces:
\[G'_{\text{DS}}(p) \equiv \bigl(p.\texttt{SFDS} = 0\bigr) \lor \bigl(p.\texttt{SFDS} < 0\bigr) \equiv p.\texttt{SFDS} \leq 0\]This correctly rejects any $p.\texttt{SFDS} \leq 0$. Combined with an upper-bound check (recommended below), the full invariant becomes enforceable:
\[G'_{\text{full}}(p) \equiv (p.\texttt{SFDS} \leq 0) \lor (p.\texttt{SFDS} > 1)\] \[\neg G'_{\text{full}}(p) \iff p.\texttt{SFDS} \in (0,\,1] \qquad \checkmark\]Recommended hardening
Beyond the minimal fix, add upper-bound checks so neither fraction can exceed 1:
if p.SlashFractionDoubleSign.GT(sdk.OneDec()) {
return fmt.Errorf("SlashFractionDoubleSign too high: %s",
p.SlashFractionDoubleSign.String())
}
if p.SlashFractionDowntime.GT(sdk.OneDec()) {
return fmt.Errorf("SlashFractionDowntime too high: %s",
p.SlashFractionDowntime.String())
}
Add defense-in-depth inside the keeper before applying the multiplication:
// In handleEvidence, before slashing:
if slashFactor.IsNegative() || slashFactor.IsZero() || slashFactor.GT(sdk.OneDec()) {
panic(fmt.Sprintf("invariant violation: invalid slash factor %s reached keeper", slashFactor))
}
And add table-driven tests that assert Validate() rejects each field independently:
var validateTests = []struct {
name string
params types.Params
wantOK bool
}{
{"double-sign negative", paramsWithDSF(sdk.NewDecWithPrec(-1, 2)), false},
{"double-sign zero", paramsWithDSF(sdk.ZeroDec()), false},
{"double-sign above one", paramsWithDSF(sdk.NewDecWithPrec(11, 1)), false},
{"downtime negative", paramsWithDT(sdk.NewDecWithPrec(-1, 4)), false},
{"both valid", validParams(), true},
}
Summary
| Field | Finding | Severity | Status |
|---|---|---|---|
SlashFractionDoubleSign |
Validate() checks wrong field for IsNegative() | High | Fixed |
SlashFractionDowntime |
Upper-bound check absent (hardening) | Low | Recommended |
Root cause: A copy-paste error in a compound validation condition. Two out of three sub-expressions named the correct variable; the one responsible for the negative check named the wrong one. Long, similar variable names make this invisible to casual code review.
Lesson for auditors: Governance-controlled parameters are a live attack surface.
Any sdk.Dec that flows from a proposal into arithmetic — without re-validation at
the execution site — is a potential invariant violation. When reviewing Validate()
functions, verify each sub-expression references the variable named in the error
message. A mismatch between the error string and the guarded variable is a
near-certain indicator of this class of bug.
Patch: GOATNetwork/goat@091da717
Author: @this-vishalsingh