Skip to content

Commit 5d9d0df

Browse files
committed
Merge branch 'feat/set-chain-meta' of https://github.com/push-protocol/push-chain into feat/set-chain-meta
2 parents 87f949e + 66c0dab commit 5d9d0df

File tree

8 files changed

+429
-100
lines changed

8 files changed

+429
-100
lines changed

test/integration/uexecutor/gas_fee_refund_test.go

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,28 +13,28 @@ import (
1313

1414
func TestGasFeeRefund(t *testing.T) {
1515

16-
t.Run("success vote with empty gasFeeUsed is rejected", func(t *testing.T) {
17-
app, ctx, vals, utxId, outbound, coreVals :=
18-
setupOutboundVotingTest(t, 4)
16+
t.Run("vote with empty gasFeeUsed is always rejected", func(t *testing.T) {
17+
// gas_fee_used is mandatory for both success and failure votes.
18+
for _, success := range []bool{true, false} {
19+
app, ctx, vals, utxId, outbound, coreVals :=
20+
setupOutboundVotingTest(t, 4)
1921

20-
valAddr, err := sdk.ValAddressFromBech32(coreVals[0].OperatorAddress)
21-
require.NoError(t, err)
22-
coreAcc := sdk.AccAddress(valAddr).String()
23-
24-
err = utils.ExecVoteOutbound(
25-
t,
26-
ctx,
27-
app,
28-
vals[0],
29-
coreAcc,
30-
utxId,
31-
outbound,
32-
true,
33-
"",
34-
"", // gas_fee_used required when success=true → must be rejected
35-
)
36-
require.Error(t, err)
37-
require.Contains(t, err.Error(), "gas_fee_used required when success=true")
22+
valAddr, err := sdk.ValAddressFromBech32(coreVals[0].OperatorAddress)
23+
require.NoError(t, err)
24+
coreAcc := sdk.AccAddress(valAddr).String()
25+
26+
revertReason := ""
27+
if !success {
28+
revertReason = "execution failed"
29+
}
30+
31+
err = utils.ExecVoteOutbound(
32+
t, ctx, app, vals[0], coreAcc, utxId, outbound,
33+
success, revertReason, "", // empty gas_fee_used → must be rejected
34+
)
35+
require.Error(t, err)
36+
require.Contains(t, err.Error(), "observed_tx.gas_fee_used is required")
37+
}
3838
})
3939

4040
t.Run("no refund when gasFeeUsed equals gasFee", func(t *testing.T) {
@@ -196,11 +196,12 @@ func TestGasFeeRefund(t *testing.T) {
196196
require.Equal(t, uexecutortypes.Status_OBSERVED, ob.OutboundStatus)
197197
})
198198

199-
t.Run("failed outbound still performs revert not refund", func(t *testing.T) {
199+
t.Run("failed outbound performs both revert and gas refund", func(t *testing.T) {
200200
app, ctx, vals, utxId, outbound, coreVals :=
201201
setupOutboundVotingTest(t, 4)
202202

203-
// Vote as FAILED with gasFeeUsed set — refund should NOT run for failed outbounds
203+
// gasFee = 111 (mock), gasFeeUsed = 50 → 61 excess to refund.
204+
// Both the bridged funds revert AND the excess gas refund must happen.
204205
for i := 0; i < 3; i++ {
205206
valAddr, err := sdk.ValAddressFromBech32(coreVals[i].OperatorAddress)
206207
require.NoError(t, err)
@@ -216,7 +217,7 @@ func TestGasFeeRefund(t *testing.T) {
216217
outbound,
217218
false,
218219
"execution failed",
219-
"50", // gasFeeUsed provided but shouldn't trigger refund on failure
220+
"50", // gasFeeUsed=50 < gasFee=111 → 61 excess
220221
)
221222
require.NoError(t, err)
222223
}
@@ -227,12 +228,13 @@ func TestGasFeeRefund(t *testing.T) {
227228
ob := utx.OutboundTx[0]
228229
require.Equal(t, uexecutortypes.Status_REVERTED, ob.OutboundStatus)
229230

230-
// Revert was executed (funds minted back)
231+
// Revert: bridged funds minted back
231232
require.NotNil(t, ob.PcRevertExecution)
232233
require.Equal(t, "SUCCESS", ob.PcRevertExecution.Status)
233234

234-
// Gas refund must NOT run for failed outbounds
235-
require.Nil(t, ob.PcRefundExecution,
236-
"gas refund must not run when outbound failed")
235+
// Gas refund: excess gas must also be returned on failure
236+
require.NotNil(t, ob.PcRefundExecution,
237+
"excess gas must be refunded even when outbound failed")
238+
require.NotEmpty(t, ob.PcRefundExecution.Status)
237239
})
238240
}

test/integration/uexecutor/vote_chain_meta_test.go

Lines changed: 229 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

@@ -262,3 +415,79 @@ func TestVoteChainMetaContractState(t *testing.T) {
262415
require.Equal(t, new(big.Int).SetUint64(observedAt), got)
263416
})
264417
}
418+
419+
// TestVoteChainMetaAbsoluteStaleness verifies that when all validators' observedAt timestamps
420+
// are older than ObservedAtStalenessThresholdSeconds relative to the current block time,
421+
// the EVM contract is NOT updated (it retains its previous value).
422+
//
423+
// These tests call VoteChainMeta directly on the keeper (bypassing authz) so that
424+
// block time can be freely manipulated without hitting authz grant expiry.
425+
func TestVoteChainMetaAbsoluteStaleness(t *testing.T) {
426+
chainId := "eip155:11155111"
427+
threshold := uexecutortypes.ObservedAtStalenessThresholdSeconds // 300
428+
429+
universalCoreAddr := utils.GetDefaultAddresses().HandlerAddr
430+
431+
readGasPrice := func(t *testing.T, testApp *app.ChainApp, ctx sdk.Context) *big.Int {
432+
t.Helper()
433+
ucABI, err := uexecutortypes.ParseUniversalCoreABI()
434+
require.NoError(t, err)
435+
caller, _ := testApp.UexecutorKeeper.GetUeModuleAddress(ctx)
436+
res, err := testApp.EVMKeeper.CallEVM(ctx, ucABI, caller, universalCoreAddr, false, "gasPriceByChainNamespace", chainId)
437+
require.NoError(t, err)
438+
return new(big.Int).SetBytes(res.Ret)
439+
}
440+
441+
t.Run("stale single vote does not update contract", func(t *testing.T) {
442+
testApp, ctx, _, vals := setupVoteChainMetaTest(t, 1)
443+
444+
staleObservedAt := uint64(1_700_000_000)
445+
// Block time is far past the staleness window
446+
staleCtx := ctx.WithBlockTime(time.Unix(int64(staleObservedAt+threshold+60), 0))
447+
448+
valAddr, err := sdk.ValAddressFromBech32(vals[0].OperatorAddress)
449+
require.NoError(t, err)
450+
451+
require.NoError(t, testApp.UexecutorKeeper.VoteChainMeta(staleCtx, valAddr, chainId,
452+
100_000_000_000, 12345, staleObservedAt))
453+
454+
// Vote was stored in state
455+
stored, found, err := testApp.UexecutorKeeper.GetChainMeta(staleCtx, chainId)
456+
require.NoError(t, err)
457+
require.True(t, found)
458+
require.Equal(t, uint64(100_000_000_000), stored.Prices[0])
459+
460+
// Contract must NOT have been updated — should still be 0
461+
require.Zero(t, readGasPrice(t, testApp, staleCtx).Sign(),
462+
"contract must not be updated when all validators are stale")
463+
})
464+
465+
t.Run("all validators stale does not update contract", func(t *testing.T) {
466+
testApp, ctx, _, vals := setupVoteChainMetaTest(t, 3)
467+
468+
freshObservedAt := uint64(1_700_000_000)
469+
470+
// First vote with fresh block time → contract gets updated
471+
freshCtx := ctx.WithBlockTime(time.Unix(int64(freshObservedAt), 0))
472+
for i := 0; i < 3; i++ {
473+
valAddr, err := sdk.ValAddressFromBech32(vals[i].OperatorAddress)
474+
require.NoError(t, err)
475+
require.NoError(t, testApp.UexecutorKeeper.VoteChainMeta(freshCtx, valAddr, chainId,
476+
200_000_000_000, uint64(12345+i), freshObservedAt+uint64(i)))
477+
}
478+
require.Equal(t, new(big.Int).SetUint64(200_000_000_000), readGasPrice(t, testApp, freshCtx))
479+
480+
// Re-vote with same old timestamps but block time past staleness window
481+
futureCtx := ctx.WithBlockTime(time.Unix(int64(freshObservedAt+threshold+60), 0))
482+
for i := 0; i < 3; i++ {
483+
valAddr, err := sdk.ValAddressFromBech32(vals[i].OperatorAddress)
484+
require.NoError(t, err)
485+
require.NoError(t, testApp.UexecutorKeeper.VoteChainMeta(futureCtx, valAddr, chainId,
486+
999_000_000_000, uint64(99999+i), freshObservedAt+uint64(i)))
487+
}
488+
489+
// Contract must retain the old fresh value — stale votes must not overwrite it
490+
require.Equal(t, new(big.Int).SetUint64(200_000_000_000), readGasPrice(t, testApp, futureCtx),
491+
"contract must retain last good value when all validators report stale data")
492+
})
493+
}

test/integration/uexecutor/vote_outbound_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ func TestOutboundVoting(t *testing.T) {
240240
outbound,
241241
false,
242242
"execution reverted", // revert reason
243-
"",
243+
outbound.GasFee, // gas_fee_used required; use full fee → no excess refund
244244
)
245245
require.NoError(t, err)
246246
}
@@ -281,7 +281,7 @@ func TestOutboundVoting(t *testing.T) {
281281
outbound,
282282
false,
283283
"failed",
284-
"",
284+
outbound.GasFee, // gas_fee_used required; use full fee → no excess refund
285285
)
286286
require.NoError(t, err)
287287
}

0 commit comments

Comments
 (0)