How an Omitted Governance Parameter Could Halt Secret Network
By Rahul Saxena (@saxenism)
Severity: Critical — requires a deposited and passing governance proposal
Affected component: Secret3dev/SecretNetwork at commit 2b8e18788e70d79245343ed53bc2cc42a345d2d4 (v1.26.0-community-continuance), Secret Cosmos SDK fork v0.50.14-secret.12 (f51dfde9658840bf59a9ed55037303b4bd52531a), and Secret Comet fork v0.38.23-secret.1.
Fix: v1.27.2, released 2026-09-23 and activated on secret-4 at height 27,286,266 (2026-09-23 12:29:45 UTC) under governance proposal 377; v1.27.3 later retained the enclave.
Secret Network v1.26 used the governance-settable distribution parameter RestakePeriod directly as a divisor in BeginBlock. The module declared that the value must be at least 1,000 blocks, but the active MsgUpdateParams path did not validate it. An update with an omitted or zero period could therefore be accepted, committed, and consumed as blockHeight % 0 at the next height.
The trigger was neither hidden nor permissionless. The proposal needed the full governance deposit and a passing vote, and its decoded parameters were available for review. The defect was that an authorized update could persist a value outside the module's own safe domain and make deterministic block execution panic.
Secret's team independently identified the parameter issue, while Bluethroat independently reproduced and documented this failure path.
From proposal to panic
Let H be the height at which governance executes a passed distribution parameter update.
- A proposal contains
/cosmos.distribution.v1beta1.MsgUpdateParamswithrestake_periodset to0, set aboveMaxInt64, or omitted. For Secret's non-nullablemath.Int, the standard proposal decoder materializes an omitted field as zero. - The proposal reaches its required deposit and passes. This governance decision is a prerequisite; there is no authority bypass.
- At
H, the distribution handler validates the authority, calls an incompleteParams.ValidateBasic, and stores the full parameter object. The zero-valued update returns success and is committed. - At the next application
BeginBlock, distribution reads the stored period and evaluates the block height modulo that value, panicking before the block can commit.
The missing invariant
The active validator checks only the tax fields. The following is copied verbatim from the affected source:
func (p Params) ValidateBasic() error {
if p.SecretFoundationTax.IsNegative() || p.SecretFoundationTax.GT(math.LegacyOneDec()) {
return fmt.Errorf(
"secret foundation tax should non-negative and less than one: %s", p.SecretFoundationTax,
)
}
return validateCommunityTax(p.CommunityTax)
}A validateRestakePeriod function exists in the same package and rejects nil, negative, and sub-1,000 values. Its caller, however, is the deprecated ParamSetPairs registration. The collections-backed MsgUpdateParams path calls ValidateBasic, not that legacy validator.
The update handler then writes the whole object after ValidateBasic succeeds. The protobuf comment says all parameters must be supplied, but the implementation has no corresponding presence or completeness check. Omission becomes a zero value before validation, and zero is never examined.
Finally, BeginBlocker treats the stored value as safe. This excerpt is copied verbatim from the affected source:
restakePeriod, err := k.GetRestakePeriod(ctx)
if err != nil {
return err
}
if ctx.BlockHeight()%restakePeriod.Int64() == 0 {
staleKeys := k.IterateRestakeEntries(ctx, restakeFunc)
for _, stale := range staleKeys {
err := k.DeleteAutoRestakeEntry(ctx, stale.Delegator, stale.Validator)
if err != nil {
k.Logger(ctx).Info(fmt.Sprintf("Err: %s, Failed to delete restake key", err))
}
}
}Zero causes integer division by zero. A value such as 2^63 causes the narrowing conversion to panic. Reconnecting only the legacy minimum check would have stopped zero while leaving the upper-bound case, which is why the repaired invariant also needed an int64 bound.
Governance's recover guard does not contain this failure. UpdateParams itself returns successfully, so governance commits its cache. The panic occurs in distribution during the following block, outside the completed proposal handler.
Reproduction and evidence boundary
Two local witnesses established the writer-to-sink path and the full-application BeginBlock panic:
- Exact-SDK witness: Omission decoded to explicit zero through the production proposal decoder, governance
Anypacking retained zero, the real distributionUpdateParamsstored it, and the real distributionBeginBlockerpanicked withruntime error: integer divide by zero. A separate2^63case passed both the active validator and the unused legacy period validator beforeInt64()panicked withInt64() out of bound. Controls confirmed that a non-governance authority was rejected and that the default period of 1,000 did not panic. - Exact-v1.26 full-application witness: At post-genesis height 2, the real
SecretNetworkApp.BeginBlocker, codec, keeper wiring, mint path, distribution allocation prefix, and module order reached the production distribution modulo and panicked on the stored zero. The default-period control cleared distribution and reached a later, expected compute mock boundary.
Neither witness ran governance tally, BaseApp FinalizeBlock, a Comet validator network, or a process restart. The all-validator and restart consequences are source-derived: every unpatched validator executes the same deterministic application code over the same committed parameter, and Comet's block-store ordering re-presents the same transition after restart, WAL replay, block sync, or state sync. Recovery therefore requires coordinated out-of-band action—deploying a compatibility binary or rolling back committed state so the update no longer executes.
No parameter-poisoning proposal was submitted to secret-4, and no transaction was broadcast during the research. Full provenance, witness receipts, and the September 5/6 network snapshots are in the technical appendix.
Mainnet reachability
The mainnet evidence consists of one on-chain proposal—proposal 326—and a separate live parameter query, both captured read-only in the September 5/6 snapshots.
Proposal 326, “Adjust Distribution Taxes: Remove Community Pool Tax & Increase SNF Tax,” passed with a /cosmos.distribution.v1beta1.MsgUpdateParams message. It supplied the complete object, including restake_period: "1000". It therefore establishes that this update route was enabled and used in production; it does not show that omission was normal practice.
The separate distribution-parameter snapshot showed the valid live value restake_period: "1000". The governance snapshot showed a total minimum deposit of 1,000,000,000uscrt, a 1% minimum individual deposit, quorum 0.334, and threshold 0.5. These are historical September 5/6 observations, not claims about current network settings.
The same snapshots showed that the compute circuit breaker's disable list contained only MsgInstantiateContract and MsgStoreCode, not distribution MsgUpdateParams. SGX also did not mediate the vulnerable operation: the deployed binary had the sgx build tag, but the parameter read and modulo were plain distribution code ordered before compute.
Remediation
The v1.27 line removed the vulnerable auto-restake walk from distribution BeginBlock and rejected the auto-restake enable path. The maintainers also described validation that rejects periods below 1,000 and values that do not fit int64, addressing both omission/zero and 2^63.
v1.27.2 was released on 2026-09-23 and activated on secret-4 at height 27,286,266 under governance proposal 377; v1.27.3 later retained the enclave. The public changelog's 1.27.0 entry, carried forward in the v1.27.2 tag, records the behavioral fix and credits reporter @saxenism; this article does not assert a line-level patch diff because the corresponding patched SDK source was not available in the published release tree.
Timeline
- 2026-09-05 / 2026-09-06 — Local witnesses and read-only v1.26 September snapshots captured, including node and parameter state, governance settings, proposal 326, and the circuit-breaker list.
- 2026-09-06 — Bluethroat reported the failure path to the Secret Network maintainers under coordinated disclosure; maintainers stated that their team had independently identified the parameter issue.
- 2026-09-21 — Governance proposal 377 submitted to upgrade
secret-4tov1.27.2at height 27,286,266; it passed on 2026-09-22. - 2026-09-22 — Maintainers confirmed the remediation: removing the
BeginBlockrestake walk, rejecting the enable path, and adding period validation. - 2026-09-23 —
v1.27.2released;secret-4upgraded at height 27,286,266, block time 12:29:45 UTC (v1.27.3followed later). - 2026-10-15 — Maintainers cleared the finding for publication.
Takeaway
Documenting an invariant is not the same as enforcing it on the active writer. Here, the module had a minimum-period validator, but governance updates took a different path and the consumer assumed the value was safe.
For consensus-critical parameters, review the complete route: decoding, presence semantics, the active write path, numeric bounds, and every consuming operation. A divisor read from state should be defended at the writer and, where practical, at the sink.
References
Params.ValidateBasicvalidateRestakePeriod- Deprecated
ParamSetPairsregistration MsgUpdateParamsschemaMsgUpdateParamshandler- Proposal JSON decoder
- Distribution
BeginBlocker - Secret
BeginBlockorder - Mainnet proposal 326
v1.27.2changelog
RESTAKE-001 Technical Appendix
This appendix contains provenance and witness receipts for How an Omitted Governance Parameter Could Halt Secret Network. Live network observations are read-only snapshots from September 5/6, 2026; they are not descriptions of current settings.
The test patches and harnesses are not published with this article, so the receipts below document the observed runs rather than presenting reader-reproducible commands.
Pinned revisions
| Component | Revision |
|---|---|
| Network observed | Secret Network mainnet, secret-4 |
| Observed live binary | secretd 1.26.0 |
| Affected public release | v1.26.0-community-continuance |
| Public release commit | 2b8e18788e70d79245343ed53bc2cc42a345d2d4 |
| Observed live-node commit | 4aebd17c3b11c574d7dad07056ee6bd30b48e140 |
| Secret Cosmos SDK fork | github.com/scrtlabs/cosmos-sdk v0.50.14-secret.12, commit f51dfde9658840bf59a9ed55037303b4bd52531a |
| Secret Comet fork | github.com/scrtlabs/tendermint v0.38.23-secret.1 |
| Fix release | v1.27.2, commit 72c548f39602e87924f69289715ae8d7c8cfa7b4, released 2026-09-23 |
The observed node reported application version 1.26.0 and commit 4aebd17…. Its go.mod replacement directives and the official package's embedded build information select the Secret SDK and Comet forks listed above. The affected go.mod, module ordering, keeper wiring, and CLI-root files were byte-identical between the observed live commit and public release commit.
Release-package provenance
The following selected build information was captured from the official v1.26 package on 2026-09-06:
package = secretnetwork_1.26.0_MAINNET_goleveldb_amd64_ubuntu-22.04.deb
package sha256 = bb9baf0ee8d32b7800845771de1224edb42876245f1ae730323481353600a6f6
binary sha256 = f6d6cb40230ec96d9a1cd6b5de65d217a679ba2db034696f5d513e6feef17a6c
toolchain = go1.24.0
dep github.com/cosmos/cosmos-sdk v0.50.9
=> github.com/scrtlabs/cosmos-sdk v0.50.14-secret.12 h1:PWRZGsDaEVGkBleaoKwCcKdFejRFEV3F+P15P/ux0BQ=
dep github.com/cometbft/cometbft v0.38.12
=> github.com/scrtlabs/tendermint v0.38.23-secret.1 h1:Fj3F3mC4P7YuRhT71OnKbVQNFl9smndqo9h3tUoJwKE=
version.Version = 1.26.0
version.Commit = 4aebd17c3b11c574d7dad07056ee6bd30b48e140
version.BuildTags = netgo ledger hw sgx productionThe sgx production tags establish the deployed build profile. The vulnerable parameter read and modulo remained plain distribution-module operations.
Exact-SDK witness
The component witness pinned the SDK and Comet replacements above. It exercised the production proposal decoder, governance Any packing, the real distribution UpdateParams, its collections-backed parameter store, and the real distribution BeginBlocker. It ran at height 1, skipping the height-gated allocation prefix, with mocks limited to unrelated bank, staking, and account dependencies.
Observed output:
=== RUN TestPartialGovernanceJSONStoresZeroAndHaltsBeginBlock
cli_omission=explicit_zero active_validation=pass governance_packing=zero
real_update_params=success stored_restake_period=0
real_begin_block_panic="runtime error: integer divide by zero"
--- PASS: TestPartialGovernanceJSONStoresZeroAndHaltsBeginBlock (0.00s)
=== RUN TestAboveInt64PassesBothValidatorsAndHaltsBeginBlock
restake_period=2^63 active_validation=pass legacy_validation=pass
real_begin_block_panic="Int64() out of bound"
--- PASS: TestAboveInt64PassesBothValidatorsAndHaltsBeginBlock (0.00s)
=== RUN TestAuthorityAndDefaultPeriodControls
non_governance_update=rejected
default_restake_period=1000 real_begin_block=no_panic
--- PASS: TestAuthorityAndDefaultPeriodControls (0.00s)
PASSThe witness passed in five additional clean repetitions, and go vet passed. The controls showed that a non-governance authority was rejected and that the default period of 1,000 did not panic.
This receipt measures the decoder-to-writer-to-sink behavior in the pinned SDK. It does not measure Secret's full module ordering or the distribution allocation prefix.
Exact-v1.26 full-application witness
The full-application witness used a clean checkout of 2b8e1878…, the application's real codec, keeper wiring, state, module ordering, mint path, distribution allocation prefix, UpdateParams, and ordered SecretNetworkApp.BeginBlocker at post-genesis height 2. A test-only patch enabled the harness under the secretcli build tag and was reversed after the run; production distribution code was not changed.
Observed output over five repetitions:
=== RUN TestFullAppPostGenesisBeginBlockDefaultControl
full_app_begin_block_height=2 default_restake_period=1000 distribution_prefix=passed
expected_secretcli_mock_boundary=x/compute/module.go panic="runtime error: slice bounds out of range [:8] with capacity 0"
--- PASS: TestFullAppPostGenesisBeginBlockDefaultControl
=== RUN TestFullAppOmittedProposalStoresZeroAndPanicsPostGenesis
cli_json_restake_field=omitted decoded_restake_period=0 governance_any_restake_period=0
real_update_params=success stored_restake_period=0
full_app_begin_block_height=2 panic="runtime error: integer divide by zero"
panic_origin=x/distribution/abci.go
--- PASS: TestFullAppOmittedProposalStoresZeroAndPanicsPostGenesis
PASSThe default-period control passed the entire distribution prefix before reaching the expected compute mock-FFI boundary. The zero-period branch stopped earlier at the real distribution modulo.
This witness measured the exact-v1.26 full-application BeginBlock panic. It did not execute governance tally, BaseApp FinalizeBlock, a Comet validator network, or process restart. The conclusion that all unpatched validators encounter the same panic, and that restart or replay re-presents it, is derived from deterministic application execution, committed parameter state, Secret's pinned module order, and Comet's block-store ordering—not from those unperformed experiments.
Load-bearing source files:
SecretNetwork/app/app.go
SecretNetwork/app/modules.go
SecretNetwork/app/keepers/keepers.go
SecretSDK/x/distribution/abci.go
SecretSDK/x/distribution/keeper/allocation.go
SecretSDK/x/distribution/keeper/msg_server.go
SecretSDK/x/distribution/types/params.go
SecretSDK/x/gov/types/v1/msgs.goSeptember 5/6 network snapshots
The mainnet reachability evidence is one on-chain proposal—proposal 326—plus a separate live distribution-parameter query. The material below was captured read-only on September 5/6, 2026.
Distribution parameters
The parameter endpoint returned:
{
"community_tax": "0.000000000000000000",
"secret_foundation_tax": "0.000000000000000000",
"secret_foundation_address": "secret16sw5ypfmdh2grp3vsxz7yzmt8vs79p8syzap23",
"minimum_restake_threshold": "10000000.000000000000000000",
"restake_period": "1000",
"withdraw_addr_enabled": true
}This is a September snapshot of a valid live value, not a claim that the same value is current.
Governance settings
Relevant snapshot values:
| Parameter | Value |
|---|---|
min_deposit | 1000000000uscrt |
min_deposit_ratio | 0.010000000000000000 |
max_deposit_period | 604800s |
voting_period | 604800s |
quorum | 0.334000000000000000 |
threshold | 0.500000000000000000 |
veto_threshold | 0.334000000000000000 |
expedited_voting_period | 86400s |
expedited_threshold | 0.666666666666666667 |
expedited_min_deposit | 2500000000uscrt |
At capture time, deposits had to total 1,000,000,000uscrt before voting. The minimum individual contribution was 1% of that total, or 10,000,000uscrt; it was not sufficient by itself to pass the deposit gate.
Proposal 326
The one on-chain proposal used as route evidence was proposal 326:
{
"id": "326",
"status": "PROPOSAL_STATUS_PASSED",
"messages": [
{
"@type": "/cosmos.distribution.v1beta1.MsgUpdateParams",
"authority": "secret10d07y265gmmuvt4z0w9aw880jnsr700jc88vt0",
"params": {
"secret_foundation_tax": "0.200000000000000000",
"secret_foundation_address": "secret16sw5ypfmdh2grp3vsxz7yzmt8vs79p8syzap23",
"minimum_restake_threshold": "10000000.000000000000000000",
"restake_period": "1000",
"withdraw_addr_enabled": true
}
}
],
"title": "Adjust Distribution Taxes: Remove Community Pool Tax & Increase SNF Tax"
}Proposal 326 supplied a complete object with restake_period: "1000". It establishes that MsgUpdateParams was enabled and used in production, not that omission was normal.
Circuit breaker and node identity
The circuit-breaker snapshot was:
{
"disabled_list": [
"/secret.compute.v1beta1.MsgInstantiateContract",
"/secret.compute.v1beta1.MsgStoreCode"
]
}Distribution MsgUpdateParams was not disabled. Node information captured at 2026-09-05T23:04:06Z reported network secret-4, application version 1.26.0, commit 4aebd17…, and build tags netgo ledger hw sgx production.
Decoder and fatal values
restake_period input | Active ValidateBasic | Legacy validateRestakePeriod | Observed BeginBlocker result |
|---|---|---|---|
| Omitted from JSON | passes; field not examined | not called | decodes to 0; integer divide by zero |
"0" | passes | rejects < 1000 | integer divide by zero |
2^63 (9223372036854775808) | passes | passes | Int64() out of bound |
"1000" | passes | passes | no distribution panic |
Secret's RestakePeriod is a non-nullable custom math.Int. The standard proposal loader uses cdc.UnmarshalInterfaceJSON; omission initializes the field to zero rather than leaving a nil value for a presence check. Both witnesses observed governance Any packing preserve that zero.
The following state relationship is source-derived, not a test receipt:
Comet block store: H+1
Comet consensus state: H
Application state: H, including RestakePeriod = 0Safety
The research used local witnesses and read-only live queries. No parameter-poisoning proposal was submitted to secret-4, and no transaction was broadcast.
The on-chain evidence count is one proposal—proposal 326—plus the separate live parameter query captured in the September 5/6 snapshots. The parameter response is network state, not a second proposal.
Fix verification boundary
The public changelog (1.27.0 entry, carried forward in the v1.27.2 tag) records removal of the auto-restake BeginBlock walk, rejection of the enable path, and reporter credit. The maintainers described added validation for periods below 1,000 and values outside int64.
The corresponding patched SDK source was not present in the published v1.27.2 tree, so neither the article nor this appendix claims a line-level source diff. The release was published on 2026-09-23 and activated on secret-4 at height 27,286,266 under governance proposal 377.

