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.
}
39 changes: 37 additions & 2 deletions universalClient/externalchains/svm/tx_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ var (
rateLimitConfigSeed = []byte("rate_limit_config")
tokenRateLimitSeed = []byte("rate_limit")
storedIxDataSeed = []byte("stored_ix_data")
eventAuthoritySeed = []byte("__event_authority")

// TSS message envelope — cross-protocol replay guard.
tssMessagePrefix = []byte("PUSH_CHAIN_SVM")
Expand Down Expand Up @@ -1468,6 +1469,16 @@ func (tb *TxBuilder) deriveTSSPDA() (solana.PublicKey, error) {
return address, err
}

// deriveEventAuthorityPDA derives the account Anchor's #[event_cpi] macro requires
// on every instruction that may self-CPI to emit an event. Every gateway
// instruction that emits an event needs this PDA, plus the gateway program
// itself, appended after its named accounts and before any remaining accounts.
func (tb *TxBuilder) deriveEventAuthorityPDA() (solana.PublicKey, error) {
seeds := [][]byte{eventAuthoritySeed}
address, _, err := solana.FindProgramAddress(seeds, tb.gatewayAddress)
return address, err
}

// fetchTSSChainID reads the TSS PDA account from on-chain and extracts the chain ID.
//
// On-chain layout (Borsh-serialized TssPda struct from state.rs):
Expand Down Expand Up @@ -2079,8 +2090,11 @@ func (tb *TxBuilder) buildRescueData(
// --- Optional ref-finalize accounts (19-20) ---
// 19 stored_ix_data read/None StoredIxData PDA (only used by ref-finalize route)
// 20 store_refund_recipient mut/None Receives store-tx fee reimbursement (ref route only)
// --- #[event_cpi] accounts (21-22) ---
// 21 event_authority read-only PDA ["__event_authority"], for the gateway's self-CPI
// 22 program read-only The gateway program itself
// --- Execute-only remaining accounts ---
// 21+ remaining_accounts varies Accounts that the target program needs
// 23+ remaining_accounts varies Accounts that the target program needs
//
// For Anchor Option<Account> fields: passing the gateway program's own ID = None.
// This is Anchor's convention for encoding "this optional account is not provided".
Expand Down Expand Up @@ -2203,6 +2217,14 @@ func (tb *TxBuilder) buildWithdrawAndExecuteAccounts(
accounts = append(accounts, &solana.AccountMeta{PublicKey: storeRefundRecipient, IsWritable: true, IsSigner: false})
}

// #[event_cpi] accounts (#21-22): required on every named-account boundary
// before remaining_accounts, so the gateway can self-CPI its emit_cpi event.
eventAuthority, _ := tb.deriveEventAuthorityPDA()
accounts = append(accounts,
&solana.AccountMeta{PublicKey: eventAuthority, IsWritable: false, IsSigner: false},
&solana.AccountMeta{PublicKey: tb.gatewayAddress, IsWritable: false, IsSigner: false},
)

// For execute mode: append the target program's accounts as "remaining_accounts".
// These are the accounts that the gateway will pass through via CPI to the target program.
if instructionID == 2 {
Expand Down Expand Up @@ -2233,11 +2255,16 @@ func (tb *TxBuilder) buildWithdrawAndExecuteAccounts(
// 6 executed_sub_tx mut Replay protection (gets created)
// 7 caller signer,mut Relayer
// 8 system_program read-only
// --- Optional SPL accounts (9-12) ---
// --- Optional SPL accounts (9-14) ---
// 9 token_vault mut/None Vault's ATA for the token
// 10 recipient_token_account mut/None Recipient's ATA
// 11 token_mint read/None The SPL token mint
// 12 token_program read/None SPL Token program
// 13 associated_token_program read/None Needed to create the recipient ATA
// 14 rent read/None Needed to create the recipient ATA
// --- #[event_cpi] accounts (15-16) ---
// 15 event_authority read-only PDA ["__event_authority"], for the gateway's self-CPI
// 16 program read-only The gateway program itself
func (tb *TxBuilder) buildRevertAccounts(
configPDA solana.PublicKey,
vaultPDA solana.PublicKey,
Expand Down Expand Up @@ -2286,6 +2313,14 @@ func (tb *TxBuilder) buildRevertAccounts(
)
}

// #[event_cpi] accounts: required so the gateway can self-CPI its emit_cpi
// event. revert_universal_tx has no remaining_accounts, so these are last.
eventAuthority, _ := tb.deriveEventAuthorityPDA()
accounts = append(accounts,
&solana.AccountMeta{PublicKey: eventAuthority, IsWritable: false, IsSigner: false},
&solana.AccountMeta{PublicKey: tb.gatewayAddress, IsWritable: false, IsSigner: false},
)

return accounts
}

Expand Down
46 changes: 39 additions & 7 deletions universalClient/externalchains/svm/tx_builder_pc20.go
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,17 @@ func (tb *TxBuilder) buildPC20ExportAccounts(
accounts = append(accounts, &solana.AccountMeta{PublicKey: storeRefundRecipient, IsWritable: true, IsSigner: false})
}

// #[event_cpi] accounts: required, at the same named-account boundary as the
// non-PC20 finalize route, so the gateway can self-CPI its emit_cpi event.
eventAuthority, err := tb.deriveEventAuthorityPDA()
if err != nil {
return nil, fmt.Errorf("failed to derive event_authority PDA: %w", err)
}
accounts = append(accounts,
&solana.AccountMeta{PublicKey: eventAuthority, IsWritable: false, IsSigner: false},
&solana.AccountMeta{PublicKey: tb.gatewayAddress, IsWritable: false, IsSigner: false},
)

// Remaining accounts: [pc20_state, pc20_mint] + the payload accounts (only when
// user_data is present). The gateway requires exactly these two when user_data is
// empty — no recipient_ata, since the wrapper is minted to cea_ata.
Expand All @@ -775,7 +786,12 @@ func (tb *TxBuilder) buildPC20ExportAccounts(

// buildPC20RemintAccounts builds the revert_universal_tx / rescue_funds account list for
// the PC20 remint branch: token_vault + recipient_token_account None, token_mint + token_program
// present, remaining = [pc20_state, pc20_mint(w), recipient_ata(w), ATA_program, rent].
// present, associated_token_program + rent None (unused: the remaining accounts below carry
// their own copies for creating recipient_ata), then the #[event_cpi] pair, then
// remaining = [pc20_state, pc20_mint(w), recipient_ata(w), ATA_program, rent].
//
// Must match the same RevertUniversalTx struct as buildRevertAccounts — PC20 remint
// dispatches through the same instruction, so positions 1-14 have to line up with it.
func (tb *TxBuilder) buildPC20RemintAccounts(
configPDA solana.PublicKey,
vaultPDA solana.PublicKey,
Expand Down Expand Up @@ -812,12 +828,28 @@ func (tb *TxBuilder) buildPC20RemintAccounts(
none, // recipient_token_account
{PublicKey: mint, IsWritable: false, IsSigner: false},
{PublicKey: solana.TokenProgramID, IsWritable: false, IsSigner: false},
// remaining accounts — exact shape required by is_pc20_remint_account_shape
{PublicKey: pc20State, IsWritable: false, IsSigner: false},
{PublicKey: mint, IsWritable: true, IsSigner: false},
{PublicKey: recipientATA, IsWritable: true, IsSigner: false},
{PublicKey: solana.SPLAssociatedTokenAccountProgramID, IsWritable: false, IsSigner: false},
{PublicKey: solana.SysVarRentPubkey, IsWritable: false, IsSigner: false},
none, // associated_token_program
none, // rent
}

// #[event_cpi] accounts: required so the gateway can self-CPI its emit_cpi
// event, at the same named-account boundary buildRevertAccounts uses.
eventAuthority, err := tb.deriveEventAuthorityPDA()
if err != nil {
return nil, fmt.Errorf("failed to derive event_authority PDA: %w", err)
}
accounts = append(accounts,
&solana.AccountMeta{PublicKey: eventAuthority, IsWritable: false, IsSigner: false},
&solana.AccountMeta{PublicKey: tb.gatewayAddress, IsWritable: false, IsSigner: false},
)

// remaining accounts — exact shape required by is_pc20_remint_account_shape
accounts = append(accounts,
&solana.AccountMeta{PublicKey: pc20State, IsWritable: false, IsSigner: false},
&solana.AccountMeta{PublicKey: mint, IsWritable: true, IsSigner: false},
&solana.AccountMeta{PublicKey: recipientATA, IsWritable: true, IsSigner: false},
&solana.AccountMeta{PublicKey: solana.SPLAssociatedTokenAccountProgramID, IsWritable: false, IsSigner: false},
&solana.AccountMeta{PublicKey: solana.SysVarRentPubkey, IsWritable: false, IsSigner: false},
)
return accounts, nil
}
Loading
Loading