Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 91 additions & 37 deletions test/integration/uexecutor/vote_chain_meta_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
96 changes: 52 additions & 44 deletions x/uexecutor/keeper/chain_meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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(),
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -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")
}
Expand Down
Loading