Skip to content

Commit d1174b9

Browse files
committed
feat: improved chain meta reporting algo
1 parent 0b75957 commit d1174b9

File tree

4 files changed

+224
-11
lines changed

4 files changed

+224
-11
lines changed

test/integration/uexecutor/vote_chain_meta_test.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
152305
func TestMigrateGasPricesToChainMeta(t *testing.T) {
153306
chainId := "eip155:11155111"
154307

x/uexecutor/keeper/chain_meta.go

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,11 @@ func (k Keeper) VoteChainMeta(ctx context.Context, universalValidator sdk.ValAdd
5151
return sdkerrors.Wrap(err, "failed to set initial chain meta entry")
5252
}
5353

54-
// EVM call with single price
5554
priceBig := math.NewUint(price).BigInt()
5655
chainHeightBig := math.NewUint(blockNumber).BigInt()
5756
observedAtBig := math.NewUint(observedAt).BigInt()
5857
if _, evmErr := k.CallUniversalCoreSetChainMeta(sdkCtx, observedChainId, priceBig, chainHeightBig, observedAtBig); evmErr != nil {
59-
// Non-fatal: log the error. The EVM call may fail if the contract hasn't been upgraded yet
60-
// (old bytecode only has setGasPrice). State is still persisted.
61-
sdkCtx.Logger().Error("VoteChainMeta: EVM setChainMeta call failed", "chain", observedChainId, "error", evmErr)
58+
return sdkerrors.Wrap(evmErr, "failed to call EVM setChainMeta")
6259
}
6360

6461
return nil
@@ -83,20 +80,24 @@ func (k Keeper) VoteChainMeta(ctx context.Context, universalValidator sdk.ValAdd
8380
entry.ObservedAts = append(entry.ObservedAts, observedAt)
8481
}
8582

86-
// Recompute median on price
87-
medianIdx := computeMedianIndex(entry.Prices)
83+
// Recompute median: filter out stale validators first, then median on price.
84+
// Staleness is measured by observedAt (Unix seconds) — chain-agnostic.
85+
// See computeMedianIndexFiltered for the two-step algorithm.
86+
medianIdx := computeMedianIndexFiltered(entry.Prices, entry.ObservedAts)
8887
entry.MedianIndex = uint64(medianIdx)
8988

9089
if err := k.SetChainMeta(ctx, observedChainId, entry); err != nil {
9190
return sdkerrors.Wrap(err, "failed to set updated chain meta entry")
9291
}
9392

93+
// Use the full observation tuple from the median-price validator.
94+
// chainHeight and observedAt are NOT independent medians — they are the
95+
// co-indexed values from whichever validator submitted the median price.
9496
medianPrice := math.NewUint(entry.Prices[medianIdx]).BigInt()
95-
medianChainHeight := math.NewUint(entry.ChainHeights[medianIdx]).BigInt()
96-
medianObservedAt := math.NewUint(entry.ObservedAts[medianIdx]).BigInt()
97-
if _, evmErr := k.CallUniversalCoreSetChainMeta(sdkCtx, observedChainId, medianPrice, medianChainHeight, medianObservedAt); evmErr != nil {
98-
// Non-fatal: log. Same forward-compat reason as above.
99-
sdkCtx.Logger().Error("VoteChainMeta: EVM setChainMeta call failed", "chain", observedChainId, "error", evmErr)
97+
coChainHeight := math.NewUint(entry.ChainHeights[medianIdx]).BigInt()
98+
coObservedAt := math.NewUint(entry.ObservedAts[medianIdx]).BigInt()
99+
if _, evmErr := k.CallUniversalCoreSetChainMeta(sdkCtx, observedChainId, medianPrice, coChainHeight, coObservedAt); evmErr != nil {
100+
return sdkerrors.Wrap(evmErr, "failed to call EVM setChainMeta")
100101
}
101102

102103
return nil

x/uexecutor/keeper/gas_price.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
sdkerrors "cosmossdk.io/errors"
1212
"cosmossdk.io/math"
1313
sdk "github.com/cosmos/cosmos-sdk/types"
14+
1415
"github.com/pushchain/push-chain-node/x/uexecutor/types"
1516
)
1617

@@ -108,3 +109,56 @@ func computeMedianIndex(values []uint64) int {
108109
sort.SliceStable(arr, func(i, j int) bool { return arr[i].Val < arr[j].Val })
109110
return arr[len(arr)/2].Idx
110111
}
112+
113+
// computeMedianIndexFiltered returns the original index (into prices/observedAts)
114+
// of the validator chosen as the representative for VoteChainMeta. The selection
115+
// is a two-step process:
116+
//
117+
// 1. Compute the median of observedAt timestamps across all validators.
118+
// 2. Exclude validators whose observedAt deviates from that median by more than
119+
// ObservedAtStalenessThresholdSeconds (they are considered lagging / stale).
120+
// Using wall-clock seconds is chain-agnostic: it works identically for Solana
121+
// (0.4 s/block) and Bitcoin (600 s/block) without per-chain configuration.
122+
// 3. Among the remaining validators compute the median of prices and return that
123+
// validator's original index.
124+
//
125+
// The returned index is valid for all three parallel slices (prices, chainHeights,
126+
// observedAts), so the caller gets a coherent (price, height, timestamp) tuple
127+
// from a single validator who is both current and representative on price.
128+
//
129+
// If – after filtering – no candidates remain (should not happen with a sensible
130+
// threshold), the function falls back to computeMedianIndex on the full price slice.
131+
func computeMedianIndexFiltered(prices, observedAts []uint64) int {
132+
// Step 1: median observedAt timestamp across all validators
133+
medianObservedAtIdx := computeMedianIndex(observedAts)
134+
medianObservedAt := observedAts[medianObservedAtIdx]
135+
136+
// Step 2: keep only validators within the staleness threshold (in seconds)
137+
type candidate struct {
138+
originalIdx int
139+
price uint64
140+
}
141+
var candidates []candidate
142+
for i, ts := range observedAts {
143+
var diff uint64
144+
if ts >= medianObservedAt {
145+
diff = ts - medianObservedAt
146+
} else {
147+
diff = medianObservedAt - ts
148+
}
149+
if diff <= types.ObservedAtStalenessThresholdSeconds {
150+
candidates = append(candidates, candidate{i, prices[i]})
151+
}
152+
}
153+
154+
// Fallback: threshold filtered everyone out (should not happen)
155+
if len(candidates) == 0 {
156+
return computeMedianIndex(prices)
157+
}
158+
159+
// Step 3: median of prices among current validators
160+
sort.SliceStable(candidates, func(i, j int) bool {
161+
return candidates[i].price < candidates[j].price
162+
})
163+
return candidates[len(candidates)/2].originalIdx
164+
}

x/uexecutor/types/constants.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ const (
4040

4141
// Default number of blocks after which ballot expires
4242
DefaultExpiryAfterBlocks = 10000
43+
44+
// ObservedAtStalenessThresholdSeconds is the maximum number of seconds a
45+
// validator's observedAt timestamp may deviate from the median observedAt before
46+
// its price vote is excluded from the median price computation in VoteChainMeta.
47+
ObservedAtStalenessThresholdSeconds uint64 = 300
4348
)
4449

4550
var UniversalTxOutboundEventSig = crypto.Keccak256Hash([]byte(

0 commit comments

Comments
 (0)