Skip to content
Merged
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
124 changes: 124 additions & 0 deletions test/integration/ucallback/cea_read_e2e_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
101 changes: 101 additions & 0 deletions test/integration/uexecutor/cea_read_ingest_test.go
Original file line number Diff line number Diff line change
@@ -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.
}
18 changes: 15 additions & 3 deletions x/uexecutor/keeper/evm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -854,7 +854,7 @@ func (k Keeper) CallExecuteUniversalTx(

ueModuleAccAddress, _ := k.GetUeModuleAddress(ctx)

return k.derivedModuleCall(
res, err := k.derivedModuleCall(
ctx,
recipientABI,
ueModuleAccAddress,
Expand All @@ -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
}
Loading