Footer
General•November 09, 2026

The Secret Network Research Path

The Modulo Halt

By Rahul Saxena (@saxenism)

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.

  1. A proposal contains /cosmos.distribution.v1beta1.MsgUpdateParams with restake_period set to 0, set above MaxInt64, or omitted. For Secret's non-nullable math.Int, the standard proposal decoder materializes an omitted field as zero.
  2. The proposal reaches its required deposit and passes. This governance decision is a prerequisite; there is no authority bypass.
  3. At H, the distribution handler validates the authority, calls an incomplete Params.ValidateBasic, and stores the full parameter object. The zero-valued update returns success and is committed.
  4. 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 Any packing retained zero, the real distribution UpdateParams stored it, and the real distribution BeginBlocker panicked with runtime error: integer divide by zero. A separate 2^63 case passed both the active validator and the unused legacy period validator before Int64() panicked with Int64() 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-4 to v1.27.2 at height 27,286,266; it passed on 2026-09-22.
  • 2026-09-22 — Maintainers confirmed the remediation: removing the BeginBlock restake walk, rejecting the enable path, and adding period validation.
  • 2026-09-23 — v1.27.2 released; secret-4 upgraded at height 27,286,266, block time 12:29:45 UTC (v1.27.3 followed 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

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

ComponentRevision
Network observedSecret Network mainnet, secret-4
Observed live binarysecretd 1.26.0
Affected public releasev1.26.0-community-continuance
Public release commit2b8e18788e70d79245343ed53bc2cc42a345d2d4
Observed live-node commit4aebd17c3b11c574d7dad07056ee6bd30b48e140
Secret Cosmos SDK forkgithub.com/scrtlabs/cosmos-sdk v0.50.14-secret.12, commit f51dfde9658840bf59a9ed55037303b4bd52531a
Secret Comet forkgithub.com/scrtlabs/tendermint v0.38.23-secret.1
Fix releasev1.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 production

The 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)
PASS

The 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
PASS

The 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.go

September 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:

ParameterValue
min_deposit1000000000uscrt
min_deposit_ratio0.010000000000000000
max_deposit_period604800s
voting_period604800s
quorum0.334000000000000000
threshold0.500000000000000000
veto_threshold0.334000000000000000
expedited_voting_period86400s
expedited_threshold0.666666666666666667
expedited_min_deposit2500000000uscrt

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 inputActive ValidateBasicLegacy validateRestakePeriodObserved BeginBlocker result
Omitted from JSONpasses; field not examinednot calleddecodes to 0; integer divide by zero
"0"passesrejects < 1000integer divide by zero
2^63 (9223372036854775808)passespassesInt64() out of bound
"1000"passespassesno 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 = 0

Safety

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.