Summary
MsgUpdateOperationalChainParams.ValidateBasic accepts values that ChainParams.Validate() would reject. The largest gap is OutboundScheduleInterval: ValidateBasic only rejects negatives, but the canonical validator rejects 0 and any value above 100. The keeper applies the update through SetChainParamsList without running ChainParams.Validate() against the resulting list, so an operational-policy holder can write an OutboundScheduleInterval = 0 to state. Every chain scheduler in zetaclient then reads that value and performs nonce % scheduleInterval, which crashes the goroutine.
Root cause
x/observer/types/message_update_operational_chain_params.go:58-81 — ValidateBasic only checks Creator, ChainId < 0, OutboundScheduleInterval < 0, OutboundScheduleLookahead < 0, and ConfirmationParams.Validate(). The four ticker fields and the two int64 schedule fields are not range-checked against ChainParams.Validate().
x/observer/keeper/msg_server_update_operational_chain_params.go:14-52 — handler assigns fields directly onto the matched ChainParams entry and calls SetChainParamsList without running cp.Validate().
x/observer/types/chain_params.go:74-164 — canonical ranges that the operational path bypasses: GasPriceTicker (0,300], InboundTicker (0,300], OutboundTicker (0,300], WatchUtxoTicker <= 300, OutboundScheduleInterval [1,100], OutboundScheduleLookahead [1,500].
Scheduler consumption sites that panic on interval = 0
All read the value via ChainParams().OutboundScheduleInterval once per tick and feed it straight into a modulo without a zero-guard:
zetaclient/chains/bitcoin/bitcoin.go:138,186
zetaclient/chains/base/signer_batch_sign.go:58-83 (EVM scheduleKeysign at evm.go:220-221)
zetaclient/chains/sui/sui.go:136,176
zetaclient/chains/ton/ton.go:187
zetaclient/chains/solana/solana.go:196
A panic in t.exec(ctx) unwinds out of blockTicker.Start (pkg/scheduler/tickers.go:81-113), back into bg.Work (pkg/bg/bg.go:35-65), which recovers and logs but does not restart. The ticker goroutine ends. Outbound CCTX processing for the affected chain stops until each observer-signer node is manually restarted with corrected params. Related but distinct from #3437 (closed) which asked for a panic-tolerance test on the scheduler — the validation gap itself was not closed.
Reachability
MsgUpdateOperationalChainParams is in OperationPolicyMessages (x/authority/types/authorization_list.go:24), gated to the groupOperational policy role. Admin-error class, not permissionless. There is no on-chain path for an untrusted account to send this message.
Fix
-
Tighten ValidateBasic to mirror ChainParams.Validate() ranges:
GasPriceTicker, InboundTicker, OutboundTicker: require > 0 && <= 300.
WatchUtxoTicker: require <= 300.
OutboundScheduleInterval: require > 0 && <= 100.
OutboundScheduleLookahead: require > 0 && <= 500.
-
In the keeper handler, after applying field assignments and before SetChainParamsList, run chainParamsList.ChainParams[i].Validate() and return on failure (belt-and-braces against future field additions).
-
Fix the test fixture at x/observer/keeper/msg_server_update_operational_chain_params_test.go:134-150 to use in-range values — current fixture asserts require.NoError(t, err) on values that ChainParams.Validate() would reject.
Optional defense-in-depth: zero-guards at the scheduler consumption sites (~10 LOC across five files) so zetaclient is resilient to any future config path that produces a zero interval.
Severity
Low. Admin-gated (operational policy is a trusted governance role), but recovery from a bad update requires a coordinated restart of every observer-signer for the affected chain. Worth fixing as validation hardening.
References
Summary
MsgUpdateOperationalChainParams.ValidateBasicaccepts values thatChainParams.Validate()would reject. The largest gap isOutboundScheduleInterval:ValidateBasiconly rejects negatives, but the canonical validator rejects0and any value above100. The keeper applies the update throughSetChainParamsListwithout runningChainParams.Validate()against the resulting list, so an operational-policy holder can write anOutboundScheduleInterval = 0to state. Every chain scheduler in zetaclient then reads that value and performsnonce % scheduleInterval, which crashes the goroutine.Root cause
x/observer/types/message_update_operational_chain_params.go:58-81—ValidateBasiconly checksCreator,ChainId < 0,OutboundScheduleInterval < 0,OutboundScheduleLookahead < 0, andConfirmationParams.Validate(). The four ticker fields and the twoint64schedule fields are not range-checked againstChainParams.Validate().x/observer/keeper/msg_server_update_operational_chain_params.go:14-52— handler assigns fields directly onto the matchedChainParamsentry and callsSetChainParamsListwithout runningcp.Validate().x/observer/types/chain_params.go:74-164— canonical ranges that the operational path bypasses:GasPriceTicker (0,300],InboundTicker (0,300],OutboundTicker (0,300],WatchUtxoTicker <= 300,OutboundScheduleInterval [1,100],OutboundScheduleLookahead [1,500].Scheduler consumption sites that panic on interval = 0
All read the value via
ChainParams().OutboundScheduleIntervalonce per tick and feed it straight into a modulo without a zero-guard:zetaclient/chains/bitcoin/bitcoin.go:138,186zetaclient/chains/base/signer_batch_sign.go:58-83(EVMscheduleKeysignatevm.go:220-221)zetaclient/chains/sui/sui.go:136,176zetaclient/chains/ton/ton.go:187zetaclient/chains/solana/solana.go:196A panic in
t.exec(ctx)unwinds out ofblockTicker.Start(pkg/scheduler/tickers.go:81-113), back intobg.Work(pkg/bg/bg.go:35-65), which recovers and logs but does not restart. The ticker goroutine ends. Outbound CCTX processing for the affected chain stops until each observer-signer node is manually restarted with corrected params. Related but distinct from #3437 (closed) which asked for a panic-tolerance test on the scheduler — the validation gap itself was not closed.Reachability
MsgUpdateOperationalChainParamsis inOperationPolicyMessages(x/authority/types/authorization_list.go:24), gated to thegroupOperationalpolicy role. Admin-error class, not permissionless. There is no on-chain path for an untrusted account to send this message.Fix
Tighten
ValidateBasicto mirrorChainParams.Validate()ranges:GasPriceTicker,InboundTicker,OutboundTicker: require> 0 && <= 300.WatchUtxoTicker: require<= 300.OutboundScheduleInterval: require> 0 && <= 100.OutboundScheduleLookahead: require> 0 && <= 500.In the keeper handler, after applying field assignments and before
SetChainParamsList, runchainParamsList.ChainParams[i].Validate()and return on failure (belt-and-braces against future field additions).Fix the test fixture at
x/observer/keeper/msg_server_update_operational_chain_params_test.go:134-150to use in-range values — current fixture assertsrequire.NoError(t, err)on values thatChainParams.Validate()would reject.Optional defense-in-depth: zero-guards at the scheduler consumption sites (~10 LOC across five files) so zetaclient is resilient to any future config path that produces a zero interval.
Severity
Low. Admin-gated (operational policy is a trusted governance role), but recovery from a bad update requires a coordinated restart of every observer-signer for the affected chain. Worth fixing as validation hardening.
References
hackeproof/analysis/blockchain/report_ZCNode-265.md