@@ -149,6 +149,159 @@ func TestVoteChainMetaIntegration(t *testing.T) {
149149 })
150150}
151151
152+ // TestVoteChainMetaStalenessFilter verifies that validators whose observedAt timestamp
153+ // deviates from the median by more than ObservedAtStalenessThresholdSeconds are excluded
154+ // from the price median computation.
155+ // Using wall-clock seconds (observedAt) is chain-agnostic: it works identically for
156+ // Solana (0.4 s/block) and Bitcoin (600 s/block) without per-chain configuration.
157+ func TestVoteChainMetaStalenessFilter (t * testing.T ) {
158+ t .Parallel ()
159+ chainId := "eip155:11155111"
160+ // threshold in seconds (300s = 5 minutes)
161+ threshold := uexecutortypes .ObservedAtStalenessThresholdSeconds
162+
163+ t .Run ("stale validator excluded when observedAt beyond staleness threshold" , func (t * testing.T ) {
164+ testApp , ctx , uvals , vals := setupVoteChainMetaTest (t , 3 )
165+
166+ // Base timestamp for "current" validators.
167+ baseTs := uint64 (1_700_000_000 )
168+ // val0: current (ts=baseTs, price=200, height=1000)
169+ // val1: stale (ts=baseTs-threshold-5, price=250, height=900) → must be excluded
170+ // val2: current (ts=baseTs+1, price=300, height=1001)
171+ //
172+ // Median observedAt = baseTs.
173+ // val1 diff = baseTs - (baseTs-threshold-5) = threshold+5 > threshold → excluded.
174+ //
175+ // Without filtering: sorted prices [200, 250, 300] → median=250 (stale val1)
176+ // With filtering: val1 excluded sorted [200, 300] → median=300 (val2)
177+ staleTs := baseTs - threshold - 5
178+ votes := []struct {
179+ price uint64
180+ height uint64
181+ observedAt uint64
182+ }{
183+ {200 , 1000 , baseTs },
184+ {250 , 900 , staleTs }, // stale
185+ {300 , 1001 , baseTs + 1 },
186+ }
187+
188+ for i , v := range votes {
189+ coreVal , _ := sdk .ValAddressFromBech32 (vals [i ].OperatorAddress )
190+ coreAcc := sdk .AccAddress (coreVal ).String ()
191+ require .NoError (t , utils .ExecVoteChainMeta (t , ctx , testApp , uvals [i ], coreAcc , chainId , v .price , v .height , v .observedAt ))
192+ }
193+
194+ stored , found , err := testApp .UexecutorKeeper .GetChainMeta (ctx , chainId )
195+ require .NoError (t , err )
196+ require .True (t , found )
197+
198+ // The chosen validator must NOT be the stale one.
199+ // With filtering the median price is 300 (val2, height=1001).
200+ require .Equal (t , uint64 (300 ), stored .Prices [stored .MedianIndex ],
201+ "median price should come from a current validator, not the stale one" )
202+ require .Equal (t , uint64 (1001 ), stored .ChainHeights [stored .MedianIndex ],
203+ "co-indexed height must be from a current validator" )
204+ })
205+
206+ t .Run ("validator exactly at threshold boundary is included" , func (t * testing.T ) {
207+ testApp , ctx , uvals , vals := setupVoteChainMetaTest (t , 3 )
208+
209+ baseTs := uint64 (1_700_000_000 )
210+ // val1 is exactly threshold seconds behind the median → diff == threshold → included (<=)
211+ exactBoundaryTs := baseTs - threshold
212+ votes := []struct {
213+ price uint64
214+ height uint64
215+ observedAt uint64
216+ }{
217+ {200 , 1000 , baseTs },
218+ {250 , 990 , exactBoundaryTs }, // diff == threshold → still included
219+ {300 , 1001 , baseTs + 1 },
220+ }
221+
222+ for i , v := range votes {
223+ coreVal , _ := sdk .ValAddressFromBech32 (vals [i ].OperatorAddress )
224+ coreAcc := sdk .AccAddress (coreVal ).String ()
225+ require .NoError (t , utils .ExecVoteChainMeta (t , ctx , testApp , uvals [i ], coreAcc , chainId , v .price , v .height , v .observedAt ))
226+ }
227+
228+ stored , found , err := testApp .UexecutorKeeper .GetChainMeta (ctx , chainId )
229+ require .NoError (t , err )
230+ require .True (t , found )
231+
232+ // All three included → sorted prices [200, 250, 300] → median = 250 (val1)
233+ require .Equal (t , uint64 (250 ), stored .Prices [stored .MedianIndex ],
234+ "boundary validator should be included in median computation" )
235+ })
236+
237+ t .Run ("all validators current: filtering does not change median" , func (t * testing.T ) {
238+ testApp , ctx , uvals , vals := setupVoteChainMetaTest (t , 4 )
239+
240+ // All timestamps are close together (within threshold).
241+ // Result must match an unfiltered median.
242+ baseTs := uint64 (1_700_000_000 )
243+ votes := []struct {
244+ price uint64
245+ height uint64
246+ observedAt uint64
247+ }{
248+ {300 , 1000 , baseTs },
249+ {200 , 1001 , baseTs + 1 },
250+ {400 , 1002 , baseTs + 2 },
251+ {250 , 999 , baseTs - 1 },
252+ }
253+
254+ for i , v := range votes {
255+ coreVal , _ := sdk .ValAddressFromBech32 (vals [i ].OperatorAddress )
256+ coreAcc := sdk .AccAddress (coreVal ).String ()
257+ require .NoError (t , utils .ExecVoteChainMeta (t , ctx , testApp , uvals [i ], coreAcc , chainId , v .price , v .height , v .observedAt ))
258+ }
259+
260+ stored , found , err := testApp .UexecutorKeeper .GetChainMeta (ctx , chainId )
261+ require .NoError (t , err )
262+ require .True (t , found )
263+
264+ // All current → sorted [200, 250, 300, 400] → upper-median (index 2) = 300
265+ require .Equal (t , uint64 (300 ), stored .Prices [stored .MedianIndex ])
266+ // Co-indexed height must belong to the same validator (height=1000)
267+ require .Equal (t , uint64 (1000 ), stored .ChainHeights [stored .MedianIndex ])
268+ })
269+
270+ t .Run ("multiple stale validators excluded, median from current set" , func (t * testing.T ) {
271+ testApp , ctx , uvals , vals := setupVoteChainMetaTest (t , 5 )
272+
273+ // 3 current validators, 2 stale (observedAt > threshold seconds behind)
274+ baseTs := uint64 (1_700_000_000 )
275+ staleTs := baseTs - threshold - 10
276+ votes := []struct {
277+ price uint64
278+ height uint64
279+ observedAt uint64
280+ }{
281+ {500 , 1000 , baseTs }, // current
282+ {100 , 800 , staleTs }, // stale → excluded
283+ {300 , 1001 , baseTs + 1 }, // current
284+ {150 , 810 , staleTs }, // stale → excluded
285+ {200 , 1002 , baseTs + 2 }, // current
286+ }
287+
288+ for i , v := range votes {
289+ coreVal , _ := sdk .ValAddressFromBech32 (vals [i ].OperatorAddress )
290+ coreAcc := sdk .AccAddress (coreVal ).String ()
291+ require .NoError (t , utils .ExecVoteChainMeta (t , ctx , testApp , uvals [i ], coreAcc , chainId , v .price , v .height , v .observedAt ))
292+ }
293+
294+ stored , found , err := testApp .UexecutorKeeper .GetChainMeta (ctx , chainId )
295+ require .NoError (t , err )
296+ require .True (t , found )
297+
298+ // After excluding the two stale validators, current set prices: [500, 300, 200]
299+ // Sorted: [200, 300, 500] → upper-median (index 1) = 300 (val with height=1001)
300+ require .Equal (t , uint64 (300 ), stored .Prices [stored .MedianIndex ])
301+ require .Equal (t , uint64 (1001 ), stored .ChainHeights [stored .MedianIndex ])
302+ })
303+ }
304+
152305func TestMigrateGasPricesToChainMeta (t * testing.T ) {
153306 chainId := "eip155:11155111"
154307
@@ -262,3 +415,79 @@ func TestVoteChainMetaContractState(t *testing.T) {
262415 require .Equal (t , new (big.Int ).SetUint64 (observedAt ), got )
263416 })
264417}
418+
419+ // TestVoteChainMetaAbsoluteStaleness verifies that when all validators' observedAt timestamps
420+ // are older than ObservedAtStalenessThresholdSeconds relative to the current block time,
421+ // the EVM contract is NOT updated (it retains its previous value).
422+ //
423+ // These tests call VoteChainMeta directly on the keeper (bypassing authz) so that
424+ // block time can be freely manipulated without hitting authz grant expiry.
425+ func TestVoteChainMetaAbsoluteStaleness (t * testing.T ) {
426+ chainId := "eip155:11155111"
427+ threshold := uexecutortypes .ObservedAtStalenessThresholdSeconds // 300
428+
429+ universalCoreAddr := utils .GetDefaultAddresses ().HandlerAddr
430+
431+ readGasPrice := func (t * testing.T , testApp * app.ChainApp , ctx sdk.Context ) * big.Int {
432+ t .Helper ()
433+ ucABI , err := uexecutortypes .ParseUniversalCoreABI ()
434+ require .NoError (t , err )
435+ caller , _ := testApp .UexecutorKeeper .GetUeModuleAddress (ctx )
436+ res , err := testApp .EVMKeeper .CallEVM (ctx , ucABI , caller , universalCoreAddr , false , "gasPriceByChainNamespace" , chainId )
437+ require .NoError (t , err )
438+ return new (big.Int ).SetBytes (res .Ret )
439+ }
440+
441+ t .Run ("stale single vote does not update contract" , func (t * testing.T ) {
442+ testApp , ctx , _ , vals := setupVoteChainMetaTest (t , 1 )
443+
444+ staleObservedAt := uint64 (1_700_000_000 )
445+ // Block time is far past the staleness window
446+ staleCtx := ctx .WithBlockTime (time .Unix (int64 (staleObservedAt + threshold + 60 ), 0 ))
447+
448+ valAddr , err := sdk .ValAddressFromBech32 (vals [0 ].OperatorAddress )
449+ require .NoError (t , err )
450+
451+ require .NoError (t , testApp .UexecutorKeeper .VoteChainMeta (staleCtx , valAddr , chainId ,
452+ 100_000_000_000 , 12345 , staleObservedAt ))
453+
454+ // Vote was stored in state
455+ stored , found , err := testApp .UexecutorKeeper .GetChainMeta (staleCtx , chainId )
456+ require .NoError (t , err )
457+ require .True (t , found )
458+ require .Equal (t , uint64 (100_000_000_000 ), stored .Prices [0 ])
459+
460+ // Contract must NOT have been updated — should still be 0
461+ require .Zero (t , readGasPrice (t , testApp , staleCtx ).Sign (),
462+ "contract must not be updated when all validators are stale" )
463+ })
464+
465+ t .Run ("all validators stale does not update contract" , func (t * testing.T ) {
466+ testApp , ctx , _ , vals := setupVoteChainMetaTest (t , 3 )
467+
468+ freshObservedAt := uint64 (1_700_000_000 )
469+
470+ // First vote with fresh block time → contract gets updated
471+ freshCtx := ctx .WithBlockTime (time .Unix (int64 (freshObservedAt ), 0 ))
472+ for i := 0 ; i < 3 ; i ++ {
473+ valAddr , err := sdk .ValAddressFromBech32 (vals [i ].OperatorAddress )
474+ require .NoError (t , err )
475+ require .NoError (t , testApp .UexecutorKeeper .VoteChainMeta (freshCtx , valAddr , chainId ,
476+ 200_000_000_000 , uint64 (12345 + i ), freshObservedAt + uint64 (i )))
477+ }
478+ require .Equal (t , new (big.Int ).SetUint64 (200_000_000_000 ), readGasPrice (t , testApp , freshCtx ))
479+
480+ // Re-vote with same old timestamps but block time past staleness window
481+ futureCtx := ctx .WithBlockTime (time .Unix (int64 (freshObservedAt + threshold + 60 ), 0 ))
482+ for i := 0 ; i < 3 ; i ++ {
483+ valAddr , err := sdk .ValAddressFromBech32 (vals [i ].OperatorAddress )
484+ require .NoError (t , err )
485+ require .NoError (t , testApp .UexecutorKeeper .VoteChainMeta (futureCtx , valAddr , chainId ,
486+ 999_000_000_000 , uint64 (99999 + i ), freshObservedAt + uint64 (i )))
487+ }
488+
489+ // Contract must retain the old fresh value — stale votes must not overwrite it
490+ require .Equal (t , new (big.Int ).SetUint64 (200_000_000_000 ), readGasPrice (t , testApp , futureCtx ),
491+ "contract must retain last good value when all validators report stale data" )
492+ })
493+ }
0 commit comments