diff --git a/test/integration/uexecutor/vote_chain_meta_test.go b/test/integration/uexecutor/vote_chain_meta_test.go index 96cb1b02..c3c48626 100644 --- a/test/integration/uexecutor/vote_chain_meta_test.go +++ b/test/integration/uexecutor/vote_chain_meta_test.go @@ -57,26 +57,56 @@ func TestVoteChainMetaIntegration(t *testing.T) { t.Parallel() chainId := "eip155:11155111" - t.Run("single validator vote stores chain meta", func(t *testing.T) { - 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(12345), stored.LastAppliedChainHeight) + 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("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, 3) + for i := range vals { + coreVal, _ := sdk.ValAddressFromBech32(vals[i].OperatorAddress) + coreAccs[i] = sdk.AccAddress(coreVal).String() + } + + // 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) + + // 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, 3) + require.Equal(t, uint64(12346), stored.LastAppliedChainHeight) }) t.Run("multiple validators vote and independent medians calculated", func(t *testing.T) { @@ -156,27 +186,36 @@ 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 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) - coreVal, err := sdk.ValAddressFromBech32(vals[0].OperatorAddress) - require.NoError(t, err) - coreAcc := sdk.AccAddress(coreVal).String() + coreAccs := make([]string, 3) + for i := range vals { + coreVal, _ := sdk.ValAddressFromBech32(vals[i].OperatorAddress) + coreAccs[i] = sdk.AccAddress(coreVal).String() + } + + // 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)) - // 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, 102)) }) t.Run("stale votes excluded from median", func(t *testing.T) { @@ -224,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) @@ -322,13 +367,22 @@ func TestVoteChainMetaContractState(t *testing.T) { height = uint64(12345) ) - testApp, ctx, uvals, vals := setupVoteChainMetaTest(t, 1) + // 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, 3) - coreVal, err := sdk.ValAddressFromBech32(vals[0].OperatorAddress) - require.NoError(t, err) - coreAcc := sdk.AccAddress(coreVal).String() + 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() + } - require.NoError(t, utils.ExecVoteChainMeta(t, ctx, testApp, uvals[0], coreAcc, chainId, price, height)) + // 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 817ff545..17964468 100644 --- a/x/uexecutor/keeper/chain_meta.go +++ b/x/uexecutor/keeper/chain_meta.go @@ -13,9 +13,20 @@ 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 (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. + chainMetaMinVotesForFirstWrite int = 3 +) func (k Keeper) GetChainMeta(ctx context.Context, chainID string) (types.ChainMeta, bool, error) { cm, err := k.ChainMetas.Get(ctx, chainID) @@ -35,54 +46,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 +84,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 +100,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 +126,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") }