@@ -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
0 commit comments