From e20fe7f40b03e738a058b3e612efc4841e60f7eb Mon Sep 17 00:00:00 2001 From: Nilesh Gupta Date: Tue, 15 Sep 2026 11:22:26 +0530 Subject: [PATCH] fix(uexecutor): ingest read requests from the CEA contract path CallExecuteUniversalTx runs a derived call, which never fires the EVM hook x/ucallback ingests from, so a ReadRequested emitted by a CEA-invoked contract was recorded nowhere and its escrowed budget stranded. --- .../ucallback/cea_read_e2e_test.go | 124 ++++++++++++++++++ .../uexecutor/cea_read_ingest_test.go | 101 ++++++++++++++ x/uexecutor/keeper/evm.go | 18 ++- 3 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 test/integration/ucallback/cea_read_e2e_test.go create mode 100644 test/integration/uexecutor/cea_read_ingest_test.go diff --git a/test/integration/ucallback/cea_read_e2e_test.go b/test/integration/ucallback/cea_read_e2e_test.go new file mode 100644 index 00000000..672ce3eb --- /dev/null +++ b/test/integration/ucallback/cea_read_e2e_test.go @@ -0,0 +1,124 @@ +package integrationtest + +import ( + "encoding/binary" + "math/big" + "testing" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + utils "github.com/pushchain/push-chain-node/test/utils" + ucallbacktypes "github.com/pushchain/push-chain-node/x/ucallback/types" +) + +// forwarderCode assembles runtime bytecode that CALLs target with value and a +// fixed calldata blob appended to the code, reverting if the inner call fails. +// It ignores its own calldata, so it answers executeUniversalTx like anything else. +func forwarderCode(target common.Address, value *big.Int, data []byte) string { + l := len(data) + push2 := func(n int) []byte { b := []byte{0x61, 0, 0}; binary.BigEndian.PutUint16(b[1:], uint16(n)); return b } + + var p []byte + // CODECOPY(destOffset=0, codeOffset=blobOff, length=l) + p = append(p, push2(l)...) + blobOffPos := len(p) + 1 // patched once the prologue length is known + p = append(p, push2(0)...) + p = append(p, 0x60, 0x00, 0x39) + + // CALL(gas, target, value, 0, l, 0, 0) — push args in reverse order + p = append(p, 0x60, 0x00) // retLength + p = append(p, 0x60, 0x00) // retOffset + p = append(p, push2(l)...) + p = append(p, 0x60, 0x00) // argsOffset + v := make([]byte, 32) + value.FillBytes(v) + p = append(p, 0x7f) + p = append(p, v...) + p = append(p, 0x73) + p = append(p, target.Bytes()...) + p = append(p, 0x5a, 0xf1) // GAS, CALL + + // success ? STOP : REVERT + dest := len(p) + 8 + p = append(p, 0x60, byte(dest), 0x57) // PUSH1 dest, JUMPI + p = append(p, 0x60, 0x00, 0x60, 0x00, 0xfd) + p = append(p, 0x5b, 0x00) // JUMPDEST, STOP + + binary.BigEndian.PutUint16(p[blobOffPos:], uint16(len(p))) + return common.Bytes2Hex(append(p, data...)) +} + +// N1 end-to-end: a CEA inbound whose recipient is a contract runs through +// CallExecuteUniversalTx. When that contract requests a read, the event is +// emitted by UniversalCallback — and must end up recorded in x/ucallback. +// +// Derived calls never fire the EVM post-tx hook, so before the hand-off in +// CallExecuteUniversalTx the request was emitted and its budget escrowed with +// nothing recording it, leaving the funds unreachable. +func TestReadRequestedFromCEAContract_IsIngested(t *testing.T) { + chainApp, ctx, _ := utils.SetAppWithValidators(t) + ctx = ctx.WithBlockTime(time.Unix(1_700_000_000, 0)) + uek := chainApp.UexecutorKeeper + uck := chainApp.UcallbackKeeper + + callback := utils.SetupUniversalCallback(t, chainApp, ctx) + core := utils.SetupMockUniversalCoreForReads(t, chainApp, ctx) + chainApp.EVMKeeper.SetState(ctx, callback, + common.BigToHash(big.NewInt(0)), common.BytesToHash(core.Bytes()).Bytes()) + + deposit := big.NewInt(4_000_000_000_000_000) + + reqABI := loadRequestABI(t) + callData, err := reqABI.Pack("requestExternalReadSelf", + readSpecArg{ + Account: accountArg{ + ChainNamespace: "eip155", + ChainId: "11155111", + Owner: common.FromHex("0x1111111111111111111111111111111111111111"), + }, + Query: common.FromHex("0xdeadbeef"), + MinConfirmations: uint16(6), + BlockNumber: uint64(8_000_000), + ExpiryPushChainHeight: uint64(ctx.BlockHeight()) + 500, + MaxFee: new(big.Int).Mul(deposit, big.NewInt(2)), + RevertRecipient: common.HexToAddress("0x00000000000000000000000000000000000BEEF1"), + }, + [4]byte{0x11, 0x22, 0x33, 0x44}, + uint64(250_000), + ) + require.NoError(t, err) + + // The CEA recipient: forwards into UniversalCallback, paying the deposit. + recipient := common.HexToAddress("0x00000000000000000000000000000000000CEA01") + utils.DeployContract(t, chainApp, ctx, recipient, forwarderCode(callback, deposit, callData)) + fund(t, chainApp, ctx, sdk.AccAddress(recipient.Bytes()), new(big.Int).Mul(deposit, big.NewInt(10))) + + var txId [32]byte + copy(txId[:], common.FromHex("0xabcd")) + + res, err := uek.CallExecuteUniversalTx( + ctx, recipient, "eip155:11155111", + common.FromHex("0x1111111111111111111111111111111111111111"), + common.FromHex("0xdeadbeef"), big.NewInt(0), + utils.GetDefaultAddresses().PRC20USDCAddr, txId, + ) + require.NoError(t, err) + require.NotNil(t, res) + require.Empty(t, res.VmError, "the CEA contract call must not revert: %s", res.VmError) + require.NotEmpty(t, res.Logs, "UniversalCallback must have emitted ReadRequested") + + var recorded []ucallbacktypes.UniversalRead + require.NoError(t, uck.IterateReadsByTxHash(ctx, res.Hash, + func(ur ucallbacktypes.UniversalRead) bool { + recorded = append(recorded, ur) + return false + })) + + require.Len(t, recorded, 1, + "the read must be recorded; without the hand-off in CallExecuteUniversalTx "+ + "the hook never fires for a derived call and the escrowed budget is stranded (N1)") + require.Equal(t, uint64(250_000), recorded[0].Request.CallbackGasLimit) +} diff --git a/test/integration/uexecutor/cea_read_ingest_test.go b/test/integration/uexecutor/cea_read_ingest_test.go new file mode 100644 index 00000000..3fa26e82 --- /dev/null +++ b/test/integration/uexecutor/cea_read_ingest_test.go @@ -0,0 +1,101 @@ +package integrationtest + +import ( + "context" + "math/big" + "testing" + "time" + + evmtypes "github.com/cosmos/evm/x/vm/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + utils "github.com/pushchain/push-chain-node/test/utils" + uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" +) + +// spyUCallback counts IngestReadRequests calls and forwards to the real keeper. +type spyUCallback struct { + inner uexecutortypes.UCallbackKeeper + calls int + receipts []*evmtypes.MsgEthereumTxResponse +} + +func (s *spyUCallback) IngestReadRequests(ctx context.Context, receipt *evmtypes.MsgEthereumTxResponse) error { + s.calls++ + s.receipts = append(s.receipts, receipt) + if s.inner == nil { + return nil + } + return s.inner.IngestReadRequests(ctx, receipt) +} + +// N1: a CEA inbound whose recipient is a contract runs through +// CallExecuteUniversalTx. Derived calls never fire the EVM post-tx hook, so +// without an explicit hand-off a ReadRequested emitted by that contract is +// recorded nowhere and its escrowed budget is stranded. +// +// The keepers are invoked directly: app.UexecutorKeeper is held by value, so a +// spy installed on it is not seen by the msg server's own copy. +func TestReadIngestHandoff_BothDerivedPaths(t *testing.T) { + chainApp, ctx, _ := utils.SetAppWithValidators(t) + ctx = ctx.WithBlockTime(time.Unix(1_700_000_000, 0)) + + spy := &spyUCallback{inner: chainApp.UcallbackKeeper} + chainApp.UexecutorKeeper.SetUCallbackKeeper(spy) + uek := chainApp.UexecutorKeeper + + moduleAddr, _ := uek.GetUeModuleAddress(ctx) + recipient := deployMockRecipientContract(t, chainApp, ctx) + + var txId [32]byte + copy(txId[:], common.FromHex("0x01")) + + t.Run("CEA contract path hands its receipt to x/ucallback", func(t *testing.T) { + before := spy.calls + + res, err := uek.CallExecuteUniversalTx( + ctx, recipient, "eip155:11155111", + common.FromHex("0x1111111111111111111111111111111111111111"), + common.FromHex("0xdeadbeef"), big.NewInt(0), + utils.GetDefaultAddresses().PRC20USDCAddr, txId, + ) + require.NoError(t, err) + require.NotNil(t, res) + + require.Equal(t, before+1, spy.calls, + "CallExecuteUniversalTx must hand its receipt to x/ucallback (N1)") + require.Same(t, res, spy.receipts[len(spy.receipts)-1], + "the receipt handed over must be the one the derived call produced") + }) + + t.Run("UEA path still hands its receipt over", func(t *testing.T) { + before := spy.calls + + deployRes, err := uek.DeployUEAV2(ctx, moduleAddr, &uexecutortypes.UniversalAccountId{ + ChainNamespace: "eip155", + ChainId: "11155111", + Owner: utils.GetDefaultAddresses().DefaultTestAddr, + }) + require.NoError(t, err) + uea := common.BytesToAddress(deployRes.Ret) + + _, _ = uek.CallUEAExecutePayload(ctx, moduleAddr, uea, &uexecutortypes.UniversalPayload{ + To: recipient.Hex(), + Value: "0", + Data: "", + GasLimit: "21000000", + MaxFeePerGas: "1000000000", + MaxPriorityFeePerGas: "200000000", + Nonce: "0", + Deadline: "9999999999", + VType: uexecutortypes.VerificationType(1), + }, nil) + + require.Greater(t, spy.calls, before, "the UEA hand-off must be unaffected") + }) + + // The cached-ctx requirement holds by construction: both call sites pass a + // CacheContext and the hand-off uses that same ctx. The commit/rollback + // behaviour of that cache is covered by the CEA fee-atomicity tests. +} diff --git a/x/uexecutor/keeper/evm.go b/x/uexecutor/keeper/evm.go index 611dcdc9..ea368319 100644 --- a/x/uexecutor/keeper/evm.go +++ b/x/uexecutor/keeper/evm.go @@ -286,8 +286,8 @@ func (k Keeper) CallUEAExecutePayload( return res, err } - // The hook x/ucallback ingests from never fires for derived calls, and every - // payload path funnels through here. + // The hook x/ucallback ingests from never fires for derived calls. + // CallExecuteUniversalTx carries the same hand-off for CEA recipients. if err := k.ucallbackKeeper.IngestReadRequests(ctx, res); err != nil { return res, errors.Wrap(err, "failed to ingest read requests") } @@ -854,7 +854,7 @@ func (k Keeper) CallExecuteUniversalTx( ueModuleAccAddress, _ := k.GetUeModuleAddress(ctx) - return k.derivedModuleCall( + res, err := k.derivedModuleCall( ctx, recipientABI, ueModuleAccAddress, @@ -869,4 +869,16 @@ func (k Keeper) CallExecuteUniversalTx( prc20AssetAddr, txId, ) + if err != nil { + return res, err + } + + // Same hand-off as CallUEAExecutePayload: the hook x/ucallback ingests from + // never fires for derived calls. Callers pass a cached ctx, so an ingested + // read is discarded with the rest if the parent execution is rolled back. + if err := k.ucallbackKeeper.IngestReadRequests(ctx, res); err != nil { + return res, errors.Wrap(err, "failed to ingest read requests") + } + + return res, nil }