From 576997a8dc5bb1201f0d5b204e61f6d4a108aaee Mon Sep 17 00:00:00 2001 From: Nilesh Gupta Date: Fri, 1 May 2026 12:29:34 +0530 Subject: [PATCH 1/3] feat: added min val required as 2 for first chain oracle vote --- x/uexecutor/keeper/chain_meta.go | 99 ++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 44 deletions(-) diff --git a/x/uexecutor/keeper/chain_meta.go b/x/uexecutor/keeper/chain_meta.go index 817ff545..8c1cca89 100644 --- a/x/uexecutor/keeper/chain_meta.go +++ b/x/uexecutor/keeper/chain_meta.go @@ -13,9 +13,23 @@ import ( "github.com/pushchain/push-chain-node/x/uexecutor/types" ) -// chainMetaVoteStalenessSeconds is the maximum age (in seconds) of a stored vote -// that is still eligible to be included in the median calculation. -const chainMetaVoteStalenessSeconds uint64 = 300 +const ( + // chainMetaVoteStalenessSeconds is the maximum age (in seconds) of a stored vote + // that is still eligible to be included in the median calculation. + chainMetaVoteStalenessSeconds uint64 = 300 + + // chainMetaMinVotesForFirstWrite is the number of fresh votes required + // before the first EVM oracle write happens for a given observed chain. + // This prevents a single validator from defining the oracle's initial + // values without aggregation. After bootstrap (LastAppliedChainHeight > 0), + // the normal median-on-each-fresh-vote behaviour applies. + // + // Note on the tradeoff: median of 2 returns the upper of the two values, + // so a single outlier validator can still sway the bootstrap write. + // Raising this to 3 would make the bootstrap median robust against one + // outlier, at the cost of needing one more vote to initialise the oracle. + chainMetaMinVotesForFirstWrite int = 2 +) func (k Keeper) GetChainMeta(ctx context.Context, chainID string) (types.ChainMeta, bool, error) { cm, err := k.ChainMetas.Get(ctx, chainID) @@ -35,54 +49,32 @@ func (k Keeper) SetChainMeta(ctx context.Context, chainID string, chainMeta type // VoteChainMeta processes a universal validator's vote on chain metadata (gas price + chain height). // // Rules: -// 1. If blockNumber <= entry.LastAppliedChainHeight the tx is rejected — the validator -// must re-vote with a newer block height. -// 2. Each vote is stamped with the current block time (storedAt) when it is recorded. -// 3. When computing medians, only votes whose storedAt is within the last +// 1. Each vote is stamped with the current block time (storedAt) when it is recorded +// and either inserted (new validator) or updated in place (existing validator). +// 2. The oracle is bootstrapped on the first EVM write only after at least +// chainMetaMinVotesForFirstWrite fresh votes have accumulated. Earlier +// votes are stored but do not yet drive an on-chain update — this prevents +// a single validator from defining the oracle's initial values. +// 3. Once bootstrapped (LastAppliedChainHeight > 0), votes whose blockNumber +// is not strictly greater than entry.LastAppliedChainHeight are rejected — +// the validator must re-vote with a newer block height. +// 4. When computing medians, only votes whose storedAt is within the last // chainMetaVoteStalenessSeconds seconds are considered. -// 4. Price median and chain-height median are computed independently (upper median = len/2). -// 5. After a successful EVM call, LastAppliedChainHeight is updated. +// 5. Price median and chain-height median are computed independently (upper median = len/2). +// 6. After a successful EVM call, LastAppliedChainHeight is updated. func (k Keeper) VoteChainMeta(ctx context.Context, universalValidator sdk.ValAddress, observedChainId string, price, blockNumber uint64) error { sdkCtx := sdk.UnwrapSDKContext(ctx) now := uint64(sdkCtx.BlockTime().Unix()) - entry, found, err := k.GetChainMeta(ctx, observedChainId) + entry, _, err := k.GetChainMeta(ctx, observedChainId) if err != nil { return sdkerrors.Wrap(err, "failed to fetch chain meta entry") } + bootstrapped := entry.LastAppliedChainHeight > 0 - if !found { - // First vote for this chain — no height check needed yet. - k.Logger().Info("chain meta first vote, initializing entry", - "chain_id", observedChainId, - "validator", universalValidator.String(), - "price", price, - "block_number", blockNumber, - ) - priceBig := math.NewUint(price).BigInt() - chainHeightBig := math.NewUint(blockNumber).BigInt() - if _, evmErr := k.CallUniversalCoreSetChainMeta(sdkCtx, observedChainId, priceBig, chainHeightBig); evmErr != nil { - return sdkerrors.Wrap(evmErr, "failed to call EVM setChainMeta") - } - - newEntry := types.ChainMeta{ - ObservedChainId: observedChainId, - Signers: []string{universalValidator.String()}, - Prices: []uint64{price}, - ChainHeights: []uint64{blockNumber}, - StoredAts: []uint64{now}, - MedianIndex: 0, - LastAppliedChainHeight: blockNumber, - } - if err := k.SetChainMeta(ctx, observedChainId, newEntry); err != nil { - return sdkerrors.Wrap(err, "failed to set initial chain meta entry") - } - - return nil - } - - // Reject votes whose chain height has already been committed to the contract. - if blockNumber <= entry.LastAppliedChainHeight { + // Stale-height check applies only after bootstrap. During cold-start there + // is no committed reference height yet, so any positive vote is acceptable. + if bootstrapped && blockNumber <= entry.LastAppliedChainHeight { k.Logger().Warn("chain meta vote rejected: stale block height", "chain_id", observedChainId, "validator", universalValidator.String(), @@ -95,6 +87,11 @@ func (k Keeper) VoteChainMeta(ctx context.Context, universalValidator sdk.ValAdd ) } + // Ensure the entry has its observed-chain id set on first-ever vote. + if entry.ObservedChainId == "" { + entry.ObservedChainId = observedChainId + } + // Update or insert vote for this validator. var updated bool for i, s := range entry.Signers { @@ -106,7 +103,6 @@ func (k Keeper) VoteChainMeta(ctx context.Context, universalValidator sdk.ValAdd break } } - if !updated { entry.Signers = append(entry.Signers, universalValidator.String()) entry.Prices = append(entry.Prices, price) @@ -133,12 +129,27 @@ func (k Keeper) VoteChainMeta(ctx context.Context, universalValidator sdk.ValAdd } } + // Cold-start gate: the first EVM write requires at least N fresh votes + // so the oracle is never defined by a single validator. Once bootstrapped, + // the existing fresh-votes-median path handles every subsequent vote. + if !bootstrapped && len(fresh) < chainMetaMinVotesForFirstWrite { + k.Logger().Info("chain meta vote recorded, awaiting bootstrap quorum", + "chain_id", observedChainId, + "validator", universalValidator.String(), + "have_fresh_votes", len(fresh), + "need_fresh_votes", chainMetaMinVotesForFirstWrite, + ) + if err := k.SetChainMeta(ctx, observedChainId, entry); err != nil { + return sdkerrors.Wrap(err, "failed to set chain meta entry during bootstrap") + } + return nil + } + if len(fresh) == 0 { k.Logger().Debug("chain meta vote recorded, no fresh votes for EVM update", "chain_id", observedChainId, "validator", universalValidator.String(), ) - // No fresh votes — persist the updated entry but skip EVM call. if err := k.SetChainMeta(ctx, observedChainId, entry); err != nil { return sdkerrors.Wrap(err, "failed to set updated chain meta entry") } From f56e14e1b26220ff5d605d424397505b9220c645 Mon Sep 17 00:00:00 2001 From: Nilesh Gupta Date: Fri, 1 May 2026 12:29:52 +0530 Subject: [PATCH 2/3] tests: updated tests for chain meta first vote changes --- .../uexecutor/vote_chain_meta_test.go | 73 +++++++++++++++---- 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/test/integration/uexecutor/vote_chain_meta_test.go b/test/integration/uexecutor/vote_chain_meta_test.go index 96cb1b02..dd16a51e 100644 --- a/test/integration/uexecutor/vote_chain_meta_test.go +++ b/test/integration/uexecutor/vote_chain_meta_test.go @@ -57,7 +57,10 @@ func TestVoteChainMetaIntegration(t *testing.T) { t.Parallel() chainId := "eip155:11155111" - t.Run("single validator vote stores chain meta", func(t *testing.T) { + t.Run("single validator vote stores chain meta but does not bootstrap oracle", func(t *testing.T) { + // With chainMetaMinVotesForFirstWrite = 2, a single vote is recorded in + // state but does NOT trigger an EVM oracle write. LastAppliedChainHeight + // stays 0 until at least 2 fresh votes accumulate. testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 1) coreVal, err := sdk.ValAddressFromBech32(vals[0].OperatorAddress) @@ -76,7 +79,29 @@ func TestVoteChainMetaIntegration(t *testing.T) { require.Equal(t, uint64(12345), stored.ChainHeights[0]) require.Len(t, stored.StoredAts, 1) require.Equal(t, uint64(ctx.BlockTime().Unix()), stored.StoredAts[0]) - require.Equal(t, uint64(12345), stored.LastAppliedChainHeight) + require.Equal(t, uint64(0), stored.LastAppliedChainHeight, "single vote should not bootstrap the oracle") + }) + + t.Run("second fresh vote bootstraps the oracle and sets LastAppliedChainHeight", func(t *testing.T) { + testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 2) + + coreAccs := make([]string, 2) + for i := range vals { + coreVal, _ := sdk.ValAddressFromBech32(vals[i].OperatorAddress) + coreAccs[i] = sdk.AccAddress(coreVal).String() + } + + // First vote — stored only, no EVM write yet + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAccs[0], chainId, 100_000_000_000, 12345)) + stored, _, _ := testApp.UexecutorKeeper.GetChainMeta(ctx, chainId) + require.Equal(t, uint64(0), stored.LastAppliedChainHeight) + + // Second vote — now ≥2 fresh votes, EVM write happens with upper-median values + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[1], coreAccs[1], chainId, 200_000_000_000, 12346)) + stored, _, _ = testApp.UexecutorKeeper.GetChainMeta(ctx, chainId) + require.Len(t, stored.Prices, 2) + // Upper median of [100B, 200B] @ index 1 = 200B; same for heights [12345, 12346] = 12346 + require.Equal(t, uint64(12346), stored.LastAppliedChainHeight) }) t.Run("multiple validators vote and independent medians calculated", func(t *testing.T) { @@ -156,27 +181,35 @@ func TestVoteChainMetaIntegration(t *testing.T) { }) t.Run("vote rejected when chain height not greater than last applied", func(t *testing.T) { - testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 1) + // Bootstrap requires 2 fresh votes before LastAppliedChainHeight is set, + // so the height-staleness check only applies after both validators have voted. + testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 2) - coreVal, err := sdk.ValAddressFromBech32(vals[0].OperatorAddress) - require.NoError(t, err) - coreAcc := sdk.AccAddress(coreVal).String() + coreAccs := make([]string, 2) + for i := range vals { + coreVal, _ := sdk.ValAddressFromBech32(vals[i].OperatorAddress) + coreAccs[i] = sdk.AccAddress(coreVal).String() + } + + // Two votes to bootstrap — heights 99 and 100. Upper median = 100, so LastApplied = 100. + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAccs[0], chainId, 100_000_000_000, 99)) + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[1], coreAccs[1], chainId, 100_000_000_000, 100)) - // First vote — establishes lastAppliedChainHeight=100 - require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAcc, chainId, 100_000_000_000, 100)) + stored, _, _ := testApp.UexecutorKeeper.GetChainMeta(ctx, chainId) + require.Equal(t, uint64(100), stored.LastAppliedChainHeight) // Same height → rejected - err = utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAcc, chainId, 200_000_000_000, 100) + err := utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAccs[0], chainId, 200_000_000_000, 100) require.Error(t, err) require.Contains(t, err.Error(), "not greater than last applied chain height") // Lower height → rejected - err = utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAcc, chainId, 200_000_000_000, 99) + err = utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAccs[0], chainId, 200_000_000_000, 99) require.Error(t, err) require.Contains(t, err.Error(), "not greater than last applied chain height") // Higher height → accepted - require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAcc, chainId, 200_000_000_000, 101)) + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAccs[0], chainId, 200_000_000_000, 101)) }) t.Run("stale votes excluded from median", func(t *testing.T) { @@ -322,13 +355,21 @@ func TestVoteChainMetaContractState(t *testing.T) { height = uint64(12345) ) - testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 1) + // Bootstrap requires chainMetaMinVotesForFirstWrite (2) fresh votes before + // the EVM oracle is written. Both validators submit identical price/height + // so the upper median equals the voted values. + testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 2) - coreVal, err := sdk.ValAddressFromBech32(vals[0].OperatorAddress) - require.NoError(t, err) - coreAcc := sdk.AccAddress(coreVal).String() + coreAccs := make([]string, 2) + for i := range vals { + coreVal, err := sdk.ValAddressFromBech32(vals[i].OperatorAddress) + require.NoError(t, err) + coreAccs[i] = sdk.AccAddress(coreVal).String() + } - require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAcc, chainId, price, height)) + // Two agreeing votes → median == voted values, oracle is written. + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAccs[0], chainId, price, height)) + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[1], coreAccs[1], chainId, price, height)) // Read from the UniversalCore contract using the public mapping getters universalCoreAddr := utils.GetDefaultAddresses().HandlerAddr From 613fd163e2e48f16278675dab7660e50f55768ee Mon Sep 17 00:00:00 2001 From: Nilesh Gupta Date: Fri, 1 May 2026 12:36:26 +0530 Subject: [PATCH 3/3] feat: updated min validator votes requird for first chain meta value to 3 instead of 2 --- .../uexecutor/vote_chain_meta_test.go | 101 ++++++++++-------- x/uexecutor/keeper/chain_meta.go | 13 +-- 2 files changed, 62 insertions(+), 52 deletions(-) diff --git a/test/integration/uexecutor/vote_chain_meta_test.go b/test/integration/uexecutor/vote_chain_meta_test.go index dd16a51e..c3c48626 100644 --- a/test/integration/uexecutor/vote_chain_meta_test.go +++ b/test/integration/uexecutor/vote_chain_meta_test.go @@ -57,50 +57,55 @@ func TestVoteChainMetaIntegration(t *testing.T) { t.Parallel() chainId := "eip155:11155111" - t.Run("single validator vote stores chain meta but does not bootstrap oracle", func(t *testing.T) { - // With chainMetaMinVotesForFirstWrite = 2, a single vote is recorded in - // state but does NOT trigger an EVM oracle write. LastAppliedChainHeight - // stays 0 until at least 2 fresh votes accumulate. - testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 1) - - coreVal, err := sdk.ValAddressFromBech32(vals[0].OperatorAddress) - require.NoError(t, err) - coreAcc := sdk.AccAddress(coreVal).String() + t.Run("votes below bootstrap quorum store but do not bootstrap oracle", func(t *testing.T) { + // With chainMetaMinVotesForFirstWrite = 3, votes 1 and 2 are recorded + // in state but do NOT trigger an EVM oracle write. LastAppliedChainHeight + // stays 0 until the third fresh vote accumulates. + testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 2) - err = utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAcc, chainId, 100_000_000_000, 12345) - require.NoError(t, err) + coreAccs := make([]string, 2) + for i := range vals { + coreVal, _ := sdk.ValAddressFromBech32(vals[i].OperatorAddress) + coreAccs[i] = sdk.AccAddress(coreVal).String() + } + // Vote 1 + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAccs[0], chainId, 100_000_000_000, 12345)) stored, found, err := testApp.UexecutorKeeper.GetChainMeta(ctx, chainId) require.NoError(t, err) require.True(t, found) require.Len(t, stored.Prices, 1) - require.Equal(t, uint64(100_000_000_000), stored.Prices[0]) - require.Len(t, stored.ChainHeights, 1) - require.Equal(t, uint64(12345), stored.ChainHeights[0]) - require.Len(t, stored.StoredAts, 1) - require.Equal(t, uint64(ctx.BlockTime().Unix()), stored.StoredAts[0]) require.Equal(t, uint64(0), stored.LastAppliedChainHeight, "single vote should not bootstrap the oracle") + + // Vote 2 + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[1], coreAccs[1], chainId, 200_000_000_000, 12346)) + stored, _, _ = testApp.UexecutorKeeper.GetChainMeta(ctx, chainId) + require.Len(t, stored.Prices, 2) + require.Equal(t, uint64(0), stored.LastAppliedChainHeight, "two votes should still not bootstrap the oracle") }) - t.Run("second fresh vote bootstraps the oracle and sets LastAppliedChainHeight", func(t *testing.T) { - testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 2) + t.Run("third fresh vote bootstraps the oracle and sets LastAppliedChainHeight to median", func(t *testing.T) { + testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 3) - coreAccs := make([]string, 2) + coreAccs := make([]string, 3) for i := range vals { coreVal, _ := sdk.ValAddressFromBech32(vals[i].OperatorAddress) coreAccs[i] = sdk.AccAddress(coreVal).String() } - // First vote — stored only, no EVM write yet + // First two votes — stored only, no EVM write yet require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAccs[0], chainId, 100_000_000_000, 12345)) + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[1], coreAccs[1], chainId, 300_000_000_000, 12346)) + stored, _, _ := testApp.UexecutorKeeper.GetChainMeta(ctx, chainId) require.Equal(t, uint64(0), stored.LastAppliedChainHeight) - // Second vote — now ≥2 fresh votes, EVM write happens with upper-median values - require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[1], coreAccs[1], chainId, 200_000_000_000, 12346)) + // Third vote — now ≥3 fresh votes, EVM write happens with the upper median. + // Sorted prices [100B, 200B, 300B] → upper median @ index 1 = 200B. + // Sorted heights [12345, 12346, 12347] → upper median @ index 1 = 12346. + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[2], coreAccs[2], chainId, 200_000_000_000, 12347)) stored, _, _ = testApp.UexecutorKeeper.GetChainMeta(ctx, chainId) - require.Len(t, stored.Prices, 2) - // Upper median of [100B, 200B] @ index 1 = 200B; same for heights [12345, 12346] = 12346 + require.Len(t, stored.Prices, 3) require.Equal(t, uint64(12346), stored.LastAppliedChainHeight) }) @@ -181,19 +186,20 @@ func TestVoteChainMetaIntegration(t *testing.T) { }) t.Run("vote rejected when chain height not greater than last applied", func(t *testing.T) { - // Bootstrap requires 2 fresh votes before LastAppliedChainHeight is set, - // so the height-staleness check only applies after both validators have voted. - testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 2) + // Bootstrap requires 3 fresh votes before LastAppliedChainHeight is set, + // so the height-staleness check only applies after all three validators have voted. + testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 3) - coreAccs := make([]string, 2) + coreAccs := make([]string, 3) for i := range vals { coreVal, _ := sdk.ValAddressFromBech32(vals[i].OperatorAddress) coreAccs[i] = sdk.AccAddress(coreVal).String() } - // Two votes to bootstrap — heights 99 and 100. Upper median = 100, so LastApplied = 100. + // Three votes to bootstrap — heights 99, 100, 101. Upper median @ index 1 = 100. require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAccs[0], chainId, 100_000_000_000, 99)) require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[1], coreAccs[1], chainId, 100_000_000_000, 100)) + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[2], coreAccs[2], chainId, 100_000_000_000, 101)) stored, _, _ := testApp.UexecutorKeeper.GetChainMeta(ctx, chainId) require.Equal(t, uint64(100), stored.LastAppliedChainHeight) @@ -209,7 +215,7 @@ func TestVoteChainMetaIntegration(t *testing.T) { require.Contains(t, err.Error(), "not greater than last applied chain height") // Higher height → accepted - require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAccs[0], chainId, 200_000_000_000, 101)) + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAccs[0], chainId, 200_000_000_000, 102)) }) t.Run("stale votes excluded from median", func(t *testing.T) { @@ -257,18 +263,24 @@ func TestVoteChainMetaIntegration(t *testing.T) { }) t.Run("last applied chain height updated after EVM call", func(t *testing.T) { - testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 2) + // Bootstrap requires 3 fresh votes; the EVM write happens on the third + // vote and LastAppliedChainHeight reflects the upper-median height. + testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 3) - coreVal0, _ := sdk.ValAddressFromBech32(vals[0].OperatorAddress) - coreVal1, _ := sdk.ValAddressFromBech32(vals[1].OperatorAddress) - coreAcc0 := sdk.AccAddress(coreVal0).String() - coreAcc1 := sdk.AccAddress(coreVal1).String() + coreAccs := make([]string, 3) + for i := range vals { + coreVal, _ := sdk.ValAddressFromBech32(vals[i].OperatorAddress) + coreAccs[i] = sdk.AccAddress(coreVal).String() + } - require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAcc0, chainId, 100_000_000_000, 1000)) - // lastApplied=1000 after first vote + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAccs[0], chainId, 100_000_000_000, 1000)) + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[1], coreAccs[1], chainId, 200_000_000_000, 2000)) + // First two votes are stored-only — no EVM write, lastApplied stays 0. + stored, _, _ := testApp.UexecutorKeeper.GetChainMeta(ctx, chainId) + require.Equal(t, uint64(0), stored.LastAppliedChainHeight) - require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[1], coreAcc1, chainId, 200_000_000_000, 2000)) - // Median height of [1000, 2000] = upper = 2000. lastApplied=2000. + // Third vote — EVM write triggers. Sorted heights [1000, 2000, 3000] → upper median = 2000. + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[2], coreAccs[2], chainId, 300_000_000_000, 3000)) stored, found, err := testApp.UexecutorKeeper.GetChainMeta(ctx, chainId) require.NoError(t, err) @@ -355,21 +367,22 @@ func TestVoteChainMetaContractState(t *testing.T) { height = uint64(12345) ) - // Bootstrap requires chainMetaMinVotesForFirstWrite (2) fresh votes before - // the EVM oracle is written. Both validators submit identical price/height + // Bootstrap requires chainMetaMinVotesForFirstWrite (3) fresh votes before + // the EVM oracle is written. All validators submit identical price/height // so the upper median equals the voted values. - testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 2) + testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 3) - coreAccs := make([]string, 2) + coreAccs := make([]string, 3) for i := range vals { coreVal, err := sdk.ValAddressFromBech32(vals[i].OperatorAddress) require.NoError(t, err) coreAccs[i] = sdk.AccAddress(coreVal).String() } - // Two agreeing votes → median == voted values, oracle is written. + // Three agreeing votes → median == voted values, oracle is written. require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAccs[0], chainId, price, height)) require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[1], coreAccs[1], chainId, price, height)) + require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[2], coreAccs[2], chainId, price, height)) // Read from the UniversalCore contract using the public mapping getters universalCoreAddr := utils.GetDefaultAddresses().HandlerAddr diff --git a/x/uexecutor/keeper/chain_meta.go b/x/uexecutor/keeper/chain_meta.go index 8c1cca89..17964468 100644 --- a/x/uexecutor/keeper/chain_meta.go +++ b/x/uexecutor/keeper/chain_meta.go @@ -20,15 +20,12 @@ const ( // chainMetaMinVotesForFirstWrite is the number of fresh votes required // before the first EVM oracle write happens for a given observed chain. - // This prevents a single validator from defining the oracle's initial - // values without aggregation. After bootstrap (LastAppliedChainHeight > 0), + // This prevents a single validator (or a single outlier) from defining + // the oracle's initial values. With 3 votes, the upper median (index + // len/2 = 1) is the middle value, which is robust against a single + // outlier on either side. After bootstrap (LastAppliedChainHeight > 0), // the normal median-on-each-fresh-vote behaviour applies. - // - // Note on the tradeoff: median of 2 returns the upper of the two values, - // so a single outlier validator can still sway the bootstrap write. - // Raising this to 3 would make the bootstrap median robust against one - // outlier, at the cost of needing one more vote to initialise the oracle. - chainMetaMinVotesForFirstWrite int = 2 + chainMetaMinVotesForFirstWrite int = 3 ) func (k Keeper) GetChainMeta(ctx context.Context, chainID string) (types.ChainMeta, bool, error) {