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
3 changes: 3 additions & 0 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,9 @@ func NewChainApp(
app.FeeMarketKeeper,
)

// Payload receipts reach x/ucallback through here, not the EVM hook.
app.UexecutorKeeper.SetUCallbackKeeper(app.UcallbackKeeper)

// Create the uvalidator Keeper
app.UvalidatorKeeper = uvalidatorkeeper.NewKeeper(
appCodec,
Expand Down
2 changes: 2 additions & 0 deletions app/txpolicy/gasless.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (

sdk "github.com/cosmos/cosmos-sdk/types"
"github.com/cosmos/cosmos-sdk/x/authz"
ucallbacktypes "github.com/pushchain/push-chain-node/x/ucallback/types"
uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types"
utsstypes "github.com/pushchain/push-chain-node/x/utss/types"
)
Expand All @@ -22,6 +23,7 @@ func IsGaslessTx(tx sdk.Tx) bool {
sdk.MsgTypeURL(&utsstypes.MsgVoteTssKeyProcess{}),
sdk.MsgTypeURL(&utsstypes.MsgVoteFundMigration{}),
sdk.MsgTypeURL(&uexecutortypes.MsgVoteChainMeta{}),
sdk.MsgTypeURL(&ucallbacktypes.MsgVoteReadResult{}),
}
)

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ replace (
cosmossdk.io/x/upgrade => cosmossdk.io/x/upgrade v0.2.0
github.com/CosmWasm/wasmd => github.com/CosmWasm/wasmd v0.55.0 // Keep v0.55.0
github.com/cosmos/cosmos-sdk => github.com/cosmos/cosmos-sdk v0.53.7 // Use stable v0.53.7
github.com/cosmos/evm => github.com/pushchain/evm v1.0.0-rc2.0.20260827045303-f6a1ba7250e2
github.com/cosmos/evm => github.com/pushchain/evm v1.0.0-rc2.0.20260907111253-e9816bddce44
github.com/ethereum/go-ethereum => github.com/cosmos/go-ethereum v0.0.0-20250806193535-2fc7571efa91
github.com/spf13/viper => github.com/spf13/viper v1.17.0
github.com/strangelove-ventures/tokenfactory => github.com/strangelove-ventures/tokenfactory v0.50.7-wasmvm2
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1769,8 +1769,8 @@ github.com/prysmaticlabs/gohashtree v0.0.4-beta.0.20240624100937-73632381301b h1
github.com/prysmaticlabs/gohashtree v0.0.4-beta.0.20240624100937-73632381301b/go.mod h1:HRuvtXLZ4WkaB1MItToVH2e8ZwKwZPY5/Rcby+CvvLY=
github.com/prysmaticlabs/prysm/v5 v5.3.0 h1:7Lr8ndapBTZg00YE+MgujN6+yvJR6Bdfn28ZDSJ00II=
github.com/prysmaticlabs/prysm/v5 v5.3.0/go.mod h1:r1KhlduqDMIGZ1GhR5pjZ2Ko8Q89noTDYTRoPKwf1+c=
github.com/pushchain/evm v1.0.0-rc2.0.20260827045303-f6a1ba7250e2 h1:eOm76NKQNJrfzsjrLMWeLXZ5s7e7o2rpHFhNYSgl/dE=
github.com/pushchain/evm v1.0.0-rc2.0.20260827045303-f6a1ba7250e2/go.mod h1:QuenX5DgRhWeYdIg0J/p65cyS/ntpgnzpZOIajZ/SHk=
github.com/pushchain/evm v1.0.0-rc2.0.20260907111253-e9816bddce44 h1:AwjH9/uMSblFHrclRp7zctH4+0VdZqChMoeXh3v6jHk=
github.com/pushchain/evm v1.0.0-rc2.0.20260907111253-e9816bddce44/go.mod h1:QuenX5DgRhWeYdIg0J/p65cyS/ntpgnzpZOIajZ/SHk=
github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo=
github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A=
github.com/quic-go/qtls-go1-20 v0.3.4 h1:MfFAPULvst4yoMgY9QmtpYmfij/em7O8UUi+bNVm7Cg=
Expand Down
106 changes: 106 additions & 0 deletions test/integration/ucallback/derived_ingest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package integrationtest

import (
"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"
uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types"
)

// A read requested from inside a UEA payload must be recorded.
//
// Payloads run through DerivedEVMCall, which never reaches ApplyTransaction and so
// never fires the EVM post-tx hook x/ucallback ingests from. Before the explicit
// hand-off in CallUEAExecutePayload the request was emitted and its budget
// escrowed with nothing recording it, leaving the funds unreachable: the sweeper
// only walks PendingByExpiry, which an un-ingested read never enters.
func TestReadRequestedInUEAPayload_IsIngested(t *testing.T) {
chainApp, ctx, _ := utils.SetAppWithValidators(t)
// The fixture leaves block time at the zero value, which reaches the EVM as a
// huge unsigned timestamp and trips the payload's ExpiredDeadline check.
ctx = ctx.WithBlockTime(time.Unix(1_700_000_000, 0))
uek := chainApp.UexecutorKeeper
uck := chainApp.UcallbackKeeper

contract := utils.SetupUniversalCallback(t, chainApp, ctx)
core := utils.SetupMockUniversalCoreForReads(t, chainApp, ctx)

// _universalCore is storage slot 0; the fixture writes runtime code directly,
// so initialize() never ran to set it.
chainApp.EVMKeeper.SetState(ctx, contract,
common.BigToHash(big.NewInt(0)), common.BytesToHash(core.Bytes()).Bytes())

moduleAddr, _ := uek.GetUeModuleAddress(ctx)

// A real UEA, deployed the way an inbound would deploy it.
owner := utils.GetDefaultAddresses().DefaultTestAddr
deployRes, err := uek.DeployUEAV2(ctx, moduleAddr, &uexecutortypes.UniversalAccountId{
ChainNamespace: "eip155",
ChainId: "11155111",
Owner: owner,
})
require.NoError(t, err)
uea := common.BytesToAddress(deployRes.Ret)
require.NotEqual(t, common.Address{}, uea, "UEA must have been deployed")

// The UEA pays the deposit out of its own balance.
deposit := big.NewInt(4_000_000_000_000_000)
fund(t, chainApp, ctx, sdk.AccAddress(uea.Bytes()), new(big.Int).Mul(deposit, big.NewInt(10)))

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)

payload := &uexecutortypes.UniversalPayload{
To: contract.Hex(),
Value: deposit.String(),
Data: common.Bytes2Hex(callData),
GasLimit: "21000000",
MaxFeePerGas: "1000000000",
MaxPriorityFeePerGas: "200000000",
Nonce: "0",
Deadline: "9999999999",
VType: uexecutortypes.VerificationType(1),
}

res, err := uek.CallUEAExecutePayload(ctx, moduleAddr, uea, payload, nil)
require.NoError(t, err)
require.NotNil(t, res)
require.Empty(t, res.VmError, "payload execution must not revert: %s", res.VmError)
require.NotEmpty(t, res.Logs, "the contract 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 CallUEAExecutePayload "+
"the hook never fires for a derived call and the escrowed budget is stranded")
require.Equal(t, uint64(250_000), recorded[0].Request.CallbackGasLimit)
}
2 changes: 1 addition & 1 deletion test/integration/ucallback/lifecycle_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ func TestLifecycle_FulfilSettleBurn_AgainstRealContract(t *testing.T) {
res, err := k.CallFulfillExternalCallback(ctx, hexID(requestID), &ucallbacktypes.ReadResult{
Status: ucallbacktypes.ReadStatus_READ_STATUS_SUCCESS,
ResultData: []byte{0x01, 0x02},
})
}, 200_000)
require.NoError(t, err, "fulfillExternalCallback must succeed against the real contract")
require.Empty(t, res.VmError)

Expand Down
136 changes: 136 additions & 0 deletions test/integration/ucallback/settle_gas_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package integrationtest

import (
"fmt"
"math/big"
"testing"

sdk "github.com/cosmos/cosmos-sdk/types"
evmtypes "github.com/cosmos/evm/x/vm/types"
"github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require"

"github.com/pushchain/push-chain-node/app"
"github.com/pushchain/push-chain-node/test/utils"
)

// expireGasLimit and reportGasLimit are fixed, not estimated: _refund swallows a
// failed push, so the call returns cleanly whether or not the refund landed and
// estimation converges on a limit where it never does. A too-low limit strands the
// refund in the admin-sweepable pool instead of reverting.
//
// This measures what the two paths actually cost and fails if a constant stops
// covering one. Run with -v for the table.
const (
measuredExpireGasLimit = 150_000 // keep in sync with keeper.expireGasLimit
measuredReportGasLimit = 150_000 // keep in sync with keeper.reportGasLimit
)

// recipientKind is a revertRecipient shape and the runtime code behind it.
var recipientKinds = []struct {
name string
code string // runtime bytecode, hex without 0x; empty = codeless EOA
}{
// Cheapest possible: a codeless address, value transfer only.
{"eoa", ""},
// Accepts value, does nothing: STOP.
{"contract-noop", "00"},
// Bookkeeping in receive(): PUSH1 1, PUSH1 0, SSTORE, STOP. A cold SSTORE is
// 22.1k, which is the shape a real app's receive() tends to have.
{"contract-sstore", "600160005500"},
}

func recipientAddr(i int) common.Address {
return common.HexToAddress(fmt.Sprintf("0x00000000000000000000000000000000000Fee%02d", i))
}

func provisionRecipient(
t *testing.T, chainApp *app.ChainApp, ctx sdk.Context, i int, code string,
) common.Address {
t.Helper()
addr := recipientAddr(i)
if code == "" {
return provisionEOA(t, chainApp, ctx, addr.Hex())
}
return utils.DeployContract(t, chainApp, ctx, addr, code)
}

// seedRead writes a request at the given status. status 1 = PENDING (what
// expireExternalRead requires), 2 = EXECUTED (what reportCallbackGas requires).
func seedRead(
t *testing.T, chainApp *app.ChainApp, ctx sdk.Context,
contract common.Address, requestID *big.Int, p pendingRead, status int64,
) {
t.Helper()
seedPendingRead(t, chainApp, ctx, contract, requestID, p)
if status != 1 {
chainApp.EVMKeeper.SetState(ctx, contract, mappingSlot(requestID, slotStatus),
common.BigToHash(big.NewInt(status)).Bytes())
}
}

func TestSettleGas_AgainstRealContract(t *testing.T) {
type row struct {
path, recipient string
gasUsed, limit uint64
}
var rows []row

for i, rk := range recipientKinds {
for _, path := range []string{"reportCallbackGas", "expireExternalRead"} {
chainApp, ctx, _ := utils.SetAppWithValidators(t)
contract := utils.SetupUniversalCallback(t, chainApp, ctx)
k := chainApp.UcallbackKeeper

requestID := big.NewInt(int64(0x7e500 + i*8))
budget := big.NewInt(3_000_000_000_000_000)
target := provisionEOA(t, chainApp, ctx, "0x00000000000000000000000000000000000c0FFE")
recipient := provisionRecipient(t, chainApp, ctx, i, rk.code)

// expire needs PENDING and a deadline already behind us; report needs
// EXECUTED and its deadline is irrelevant.
status := int64(2)
expiry := uint64(ctx.BlockHeight()) + 1000
if path == "expireExternalRead" {
status = 1
expiry = 1
}

seedRead(t, chainApp, ctx, contract, requestID, pendingRead{
callbackTarget: target,
callbackSelector: [4]byte{0xaa, 0xbb, 0xcc, 0xdd},
callbackGasLimit: 200_000,
originalFunder: target,
expiryHeight: expiry,
revertRecipient: recipient,
callbackBudget: budget,
}, status)
fund(t, chainApp, ctx, sdk.AccAddress(contract.Bytes()), budget)

var (
res *evmtypes.MsgEthereumTxResponse
err error
limit uint64
)
if path == "reportCallbackGas" {
res, err = k.CallReportCallbackGas(ctx, hexID(requestID), big.NewInt(1_000_000))
limit = measuredReportGasLimit
} else {
res, err = k.CallExpireExternalRead(ctx, hexID(requestID))
limit = measuredExpireGasLimit
}
require.NoError(t, err, "%s [%s]", path, rk.name)
require.Empty(t, res.VmError, "%s [%s] must not revert", path, rk.name)
rows = append(rows, row{path, rk.name, res.GasUsed, limit})
}
}

t.Log("path recipient gasUsed limit headroom")
for _, r := range rows {
t.Logf("%-20s %-18s %8d %8d %+9d", r.path, r.recipient, r.gasUsed, r.limit,
int64(r.limit)-int64(r.gasUsed))
require.Less(t, r.gasUsed, r.limit,
"%s with a %s recipient costs %d gas but the limit is %d -- raise the constant",
r.path, r.recipient, r.gasUsed, r.limit)
}
}
2 changes: 1 addition & 1 deletion x/ucallback/keeper/ballot_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ func (k Keeper) FulfilRead(ctx sdk.Context, ur types.UniversalRead) error {
}

tmpCtx, commit := ctx.CacheContext()
res, callErr := k.CallFulfillExternalCallback(tmpCtx, ur.Id, ur.Result)
res, callErr := k.CallFulfillExternalCallback(tmpCtx, ur.Id, ur.Result, ur.Request.GetCallbackGasLimit())

var vmErr string
var revertData []byte
Expand Down
3 changes: 2 additions & 1 deletion x/ucallback/keeper/ballot_hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ func TestAfterBallotTerminal_FulfilsOnPassed(t *testing.T) {
require.True(t, ok)
require.Equal(t, moduleEVMAddr(), c.from, "must be sent as the x/ucallback module account")
require.True(t, c.isModule)
require.Nil(t, c.gasLimit, "the contract enforces the callback budget, not us")
require.Equal(t, big.NewInt(testCallbackGasLimit+50_000), c.gasLimit,
"explicit limit, or the estimator picks one that starves the callback")

// requestId reaches the contract as a uint256, not a string
require.Len(t, c.args, 2,
Expand Down
30 changes: 24 additions & 6 deletions x/ucallback/keeper/evm.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@ func trim0x(s string) string {
return s
}

// Gas limits for the module's own calls into UniversalCallback.
//
// Passed explicitly, never nil: a nil limit makes DerivedEVMCallWithData estimate,
// and the estimator's only failure signal is a top-level VmError. Both
// fulfillExternalCallback and _refund catch their inner failure and return
// cleanly, so estimation converges on a limit that starves them.
const (
// fulfilGasBuffer covers the contract's own work on top of the callbackGasLimit
// forwarded to the app.
fulfilGasBuffer = 50_000

// Fixed: neither method forwards a caller-declared budget. Worst measured cost
// is 89,372 (TestSettleGas_AgainstRealContract), leaving ~60k for an expensive
// revertRecipient receive().
expireGasLimit = 150_000
reportGasLimit = 150_000
)

// callAsModule issues a DerivedEVMCall to UniversalCallback from the x/ucallback
// module account.
//
Expand All @@ -48,6 +66,7 @@ func trim0x(s string) string {
func (k Keeper) callAsModule(
ctx sdk.Context,
method string,
gasLimit *big.Int,
args ...interface{},
) (*evmtypes.MsgEthereumTxResponse, error) {
callbackABI, err := types.ParseUniversalCallbackABI()
Expand Down Expand Up @@ -86,10 +105,7 @@ func (k Keeper) callAsModule(
false, // not gasless — we want gas accounted in the receipt
true, // isModuleSender
big.NewInt(0),
// nil gas limit — the callback's own budget is enforced by the contract
// (callbackGasLimit, capped at MAX_CALLBACK_GAS_LIMIT), so a limit here
// would only add a second ceiling that could cut the callback short.
nil,
gasLimit,
&nonce,
)
}
Expand All @@ -100,6 +116,7 @@ func (k Keeper) CallFulfillExternalCallback(
ctx sdk.Context,
requestID string,
result *types.ReadResult,
callbackGasLimit uint64,
) (*evmtypes.MsgEthereumTxResponse, error) {
if result == nil {
return nil, fmt.Errorf("cannot fulfil %s: nil result", requestID)
Expand All @@ -117,6 +134,7 @@ func (k Keeper) CallFulfillExternalCallback(

return k.callAsModule(ctx,
types.MethodFulfillExternalCallback,
new(big.Int).SetUint64(callbackGasLimit+fulfilGasBuffer),
id,
result.ResultData,
)
Expand All @@ -134,7 +152,7 @@ func (k Keeper) CallExpireExternalRead(

k.Logger().Debug("EVM call: expireExternalRead", "request_id", requestID)

return k.callAsModule(ctx, types.MethodExpireExternalRead, id)
return k.callAsModule(ctx, types.MethodExpireExternalRead, big.NewInt(expireGasLimit), id)
}

// pcTxFrom renders an EVM call attempt as a PCTx audit entry. Both the success and
Expand Down Expand Up @@ -225,7 +243,7 @@ func (k Keeper) CallReportCallbackGas(
return nil, err
}
k.Logger().Debug("EVM call: reportCallbackGas", "request_id", requestID, "cost", cost.String())
return k.callAsModule(ctx, types.MethodReportCallbackGas, id, cost)
return k.callAsModule(ctx, types.MethodReportCallbackGas, big.NewInt(reportGasLimit), id, cost)
}

// TakeAndBurn moves the consumed callback budget out of UniversalCallback and
Expand Down
Loading
Loading