Secret Network Chain Halt: A Single Malformed Raw Transaction Triggers a Chain Halt During FinalizeBlock
Vulnerability WriteupJuly 28, 2026

Finding 2 of 4

Secret Network Chain Halt: A Single Malformed Raw Transaction Triggers a Chain Halt During FinalizeBlock

By Rahul Saxena (@saxenism)

Severity: Critical (chain halt)

Affected component: scrtlabs/SecretNetwork at commit edcbccbbbe65cae1bd93d4d9089ff5d98228f704 (mainnet-affected line: v1.24.1); Cosmos SDK fork scrtlabs/cosmos-sdk@v0.50.14-secret.12; consensus fork scrtlabs/tendermint@v0.38.23-secret.1; SGX enclave block-verifier

Fix: SecretNetwork commit 7eec819ef45fb4d08b0604233ca32bb8fa6685e0, "skipping malformed txs in submit_block_signatures", packaged in a SecretNetwork release on 2026-06-10

Disclosure: coordinated; reported 2026-06-02, fix released 2026-06-10

Cosmos SDK ships a safety net for malformed transactions inside the FinalizeBlock tx-decode loop, which by design runs after beginBlock. Secret's x/compute.BeginBlock forwards attacker-controlled raw tx bytes into an SGX enclave that parses them with unwrap(), before the safety net has a chance to fire.

This writeup describes a vulnerability disclosed to Secret Labs by Bluethroat Labs on 2026-06-02 and fixed on 2026-06-10 under coordinated disclosure. All observations reflect the audited revision. No exploitation was observed in the wild.

Separate incident: This consensus-layer chain-halt vulnerability is unrelated to the June 2026 CW20-ICS20/Axelar bridge infinite-mint exploit. The two incidents affected different components and had different root causes.

Executive Summary

  • A single Byzantine active-set proposer, at any height where CometBFT's stake-weighted round-robin selects it, can build a proposal whose transaction list contains one byte, 0xff, and gossip it. No Byzantine voting-power threshold is required. One proposer slot is enough.
  • Tendermint's Txs.Validate checks only aggregate encoded size, so 0xff is a valid block-data byte string at the consensus layer. Secret ships BaseApp without an app mempool, which routes ProcessProposal through Cosmos SDK's NoOpProcessProposal, which unconditionally returns ACCEPT. Honest validators precommit the malformed proposal.
  • During FinalizeBlock, BaseApp calls beginBlock(req) before it iterates and decodes req.Txs. Secret's x/compute.BeginBlock reads the raw proposal transaction bytes off the SDK context and hands them to the SGX enclave via api.SubmitBlockSignatures, before the SDK tx loop that would have turned malformed bytes into ErrTxDecode responses ever runs.
  • Inside the enclave, tx_from_bytes(raw_tx) calls Tx::parse_from_bytes(raw_tx).unwrap(). On 0xff, the parser returns WireError(UnexpectedEof), the unwrap() panics, the ECALL returns SGX_ERROR_ENCLAVE_CRASHED, and Go surfaces CONSENSUS FAILURE!!! err="failed to apply block; error SGX_ERROR_ENCLAVE_CRASHED". The malformed height reaches the consensus commit boundary and finalization fails.
  • Reproduced end-to-end on real Intel SGX silicon on Azure Standard_DC8s_v3 in eastus with Azure DCAP-backed remote attestation. Honest and malicious runtime images share byte-identical enclave and SGX-bridge artifacts; only /usr/bin/secretd differs. All four RPC endpoints stayed pinned at H = 10 with block hash D275E21A6EE100632E03424914F69086E40E8F91A56CEEB24A0D222FD235DE32 for the full 300-second capture window.

Background

Three composed pieces of shipped software each make one decision that is defensible in isolation. The bug lives in the composition.

Tendermint treats block transactions as opaque bytes. In the Secret consensus fork, Tx is defined as:

// scrtlabs/tendermint@v0.38.23-secret.1/types/tx.go:19
// this is the load-bearing "opaque bytes" contract: Tendermint is the
// consensus layer and by design does not care what the app encodes inside.
// any subsequent "malformed tx" check has to live above this line.
// Tx is arbitrary bytes to be transmitted through the consensus layer.
type Tx []byte

Txs.Validate at types/tx.go:107 bounds only the aggregate encoded size. This is correct behavior at the consensus layer: Tendermint is application-agnostic and does not know that some app on top happens to encode cosmos.tx.v1beta1.Tx protobufs inside those bytes. A single-byte raw tx of 0xff is admissible here by design.

Cosmos SDK BaseApp defaults to a NoOp ProcessProposal. When an application does not install a mempool, BaseApp falls back to mempool.NoOpMempool{} at baseapp/baseapp.go:225. ProcessProposalHandler at abci_utils.go:380 then returns NoOpProcessProposal, which at line 426 unconditionally responds ACCEPT. Also defensible: ProcessProposal is a chain-specific validation hook, and the SDK cannot know what any given chain wants to validate at proposal time.

Secret's SGX block verifier needs raw Tendermint tx bytes. The enclave binds validator-set evidence and inner-message witnesses to actual block contents, so x/compute reads the raw proposal txs off the SDK context inside BeginBlock and pushes them across the ECALL. Also defensible: the enclave is the source of truth for the next height's validator-set evidence, and that binding must run against block contents.

Individually, each choice is a reasonable engineering call. The failure is that each one assumes the layer above it has already done work no layer above it has actually done.

Vulnerability Details

Three assumptions, each unfounded

The composition trap looks like this:

LayerWhat it assumes the layer above has enforcedWhat the layer above actually enforces
TendermintThe application will reject anything malformed before it reaches consensus.Nothing. Tendermint is the consensus layer; nothing sits above it in the block-data path.
Cosmos SDK NoOpProcessProposalIf the chain cares about proposal contents, it will install a real handler.Secret does not. app.go:294 constructs BaseApp with no mempool and no ProcessProposal override.
SGX block verifierEvery raw tx it receives has already been decoded as a valid Cosmos Tx somewhere.Nothing has decoded it. x/compute.BeginBlock runs before the SDK tx decode loop.

Each assumption mirrors the layer above's defensibility. Tendermint is right to be chain-agnostic, which forces the SDK to install a validation policy. The SDK's default policy is right to be permissive, which forces the app to install one. The enclave is right to want raw bytes, which forces someone upstream to validate them. Nobody does.

Layer 1: the SDK's proposal gate is a NoOp

Secret constructs BaseApp without a custom mempool or ProcessProposal handler:

// scrtlabs/SecretNetwork/app/app.go:294 (BaseApp construction)
// no bApp.SetMempool(...), no bApp.SetProcessProposal(...). the app inherits
// the SDK default. the SDK default, when no mempool is installed, is
// NoOpMempool -> NoOpProcessProposal.
bApp := baseapp.NewBaseApp(appName, logger, db, encodingConfig.TxConfig.TxDecoder(), baseAppOptions...)

Verified as absence with grep -rn 'SetMempool\|SetProcessProposal' scrtlabs/SecretNetwork/app/ at the audited revision, which returns no results. The dispatch chain, end to end, is: BaseApp's ABCI ProcessProposal entry point dispatches to the bApp.processProposal field. NewBaseApp at baseapp/baseapp.go:225 seeds that field, without an app override, from abci_utils.ProcessProposalHandler(mempool). With Secret's NoOp mempool that call returns NoOpProcessProposal. There is no other layer between Tendermint accepting the proposal and honest validators voting on it.

BaseApp fills the gap unconditionally:

// scrtlabs/cosmos-sdk@v0.50.14-secret.12/baseapp/abci_utils.go:380
// this branch fires whenever no app mempool is installed, which is Secret's config.
func NoOpProcessProposal() sdk.ProcessProposalHandler {
    return func(_ sdk.Context, _ *abci.RequestProcessProposal) (*abci.ResponseProcessProposal, error) {
        // scrtlabs/cosmos-sdk@v0.50.14-secret.12/baseapp/abci_utils.go:426
        // ACCEPT is unconditional. no decoding, no parsing, no length check
        // beyond what Tendermint already did. any bytes Tendermint accepted,
        // this returns yes to.
        return &abci.ResponseProcessProposal{Status: abci.ResponseProcessProposal_ACCEPT}, nil
    }
}

The consensus-time gate that should reject "bytes this chain cannot finalize" is a function that always says yes.

Layer 2: the SDK's safety net runs after the crash

Inside FinalizeBlock, BaseApp runs beginBlock(req) before the tx-decode loop:

// scrtlabs/cosmos-sdk@v0.50.14-secret.12/baseapp/abci.go:782
if err := app.beginBlock(req); err != nil { ... }   // BeginBlock runs FIRST

// baseapp/abci.go:811 (illustrative; the actual iterator name is not
// load-bearing -- what matters is that the decode loop is below :782)
for _, rawTx := range req.Txs {                     // decode loop runs SECOND
    // ...
    // baseapp/abci.go:817
    // this is where ErrTxDecode lives. the graceful path for malformed
    // proposal txs. Secret never reaches it because beginBlock crashed
    // the enclave for us.
}

The SDK has a safety net for malformed transactions. It is in step 2. The bug is that Secret's SGX call is in step 1.

Layer 3: x/compute hands raw bytes to SGX

// scrtlabs/SecretNetwork/x/compute/module.go:187, 220, 228 (BeginBlock)
x2_data := scrt.UnFlatten(ctx.TxBytes())          // raw proposal tx bytes, undecoded
tm_data := tm_type.Data{Txs: x2_data}             // wrapped as Tendermint block data
// ...
// api.SubmitBlockSignatures crosses the SGX boundary. whatever the proposer
// sent, the enclave sees. there is no "does every raw tx decode as a Cosmos
// Tx?" check on this host-side path.
random, validator_set_evidence, err = api.SubmitBlockSignatures(
    header, b_commit, data, randomAndProof,
)

The last host-side moment where the raw bytes could be rejected before the ECALL is right here. There is no rejection.

Layer 4: the enclave's unwrap() on attacker-controlled bytes

Inside the enclave, submit_block_signatures first runs validate_txs, which recomputes the Tendermint DataHash from the same raw bytes:

// scrtlabs/SecretNetwork/cosmwasm/enclaves/shared/block-verifier/src/verify/txs.rs:8
// re-checks Tendermint's own invariant (raw tx bytes hash to header DataHash).
// does NOT enforce Cosmos decodability. re-checks what Tendermint already
// checked; does not check what Tendermint (correctly) declines to check.
pub fn validate_txs(header: &Header, txs: &[Vec<u8>]) -> bool { ... }

Then the parse loop:

// scrtlabs/SecretNetwork/cosmwasm/enclaves/shared/block-verifier/src/submit_block_signatures.rs:110-119
// this loop sits BEFORE the #[cfg(feature = "random")] block later in the
// same function. production and verify-validator-whitelist do not gate it.
// any build that reaches ecall_submit_block_signatures (i.e. any build with
// light-client-validation, which mainnet includes) reaches it.
for raw_tx in raw_txs.iter() {
    let tx = tx_from_bytes(raw_tx)?;
    // ...
}

And the panic site itself:

// scrtlabs/SecretNetwork/cosmwasm/enclaves/shared/block-verifier/src/txs.rs:23
pub fn tx_from_bytes(raw_tx: &[u8]) -> SgxResult<Tx> {
    // this .unwrap() runs inside the SGX enclave. Tx::parse_from_bytes
    // returns Err(WireError(UnexpectedEof)) for 0xff. unwrap() then panics.
    // the panic aborts the enclave thread; sgx_status_t bubbles out as
    // SGX_ERROR_ENCLAVE_CRASHED, which the Go side translates to a
    // consensus panic in FinalizeBlock.
    let res = Tx::parse_from_bytes(raw_tx).unwrap();
    Ok(res)
}

The function's return type is SgxResult<Tx>. The parser's failure mode is representable in that return type. unwrap() discards it. This is the entire finding: one line inside a TEE, on attacker-controlled input, in a function whose signature already had a slot for the error case.

The feature-flag question

Mainnet builds with:

// scrtlabs/SecretNetwork/Makefile:315 (build-mainnet feature list)
// the vulnerable loop at submit_block_signatures.rs:110-119 is reachable
// under this exact flag set. light-client-validation gates the ECALL;
// production and verify-validator-whitelist do not remove or wrap the loop.
verify-validator-whitelist,light-client-validation,production,random

The panicking parse loop at submit_block_signatures.rs:110-119 sits above the later #[cfg(feature = "random")] block in the same function; production and verify-validator-whitelist do not gate it. light-client-validation is required to enable the ECALL at all in HW mode (see enclaves/shared/ecalls.rs:43-48); without it, the ECALL returns SGX_ERROR_ECALL_NOT_ALLOWED. Mainnet includes it. The mainnet flag set makes the vulnerable code path reachable, not safer.

Attack Scenario

H-1  honest proposer          normal block, enclave finalizes normally
H    Byzantine proposer       proposal.txs = [0xff]; header.DataHash matches;
                              EncryptedRandom is non-nil
     Tendermint layer         opaque bytes; aggregate-size check passes; gossips proposal
     honest validators        BaseApp -> NoOpProcessProposal -> ACCEPT
                              prevote, precommit, reach consensus commit boundary
     FinalizeBlock            beginBlock(req) runs BEFORE the SDK tx decode loop
     x/compute.BeginBlock     ctx.TxBytes() -> api.SubmitBlockSignatures(raw)
     enclave validate_txs     recomputes DataHash, matches, returns Ok
     enclave loop             tx_from_bytes(0xff) -> Tx::parse_from_bytes(0xff).unwrap()
                              panic: WireError(UnexpectedEof)
     enclave                  SGX_ERROR_ENCLAVE_CRASHED bubbles out of the ECALL
     host                     CONSENSUS FAILURE!!! err="failed to apply block;
                                                       error SGX_ERROR_ENCLAVE_CRASHED"
H+1  no honest validator can produce it; chain does not advance

The malicious diff is a byte choice, not an implementation trick

The PoC modifies one branch of the proposer path in the consensus fork and nothing else. It prepends []byte{0xff} to rpp.Txs when the proposer is producing the target height and logs a marker line for evidence:

// injected by the PoC build in the proposer path only (scrtlabs/tendermint
// state/execution.go), before types.ToTxs(rpp.Txs). this is the byte-choice
// primitive a Byzantine proposer has for free: they choose what goes in
// their own proposal. no software patch is required to exercise it in a
// real attack; the patch here only pins the byte for deterministic
// reproduction.
if os.Getenv("POC_MALFORMED_RAW_TX_HEIGHT") == fmt.Sprint(height) {
    rpp.Txs = append([][]byte{{0xff}}, rpp.Txs...)
    blockExec.logger.Error("POC: injecting malformed raw tx into proposed block",
        "height", height, "tx_hex", "ff")
}
txl := types.ToTxs(rpp.Txs)

The point of the patch is determinism for the harness, not capability for the attacker. A Byzantine proposer already has the primitive by definition: they pick the bytes of the transactions in the proposal they gossip. The honest validators in the run below use the unmodified image.

The Azure HW witness

On Standard_DC8s_v3 in eastus (Intel Xeon Platinum 8370C-class, /dev/sgx_enclave and /dev/sgx_provision, SGX_MODE=HW, Azure DCAP az-dcap-client 1.13.1 against https://global.acccache.azure.net/sgx/certification/v3/, secretd check-enclave reporting SGX enclave health status: Success before the run), the malicious val0 produced height 10 with proposer address 4114F98023D24486B3581D763E69E8541D431663. All four RPC endpoints returned matching height-10 blocks:

height              = 10
encrypted_random    = non-null                    # normal encrypted-random path
txs                 = ["/w=="]                    # base64 for 0xff
block_id.hash       = D275E21A6EE100632E03424914F69086E40E8F91A56CEEB24A0D222FD235DE32
parts.hash          = EEB4BDD79FA6F55E5EA91D600DC211A80CFC71EE4079EC3075DDD623065C8F5C
data hash           = E3F3EDBDC95F66B3CECFAC7F365650BDC9B243ECF2E87C40D36F553D9C952760

All four validator logs, including malicious val0, carried the same panic at the same source location:

thread '<unnamed>' panicked at 'called `Result::unwrap()` on an `Err` value: WireError(UnexpectedEof)',
    shared/block-verifier/src/txs.rs:23:44
Failed to submit block signatures
error in proxyAppConn.FinalizeBlock err=SGX_ERROR_ENCLAVE_CRASHED
CONSENSUS FAILURE!!! err="failed to apply block; error SGX_ERROR_ENCLAVE_CRASHED"

Post-attack /status from all four endpoints reported:

latest_block_height = "10"
latest_block_hash   = "D275E21A6EE100632E03424914F69086E40E8F91A56CEEB24A0D222FD235DE32"

for the full 300-second capture window. The chain committed height 10 at the consensus layer and then could not apply it. That is the shape of this bug: a committed-but-un-finalizable state that recovery tooling has no obvious hook for.

A four-validator Docker localnet SW witness (one malicious, three honest, 240-second capture) reproduced the same root-cause panic; the process-level post-crash shape differs because whether the container outlives the enclave depends on the runtime harness, but the enclave panic and the halt are identical.

The enclave was not tampered with

Between honest and malicious runtime images, only /usr/bin/secretd differs. The three artifacts that determine enclave and SGX-bridge behavior match byte-for-byte:

c033f743bc5a254abede89cfe749487285d0eb7e7771abefd87766f5940ae266  /usr/lib/librandom_api.so
b7727ec129774a3ce1a97b5b143537e753090b4286ae9c79d1a7224b62f2c1ab  /usr/lib/librust_cosmwasm_enclave.signed.so
ee56c94bc283ec6dd8813568f2d11d566c87bcc53f8b761b3fcf24426394c678  /usr/lib/libgo_cosmwasm.so

The panic is inside the genuine Secret-Labs-signed enclave, driven by attacker-controlled input. Nothing about the attack requires modifying the enclave, forging attestation, or breaking the TEE. The malicious host binary changes only what bytes the malicious node proposes; every honest node runs the unmodified enclave and dies inside it. val0 poisoned the well and drank from it.

Why This Matters

Secret Network is a semi-permissioned SGX-attested validator set. Admission is gated; proposer selection is not. Once inside the active set, CometBFT's stake-weighted proposer-priority scheduler selects every validator as proposer with frequency proportional to voting power. A validator with 1% voting power is scheduled as proposer approximately once per 100 heights under that deterministic selection. On a live active-set chain with block cadence measured in single-digit seconds, that translates to multiple proposer slots per hour for any active participant. There is no minimum-stake threshold below which the schedule skips a validator, and there is no >1/3 requirement for this attack. One proposer slot suffices.

The economics favor the attacker. No safety violation means no on-chain slashing rule fires. No double-sign evidence, no light-client evidence, no application-layer misbehavior report is produced. The honest supermajority signs the malformed block with legitimate keys against a proposal that legitimately passes their shipped validation logic. The only stake-side exposure to the attacker is jail-for-downtime if their validator later stops signing. The defender pays for it with a network-wide halt affecting every dApp, every user, every IBC channel, and every governance action until operators coordinate a patched binary.

Secret Labs' own operational posture reinforces the framing. The v1.18 upgrade proposal treats enclave-bricking as a network-wide production risk severe enough to warrant a documented emergency-upgrade procedure. That procedure exists because "enclave crash during finalization" is a class the chain must survive. This finding is one instance of that class, produced by one byte. The x/emergencybutton circuit breaker cannot help, for the same reason it cannot help any consensus-layer halt: it operates above consensus, and blocking consensus blocks the block production that would fire the breaker.

The composition trap also generalizes. Any Cosmos SDK chain that (a) leaves ProcessProposal at the NoOp default, and (b) touches raw proposal tx bytes inside a module BeginBlock before the SDK's tx decode loop runs, inherits the same class of exposure. Secret's SGX enclave is the dramatic example because the crash surfaces as SGX_ERROR_ENCLAVE_CRASHED rather than as a recoverable ErrTxDecode. The same shape appears wherever any pre-decode consumer of raw tx bytes has less tolerant error handling than the SDK's post-decode path.

What This Did Not Allow

  • No safety violation. Honest validators do not double-sign, do not fork, do not commit conflicting blocks. Every honest validator that precommits the malformed height precommits the same block hash. The failure is at finalization, downstream of the precommit.
  • No forged signatures. Honest validators sign the block with legitimate keys against a proposal that legitimately passes Tendermint validation, ProcessProposal, and consensus voting rules. The bug is that those rules do not enforce Cosmos-tx decodability.
  • No enclave compromise. The three enclave and SGX-bridge artifacts are byte-identical between honest and malicious runtime images. No enclave code is modified, no attestation is forged, no sealed data is accessed. Remote attestation reported healthy end-to-end.
  • No exfiltration of enclave secrets, seeds, or sealed data. The enclave aborts on the parser panic before it does any work with the malformed tx.
  • No permissionless validator entry. The attacker must operate or compromise an existing active-set validator. Secret's admission remains semi-permissioned.
  • No permanent chain destruction. Coordinated recovery via a patched binary rollout remains possible. The window between exploitation and recovery is the window in which the network is down.
  • No exploit reachable via public RPC. CheckTx and mempool admission rules can reject malformed transactions before they reach the mempool. The relevant adversary is a Byzantine proposer, which bypasses those rules by construction, not an external RPC caller.

Severity Classification

Bluethroat classification: Critical (chain halt). Severity refers to the pre-patch state at the time of report; the shipped v1.25.0 release closes the trigger.

Immunefi's blockchain/DLT severity classification system v2.3 places outcomes that prevent a network from confirming new transactions, and total network shutdown, in the Critical tier. The demonstrated effect satisfies that bar: after the malformed height commits at the consensus layer, no honest validator can apply the block, and therefore no validator can produce the next height. The trigger is a single Byzantine proposer at one slot, the textbook BFT-tolerated fault. Consensus liveness in a fork of CometBFT is expected to continue with less than one-third Byzantine voting power. It does not, here, with one-quarter.

The severity rests on a mechanically forced construction. x/compute.BeginBlock is the sole caller of api.SubmitBlockSignatures; that call is executed by BaseApp before the SDK tx decode loop; the enclave-side parser unwraps. There is no second gate between the malformed byte and the panic on the shipped code path. Secret Labs shipped a fix at Critical-appropriate speed (eight calendar days from report to release).

Fix

Shipped in SecretNetwork commit 7eec819ef45fb4d08b0604233ca32bb8fa6685e0, subject "skipping malformed txs in submit_block_signatures", packaged in a SecretNetwork release on 2026-06-10. The patch removes the internal unwrap() in tx_from_bytes so that the parser error propagates to the caller, and the companion change in submit_block_signatures.rs handles that error rather than crashing:

// AFTER, cosmwasm/enclaves/shared/block-verifier/src/txs.rs
// (see linked fix commit for the exact post-fix signature)
// the .unwrap() is gone. tx_from_bytes now returns the parser error
// to its caller instead of panicking; submit_block_signatures.rs
// consumes that error and skips the malformed tx rather than taking
// the enclave thread down.
pub fn tx_from_bytes(raw_tx: &[u8]) -> /* ProtobufResult<Tx> */ ... {
    Tx::parse_from_bytes(raw_tx)
}

The fix answers the compositional question by declaring, in the enclave code, that malformed raw txs are a normal condition to be skipped rather than an unreachable state. That is a legitimate design choice given the constraint that the enclave sees the txs first. It does not add a ProcessProposal handler, it does not move the SGX call to after the SDK tx decode loop, and it does not change Tendermint's opaque-bytes contract.

Defense-in-depth items not covered by the shipped patch, offered as follow-up:

  • Install a real ProcessProposal handler in NewSecretNetworkApp that at minimum attempts a full cosmos.tx.v1beta1.Tx decode for every proposal tx and rejects the proposal on failure. This restores the consensus-time gate that the SDK's NoOpProcessProposal fallback removed.
  • Audit every module BeginBlock and EndBlock for any other consumer of raw ctx.TxBytes() that runs before the SDK tx decode loop. tx_from_bytes was one line; other pre-decode consumers of raw tx bytes may have the same shape.
  • Audit remaining unwrap() and .expect() call sites inside the enclave that operate on attacker-influenceable inputs.
  • Add regression tests: a proposal whose tx list contains 0xff; a proposal containing a truncated valid Cosmos tx; a proposal containing a wire-type-mismatched protobuf. A 4-validator integration test where one proposer includes such a proposal and honest validators must reject it, not precommit and crash.

Timeline

  • 2026-06-02 -- Finding reported to Secret Labs under coordinated disclosure with the full bundle (report, SW witness, Azure real-SGX HW witness, PoC patches, provenance hashes).
  • 2026-06-09 -- Fix commit 7eec819ef45fb4d08b0604233ca32bb8fa6685e0 authored by Secret Labs.
  • 2026-06-10 -- Fix released in a SecretNetwork release on the v1.25 line.
  • 2026-07-28 -- Public writeup published following the coordinated-disclosure window.

Key Takeaway

Composition is an attack surface. When three layers each hand a responsibility to somebody else, one of them is wrong, and the answer is architectural, not local. Every optional check across a trust boundary needs a named owner, and "the layer below will catch it" is not an owner.

If you carry one rule out of this to other systems: when a module reads attacker-controlled inputs before the generic decoder does, that module inherits the decoder's failure obligations too. SDK's ErrTxDecode is genuine, and it works, for chains that do not shove raw bytes into an unrecoverable layer inside BeginBlock. unwrap() on attacker-controlled input is a bug in ordinary Rust; inside an SGX enclave sitting downstream of an unauthenticated consensus-time gate, it is an availability primitive.

References

Bluethroat Labs is a security research firm focused on smart contracts, distributed consensus, and TEE-integrated systems. This is finding 2 of 4 disclosed to Secret Labs in this engagement; companion writeups will follow as vendor timelines allow.