From d451fd26edfa832d54bb84d4324249f2583527b8 Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 28 Jul 2026 16:58:51 +0530 Subject: [PATCH 01/11] add: READ_REQUEST event --- universalClient/store/models.go | 1 + 1 file changed, 1 insertion(+) diff --git a/universalClient/store/models.go b/universalClient/store/models.go index 98ef87d8..b3cc331b 100644 --- a/universalClient/store/models.go +++ b/universalClient/store/models.go @@ -28,6 +28,7 @@ const ( EventTypeSignFundMigrate = "SIGN_FUND_MIGRATE" EventTypeInbound = "INBOUND" EventTypeOutbound = "OUTBOUND" + EventTypeReadRequest = "READ_REQUEST" ) // Confirmation type values. From 7c6f3c53754fe996e000d2daa0bda89345963a58 Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 28 Jul 2026 16:58:51 +0530 Subject: [PATCH 02/11] temp: proto results, will be replaced by core implementation --- universalClient/uread/types.go | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 universalClient/uread/types.go diff --git a/universalClient/uread/types.go b/universalClient/uread/types.go new file mode 100644 index 00000000..a7b2ca34 --- /dev/null +++ b/universalClient/uread/types.go @@ -0,0 +1,49 @@ +// Package uread is a TEMPORARY package: it mirrors the read-request proto types +// x/uexecutor will generate (proto/uexecutor/v1/read_request.proto + tx.proto). +// +// TODO(core): once core lands, replace every uread.* reference with the +// generated uexecutortypes equivalents and delete this package. +package uread + +// ReadRequest mirrors the pending read request tracked by x/uexecutor. +type ReadRequest struct { + RequestID string // uint256 as 0x-prefixed hex (from ReadRequested event) + ChainNamespace string // e.g. "eip155", "solana" + ChainID string // e.g. "1", "42161", "mainnet-beta" + Owner []byte // ReadSpec.account.owner (20-byte addr / 32-byte pubkey) + Query []byte // chain-specific envelope, abi.encode(...) + MinConfirmations uint16 + MaxAgeSeconds uint64 + MaxDelaySeconds uint64 + PinnedBlockHeight uint64 // height all validators must query; 0 = not pinned by core + ExpiryTimestamp int64 // unix seconds; 0 = no expiry known + CreatedAtHeight uint64 // Push chain height at which the request was created +} + +// ReadStatus is the observed outcome a validator votes on. +type ReadStatus int32 + +const ( + ReadStatusSuccess ReadStatus = 1 + ReadStatusError ReadStatus = 2 +) + +// ReadResult is the canonical observation submitted via MsgVoteReadResult. +// All fields must be byte-identical across validators for quorum. +type ReadResult struct { + Status ReadStatus + ResultData []byte + ObservedBlockHeight uint64 // block number (EVM) or slot (SVM) + ObservedBlockHash []byte // 32 bytes; empty when the chain cannot pin one deterministically + ErrorMsg string // local diagnostic only — never part of the ballot +} + +// NewErrorResult builds an ERROR observation. ResultData stays empty so all +// validators voting ERROR converge on the same ballot regardless of local error text. +func NewErrorResult(err error) *ReadResult { + msg := "" + if err != nil { + msg = err.Error() + } + return &ReadResult{Status: ReadStatusError, ErrorMsg: msg} +} From d4bbe6211201b945491c479b02ccbe4dc19eb991 Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 28 Jul 2026 16:58:51 +0530 Subject: [PATCH 03/11] temp: pushCore fetch, to be replaced by core grpc fn --- universalClient/pushcore/pushCore.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/universalClient/pushcore/pushCore.go b/universalClient/pushcore/pushCore.go index 647b548e..3de4dda0 100644 --- a/universalClient/pushcore/pushCore.go +++ b/universalClient/pushcore/pushCore.go @@ -16,6 +16,7 @@ import ( "github.com/cosmos/cosmos-sdk/types/tx" authtypes "github.com/cosmos/cosmos-sdk/x/auth/types" "github.com/cosmos/cosmos-sdk/x/authz" + "github.com/pushchain/push-chain-node/universalClient/uread" uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" uregistrytypes "github.com/pushchain/push-chain-node/x/uregistry/types" utsstypes "github.com/pushchain/push-chain-node/x/utss/types" @@ -367,6 +368,21 @@ func (c *Client) GetAllPendingOutbounds(ctx context.Context) ([]*uexecutortypes. return resp.Entries, resp.Outbounds, nil } +// ErrReadQueriesNotAvailable is returned until the core-side pending-read query +// exists. Callers treat it as "feature not live yet", not as a failure. +var ErrReadQueriesNotAvailable = errors.New("pushcore: pending read requests query not available yet (blocked on core)") + +// GetAllPendingReadRequests retrieves pending external read requests from Push Chain. +// +// TODO(core): blocked on x/uexecutor Query/PendingReadRequests +// (proto/uexecutor/v1/query.proto). Once it lands, mirror GetAllPendingOutbounds: +// call c.uexecutorClients[idx].AllPendingReadRequests with retryWithRoundRobin, +// map uexecutortypes.ReadRequest -> uread.ReadRequest (or drop the local type +// entirely), and delete ErrReadQueriesNotAvailable. +func (c *Client) GetAllPendingReadRequests(ctx context.Context) ([]*uread.ReadRequest, error) { + return nil, ErrReadQueriesNotAvailable +} + // createGRPCConnection creates a gRPC connection with appropriate transport security. // It automatically detects whether to use TLS based on the URL scheme // and adds default port 9090 if no port is specified. From 4ebd9462c225653b5d3f9419359124e00d993f95 Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 28 Jul 2026 16:58:51 +0530 Subject: [PATCH 04/11] temp: readResult temp vote, to be replace by core impl --- universalClient/pushsigner/pushsigner.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/universalClient/pushsigner/pushsigner.go b/universalClient/pushsigner/pushsigner.go index 8e8dcbfe..f7f9dbe1 100644 --- a/universalClient/pushsigner/pushsigner.go +++ b/universalClient/pushsigner/pushsigner.go @@ -2,6 +2,7 @@ package pushsigner import ( "context" + "errors" "fmt" "strings" "sync" @@ -24,6 +25,7 @@ import ( "github.com/pushchain/push-chain-node/universalClient/config" "github.com/pushchain/push-chain-node/universalClient/pushcore" "github.com/pushchain/push-chain-node/universalClient/pushsigner/keys" + "github.com/pushchain/push-chain-node/universalClient/uread" uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" ) @@ -132,6 +134,22 @@ func (s *Signer) VoteFundMigration(ctx context.Context, migrationID uint64, txHa return voteFundMigration(ctx, s, s.log, s.granter, migrationID, txHash, success) } +// ErrVoteReadNotAvailable is returned until the core-side vote msg exists. +var ErrVoteReadNotAvailable = errors.New("pushsigner: MsgVoteReadResult not available yet (blocked on core)") + +// VoteReadResult votes on an external read observation. +// +// TODO(core): blocked on uexecutortypes.MsgVoteReadResult +// (proto/uexecutor/v1/tx.proto). Once it lands: +// - add a voteReadResult builder in vote.go (Signer: granter, RequestId, +// Status, ResultData, ObservedBlockHeight, ObservedBlockHash) and route +// through vote() like voteInbound does; +// - ensure the validator AuthZ grant set includes the new msg type URL +// (grant_verifier.go + core-side grant creation). +func (s *Signer) VoteReadResult(ctx context.Context, requestID string, result *uread.ReadResult) (string, error) { + return "", ErrVoteReadNotAvailable +} + // signAndBroadcastAuthZTx signs and broadcasts an AuthZ transaction func (s *Signer) signAndBroadcastAuthZTx( ctx context.Context, From a11aff840d9bcc790a5d6e06b84772e4ae0af3de Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 28 Jul 2026 16:59:02 +0530 Subject: [PATCH 05/11] feat(uclient): evm/svm read query execution - decode EvmQueryEnvelope (AccountBalance / ERC20Balance / ContractCall / StorageSlot) and SolanaQueryEnvelope (LamportBalance / SPLTokenAccount / RawAccountData) from ReadSpec.query - ExecuteRead on each chain client: deterministic pinned-height queries, canonical result encoding for identical-bytes ballot voting - new RPC methods: GetBalanceAt / GetStorageAt / GetHeaderByNumber (EVM), GetBalanceWithSlot / GetAccountInfoWithSlot (SVM) --- universalClient/externalchains/common/read.go | 51 +++++++ .../externalchains/common/read_test.go | 42 ++++++ .../externalchains/evm/read_envelope.go | 131 ++++++++++++++++++ .../externalchains/evm/read_envelope_test.go | 80 +++++++++++ .../externalchains/evm/read_executor.go | 125 +++++++++++++++++ .../externalchains/evm/rpc_client.go | 39 ++++++ .../externalchains/svm/read_envelope.go | 66 +++++++++ .../externalchains/svm/read_envelope_test.go | 34 +++++ .../externalchains/svm/read_executor.go | 99 +++++++++++++ .../externalchains/svm/rpc_client.go | 47 +++++++ 10 files changed, 714 insertions(+) create mode 100644 universalClient/externalchains/common/read.go create mode 100644 universalClient/externalchains/common/read_test.go create mode 100644 universalClient/externalchains/evm/read_envelope.go create mode 100644 universalClient/externalchains/evm/read_envelope_test.go create mode 100644 universalClient/externalchains/evm/read_executor.go create mode 100644 universalClient/externalchains/svm/read_envelope.go create mode 100644 universalClient/externalchains/svm/read_envelope_test.go create mode 100644 universalClient/externalchains/svm/read_executor.go diff --git a/universalClient/externalchains/common/read.go b/universalClient/externalchains/common/read.go new file mode 100644 index 00000000..58203b10 --- /dev/null +++ b/universalClient/externalchains/common/read.go @@ -0,0 +1,51 @@ +package common + +import ( + "context" + "fmt" + "math/big" + + "github.com/pushchain/push-chain-node/universalClient/uread" +) + +// ChainReader executes an external read request against one chain. +// Implemented by chains/evm.Client and chains/svm.Client. +type ChainReader interface { + ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) +} + +// ReadStoreResolver resolves a CAIP-2 chain ID to that chain's event store, so +// READ_REQUEST events can be routed into the target chain's own database. +// Implemented by externalchains.Chains. +type ReadStoreResolver interface { + GetStore(chainID string) (*ChainStore, error) +} + +// CAIP2 joins a ReadSpec domain (chainNamespace, chainId) into the CAIP-2 key +// used by the chains registry, e.g. ("eip155", "1") -> "eip155:1". +func CAIP2(chainNamespace, chainID string) (string, error) { + if chainNamespace == "" || chainID == "" { + return "", fmt.Errorf("empty chain namespace or id") + } + return chainNamespace + ":" + chainID, nil +} + +// EncodeUint256Result canonically encodes a balance/amount as abi.encode(uint256). +func EncodeUint256Result(v *big.Int) ([]byte, error) { + if v == nil { + v = big.NewInt(0) + } + if v.Sign() < 0 || v.BitLen() > 256 { + return nil, fmt.Errorf("value out of uint256 range") + } + out := make([]byte, 32) + v.FillBytes(out) + return out, nil +} + +// EncodeBytes32Result canonically encodes a storage slot value as abi.encode(bytes32). +func EncodeBytes32Result(v [32]byte) ([]byte, error) { + out := make([]byte, 32) + copy(out, v[:]) + return out, nil +} diff --git a/universalClient/externalchains/common/read_test.go b/universalClient/externalchains/common/read_test.go new file mode 100644 index 00000000..63087580 --- /dev/null +++ b/universalClient/externalchains/common/read_test.go @@ -0,0 +1,42 @@ +package common + +import ( + "bytes" + "math/big" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncodeUint256Result(t *testing.T) { + out, err := EncodeUint256Result(big.NewInt(1_000_000)) + require.NoError(t, err) + require.Len(t, out, 32) + assert.Equal(t, big.NewInt(1_000_000), new(big.Int).SetBytes(out)) + + out, err = EncodeUint256Result(nil) + require.NoError(t, err) + assert.True(t, bytes.Equal(out, make([]byte, 32))) + + _, err = EncodeUint256Result(big.NewInt(-1)) + assert.Error(t, err) +} + +func TestEncodeBytes32Result(t *testing.T) { + var v [32]byte + v[31] = 0xff + out, err := EncodeBytes32Result(v) + require.NoError(t, err) + require.Len(t, out, 32) + assert.Equal(t, v[:], out) +} + +func TestCAIP2(t *testing.T) { + got, err := CAIP2("eip155", "1") + require.NoError(t, err) + assert.Equal(t, "eip155:1", got) + + _, err = CAIP2("", "1") + assert.Error(t, err) +} diff --git a/universalClient/externalchains/evm/read_envelope.go b/universalClient/externalchains/evm/read_envelope.go new file mode 100644 index 00000000..8580ba0e --- /dev/null +++ b/universalClient/externalchains/evm/read_envelope.go @@ -0,0 +1,131 @@ +package evm + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi" + ethcommon "github.com/ethereum/go-ethereum/common" +) + +// evmQueryType mirrors the EvmQueryEnvelope enum from the read spec. +type evmQueryType uint8 + +const ( + evmQueryAccountBalance evmQueryType = 0 + evmQueryERC20Balance evmQueryType = 1 + evmQueryContractCall evmQueryType = 2 + evmQueryStorageSlot evmQueryType = 3 +) + +// evmBlockRefType mirrors the EvmBlockRefType enum. Only AT_NUMBER exists in v1. +type evmBlockRefType uint8 + +const evmBlockRefAtNumber evmBlockRefType = 0 + +// evmQueryEnvelope is the decoded abi.encode(EvmQueryEnvelope) query. +type evmQueryEnvelope struct { + QueryType evmQueryType + RefType evmBlockRefType + BlockNumber uint64 + Payload []byte +} + +var ( + evmEnvelopeArgs = mustReadArgs(abi.ArgumentMarshaling{Type: "tuple", Components: []abi.ArgumentMarshaling{ + {Name: "queryType", Type: "uint8"}, + {Name: "blockRef", Type: "tuple", Components: []abi.ArgumentMarshaling{ + {Name: "refType", Type: "uint8"}, + {Name: "blockNumber", Type: "uint64"}, + }}, + {Name: "payload", Type: "bytes"}, + }}) + + addressArgs = mustReadArgs(abi.ArgumentMarshaling{Type: "address"}) + addressPairArgs = mustReadArgs(abi.ArgumentMarshaling{Type: "address"}, abi.ArgumentMarshaling{Type: "address"}) + addressBytesArgs = mustReadArgs(abi.ArgumentMarshaling{Type: "address"}, abi.ArgumentMarshaling{Type: "bytes"}) + addressBytes32Args = mustReadArgs(abi.ArgumentMarshaling{Type: "address"}, abi.ArgumentMarshaling{Type: "bytes32"}) +) + +func mustReadArgs(marshalings ...abi.ArgumentMarshaling) abi.Arguments { + args := make(abi.Arguments, 0, len(marshalings)) + for i, m := range marshalings { + if m.Name == "" { + m.Name = fmt.Sprintf("arg%d", i) + } + typ, err := abi.NewType(m.Type, "", m.Components) + if err != nil { + panic(fmt.Sprintf("evm: invalid abi type %q: %v", m.Type, err)) + } + args = append(args, abi.Argument{Name: m.Name, Type: typ}) + } + return args +} + +type rawEvmEnvelope struct { + QueryType uint8 + BlockRef struct { + RefType uint8 + BlockNumber uint64 + } + Payload []byte +} + +// decodeEvmQueryEnvelope decodes ReadSpec.query for eip155 chains. +func decodeEvmQueryEnvelope(query []byte) (*evmQueryEnvelope, error) { + vals, err := evmEnvelopeArgs.Unpack(query) + if err != nil { + return nil, fmt.Errorf("failed to unpack EvmQueryEnvelope: %w", err) + } + raw := *abi.ConvertType(vals[0], new(rawEvmEnvelope)).(*rawEvmEnvelope) + + env := &evmQueryEnvelope{ + QueryType: evmQueryType(raw.QueryType), + RefType: evmBlockRefType(raw.BlockRef.RefType), + BlockNumber: raw.BlockRef.BlockNumber, + Payload: raw.Payload, + } + if env.QueryType > evmQueryStorageSlot { + return nil, fmt.Errorf("unknown EvmQueryType %d", env.QueryType) + } + if env.RefType != evmBlockRefAtNumber { + return nil, fmt.Errorf("unsupported EvmBlockRefType %d", env.RefType) + } + return env, nil +} + +// decodeAccountBalancePayload decodes abi.encode(address target). +func decodeAccountBalancePayload(payload []byte) (ethcommon.Address, error) { + vals, err := addressArgs.Unpack(payload) + if err != nil { + return ethcommon.Address{}, fmt.Errorf("failed to unpack AccountBalance payload: %w", err) + } + return vals[0].(ethcommon.Address), nil +} + +// decodeERC20BalancePayload decodes abi.encode(address token, address owner). +func decodeERC20BalancePayload(payload []byte) (token, owner ethcommon.Address, err error) { + vals, err := addressPairArgs.Unpack(payload) + if err != nil { + return ethcommon.Address{}, ethcommon.Address{}, fmt.Errorf("failed to unpack ERC20Balance payload: %w", err) + } + return vals[0].(ethcommon.Address), vals[1].(ethcommon.Address), nil +} + +// decodeContractCallPayload decodes abi.encode(address target, bytes callData). +func decodeContractCallPayload(payload []byte) (ethcommon.Address, []byte, error) { + vals, err := addressBytesArgs.Unpack(payload) + if err != nil { + return ethcommon.Address{}, nil, fmt.Errorf("failed to unpack ContractCall payload: %w", err) + } + return vals[0].(ethcommon.Address), vals[1].([]byte), nil +} + +// decodeStorageSlotPayload decodes abi.encode(address contractAddr, bytes32 slot). +func decodeStorageSlotPayload(payload []byte) (ethcommon.Address, ethcommon.Hash, error) { + vals, err := addressBytes32Args.Unpack(payload) + if err != nil { + return ethcommon.Address{}, ethcommon.Hash{}, fmt.Errorf("failed to unpack StorageSlot payload: %w", err) + } + slot := vals[1].([32]byte) + return vals[0].(ethcommon.Address), ethcommon.Hash(slot), nil +} diff --git a/universalClient/externalchains/evm/read_envelope_test.go b/universalClient/externalchains/evm/read_envelope_test.go new file mode 100644 index 00000000..af04832a --- /dev/null +++ b/universalClient/externalchains/evm/read_envelope_test.go @@ -0,0 +1,80 @@ +package evm + +import ( + "testing" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func packEvmEnvelope(t *testing.T, queryType, refType uint8, blockNumber uint64, payload []byte) []byte { + t.Helper() + data, err := evmEnvelopeArgs.Pack(rawEvmEnvelope{ + QueryType: queryType, + BlockRef: struct { + RefType uint8 + BlockNumber uint64 + }{refType, blockNumber}, + Payload: payload, + }) + require.NoError(t, err) + return data +} + +func TestDecodeEvmQueryEnvelope(t *testing.T) { + target := ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") + payload, err := addressArgs.Pack(target) + require.NoError(t, err) + + env, err := decodeEvmQueryEnvelope(packEvmEnvelope(t, uint8(evmQueryAccountBalance), 0, 1234, payload)) + require.NoError(t, err) + assert.Equal(t, evmQueryAccountBalance, env.QueryType) + assert.Equal(t, evmBlockRefAtNumber, env.RefType) + assert.Equal(t, uint64(1234), env.BlockNumber) + + decoded, err := decodeAccountBalancePayload(env.Payload) + require.NoError(t, err) + assert.Equal(t, target, decoded) +} + +func TestDecodeEvmQueryEnvelope_Invalid(t *testing.T) { + _, err := decodeEvmQueryEnvelope([]byte{0x01, 0x02}) + assert.Error(t, err) + + // unknown query type + _, err = decodeEvmQueryEnvelope(packEvmEnvelope(t, 9, 0, 0, nil)) + assert.Error(t, err) + + // unknown block ref type + _, err = decodeEvmQueryEnvelope(packEvmEnvelope(t, 0, 7, 0, nil)) + assert.Error(t, err) +} + +func TestDecodeEvmPayloads(t *testing.T) { + token := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + owner := ethcommon.HexToAddress("0x3333333333333333333333333333333333333333") + + erc20Payload, err := addressPairArgs.Pack(token, owner) + require.NoError(t, err) + gotToken, gotOwner, err := decodeERC20BalancePayload(erc20Payload) + require.NoError(t, err) + assert.Equal(t, token, gotToken) + assert.Equal(t, owner, gotOwner) + + callData := []byte{0xde, 0xad, 0xbe, 0xef} + callPayload, err := addressBytesArgs.Pack(token, callData) + require.NoError(t, err) + gotTarget, gotData, err := decodeContractCallPayload(callPayload) + require.NoError(t, err) + assert.Equal(t, token, gotTarget) + assert.Equal(t, callData, gotData) + + slot := [32]byte{0x0a} + slotPayload, err := addressBytes32Args.Pack(token, slot) + require.NoError(t, err) + gotAddr, gotSlot, err := decodeStorageSlotPayload(slotPayload) + require.NoError(t, err) + assert.Equal(t, token, gotAddr) + assert.Equal(t, ethcommon.Hash(slot), gotSlot) +} diff --git a/universalClient/externalchains/evm/read_executor.go b/universalClient/externalchains/evm/read_executor.go new file mode 100644 index 00000000..3f1a950b --- /dev/null +++ b/universalClient/externalchains/evm/read_executor.go @@ -0,0 +1,125 @@ +package evm + +import ( + "context" + "fmt" + "math/big" + + ethcommon "github.com/ethereum/go-ethereum/common" + + "github.com/pushchain/push-chain-node/universalClient/externalchains/common" + "github.com/pushchain/push-chain-node/universalClient/uread" +) + +// balanceOfSelector is the 4-byte selector for balanceOf(address). +var balanceOfSelector = []byte{0x70, 0xa0, 0x82, 0x31} + +// ExecuteRead implements common.ChainReader for EVM chains. +// All validators must produce byte-identical results, so every query runs at a +// deterministic block height. +func (c *Client) ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) { + env, err := decodeEvmQueryEnvelope(req.Query) + if err != nil { + return uread.NewErrorResult(err), nil + } + + height, err := c.resolveReadHeight(ctx, req, env) + if err != nil { + return nil, err + } + blockNum := new(big.Int).SetUint64(height) + + header, err := c.rpcClient.GetHeaderByNumber(ctx, blockNum) + if err != nil { + return nil, fmt.Errorf("failed to fetch header at %d: %w", height, err) + } + + var resultData []byte + switch env.QueryType { + case evmQueryAccountBalance: + target, decErr := decodeAccountBalancePayload(env.Payload) + if decErr != nil { + return uread.NewErrorResult(decErr), nil + } + balance, rpcErr := c.rpcClient.GetBalanceAt(ctx, target, blockNum) + if rpcErr != nil { + return nil, rpcErr + } + resultData, err = common.EncodeUint256Result(balance) + + case evmQueryERC20Balance: + token, owner, decErr := decodeERC20BalancePayload(env.Payload) + if decErr != nil { + return uread.NewErrorResult(decErr), nil + } + callData := append(append([]byte{}, balanceOfSelector...), ethcommon.LeftPadBytes(owner.Bytes(), 32)...) + ret, rpcErr := c.rpcClient.CallContract(ctx, token, callData, blockNum) + if rpcErr != nil { + return nil, rpcErr + } + if len(ret) < 32 { + return uread.NewErrorResult(fmt.Errorf("balanceOf returned %d bytes", len(ret))), nil + } + resultData, err = common.EncodeUint256Result(new(big.Int).SetBytes(ret[:32])) + + case evmQueryContractCall: + target, callData, decErr := decodeContractCallPayload(env.Payload) + if decErr != nil { + return uread.NewErrorResult(decErr), nil + } + ret, rpcErr := c.rpcClient.CallContract(ctx, target, callData, blockNum) + if rpcErr != nil { + // eth_call reverts are deterministic at a pinned height — observable as ERROR. + return uread.NewErrorResult(rpcErr), nil + } + resultData = ret + + case evmQueryStorageSlot: + target, slot, decErr := decodeStorageSlotPayload(env.Payload) + if decErr != nil { + return uread.NewErrorResult(decErr), nil + } + value, rpcErr := c.rpcClient.GetStorageAt(ctx, target, slot, blockNum) + if rpcErr != nil { + return nil, rpcErr + } + var slotValue [32]byte + copy(slotValue[32-min(len(value), 32):], value) + resultData, err = common.EncodeBytes32Result(slotValue) + + default: + return uread.NewErrorResult(fmt.Errorf("unknown EvmQueryType %d", env.QueryType)), nil + } + if err != nil { + return uread.NewErrorResult(err), nil + } + + return &uread.ReadResult{ + Status: uread.ReadStatusSuccess, + ResultData: resultData, + ObservedBlockHeight: height, + ObservedBlockHash: header.Hash().Bytes(), + }, nil +} + +// resolveReadHeight picks the deterministic block height for a read. +// TODO(core): once x/uexecutor pins the height at request creation, +// PinnedBlockHeight is always set and the fallback below must be removed — +// latest-minConfirmations is NOT identical across validators. +func (c *Client) resolveReadHeight(ctx context.Context, req *uread.ReadRequest, env *evmQueryEnvelope) (uint64, error) { + if req.PinnedBlockHeight > 0 { + return req.PinnedBlockHeight, nil + } + if env.BlockNumber > 0 { + return env.BlockNumber, nil + } + latest, err := c.rpcClient.GetLatestBlock(ctx) + if err != nil { + return 0, fmt.Errorf("failed to get latest block: %w", err) + } + conf := uint64(req.MinConfirmations) + if latest <= conf { + return 0, fmt.Errorf("chain height %d below min confirmations %d", latest, conf) + } + return latest - conf, nil +} diff --git a/universalClient/externalchains/evm/rpc_client.go b/universalClient/externalchains/evm/rpc_client.go index b8c83d04..433a8ef3 100644 --- a/universalClient/externalchains/evm/rpc_client.go +++ b/universalClient/externalchains/evm/rpc_client.go @@ -182,6 +182,45 @@ func (rc *RPCClient) GetBalance(ctx context.Context, address ethcommon.Address) return balance, err } +// GetBalanceAt fetches the native token balance for an address at a specific block. +func (rc *RPCClient) GetBalanceAt(ctx context.Context, address ethcommon.Address, blockNumber *big.Int) (*big.Int, error) { + var balance *big.Int + err := rc.executeWithFailover(ctx, "get_balance_at", func(client *ethclient.Client) error { + callCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + var innerErr error + balance, innerErr = client.BalanceAt(callCtx, address, blockNumber) + return innerErr + }) + return balance, err +} + +// GetStorageAt fetches a storage slot value for a contract at a specific block. +func (rc *RPCClient) GetStorageAt(ctx context.Context, address ethcommon.Address, slot ethcommon.Hash, blockNumber *big.Int) ([]byte, error) { + var value []byte + err := rc.executeWithFailover(ctx, "get_storage_at", func(client *ethclient.Client) error { + callCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + var innerErr error + value, innerErr = client.StorageAt(callCtx, address, slot, blockNumber) + return innerErr + }) + return value, err +} + +// GetHeaderByNumber fetches a block header by number. +func (rc *RPCClient) GetHeaderByNumber(ctx context.Context, blockNumber *big.Int) (*types.Header, error) { + var header *types.Header + err := rc.executeWithFailover(ctx, "get_header_by_number", func(client *ethclient.Client) error { + callCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + var innerErr error + header, innerErr = client.HeaderByNumber(callCtx, blockNumber) + return innerErr + }) + return header, err +} + // FilterLogs fetches logs matching the filter query func (rc *RPCClient) FilterLogs(ctx context.Context, query ethereum.FilterQuery) ([]types.Log, error) { var logs []types.Log diff --git a/universalClient/externalchains/svm/read_envelope.go b/universalClient/externalchains/svm/read_envelope.go new file mode 100644 index 00000000..b32d172b --- /dev/null +++ b/universalClient/externalchains/svm/read_envelope.go @@ -0,0 +1,66 @@ +package svm + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi" +) + +// solanaQueryType mirrors the SolanaQueryEnvelope enum from the read spec. +type solanaQueryType uint8 + +const ( + solanaQueryLamportBalance solanaQueryType = 0 + solanaQuerySPLTokenAccount solanaQueryType = 1 + solanaQueryRawAccountData solanaQueryType = 2 +) + +// solanaQueryEnvelope is the decoded abi.encode(SolanaQueryEnvelope) query — +// ABI-encoded because it is built by UniversalCallback.sol on Push EVM. +// The target account pubkey travels in ReadSpec.account.owner (32 bytes), not here. +type solanaQueryEnvelope struct { + QueryType solanaQueryType + MinSlot uint64 + Payload []byte // empty for all v1 query types +} + +var svmEnvelopeArgs = func() abi.Arguments { + tupleTy, err := abi.NewType("tuple", "", []abi.ArgumentMarshaling{ + {Name: "queryType", Type: "uint8"}, + {Name: "slotRef", Type: "tuple", Components: []abi.ArgumentMarshaling{ + {Name: "minSlot", Type: "uint64"}, + }}, + {Name: "payload", Type: "bytes"}, + }) + if err != nil { + panic(fmt.Sprintf("svm: invalid envelope abi type: %v", err)) + } + return abi.Arguments{{Name: "envelope", Type: tupleTy}} +}() + +type rawSvmEnvelope struct { + QueryType uint8 + SlotRef struct { + MinSlot uint64 + } + Payload []byte +} + +// decodeSolanaQueryEnvelope decodes ReadSpec.query for solana chains. +func decodeSolanaQueryEnvelope(query []byte) (*solanaQueryEnvelope, error) { + vals, err := svmEnvelopeArgs.Unpack(query) + if err != nil { + return nil, fmt.Errorf("failed to unpack SolanaQueryEnvelope: %w", err) + } + raw := *abi.ConvertType(vals[0], new(rawSvmEnvelope)).(*rawSvmEnvelope) + + env := &solanaQueryEnvelope{ + QueryType: solanaQueryType(raw.QueryType), + MinSlot: raw.SlotRef.MinSlot, + Payload: raw.Payload, + } + if env.QueryType > solanaQueryRawAccountData { + return nil, fmt.Errorf("unknown SolanaQueryType %d", env.QueryType) + } + return env, nil +} diff --git a/universalClient/externalchains/svm/read_envelope_test.go b/universalClient/externalchains/svm/read_envelope_test.go new file mode 100644 index 00000000..d5103053 --- /dev/null +++ b/universalClient/externalchains/svm/read_envelope_test.go @@ -0,0 +1,34 @@ +package svm + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeSolanaQueryEnvelope(t *testing.T) { + data, err := svmEnvelopeArgs.Pack(rawSvmEnvelope{ + QueryType: uint8(solanaQuerySPLTokenAccount), + SlotRef: struct { + MinSlot uint64 + }{42}, + Payload: nil, + }) + require.NoError(t, err) + + env, err := decodeSolanaQueryEnvelope(data) + require.NoError(t, err) + assert.Equal(t, solanaQuerySPLTokenAccount, env.QueryType) + assert.Equal(t, uint64(42), env.MinSlot) + assert.Empty(t, env.Payload) + + _, err = decodeSolanaQueryEnvelope([]byte{0x00}) + assert.Error(t, err) + + // unknown query type + bad, err := svmEnvelopeArgs.Pack(rawSvmEnvelope{QueryType: 9}) + require.NoError(t, err) + _, err = decodeSolanaQueryEnvelope(bad) + assert.Error(t, err) +} diff --git a/universalClient/externalchains/svm/read_executor.go b/universalClient/externalchains/svm/read_executor.go new file mode 100644 index 00000000..4b305e29 --- /dev/null +++ b/universalClient/externalchains/svm/read_executor.go @@ -0,0 +1,99 @@ +package svm + +import ( + "context" + "encoding/binary" + "fmt" + "math/big" + + "github.com/gagliardetto/solana-go" + + "github.com/pushchain/push-chain-node/universalClient/externalchains/common" + "github.com/pushchain/push-chain-node/universalClient/uread" +) + +// splTokenAmountOffset is the byte offset of the u64 amount in an SPL token account. +const splTokenAmountOffset = 64 + +// ExecuteRead implements common.ChainReader for Solana chains. +// +// Determinism caveat: Solana RPC cannot query state at an exact past slot, only +// ">= minSlot" via minContextSlot, so ObservedBlockHeight may differ across +// validators. TODO(core): ballot key must cover ResultData only (drop +// slot/hash) for solana, or quorum will never converge — flagged in +// docs/read-from-chains-implementation-plan.md. +func (c *Client) ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) { + env, err := decodeSolanaQueryEnvelope(req.Query) + if err != nil { + return uread.NewErrorResult(err), nil + } + + if len(req.Owner) != solana.PublicKeyLength { + return uread.NewErrorResult(fmt.Errorf("owner must be a 32-byte pubkey, got %d bytes", len(req.Owner))), nil + } + account := solana.PublicKeyFromBytes(req.Owner) + + minSlot := max(env.MinSlot, req.PinnedBlockHeight) + + switch env.QueryType { + case solanaQueryLamportBalance: + balance, slot, rpcErr := c.rpcClient.GetBalanceWithSlot(ctx, account) + if rpcErr != nil { + return nil, rpcErr + } + if slot < minSlot { + return nil, fmt.Errorf("observed slot %d below min slot %d", slot, minSlot) + } + resultData, encErr := common.EncodeUint256Result(new(big.Int).SetUint64(balance)) + if encErr != nil { + return uread.NewErrorResult(encErr), nil + } + return &uread.ReadResult{ + Status: uread.ReadStatusSuccess, + ResultData: resultData, + ObservedBlockHeight: slot, + }, nil + + case solanaQuerySPLTokenAccount: + data, owner, found, slot, rpcErr := c.rpcClient.GetAccountInfoWithSlot(ctx, account, minSlot) + if rpcErr != nil { + return nil, rpcErr + } + if !found { + return uread.NewErrorResult(fmt.Errorf("token account %s not found", account)), nil + } + if !owner.Equals(solana.TokenProgramID) && !owner.Equals(solana.Token2022ProgramID) { + return uread.NewErrorResult(fmt.Errorf("account %s is not owned by a token program", account)), nil + } + if len(data) < splTokenAmountOffset+8 { + return uread.NewErrorResult(fmt.Errorf("token account data too short: %d bytes", len(data))), nil + } + amount := binary.LittleEndian.Uint64(data[splTokenAmountOffset : splTokenAmountOffset+8]) + resultData, encErr := common.EncodeUint256Result(new(big.Int).SetUint64(amount)) + if encErr != nil { + return uread.NewErrorResult(encErr), nil + } + return &uread.ReadResult{ + Status: uread.ReadStatusSuccess, + ResultData: resultData, + ObservedBlockHeight: slot, + }, nil + + case solanaQueryRawAccountData: + data, _, found, slot, rpcErr := c.rpcClient.GetAccountInfoWithSlot(ctx, account, minSlot) + if rpcErr != nil { + return nil, rpcErr + } + if !found { + return uread.NewErrorResult(fmt.Errorf("account %s not found", account)), nil + } + return &uread.ReadResult{ + Status: uread.ReadStatusSuccess, + ResultData: data, + ObservedBlockHeight: slot, + }, nil + + default: + return uread.NewErrorResult(fmt.Errorf("unknown SolanaQueryType %d", env.QueryType)), nil + } +} diff --git a/universalClient/externalchains/svm/rpc_client.go b/universalClient/externalchains/svm/rpc_client.go index fb788b7a..ee1ac9a0 100644 --- a/universalClient/externalchains/svm/rpc_client.go +++ b/universalClient/externalchains/svm/rpc_client.go @@ -386,6 +386,53 @@ func (rc *RPCClient) GetAccountData(ctx context.Context, pubkey solana.PublicKey return accountData, err } +// GetBalanceWithSlot fetches the lamport balance for an account at finalized +// commitment, returning the context slot the value was observed at. +func (rc *RPCClient) GetBalanceWithSlot(ctx context.Context, pubkey solana.PublicKey) (uint64, uint64, error) { + var balance, slot uint64 + err := rc.executeWithFailover(ctx, "get_balance", func(client *rpc.Client) error { + resp, innerErr := client.GetBalance(ctx, pubkey, rpc.CommitmentFinalized) + if innerErr != nil { + return innerErr + } + balance = resp.Value + slot = resp.RPCContext.Context.Slot + return nil + }) + return balance, slot, err +} + +// GetAccountInfoWithSlot fetches account data at finalized commitment with an +// optional minimum context slot, returning the context slot it was observed at. +// found=false means the account does not exist (a valid, votable observation). +func (rc *RPCClient) GetAccountInfoWithSlot(ctx context.Context, pubkey solana.PublicKey, minContextSlot uint64) (data []byte, owner solana.PublicKey, found bool, slot uint64, err error) { + err = rc.executeWithFailover(ctx, "get_account_info", func(client *rpc.Client) error { + opts := &rpc.GetAccountInfoOpts{Commitment: rpc.CommitmentFinalized} + if minContextSlot > 0 { + opts.MinContextSlot = &minContextSlot + } + resp, innerErr := client.GetAccountInfoWithOpts(ctx, pubkey, opts) + if innerErr != nil { + if innerErr == rpc.ErrNotFound { + found = false + return nil + } + return innerErr + } + if resp.Value == nil { + found = false + slot = resp.RPCContext.Context.Slot + return nil + } + found = true + data = resp.Value.Data.GetBinary() + owner = resp.Value.Owner + slot = resp.RPCContext.Context.Slot + return nil + }) + return data, owner, found, slot, err +} + // Close closes all RPC connections func (rc *RPCClient) Close() { rc.mu.Lock() From 7eede40556591575f474da409002d3baaf655e6f Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 28 Jul 2026 16:59:11 +0530 Subject: [PATCH 06/11] feat(uclient): route read requests to target chains and vote results - pushwatcher listener polls pending reads via gRPC and routes each READ_REQUEST event into the target chain's DB (Chains.GetStore) - EventProcessor gains a READ_REQUEST branch: execute on the chain's own reader -> vote -> COMPLETED; corrupt/expired -> REVERTED; transient -> retry - signer narrowed to consumer-side VoteSigner interface; evm/svm clients pass themselves as reader - implementation plan in docs/read-from-chains-implementation-plan.md --- docs/read-from-chains-implementation-plan.md | 164 +++++++++++ universalClient/core/client.go | 2 + universalClient/externalchains/chains.go | 23 ++ .../externalchains/common/event_processor.go | 102 +++++-- .../common/event_processor_test.go | 267 +++++++++++++----- universalClient/externalchains/evm/client.go | 3 + universalClient/externalchains/svm/client.go | 3 + universalClient/pushwatcher/client.go | 5 +- universalClient/pushwatcher/client_test.go | 32 +-- universalClient/pushwatcher/event_listener.go | 90 +++++- .../pushwatcher/event_listener_test.go | 14 +- universalClient/pushwatcher/event_parser.go | 22 ++ .../pushwatcher/event_parser_test.go | 1 + 13 files changed, 597 insertions(+), 131 deletions(-) create mode 100644 docs/read-from-chains-implementation-plan.md diff --git a/docs/read-from-chains-implementation-plan.md b/docs/read-from-chains-implementation-plan.md new file mode 100644 index 00000000..14586282 --- /dev/null +++ b/docs/read-from-chains-implementation-plan.md @@ -0,0 +1,164 @@ +# Read from Chains — Core + universalClient Implementation Plan + +## References +- Spec v1: `read_v1.pdf` (UniversalCallback + MetaCallbackSpec model — superseded) +- Spec v2: `read_v2.pdf` (UniversalReadClient model — **adopted**) +- Contracts: [pushchain/push-chain-core-contracts@51e5aeb](https://github.com/pushchain/push-chain-core-contracts/commit/51e5aeb2dd0cc0ecd23134f3b313a455ca52fdde) (`read-state-v1`) + +## What the contracts already define (fixed surface we integrate against) + +- `UniversalCallback.sol` (singleton, upgradeable): + - `requestExternalReadSelf(ReadSpec spec, bytes4 callbackSelector, uint64 callbackGasLimit) payable → requestId` + - validates: non-empty account/query, `minConfirmations >= 1`, `maxAgeSeconds/maxDelaySeconds != 0`, `supportedDomains[ns][id]`, `callbackGasLimit <= 1_000_000`, `fee <= msg.value <= spec.maxFee` + - `requestId = keccak256(block.chainid, block.number, address(this), keccak256(spec), nonce++)` + - emits **`ReadRequested(uint256 indexed requestId, ReadSpec spec, address indexed callbackTarget, address indexed originalFunder, uint256 feesDeposited)`** + - `fulfillExternalCallback(uint256 requestId, bytes resultData, uint64 observedBlockHeight, bytes32 observedBlockHash)` — **`onlyUEModule`** + - calls `callbackTarget.call{gas}(selector, requestId, resultData)`; handles fee split (protocol fee → VaultPC, refund → funder) internally + - emits `ReadFulfilled` / `CallbackFailed` + - `expireExternalRead(uint256 requestId)` — **`onlyUEModule`**, emits `RequestExpired` + - `UNIVERSAL_EXECUTOR_MODULE = 0x14191Ea54B4c176fCf86f51b0FAc7CB1E71Df7d7` (hardcoded) +- `ReadTypes.sol`: + - `ReadSpec { UniversalAccountId account; bytes query; uint16 minConfirmations; uint64 maxAgeSeconds; uint64 maxDelaySeconds; uint256 maxFee; }` + - `BALLOT_OBSERVATION_TYPE_READ_REQUEST = 0x3dad9a0d…` — contracts expect a matching ballot type in core +- `UniversalReadClient.sol` — app-side base (`_requestRead` / `onUniversalData` / `_onReadResult`, `_localContext` storage); no core/uClient work needed +- `UniversalCore.sol` — new `readBaseFeeByChainNamespace[ns][id]` + `updateReadBaseFeeByChain` (admin) +- Query envelopes (spec v1, still applies): `EvmQueryEnvelope` (AccountBalance / ERC20Balance / ContractCall / StorageSlot + `EvmBlockRef`), `SolanaQueryEnvelope` (LamportBalance / SPLTokenAccount / RawAccountData + `minSlot`), `Web2QueryEnvelope` (GET/POST) + +## End-to-end flow (target) + +1. App inherits `UniversalReadClient`, calls `_requestRead` mid-execution → `ReadRequested` emitted on Push EVM +2. `x/uexecutor` `PostTxProcessing` hook decodes the event in-block → stores `PendingReadRequest` +3. universalClient (each validator) polls pending reads via gRPC → executes the query envelope against the external chain RPC → canonical-encodes result +4. Validator submits `MsgVoteReadResult` → uvalidator ballot; identical `(resultData, height, hash)` → same ballot key → >2/3 quorum +5. On finalization, uexecutor calls `fulfillExternalCallback` on `UniversalCallback` via module EVM call +6. Expiry: request not finalized within `maxDelaySeconds` → EndBlocker calls `expireExternalRead` + +--- + +## Core (`push-chain-node`) changes + +### 1. Proto (`proto/…` + buf regen) + +- `proto/uvalidator/v1/ballot.proto` + - add `BALLOT_OBSERVATION_TYPE_READ_REQUEST` to `BallotObservationType` +- `proto/uexecutor/v1/` (new `read_request.proto` + `tx.proto` + `query.proto`) + - `ReadRequest` type: `request_id (bytes/hex)`, decoded `ReadSpec` fields (`chain_namespace`, `chain_id`, `owner`, `query`, `min_confirmations`, `max_age_seconds`, `max_delay_seconds`), `callback_target`, `pinned_block_height`, `created_at_height`, `expiry_timestamp`, `status (PENDING | FULFILLED | EXPIRED)` + - `MsgVoteReadResult { signer, request_id, result_data, observed_block_height, observed_block_hash, status (SUCCESS | ERROR) }` + - `Query/PendingReadRequests` (paginated) — mirror `GetAllPendingOutbounds` + +### 2. Event detection (`x/uexecutor`) + +- `types/events.go`: add `ReadRequestedEventSig` (topic0 of the event above) +- `types/gateway_pc_event_decode.go`: add `DecodeReadRequestedFromLog` (ABI-decode `ReadSpec` tuple from log data) +- `keeper/evm_hooks.go` `PostTxProcessing`: match logs from the `UniversalCallback` address → decode → `CreatePendingReadRequest` + - follows the existing outbound-detection precedent (`BuildOutboundsFromReceipt`); no Push-EVM log polling needed in uClient +- `x/uregistry`: register `UNIVERSAL_CALLBACK` in `SYSTEM_CONTRACTS` (address source for hook filtering + callback calls) + +### 3. Pending-read storage (`x/uexecutor/keeper`) + +- new `Keeper.PendingReadRequests` collection + `keeper/pending_read_request.go` (CRUD, mirror `pending_outbound.go`) +- **Pin the query height at creation**: `pinned_block_height = ChainMeta[chain].LastAppliedChainHeight − spec.minConfirmations` (clamped) + - all validators query the same height → identical bytes; satisfies the spec rule "block taken must be below gas-oracle minimum" + - reject/park request if no ChainMeta exists for the chain +- set `expiry_timestamp = block.time + maxDelaySeconds` + +### 4. Voting (`x/uexecutor`) + +- `types/msg_vote_read_result.go` — ValidateBasic (mirror `msg_vote_inbound.go`) +- ballot key: `GetReadBallotKey = hash(request_id ‖ status ‖ result_data ‖ observed_block_height ‖ observed_block_hash)` — identical-bytes quorum +- `keeper/voting.go`: `VoteOnReadBallot` → `uvalidatorKeeper.VoteOnBallot` with the new ballot type, threshold `(2*validators)/3 + 1` (same as inbound) +- `keeper/msg_vote_read_result.go`: + - reject if request unknown / not PENDING / past expiry + - on finalizing vote (SUCCESS): `CallFulfillExternalCallback`, mark FULFILLED + - on finalizing vote (ERROR quorum — e.g. query invalid, target chain reorged): mark EXPIRED + `CallExpireExternalRead` (refund path) +- `keeper/ballot_hooks.go`: handle terminal FAILED/EXPIRED ballots for the new type (cleanup) + +### 5. EVM callback (`x/uexecutor`) + +- `types/abi.go`: add `UniversalCallbackABI` const + `ParseUniversalCallbackABI` (only `fulfillExternalCallback`, `expireExternalRead`) +- `keeper/evm.go`: `CallFulfillExternalCallback(...)` + `CallExpireExternalRead(...)` via `DerivedEVMCall` (template: `CallExecuteUniversalTx` / `CallUniversalCoreSetChainMeta`; uses `ModuleAccountNonce`) +- gas: callback gas is bounded on the contract side (`callbackGasLimit ≤ 1M`); give the module call a fixed generous limit +- **verify** module account EVM address == `0x14191Ea54B4c176fCf86f51b0FAc7CB1E71Df7d7` (contract hardcodes it); mismatch = every fulfill reverts + +### 6. Expiry sweep + +- `x/uexecutor` EndBlocker (`abci.go`): iterate PENDING reads with `expiry_timestamp < block.time` → `CallExpireExternalRead` → mark EXPIRED + - deterministic on-chain, no vote needed + - bound per-block work (process N per block) to avoid unbounded EndBlocker gas + +### 7. Queries / CLI + +- `keeper/grpc_query.go` (or new file): `PendingReadRequests`, `ReadRequest(id)` +- autocli entries for inspection + +--- + +## universalClient changes + +> **Status: implemented** (UV side done; items marked `TODO(core)` are stubbed and unblock mechanically once core lands — grep `TODO(core)` in `universalClient/`). +> +> - `universalClient/uread/` — **temporary package**: only proto-mirror types (`ReadRequest`/`ReadStatus`/`ReadResult`); delete it once core proto lands by swapping every `uread.*` reference to `uexecutortypes.*` +> - `externalchains/common/read.go` — shared permanent bits: `ChainReader`/`ChainResolver` interfaces, `CAIP2`, canonical result encoders (`EncodeUint256Result`/`EncodeBytes32Result`) +> - `externalchains/evm/read_envelope.go` + `read_executor.go` + RPC additions (`GetBalanceAt`, `GetStorageAt`, `GetHeaderByNumber`) — all 4 query types at pinned height (**TODO(core): remove latest−minConfirmations fallback once core pins height**) +> - `externalchains/svm/read_envelope.go` + `read_executor.go` + RPC additions (`GetBalanceWithSlot`, `GetAccountInfoWithSlot`) — all 3 query types, minContextSlot semantics +> - `universalClient/pushwatcher/` (moved out of the chains manager — push is core-managed, not a registry chain) — `event_listener.go` + `event_parser.go` fetch pending reads via gRPC and **route each `READ_REQUEST` event into the target chain's DB** (`common.ReadStoreResolver`, implemented by `chains.Chains.GetStore`); requests for unserved chains are skipped and retried next poll (core re-serves pending requests; expiry is the backstop) +> - `externalchains/common/event_processor.go` — original type-switch shape kept; gained a `READ_REQUEST` branch (`processReadRequestEvent`: execute on the chain's own `ChainReader` → vote → COMPLETED; corrupt/expired → REVERTED; transient → retry), a `reader ChainReader` constructor param (evm/svm pass the client itself), and the consumer-side `VoteSigner` interface; push client has no processor at all +> - `pushcore/pushCore.go` `GetAllPendingReadRequests` (**TODO(core): wire to Query/PendingReadRequests**; returns sentinel until then, processor idles silently) +> - `pushsigner/pushsigner.go` `VoteReadResult` (**TODO(core): build MsgVoteReadResult in vote.go + add to AuthZ grant set**) +> - wiring: push client is owned by `core/client.go` (not the chains manager) — core opens the push DB once (shared with TSS), creates `push.NewClient(..., chainsManager)` and manages its lifecycle; `externalchains.Chains` only manages registry-driven external chains and implements `GetStore` for read routing + +### 1. pushcore (`universalClient/pushcore/pushCore.go`) + +- `GetAllPendingReadRequests()` — new gRPC query wrapper (mirror `GetAllPendingOutbounds`) + +### 2. pushsigner (`universalClient/pushsigner/`) + +- `VoteReadResult(ctx, msg)` — build `MsgVoteReadResult`, AuthZ-wrap, sign, broadcast (mirror `VoteInbound`) +- add msg type to AuthZ grant set (hot-key authorization for the new msg URL) + +### 3. Read worker (`universalClient/chains/push/` — new component) + +- new `read_request_processor.go` on the Push client (alongside `event_listener.go`): + - poll `GetAllPendingReadRequests` on the existing polling interval + - local store dedup (per-chain SQLite, reuse `common.ChainStore`) so a request is executed/voted once; retry until vote tx confirmed + - skip requests already past `expiry_timestamp` +- **query executor** (new `universalClient/readexecutor/` or under `chains/common/`): + - resolve target chain client: `chainNamespace + ":" + chainId` → CAIP-2 → existing `Chains` registry RPC client + - decode envelope by namespace: + - `eip155` → `EvmQueryEnvelope`: AccountBalance → `eth_getBalance`, ERC20Balance → `balanceOf` via `eth_call`, ContractCall → `eth_call`, StorageSlot → `eth_getStorageAt` — all at `pinned_block_height`; fetch `observedBlockHash` for that height + - `solana` → `SolanaQueryEnvelope`: LamportBalance / SPLTokenAccount / RawAccountData via `getAccountInfo`/`getBalance` with `minContextSlot = pinned height`; observed slot + blockhash from response context + - `web2` → **out of scope for v1** (non-deterministic responses; needs canonicalization design) — vote ERROR if received + - canonical result encoding (must be byte-identical across validators): + - AccountBalance/LamportBalance → `abi.encode(uint256)` + - ERC20Balance/SPLTokenAccount → `abi.encode(uint256)` + - ContractCall → raw returndata + - StorageSlot → `abi.encode(bytes32)`; RawAccountData → raw account bytes + - on RPC/decode failure after retries → `VoteReadResult(status = ERROR)` +- interaction with per-chain clients: reads target chains uClient already watches (registry-driven); if a supported domain has no chain client, vote ERROR + +### 4. Config (`universalClient/config/`) + +- optional: `read_polling_interval_seconds` per Push chain entry (else reuse `event_polling_interval_seconds`) +- no new per-external-chain config — reuse existing `rpc_urls` + +--- + +## Cross-cutting decisions / open questions + +- **Height pinning source**: plan uses ChainMeta (gas oracle) height at request time; confirm ChainMeta exists for all chains that will be `supportedDomains` on the contract (contract-side whitelist and core-side ChainMeta must stay in sync — no core check enforces this) +- **`maxAgeSeconds`**: with pinned-height reads, "freshness" = pinned height recency; ChainMeta staleness already bounds this — decide whether core must additionally reject requests when ChainMeta is older than `maxAgeSeconds` +- **Solana determinism**: account data can change between slots and `getAccountInfo` can't query an exact past slot; `minContextSlot` gives ≥ semantics, so identical-bytes quorum may need slot-tolerant ballot design (e.g. vote on value only, drop observed slot from ballot key) — flag for design review +- **`expireExternalRead` vs ERROR quorum**: both route to expiry on the contract; keep both (EndBlocker for timeout, ERROR ballot for definitively-failing queries) or simplify to timeout-only +- **Module address**: `UNIVERSAL_EXECUTOR_MODULE` is hardcoded in the contract — verify against `authtypes.NewModuleAddress(uexecutortypes.ModuleName)` EVM mapping before deploy +- **Fee flow**: fully contract-side (protocol fee → VaultPC, refunds); core only triggers callbacks — no bank/fee logic needed in module +- **Nomenclature**: PDFs say `x/UCallback` as a separate module; plan puts everything in `x/uexecutor` (reuses EVM hooks, module nonce, ballot plumbing, existing AuthZ grants) — confirm + +## Suggested implementation order + +1. Proto + ballot type + codegen +2. Event decode + PendingReadRequest storage + evm_hooks detection +3. ABI + `CallFulfillExternalCallback` / `CallExpireExternalRead` +4. `MsgVoteReadResult` handler + ballot wiring + EndBlocker expiry +5. Queries (gRPC) + autocli +6. uClient: pushcore query + pushsigner vote + read processor + query executor (EVM first, then SVM) +7. E2E test: local chain + mock external RPC (extend `scripts/test_universal.sh`) diff --git a/universalClient/core/client.go b/universalClient/core/client.go index 965e7c20..25fb17c6 100644 --- a/universalClient/core/client.go +++ b/universalClient/core/client.go @@ -76,12 +76,14 @@ func NewUniversalClient(ctx context.Context, cfg *config.Config) (*UniversalClie return nil, err } + // chainsManager routes read request events into target chain DBs. pushChain, err := pushwatcher.NewClient( pushDB, cfg.GetChainConfig(cfg.PushChainID), pushCore, cfg.PushChainID, log, + chainsManager, ) if err != nil { return nil, fmt.Errorf("failed to create push chain client: %w", err) diff --git a/universalClient/externalchains/chains.go b/universalClient/externalchains/chains.go index df25f2b8..7b4f4095 100644 --- a/universalClient/externalchains/chains.go +++ b/universalClient/externalchains/chains.go @@ -28,6 +28,7 @@ type Chains struct { // Chain client management chains map[string]common.ChainClient // key: CAIP-2 chain ID chainConfigs map[string]*uregistrytypes.ChainConfig // key: CAIP-2 chain ID + chainDBs map[string]*db.DB // key: CAIP-2 chain ID chainsMu sync.RWMutex pushChainID string // Push chain ID (always present) @@ -57,6 +58,7 @@ func NewChains( logger: logger.With().Str("component", "chains").Logger(), chains: make(map[string]common.ChainClient), chainConfigs: make(map[string]*uregistrytypes.ChainConfig), + chainDBs: make(map[string]*db.DB), pushChainID: cfg.PushChainID, } } @@ -286,6 +288,7 @@ func (c *Chains) addChain(ctx context.Context, cfg *uregistrytypes.ChainConfig) c.chainsMu.Lock() c.chains[cfg.Chain] = client c.chainConfigs[cfg.Chain] = cfg + c.chainDBs[cfg.Chain] = chainDB c.chainsMu.Unlock() c.logger.Info(). @@ -314,6 +317,7 @@ func (c *Chains) removeChain(chainID string) error { delete(c.chains, chainID) delete(c.chainConfigs, chainID) + delete(c.chainDBs, chainID) c.logger.Info(). Str("chain", chainID). @@ -341,6 +345,7 @@ func (c *Chains) StopAll() { // Clear the registry c.chains = make(map[string]common.ChainClient) c.chainConfigs = make(map[string]*uregistrytypes.ChainConfig) + c.chainDBs = make(map[string]*db.DB) } // GetClient returns the chain client for the specified chain ID @@ -356,6 +361,24 @@ func (c *Chains) GetClient(chainID string) (common.ChainClient, error) { return client, nil } +// GetStore implements common.ReadStoreResolver: resolves a CAIP-2 chain ID to +// that chain's event store, so read requests can be routed into the target +// chain's database. +func (c *Chains) GetStore(chainID string) (*common.ChainStore, error) { + if chainID == c.pushChainID { + return nil, fmt.Errorf("read requests cannot target push chain itself") + } + + c.chainsMu.RLock() + defer c.chainsMu.RUnlock() + + chainDB, exists := c.chainDBs[chainID] + if !exists { + return nil, fmt.Errorf("no database for chain %s", chainID) + } + return common.NewChainStore(chainDB), nil +} + // IsEVMChain returns true if the chain uses EVM (e.g. Ethereum, BSC). Used by coordinator for nonce behaviour. func (c *Chains) IsEVMChain(chainID string) bool { c.chainsMu.RLock() diff --git a/universalClient/externalchains/common/event_processor.go b/universalClient/externalchains/common/event_processor.go index 3625d306..bd059727 100644 --- a/universalClient/externalchains/common/event_processor.go +++ b/universalClient/externalchains/common/event_processor.go @@ -12,32 +12,44 @@ import ( "github.com/mr-tron/base58" "github.com/pushchain/push-chain-node/universalClient/db" - "github.com/pushchain/push-chain-node/universalClient/pushsigner" "github.com/pushchain/push-chain-node/universalClient/store" + "github.com/pushchain/push-chain-node/universalClient/uread" uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" "github.com/rs/zerolog" ) +// VoteSigner is the subset of pushsigner.Signer used by EventProcessor. +// Defined here (consumer-side) so tests can provide mock implementations. +type VoteSigner interface { + VoteInbound(ctx context.Context, inbound *uexecutortypes.Inbound) (string, error) + VoteOutbound(ctx context.Context, txID string, utxID string, observation *uexecutortypes.OutboundObservation) (string, error) + VoteReadResult(ctx context.Context, requestID string, result *uread.ReadResult) (string, error) +} + // EventProcessor processes events from the chain's database and votes on them type EventProcessor struct { - signer *pushsigner.Signer + signer VoteSigner chainStore *ChainStore logger zerolog.Logger chainID string inboundEnabled bool outboundEnabled bool - running bool - stopCh chan struct{} - wg sync.WaitGroup + // reader executes READ_REQUEST events against this chain (the push event + // listener routes them into this chain's DB). Nil disables read processing. + reader ChainReader + running bool + stopCh chan struct{} + wg sync.WaitGroup } // NewEventProcessor creates a new event processor func NewEventProcessor( - signer *pushsigner.Signer, + signer VoteSigner, database *db.DB, chainID string, inboundEnabled bool, outboundEnabled bool, + reader ChainReader, logger zerolog.Logger, ) *EventProcessor { return &EventProcessor{ @@ -46,6 +58,7 @@ func NewEventProcessor( chainID: chainID, inboundEnabled: inboundEnabled, outboundEnabled: outboundEnabled, + reader: reader, logger: logger.With().Str("component", "event_processor").Str("chain", chainID).Logger(), stopCh: make(chan struct{}), } @@ -111,7 +124,7 @@ func (ep *EventProcessor) processLoop(ctx context.Context) { } } -// processConfirmedEvents processes confirmed events (both inbound and outbound) +// processConfirmedEvents processes confirmed events (inbound, outbound and read requests) func (ep *EventProcessor) processConfirmedEvents(ctx context.Context) error { events, err := ep.chainStore.GetConfirmedEvents(1000) if err != nil { @@ -143,6 +156,18 @@ func (ep *EventProcessor) processConfirmedEvents(ctx context.Context) error { Msg("failed to vote on outbound event") continue } + } else if event.Type == store.EventTypeReadRequest { + if ep.reader == nil { + ep.logger.Warn().Str("event_id", event.EventID).Msg("no reader configured, skipping read request event processing") + continue + } + if err := ep.processReadRequestEvent(ctx, &event); err != nil { + ep.logger.Error(). + Err(err). + Str("event_id", event.EventID). + Msg("failed to vote on read request event") + continue + } } } @@ -176,23 +201,7 @@ func (ep *EventProcessor) processOutboundEvent(ctx context.Context, event *store return fmt.Errorf("failed to vote on outbound: %w", err) } - // Atomically record vote hash and flip status in one DB write - rowsAffected, err := ep.chainStore.UpdateStatusAndVoteTxHash(event.EventID, store.StatusConfirmed, store.StatusCompleted, voteTxHash) - if err != nil { - return fmt.Errorf("failed to update event status and vote_tx_hash: %w", err) - } - - if rowsAffected == 0 { - return nil // already completed by another validator - } - - ep.logger.Info(). - Str("event_id", event.EventID). - Str("type", event.Type). - Str("vote_tx_hash", voteTxHash). - Msg("event marked as COMPLETED") - - return nil + return ep.markCompleted(event, voteTxHash) } // processInboundEvent processes an inbound event by voting on it and confirming it @@ -217,14 +226,49 @@ func (ep *EventProcessor) processInboundEvent(ctx context.Context, event *store. return err } - // Atomically record vote hash and flip status in one DB write + return ep.markCompleted(event, voteTxHash) +} + +// processReadRequestEvent executes an external read request against this chain +// and votes the observation. Transient failures (RPC errors, vote failure) +// keep the event CONFIRMED for retry; corrupt or expired requests flip to +// REVERTED without voting (core's EndBlocker expires them on-chain). +func (ep *EventProcessor) processReadRequestEvent(ctx context.Context, event *store.Event) error { + var req uread.ReadRequest + if err := json.Unmarshal(event.EventData, &req); err != nil { + ep.markReadReverted(event.EventID) + return fmt.Errorf("corrupt read request event data: %w", err) + } + + if req.ExpiryTimestamp > 0 && time.Now().Unix() >= req.ExpiryTimestamp { + ep.logger.Info().Str("request_id", req.RequestID).Msg("read request expired; skipping (core EndBlocker expires it on-chain)") + ep.markReadReverted(event.EventID) + return nil + } + + result, err := ep.reader.ExecuteRead(ctx, &req) + if err != nil { + return fmt.Errorf("read execution failed: %w", err) + } + + voteTxHash, err := ep.signer.VoteReadResult(ctx, req.RequestID, result) + if err != nil { + // TODO(core): ErrVoteReadNotAvailable falls through here until MsgVoteReadResult lands. + return fmt.Errorf("failed to vote read result: %w", err) + } + + return ep.markCompleted(event, voteTxHash) +} + +// markCompleted atomically records the vote hash and flips CONFIRMED -> COMPLETED. +func (ep *EventProcessor) markCompleted(event *store.Event, voteTxHash string) error { rowsAffected, err := ep.chainStore.UpdateStatusAndVoteTxHash(event.EventID, store.StatusConfirmed, store.StatusCompleted, voteTxHash) if err != nil { return fmt.Errorf("failed to update event status after successful vote: %w", err) } if rowsAffected == 0 { - return nil // already completed by another validator + return nil // already completed } ep.logger.Info(). @@ -236,6 +280,12 @@ func (ep *EventProcessor) processInboundEvent(ctx context.Context, event *store. return nil } +func (ep *EventProcessor) markReadReverted(eventID string) { + if _, err := ep.chainStore.UpdateEventStatus(eventID, store.StatusConfirmed, store.StatusReverted); err != nil { + ep.logger.Error().Err(err).Str("event_id", eventID).Msg("failed to mark read request reverted") + } +} + // constructInbound creates an Inbound message from event data func (ep *EventProcessor) constructInbound(event *store.Event) (*uexecutortypes.Inbound, error) { var eventData UniversalTx diff --git a/universalClient/externalchains/common/event_processor_test.go b/universalClient/externalchains/common/event_processor_test.go index 4a08c305..24fd52d8 100644 --- a/universalClient/externalchains/common/event_processor_test.go +++ b/universalClient/externalchains/common/event_processor_test.go @@ -3,6 +3,7 @@ package common import ( "context" "encoding/json" + "fmt" "testing" "time" @@ -12,15 +13,174 @@ import ( ucdb "github.com/pushchain/push-chain-node/universalClient/db" "github.com/pushchain/push-chain-node/universalClient/store" + "github.com/pushchain/push-chain-node/universalClient/uread" uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" ) +type fakeVoteSigner struct { + readVotes map[string]*uread.ReadResult + txHash string + err error +} + +func (f *fakeVoteSigner) VoteInbound(ctx context.Context, inbound *uexecutortypes.Inbound) (string, error) { + if f.err != nil { + return "", f.err + } + return "", fmt.Errorf("inbound vote not supported by fake") +} + +func (f *fakeVoteSigner) VoteOutbound(ctx context.Context, txID string, utxID string, observation *uexecutortypes.OutboundObservation) (string, error) { + if f.err != nil { + return "", f.err + } + return "", fmt.Errorf("outbound vote not supported by fake") +} + +func (f *fakeVoteSigner) VoteReadResult(ctx context.Context, requestID string, result *uread.ReadResult) (string, error) { + if f.err != nil { + return "", f.err + } + if f.readVotes == nil { + f.readVotes = make(map[string]*uread.ReadResult) + } + f.readVotes[requestID] = result + return f.txHash, nil +} + +type fakeChainReader struct { + result *uread.ReadResult + err error +} + +func (f *fakeChainReader) ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) { + return f.result, f.err +} + +func testReadRequest() *uread.ReadRequest { + return &uread.ReadRequest{ + RequestID: "0xabc123", + ChainNamespace: "eip155", + ChainID: "11155111", + Query: []byte{0x01}, + MinConfirmations: 1, + PinnedBlockHeight: 100, + CreatedAtHeight: 7, + } +} + +func newReadTestProcessor(t *testing.T, signer VoteSigner, reader ChainReader) (*EventProcessor, *ChainStore) { + t.Helper() + database, err := ucdb.OpenInMemoryDB(true) + require.NoError(t, err) + ep := NewEventProcessor(signer, database, "eip155:11155111", false, false, reader, zerolog.Nop()) + return ep, NewChainStore(database) +} + +func seedReadRequest(t *testing.T, cs *ChainStore, req *uread.ReadRequest) string { + t.Helper() + eventData, err := json.Marshal(req) + require.NoError(t, err) + eventID := "read:" + req.RequestID + stored, err := cs.InsertEventIfNotExists(&store.Event{ + EventID: eventID, + BlockHeight: req.CreatedAtHeight, + Type: store.EventTypeReadRequest, + ConfirmationType: store.ConfirmationInstant, + Status: store.StatusConfirmed, + EventData: eventData, + }) + require.NoError(t, err) + require.True(t, stored) + return eventID +} + +func eventStatus(t *testing.T, cs *ChainStore, eventID string) string { + t.Helper() + var event store.Event + require.NoError(t, cs.database.Client().Where("event_id = ?", eventID).First(&event).Error) + return event.Status +} + +func TestProcessReadRequest_SuccessFlow(t *testing.T) { + req := testReadRequest() + result := &uread.ReadResult{ + Status: uread.ReadStatusSuccess, + ResultData: []byte{0xaa}, + ObservedBlockHeight: 100, + } + signer := &fakeVoteSigner{txHash: "VOTE_TX"} + ep, cs := newReadTestProcessor(t, signer, &fakeChainReader{result: result}) + eventID := seedReadRequest(t, cs, req) + + require.NoError(t, ep.processConfirmedEvents(context.Background())) + + require.Contains(t, signer.readVotes, req.RequestID) + assert.Equal(t, result, signer.readVotes[req.RequestID]) + assert.Equal(t, store.StatusCompleted, eventStatus(t, cs, eventID)) + + // second tick must not re-vote + signer.readVotes = nil + require.NoError(t, ep.processConfirmedEvents(context.Background())) + assert.Empty(t, signer.readVotes) +} + +func TestProcessReadRequest_VoteFailureKeepsConfirmed(t *testing.T) { + req := testReadRequest() + signer := &fakeVoteSigner{err: fmt.Errorf("MsgVoteReadResult not available")} + ep, cs := newReadTestProcessor(t, signer, &fakeChainReader{result: &uread.ReadResult{Status: uread.ReadStatusSuccess}}) + eventID := seedReadRequest(t, cs, req) + + require.NoError(t, ep.processConfirmedEvents(context.Background())) + + assert.Equal(t, store.StatusConfirmed, eventStatus(t, cs, eventID)) +} + +func TestProcessReadRequest_ExpiredMarkedReverted(t *testing.T) { + req := testReadRequest() + req.ExpiryTimestamp = time.Now().Add(-time.Minute).Unix() + signer := &fakeVoteSigner{txHash: "VOTE_TX"} + ep, cs := newReadTestProcessor(t, signer, &fakeChainReader{result: &uread.ReadResult{Status: uread.ReadStatusSuccess}}) + eventID := seedReadRequest(t, cs, req) + + require.NoError(t, ep.processConfirmedEvents(context.Background())) + + assert.Empty(t, signer.readVotes) + assert.Equal(t, store.StatusReverted, eventStatus(t, cs, eventID)) +} + +func TestProcessReadRequest_ExecutionFailureRetries(t *testing.T) { + req := testReadRequest() + signer := &fakeVoteSigner{txHash: "VOTE_TX"} + ep, cs := newReadTestProcessor(t, signer, &fakeChainReader{err: fmt.Errorf("rpc down")}) + eventID := seedReadRequest(t, cs, req) + + require.NoError(t, ep.processConfirmedEvents(context.Background())) + + // no vote, still CONFIRMED (transient RPC failure) + assert.Empty(t, signer.readVotes) + assert.Equal(t, store.StatusConfirmed, eventStatus(t, cs, eventID)) +} + +func TestProcessReadRequest_NoReaderSkips(t *testing.T) { + req := testReadRequest() + signer := &fakeVoteSigner{txHash: "VOTE_TX"} + // nil reader -> read events are skipped, left CONFIRMED + ep, cs := newReadTestProcessor(t, signer, nil) + eventID := seedReadRequest(t, cs, req) + + require.NoError(t, ep.processConfirmedEvents(context.Background())) + + assert.Empty(t, signer.readVotes) + assert.Equal(t, store.StatusConfirmed, eventStatus(t, cs, eventID)) +} + func TestNewEventProcessor(t *testing.T) { t.Run("creates event processor with valid params", func(t *testing.T) { logger := zerolog.Nop() chainID := "eip155:1" - processor := NewEventProcessor(nil, nil, chainID, true, true, logger) + processor := NewEventProcessor(nil, nil, chainID, true, true, nil, logger) require.NotNil(t, processor) assert.Equal(t, chainID, processor.chainID) @@ -52,7 +212,7 @@ func TestEventProcessorStop(t *testing.T) { func TestEventProcessorBase58ToHex(t *testing.T) { logger := zerolog.Nop() - processor := NewEventProcessor(nil, nil, "test-chain", true, true, logger) + processor := NewEventProcessor(nil, nil, "test-chain", true, true, nil, logger) t.Run("empty string returns 0x", func(t *testing.T) { result, err := processor.base58ToHex("") @@ -86,7 +246,7 @@ func TestEventProcessorBase58ToHex(t *testing.T) { func TestEventProcessorConstructInbound(t *testing.T) { logger := zerolog.Nop() - processor := NewEventProcessor(nil, nil, "eip155:1", true, true, logger) + processor := NewEventProcessor(nil, nil, "eip155:1", true, true, nil, logger) t.Run("nil event returns error", func(t *testing.T) { inbound, err := processor.constructInbound(nil) @@ -252,7 +412,7 @@ func TestEventProcessorConstructInbound(t *testing.T) { func TestEventProcessorParseOutboundEventData(t *testing.T) { logger := zerolog.Nop() - processor := NewEventProcessor(nil, nil, "eip155:1", true, true, logger) + processor := NewEventProcessor(nil, nil, "eip155:1", true, true, nil, logger) t.Run("nil event returns error", func(t *testing.T) { data, err := processor.parseOutboundEventData(nil) @@ -331,7 +491,7 @@ func TestEventProcessorParseOutboundEventData(t *testing.T) { func TestEventProcessorBuildOutboundObservation(t *testing.T) { logger := zerolog.Nop() - processor := NewEventProcessor(nil, nil, "eip155:1", true, true, logger) + processor := NewEventProcessor(nil, nil, "eip155:1", true, true, nil, logger) t.Run("builds observation with gas fee from parsed data", func(t *testing.T) { outboundData := &OutboundEvent{ @@ -403,7 +563,7 @@ func TestProcessOutboundEvent(t *testing.T) { t.Run("nil event data returns parse error", func(t *testing.T) { database := setupDB(t) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) event := &store.Event{ EventID: "0xabc:0", @@ -417,7 +577,7 @@ func TestProcessOutboundEvent(t *testing.T) { t.Run("empty event data returns parse error", func(t *testing.T) { database := setupDB(t) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) event := &store.Event{ EventID: "0xabc:0", @@ -431,7 +591,7 @@ func TestProcessOutboundEvent(t *testing.T) { t.Run("invalid JSON event data returns parse error", func(t *testing.T) { database := setupDB(t) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) event := &store.Event{ EventID: "0xabc:0", @@ -445,7 +605,7 @@ func TestProcessOutboundEvent(t *testing.T) { t.Run("missing tx_id returns parse error", func(t *testing.T) { database := setupDB(t) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) eventData, _ := json.Marshal(OutboundEvent{ TxID: "", @@ -463,7 +623,7 @@ func TestProcessOutboundEvent(t *testing.T) { t.Run("missing universal_tx_id returns parse error", func(t *testing.T) { database := setupDB(t) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) eventData, _ := json.Marshal(OutboundEvent{ TxID: "0xtxid", @@ -493,7 +653,7 @@ func TestProcessInboundEvent(t *testing.T) { t.Run("nil event data returns construct error", func(t *testing.T) { database := setupDB(t) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) event := &store.Event{ EventID: "0xabc:0", @@ -507,7 +667,7 @@ func TestProcessInboundEvent(t *testing.T) { t.Run("invalid JSON event data returns construct error", func(t *testing.T) { database := setupDB(t) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) event := &store.Event{ EventID: "0xabc:0", @@ -537,7 +697,7 @@ func TestProcessConfirmedEventsRouting(t *testing.T) { t.Run("no confirmed events returns nil", func(t *testing.T) { database := setupDB(t, nil) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) @@ -553,7 +713,7 @@ func TestProcessConfirmedEventsRouting(t *testing.T) { }, }) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) @@ -580,7 +740,7 @@ func TestProcessConfirmedEventsRouting(t *testing.T) { }, }) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) // Should not return error - errors on individual events are logged and skipped err := ep.processConfirmedEvents(ctx) @@ -610,7 +770,7 @@ func TestProcessConfirmedEventsRouting(t *testing.T) { }, }) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) @@ -623,55 +783,23 @@ func TestProcessConfirmedEventsRouting(t *testing.T) { assert.Equal(t, store.StatusConfirmed, evt2.Status) }) - t.Run("mixed inbound and outbound with bad data both fail gracefully", func(t *testing.T) { + t.Run("read request without reader is skipped", func(t *testing.T) { database := setupDB(t, []store.Event{ { - EventID: "0xin:0", + EventID: "0xread:0", Status: store.StatusConfirmed, - Type: store.EventTypeInbound, - EventData: []byte("bad"), - }, - { - EventID: "0xout:0", - Status: store.StatusConfirmed, - Type: store.EventTypeOutbound, - EventData: []byte("bad"), - }, - }) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) - - var inEvt, outEvt store.Event - database.Client().Where("event_id = ?", "0xin:0").First(&inEvt) - assert.Equal(t, store.StatusConfirmed, inEvt.Status) - database.Client().Where("event_id = ?", "0xout:0").First(&outEvt) - assert.Equal(t, store.StatusConfirmed, outEvt.Status) - }) - - t.Run("outbound missing tx_id in valid JSON stays CONFIRMED", func(t *testing.T) { - eventData, _ := json.Marshal(OutboundEvent{ - TxID: "", - UniversalTxID: "0xutxid", - }) - database := setupDB(t, []store.Event{ - { - EventID: "0xno_txid:0", - Status: store.StatusConfirmed, - Type: store.EventTypeOutbound, - EventData: eventData, + Type: store.EventTypeReadRequest, + EventData: []byte("{}"), }, }) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) var evt store.Event - database.Client().Where("event_id = ?", "0xno_txid:0").First(&evt) + database.Client().Where("event_id = ?", "0xread:0").First(&evt) assert.Equal(t, store.StatusConfirmed, evt.Status) }) @@ -685,7 +813,7 @@ func TestProcessConfirmedEventsRouting(t *testing.T) { }, }) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) @@ -703,7 +831,7 @@ func TestProcessLoopContextCancellation(t *testing.T) { require.NoError(t, err) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) t.Run("processLoop exits promptly on context cancel", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) @@ -739,7 +867,7 @@ func TestProcessLoopStopChannel(t *testing.T) { require.NoError(t, err) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) t.Run("processLoop exits promptly on stop signal", func(t *testing.T) { ctx := context.Background() @@ -789,6 +917,7 @@ func TestEventProcessorStruct(t *testing.T) { ep := &EventProcessor{} assert.Nil(t, ep.signer) assert.Nil(t, ep.chainStore) + assert.Nil(t, ep.reader) assert.Empty(t, ep.chainID) assert.False(t, ep.running) assert.Nil(t, ep.stopCh) @@ -801,25 +930,25 @@ func TestNewEventProcessorEnabledFlags(t *testing.T) { logger := zerolog.Nop() t.Run("both enabled", func(t *testing.T) { - ep := NewEventProcessor(nil, nil, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, nil, "eip155:1", true, true, nil, logger) assert.True(t, ep.inboundEnabled) assert.True(t, ep.outboundEnabled) }) t.Run("inbound only", func(t *testing.T) { - ep := NewEventProcessor(nil, nil, "eip155:1", true, false, logger) + ep := NewEventProcessor(nil, nil, "eip155:1", true, false, nil, logger) assert.True(t, ep.inboundEnabled) assert.False(t, ep.outboundEnabled) }) t.Run("outbound only", func(t *testing.T) { - ep := NewEventProcessor(nil, nil, "eip155:1", false, true, logger) + ep := NewEventProcessor(nil, nil, "eip155:1", false, true, nil, logger) assert.False(t, ep.inboundEnabled) assert.True(t, ep.outboundEnabled) }) t.Run("both disabled", func(t *testing.T) { - ep := NewEventProcessor(nil, nil, "eip155:1", false, false, logger) + ep := NewEventProcessor(nil, nil, "eip155:1", false, false, nil, logger) assert.False(t, ep.inboundEnabled) assert.False(t, ep.outboundEnabled) }) @@ -831,7 +960,7 @@ func TestEventProcessorStartDoubleStart(t *testing.T) { require.NoError(t, err) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -858,7 +987,7 @@ func TestEventProcessorStopIdempotent(t *testing.T) { require.NoError(t, err) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -888,7 +1017,7 @@ func TestEventProcessorIsRunningStateTransitions(t *testing.T) { require.NoError(t, err) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) // Initial state: not running assert.False(t, ep.IsRunning()) @@ -923,7 +1052,7 @@ func TestEventProcessorStopViaContextCancel(t *testing.T) { require.NoError(t, err) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) ctx, cancel := context.WithCancel(context.Background()) @@ -988,7 +1117,7 @@ func TestProcessConfirmedEventsEnabledFlags(t *testing.T) { t.Run("inbound disabled skips inbound events, leaves them CONFIRMED", func(t *testing.T) { database := setupDB(t, makeEvents()) // inbound=false, outbound=false (no signer so outbound will also fail to vote, but that's ok) - ep := NewEventProcessor(nil, database, "eip155:1", false, false, logger) + ep := NewEventProcessor(nil, database, "eip155:1", false, false, nil, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) @@ -1001,7 +1130,7 @@ func TestProcessConfirmedEventsEnabledFlags(t *testing.T) { t.Run("outbound disabled skips outbound events, leaves them CONFIRMED", func(t *testing.T) { database := setupDB(t, makeEvents()) - ep := NewEventProcessor(nil, database, "eip155:1", false, false, logger) + ep := NewEventProcessor(nil, database, "eip155:1", false, false, nil, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) @@ -1022,7 +1151,7 @@ func TestProcessConfirmedEventsEnabledFlags(t *testing.T) { EventData: outboundEventData, }, }) - ep := NewEventProcessor(nil, database, "eip155:1", true, false, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, false, nil, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) @@ -1043,7 +1172,7 @@ func TestProcessConfirmedEventsEnabledFlags(t *testing.T) { EventData: inboundEventData, }, }) - ep := NewEventProcessor(nil, database, "eip155:1", false, true, logger) + ep := NewEventProcessor(nil, database, "eip155:1", false, true, nil, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) diff --git a/universalClient/externalchains/evm/client.go b/universalClient/externalchains/evm/client.go index a9cf0c67..7df8117b 100644 --- a/universalClient/externalchains/evm/client.go +++ b/universalClient/externalchains/evm/client.go @@ -88,12 +88,15 @@ func NewClient( if pushSigner != nil { inboundEnabled := config.Enabled != nil && config.Enabled.IsInboundEnabled outboundEnabled := config.Enabled != nil && config.Enabled.IsOutboundEnabled + // client is the reader for READ_REQUEST events routed into this chain's + // DB by the push event listener. client.eventProcessor = common.NewEventProcessor( pushSigner, database, chainIDStr, inboundEnabled, outboundEnabled, + client, log, ) } diff --git a/universalClient/externalchains/svm/client.go b/universalClient/externalchains/svm/client.go index bdafd098..8bd0233b 100644 --- a/universalClient/externalchains/svm/client.go +++ b/universalClient/externalchains/svm/client.go @@ -98,12 +98,15 @@ func NewClient( if pushSigner != nil { inboundEnabled := config.Enabled != nil && config.Enabled.IsInboundEnabled outboundEnabled := config.Enabled != nil && config.Enabled.IsOutboundEnabled + // client is the reader for READ_REQUEST events routed into this chain's + // DB by the push event listener. client.eventProcessor = common.NewEventProcessor( pushSigner, database, chainIDStr, inboundEnabled, outboundEnabled, + client, log, ) } diff --git a/universalClient/pushwatcher/client.go b/universalClient/pushwatcher/client.go index 351b38b9..beaf5c77 100644 --- a/universalClient/pushwatcher/client.go +++ b/universalClient/pushwatcher/client.go @@ -24,13 +24,15 @@ type Client struct { cancel context.CancelFunc } -// NewClient creates a new Push chain client +// NewClient creates a new Push chain client. +// readStoreResolver may be nil; the listener then skips read request polling. func NewClient( database *db.DB, chainConfig *config.ChainSpecificConfig, pushCore *pushcore.Client, chainID string, logger zerolog.Logger, + readStoreResolver common.ReadStoreResolver, ) (*Client, error) { // Normalize nil config so downstream uses don't need nil guards. if chainConfig == nil { @@ -43,6 +45,7 @@ func NewClient( database, logger, chainConfig, + readStoreResolver, ) if err != nil { return nil, fmt.Errorf("failed to create event listener: %w", err) diff --git a/universalClient/pushwatcher/client_test.go b/universalClient/pushwatcher/client_test.go index 26877bea..9f3a0b01 100644 --- a/universalClient/pushwatcher/client_test.go +++ b/universalClient/pushwatcher/client_test.go @@ -34,7 +34,7 @@ func TestNewClient(t *testing.T) { pc := newTestPushCoreClient() t.Run("success with nil config", func(t *testing.T) { - client, err := NewClient(database, nil, pc, "push-chain", logger) + client, err := NewClient(database, nil, pc, "push-chain", logger, nil) require.NoError(t, err) require.NotNil(t, client) assert.NotNil(t, client.eventListener) @@ -48,27 +48,27 @@ func TestNewClient(t *testing.T) { CleanupIntervalSeconds: &cleanup, RetentionPeriodSeconds: &retention, } - client, err := NewClient(database, cfg, pc, "push-chain", logger) + client, err := NewClient(database, cfg, pc, "push-chain", logger, nil) require.NoError(t, err) require.NotNil(t, client) assert.NotNil(t, client.eventCleaner) }) t.Run("nil pushcore fails", func(t *testing.T) { - _, err := NewClient(database, nil, nil, "push-chain", logger) + _, err := NewClient(database, nil, nil, "push-chain", logger, nil) require.Error(t, err) assert.Contains(t, err.Error(), "push client is nil") }) t.Run("nil database fails", func(t *testing.T) { - _, err := NewClient(nil, nil, pc, "push-chain", logger) + _, err := NewClient(nil, nil, pc, "push-chain", logger, nil) require.Error(t, err) assert.Contains(t, err.Error(), "database is nil") }) } func TestClient_StartStop(t *testing.T) { - client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop()) + client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop(), nil) require.NoError(t, err) ctx := context.Background() @@ -96,7 +96,7 @@ func TestClient_StopBeforeStart(t *testing.T) { // Stop on a freshly created client (never started) should not panic. // The cancel func is nil, eventListener.Stop() returns ErrNotRunning but // the client logs and swallows that error, returning nil. - client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop()) + client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop(), nil) require.NoError(t, err) // Should not panic or return error @@ -104,7 +104,7 @@ func TestClient_StopBeforeStart(t *testing.T) { } func TestClient_DoubleStop(t *testing.T) { - client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop()) + client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop(), nil) require.NoError(t, err) ctx := context.Background() @@ -122,7 +122,7 @@ func TestClient_StartStopWithEventCleaner(t *testing.T) { CleanupIntervalSeconds: &cleanup, RetentionPeriodSeconds: &retention, } - client, err := NewClient(newTestDB(t), cfg, newTestPushCoreClient(), "push-chain", zerolog.Nop()) + client, err := NewClient(newTestDB(t), cfg, newTestPushCoreClient(), "push-chain", zerolog.Nop(), nil) require.NoError(t, err) require.NotNil(t, client.eventCleaner) @@ -140,7 +140,7 @@ func TestClient_StartStopWithEventCleaner(t *testing.T) { func TestClient_StartStopLifecycleMultiple(t *testing.T) { // Verify the client can be started and stopped multiple times (restart). - client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop()) + client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop(), nil) require.NoError(t, err) ctx := context.Background() @@ -183,7 +183,7 @@ func TestNewClient_CleanerAlwaysWired(t *testing.T) { } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - client, err := NewClient(database, tc.cfg, pc, "push-chain", logger) + client, err := NewClient(database, tc.cfg, pc, "push-chain", logger, nil) require.NoError(t, err) require.NotNil(t, client.eventCleaner, "cleaner must always be wired up") }) @@ -199,7 +199,7 @@ func TestNewClient_NegativePollInterval(t *testing.T) { cfg := &config.ChainSpecificConfig{ EventPollingIntervalSeconds: &poll, } - client, err := NewClient(database, cfg, pc, "push-chain", logger) + client, err := NewClient(database, cfg, pc, "push-chain", logger, nil) require.NoError(t, err) // Negative poll interval should fall back to default assert.Equal(t, DefaultPollInterval, client.eventListener.cfg.PollInterval) @@ -215,7 +215,7 @@ func TestStoreEvent(t *testing.T) { pc := newTestPushCoreClient() logger := zerolog.Nop() - el, err := NewEventListener(pc, database, logger, nil) + el, err := NewEventListener(pc, database, logger, nil, nil) require.NoError(t, err) event := &store.Event{ @@ -236,7 +236,7 @@ func TestStoreEvent(t *testing.T) { pc := newTestPushCoreClient() logger := zerolog.Nop() - el, err := NewEventListener(pc, database, logger, nil) + el, err := NewEventListener(pc, database, logger, nil, nil) require.NoError(t, err) event := &store.Event{ @@ -260,7 +260,7 @@ func TestStoreEvent(t *testing.T) { pc := newTestPushCoreClient() logger := zerolog.Nop() - el, err := NewEventListener(pc, database, logger, nil) + el, err := NewEventListener(pc, database, logger, nil, nil) require.NoError(t, err) for i := 0; i < 5; i++ { @@ -282,7 +282,7 @@ func TestStoreEvent(t *testing.T) { pc := newTestPushCoreClient() logger := zerolog.Nop() - el, err := NewEventListener(pc, database, logger, nil) + el, err := NewEventListener(pc, database, logger, nil, nil) require.NoError(t, err) event := &store.Event{ @@ -310,7 +310,7 @@ func TestStoreEvent(t *testing.T) { pc := newTestPushCoreClient() logger := zerolog.Nop() - el, err := NewEventListener(pc, database, logger, nil) + el, err := NewEventListener(pc, database, logger, nil, nil) require.NoError(t, err) event := &store.Event{ diff --git a/universalClient/pushwatcher/event_listener.go b/universalClient/pushwatcher/event_listener.go index 7193f444..ec8f35be 100644 --- a/universalClient/pushwatcher/event_listener.go +++ b/universalClient/pushwatcher/event_listener.go @@ -28,13 +28,17 @@ type Config struct { PollInterval time.Duration } -// EventListener polls Push chain for active TSS events and pending outbounds -// via gRPC, converts them to store.Events, and inserts them into the local DB. +// EventListener polls Push chain for active TSS events, pending outbounds and +// pending read requests via gRPC, converts them to store.Events, and inserts +// them into the local DB. Read request events are routed into the target +// chain's DB (via readStoreResolver) so that chain's own event processor +// executes and votes them. type EventListener struct { - pushCore *pushcore.Client - chainStore *common.ChainStore - cfg Config - logger zerolog.Logger + pushCore *pushcore.Client + chainStore *common.ChainStore + readStoreResolver common.ReadStoreResolver + cfg Config + logger zerolog.Logger mu sync.Mutex running bool @@ -43,11 +47,13 @@ type EventListener struct { } // NewEventListener creates a new Push event listener. +// readStoreResolver may be nil; read request polling is skipped without it. func NewEventListener( pushCore *pushcore.Client, database *db.DB, logger zerolog.Logger, chainConfig *config.ChainSpecificConfig, + readStoreResolver common.ReadStoreResolver, ) (*EventListener, error) { if pushCore == nil { return nil, ErrNilClient @@ -62,10 +68,11 @@ func NewEventListener( } return &EventListener{ - pushCore: pushCore, - chainStore: common.NewChainStore(database), - cfg: Config{PollInterval: pollInterval}, - logger: logger.With().Str("component", "push_event_listener").Logger(), + pushCore: pushCore, + chainStore: common.NewChainStore(database), + readStoreResolver: readStoreResolver, + cfg: Config{PollInterval: pollInterval}, + logger: logger.With().Str("component", "push_event_listener").Logger(), }, nil } @@ -134,17 +141,19 @@ func (el *EventListener) run(ctx context.Context) { } } -// poll fetches pending TSS, outbound & fund migration events, stores them, and updates latest block height. +// poll fetches pending TSS, outbound, fund migration & read request events, stores them, and updates latest block height. func (el *EventListener) poll(ctx context.Context) { tssCount := el.pollTssEvents(ctx) outboundCount := el.pollOutboundEvents(ctx) migrationCount := el.pollFundMigrationEvents(ctx) + readCount := el.pollReadRequestEvents(ctx) - if total := tssCount + outboundCount + migrationCount; total > 0 { + if total := tssCount + outboundCount + migrationCount + readCount; total > 0 { el.logger.Info(). Int("tss_events", tssCount). Int("outbound_events", outboundCount). Int("migration_events", migrationCount). + Int("read_request_events", readCount). Msg("stored new events") } @@ -235,6 +244,63 @@ func (el *EventListener) pollFundMigrationEvents(ctx context.Context) int { return newCount } +// pollReadRequestEvents fetches pending external read requests and routes each +// into its target chain's DB, where that chain's event processor executes and +// votes it. Requests for chains this validator doesn't serve are skipped and +// retried next poll (core keeps returning them until fulfilled or expired). +// Returns new event count. +func (el *EventListener) pollReadRequestEvents(ctx context.Context) int { + if el.readStoreResolver == nil { + return 0 + } + + requests, err := el.pushCore.GetAllPendingReadRequests(ctx) + if err != nil { + if errors.Is(err, pushcore.ErrReadQueriesNotAvailable) { + // TODO(core): remove once Query/PendingReadRequests lands. + return 0 + } + el.logger.Error().Err(err).Msg("failed to fetch pending read requests") + return 0 + } + + var newCount int + for _, req := range requests { + caip2, err := common.CAIP2(req.ChainNamespace, req.ChainID) + if err != nil { + el.logger.Warn().Err(err).Str("request_id", req.RequestID).Msg("invalid read request domain") + continue + } + + targetStore, err := el.readStoreResolver.GetStore(caip2) + if err != nil { + el.logger.Debug().Err(err).Str("request_id", req.RequestID).Str("target_chain", caip2).Msg("target chain not served; skipping read request") + continue + } + + event, err := convertReadRequestEvent(req) + if err != nil { + el.logger.Warn().Err(err).Str("request_id", req.RequestID).Msg("failed to convert read request") + continue + } + + stored, err := targetStore.InsertEventIfNotExists(event) + if err != nil { + el.logger.Error().Err(err).Str("event_id", event.EventID).Str("target_chain", caip2).Msg("failed to store read request") + continue + } + if stored { + el.logger.Debug(). + Str("event_id", event.EventID). + Str("target_chain", caip2). + Msg("routed read request to target chain") + newCount++ + } + } + + return newCount +} + // storeEvent inserts an event into the DB if it doesn't already exist. // Returns 1 if stored, 0 if duplicate or error. func (el *EventListener) storeEvent(event *store.Event) int { diff --git a/universalClient/pushwatcher/event_listener_test.go b/universalClient/pushwatcher/event_listener_test.go index 983b3fb3..4805df99 100644 --- a/universalClient/pushwatcher/event_listener_test.go +++ b/universalClient/pushwatcher/event_listener_test.go @@ -17,7 +17,7 @@ func TestNewEventListener(t *testing.T) { client := newTestPushCoreClient() t.Run("success with defaults", func(t *testing.T) { - el, err := NewEventListener(client, db, logger, nil) + el, err := NewEventListener(client, db, logger, nil, nil) require.NoError(t, err) require.NotNil(t, el) assert.Equal(t, DefaultPollInterval, el.cfg.PollInterval) @@ -25,19 +25,19 @@ func TestNewEventListener(t *testing.T) { }) t.Run("nil client", func(t *testing.T) { - _, err := NewEventListener(nil, db, logger, nil) + _, err := NewEventListener(nil, db, logger, nil, nil) assert.ErrorIs(t, err, ErrNilClient) }) t.Run("nil database", func(t *testing.T) { - _, err := NewEventListener(client, nil, logger, nil) + _, err := NewEventListener(client, nil, logger, nil, nil) assert.ErrorIs(t, err, ErrNilDatabase) }) t.Run("custom poll interval from config", func(t *testing.T) { poll := 10 cfg := config.ChainSpecificConfig{EventPollingIntervalSeconds: &poll} - el, err := NewEventListener(client, db, logger, &cfg) + el, err := NewEventListener(client, db, logger, &cfg, nil) require.NoError(t, err) assert.Equal(t, 10*time.Second, el.cfg.PollInterval) }) @@ -45,14 +45,14 @@ func TestNewEventListener(t *testing.T) { t.Run("zero poll interval uses default", func(t *testing.T) { poll := 0 cfg := config.ChainSpecificConfig{EventPollingIntervalSeconds: &poll} - el, err := NewEventListener(client, db, logger, &cfg) + el, err := NewEventListener(client, db, logger, &cfg, nil) require.NoError(t, err) assert.Equal(t, DefaultPollInterval, el.cfg.PollInterval) }) } func TestEventListener_StartStop(t *testing.T) { - el, err := NewEventListener(newTestPushCoreClient(), newTestDB(t), zerolog.Nop(), nil) + el, err := NewEventListener(newTestPushCoreClient(), newTestDB(t), zerolog.Nop(), nil, nil) require.NoError(t, err) ctx := context.Background() @@ -73,7 +73,7 @@ func TestEventListener_StartStop(t *testing.T) { } func TestEventListener_RestartAfterStop(t *testing.T) { - el, err := NewEventListener(newTestPushCoreClient(), newTestDB(t), zerolog.Nop(), nil) + el, err := NewEventListener(newTestPushCoreClient(), newTestDB(t), zerolog.Nop(), nil, nil) require.NoError(t, err) ctx := context.Background() diff --git a/universalClient/pushwatcher/event_parser.go b/universalClient/pushwatcher/event_parser.go index a2644d7d..c6f87960 100644 --- a/universalClient/pushwatcher/event_parser.go +++ b/universalClient/pushwatcher/event_parser.go @@ -7,6 +7,7 @@ import ( "fmt" "github.com/pushchain/push-chain-node/universalClient/store" + "github.com/pushchain/push-chain-node/universalClient/uread" uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" utsstypes "github.com/pushchain/push-chain-node/x/utss/types" ) @@ -94,6 +95,27 @@ func convertFundMigrationEvent(migration *utsstypes.FundMigration) (*store.Event }, nil } +// convertReadRequestEvent converts a pending external read request to a store.Event. +func convertReadRequestEvent(req *uread.ReadRequest) (*store.Event, error) { + if req == nil || req.RequestID == "" { + return nil, fmt.Errorf("read request is nil or missing request id") + } + + eventData, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal read request event data: %w", err) + } + + return &store.Event{ + EventID: hashEventID(store.EventTypeReadRequest, req.RequestID), + BlockHeight: req.CreatedAtHeight, + Type: store.EventTypeReadRequest, + ConfirmationType: store.ConfirmationInstant, + Status: store.StatusConfirmed, + EventData: eventData, + }, nil +} + // convertOutboundToEvent converts a PendingOutboundEntry + OutboundTx to a store.Event. func convertOutboundToEvent(entry *uexecutortypes.PendingOutboundEntry, outbound *uexecutortypes.OutboundTx) (*store.Event, error) { if entry == nil || outbound == nil { diff --git a/universalClient/pushwatcher/event_parser_test.go b/universalClient/pushwatcher/event_parser_test.go index cf8a98dc..acd38a7a 100644 --- a/universalClient/pushwatcher/event_parser_test.go +++ b/universalClient/pushwatcher/event_parser_test.go @@ -421,3 +421,4 @@ func TestHashEventID(t *testing.T) { assert.Len(t, id, 64) // sha256 = 32 bytes = 64 hex chars }) } + From 04ca9d582e908d94afe2a9adb25d42029bd40dbf Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 28 Jul 2026 19:34:02 +0530 Subject: [PATCH 07/11] remove docs --- docs/read-from-chains-implementation-plan.md | 164 ------------------- 1 file changed, 164 deletions(-) delete mode 100644 docs/read-from-chains-implementation-plan.md diff --git a/docs/read-from-chains-implementation-plan.md b/docs/read-from-chains-implementation-plan.md deleted file mode 100644 index 14586282..00000000 --- a/docs/read-from-chains-implementation-plan.md +++ /dev/null @@ -1,164 +0,0 @@ -# Read from Chains — Core + universalClient Implementation Plan - -## References -- Spec v1: `read_v1.pdf` (UniversalCallback + MetaCallbackSpec model — superseded) -- Spec v2: `read_v2.pdf` (UniversalReadClient model — **adopted**) -- Contracts: [pushchain/push-chain-core-contracts@51e5aeb](https://github.com/pushchain/push-chain-core-contracts/commit/51e5aeb2dd0cc0ecd23134f3b313a455ca52fdde) (`read-state-v1`) - -## What the contracts already define (fixed surface we integrate against) - -- `UniversalCallback.sol` (singleton, upgradeable): - - `requestExternalReadSelf(ReadSpec spec, bytes4 callbackSelector, uint64 callbackGasLimit) payable → requestId` - - validates: non-empty account/query, `minConfirmations >= 1`, `maxAgeSeconds/maxDelaySeconds != 0`, `supportedDomains[ns][id]`, `callbackGasLimit <= 1_000_000`, `fee <= msg.value <= spec.maxFee` - - `requestId = keccak256(block.chainid, block.number, address(this), keccak256(spec), nonce++)` - - emits **`ReadRequested(uint256 indexed requestId, ReadSpec spec, address indexed callbackTarget, address indexed originalFunder, uint256 feesDeposited)`** - - `fulfillExternalCallback(uint256 requestId, bytes resultData, uint64 observedBlockHeight, bytes32 observedBlockHash)` — **`onlyUEModule`** - - calls `callbackTarget.call{gas}(selector, requestId, resultData)`; handles fee split (protocol fee → VaultPC, refund → funder) internally - - emits `ReadFulfilled` / `CallbackFailed` - - `expireExternalRead(uint256 requestId)` — **`onlyUEModule`**, emits `RequestExpired` - - `UNIVERSAL_EXECUTOR_MODULE = 0x14191Ea54B4c176fCf86f51b0FAc7CB1E71Df7d7` (hardcoded) -- `ReadTypes.sol`: - - `ReadSpec { UniversalAccountId account; bytes query; uint16 minConfirmations; uint64 maxAgeSeconds; uint64 maxDelaySeconds; uint256 maxFee; }` - - `BALLOT_OBSERVATION_TYPE_READ_REQUEST = 0x3dad9a0d…` — contracts expect a matching ballot type in core -- `UniversalReadClient.sol` — app-side base (`_requestRead` / `onUniversalData` / `_onReadResult`, `_localContext` storage); no core/uClient work needed -- `UniversalCore.sol` — new `readBaseFeeByChainNamespace[ns][id]` + `updateReadBaseFeeByChain` (admin) -- Query envelopes (spec v1, still applies): `EvmQueryEnvelope` (AccountBalance / ERC20Balance / ContractCall / StorageSlot + `EvmBlockRef`), `SolanaQueryEnvelope` (LamportBalance / SPLTokenAccount / RawAccountData + `minSlot`), `Web2QueryEnvelope` (GET/POST) - -## End-to-end flow (target) - -1. App inherits `UniversalReadClient`, calls `_requestRead` mid-execution → `ReadRequested` emitted on Push EVM -2. `x/uexecutor` `PostTxProcessing` hook decodes the event in-block → stores `PendingReadRequest` -3. universalClient (each validator) polls pending reads via gRPC → executes the query envelope against the external chain RPC → canonical-encodes result -4. Validator submits `MsgVoteReadResult` → uvalidator ballot; identical `(resultData, height, hash)` → same ballot key → >2/3 quorum -5. On finalization, uexecutor calls `fulfillExternalCallback` on `UniversalCallback` via module EVM call -6. Expiry: request not finalized within `maxDelaySeconds` → EndBlocker calls `expireExternalRead` - ---- - -## Core (`push-chain-node`) changes - -### 1. Proto (`proto/…` + buf regen) - -- `proto/uvalidator/v1/ballot.proto` - - add `BALLOT_OBSERVATION_TYPE_READ_REQUEST` to `BallotObservationType` -- `proto/uexecutor/v1/` (new `read_request.proto` + `tx.proto` + `query.proto`) - - `ReadRequest` type: `request_id (bytes/hex)`, decoded `ReadSpec` fields (`chain_namespace`, `chain_id`, `owner`, `query`, `min_confirmations`, `max_age_seconds`, `max_delay_seconds`), `callback_target`, `pinned_block_height`, `created_at_height`, `expiry_timestamp`, `status (PENDING | FULFILLED | EXPIRED)` - - `MsgVoteReadResult { signer, request_id, result_data, observed_block_height, observed_block_hash, status (SUCCESS | ERROR) }` - - `Query/PendingReadRequests` (paginated) — mirror `GetAllPendingOutbounds` - -### 2. Event detection (`x/uexecutor`) - -- `types/events.go`: add `ReadRequestedEventSig` (topic0 of the event above) -- `types/gateway_pc_event_decode.go`: add `DecodeReadRequestedFromLog` (ABI-decode `ReadSpec` tuple from log data) -- `keeper/evm_hooks.go` `PostTxProcessing`: match logs from the `UniversalCallback` address → decode → `CreatePendingReadRequest` - - follows the existing outbound-detection precedent (`BuildOutboundsFromReceipt`); no Push-EVM log polling needed in uClient -- `x/uregistry`: register `UNIVERSAL_CALLBACK` in `SYSTEM_CONTRACTS` (address source for hook filtering + callback calls) - -### 3. Pending-read storage (`x/uexecutor/keeper`) - -- new `Keeper.PendingReadRequests` collection + `keeper/pending_read_request.go` (CRUD, mirror `pending_outbound.go`) -- **Pin the query height at creation**: `pinned_block_height = ChainMeta[chain].LastAppliedChainHeight − spec.minConfirmations` (clamped) - - all validators query the same height → identical bytes; satisfies the spec rule "block taken must be below gas-oracle minimum" - - reject/park request if no ChainMeta exists for the chain -- set `expiry_timestamp = block.time + maxDelaySeconds` - -### 4. Voting (`x/uexecutor`) - -- `types/msg_vote_read_result.go` — ValidateBasic (mirror `msg_vote_inbound.go`) -- ballot key: `GetReadBallotKey = hash(request_id ‖ status ‖ result_data ‖ observed_block_height ‖ observed_block_hash)` — identical-bytes quorum -- `keeper/voting.go`: `VoteOnReadBallot` → `uvalidatorKeeper.VoteOnBallot` with the new ballot type, threshold `(2*validators)/3 + 1` (same as inbound) -- `keeper/msg_vote_read_result.go`: - - reject if request unknown / not PENDING / past expiry - - on finalizing vote (SUCCESS): `CallFulfillExternalCallback`, mark FULFILLED - - on finalizing vote (ERROR quorum — e.g. query invalid, target chain reorged): mark EXPIRED + `CallExpireExternalRead` (refund path) -- `keeper/ballot_hooks.go`: handle terminal FAILED/EXPIRED ballots for the new type (cleanup) - -### 5. EVM callback (`x/uexecutor`) - -- `types/abi.go`: add `UniversalCallbackABI` const + `ParseUniversalCallbackABI` (only `fulfillExternalCallback`, `expireExternalRead`) -- `keeper/evm.go`: `CallFulfillExternalCallback(...)` + `CallExpireExternalRead(...)` via `DerivedEVMCall` (template: `CallExecuteUniversalTx` / `CallUniversalCoreSetChainMeta`; uses `ModuleAccountNonce`) -- gas: callback gas is bounded on the contract side (`callbackGasLimit ≤ 1M`); give the module call a fixed generous limit -- **verify** module account EVM address == `0x14191Ea54B4c176fCf86f51b0FAc7CB1E71Df7d7` (contract hardcodes it); mismatch = every fulfill reverts - -### 6. Expiry sweep - -- `x/uexecutor` EndBlocker (`abci.go`): iterate PENDING reads with `expiry_timestamp < block.time` → `CallExpireExternalRead` → mark EXPIRED - - deterministic on-chain, no vote needed - - bound per-block work (process N per block) to avoid unbounded EndBlocker gas - -### 7. Queries / CLI - -- `keeper/grpc_query.go` (or new file): `PendingReadRequests`, `ReadRequest(id)` -- autocli entries for inspection - ---- - -## universalClient changes - -> **Status: implemented** (UV side done; items marked `TODO(core)` are stubbed and unblock mechanically once core lands — grep `TODO(core)` in `universalClient/`). -> -> - `universalClient/uread/` — **temporary package**: only proto-mirror types (`ReadRequest`/`ReadStatus`/`ReadResult`); delete it once core proto lands by swapping every `uread.*` reference to `uexecutortypes.*` -> - `externalchains/common/read.go` — shared permanent bits: `ChainReader`/`ChainResolver` interfaces, `CAIP2`, canonical result encoders (`EncodeUint256Result`/`EncodeBytes32Result`) -> - `externalchains/evm/read_envelope.go` + `read_executor.go` + RPC additions (`GetBalanceAt`, `GetStorageAt`, `GetHeaderByNumber`) — all 4 query types at pinned height (**TODO(core): remove latest−minConfirmations fallback once core pins height**) -> - `externalchains/svm/read_envelope.go` + `read_executor.go` + RPC additions (`GetBalanceWithSlot`, `GetAccountInfoWithSlot`) — all 3 query types, minContextSlot semantics -> - `universalClient/pushwatcher/` (moved out of the chains manager — push is core-managed, not a registry chain) — `event_listener.go` + `event_parser.go` fetch pending reads via gRPC and **route each `READ_REQUEST` event into the target chain's DB** (`common.ReadStoreResolver`, implemented by `chains.Chains.GetStore`); requests for unserved chains are skipped and retried next poll (core re-serves pending requests; expiry is the backstop) -> - `externalchains/common/event_processor.go` — original type-switch shape kept; gained a `READ_REQUEST` branch (`processReadRequestEvent`: execute on the chain's own `ChainReader` → vote → COMPLETED; corrupt/expired → REVERTED; transient → retry), a `reader ChainReader` constructor param (evm/svm pass the client itself), and the consumer-side `VoteSigner` interface; push client has no processor at all -> - `pushcore/pushCore.go` `GetAllPendingReadRequests` (**TODO(core): wire to Query/PendingReadRequests**; returns sentinel until then, processor idles silently) -> - `pushsigner/pushsigner.go` `VoteReadResult` (**TODO(core): build MsgVoteReadResult in vote.go + add to AuthZ grant set**) -> - wiring: push client is owned by `core/client.go` (not the chains manager) — core opens the push DB once (shared with TSS), creates `push.NewClient(..., chainsManager)` and manages its lifecycle; `externalchains.Chains` only manages registry-driven external chains and implements `GetStore` for read routing - -### 1. pushcore (`universalClient/pushcore/pushCore.go`) - -- `GetAllPendingReadRequests()` — new gRPC query wrapper (mirror `GetAllPendingOutbounds`) - -### 2. pushsigner (`universalClient/pushsigner/`) - -- `VoteReadResult(ctx, msg)` — build `MsgVoteReadResult`, AuthZ-wrap, sign, broadcast (mirror `VoteInbound`) -- add msg type to AuthZ grant set (hot-key authorization for the new msg URL) - -### 3. Read worker (`universalClient/chains/push/` — new component) - -- new `read_request_processor.go` on the Push client (alongside `event_listener.go`): - - poll `GetAllPendingReadRequests` on the existing polling interval - - local store dedup (per-chain SQLite, reuse `common.ChainStore`) so a request is executed/voted once; retry until vote tx confirmed - - skip requests already past `expiry_timestamp` -- **query executor** (new `universalClient/readexecutor/` or under `chains/common/`): - - resolve target chain client: `chainNamespace + ":" + chainId` → CAIP-2 → existing `Chains` registry RPC client - - decode envelope by namespace: - - `eip155` → `EvmQueryEnvelope`: AccountBalance → `eth_getBalance`, ERC20Balance → `balanceOf` via `eth_call`, ContractCall → `eth_call`, StorageSlot → `eth_getStorageAt` — all at `pinned_block_height`; fetch `observedBlockHash` for that height - - `solana` → `SolanaQueryEnvelope`: LamportBalance / SPLTokenAccount / RawAccountData via `getAccountInfo`/`getBalance` with `minContextSlot = pinned height`; observed slot + blockhash from response context - - `web2` → **out of scope for v1** (non-deterministic responses; needs canonicalization design) — vote ERROR if received - - canonical result encoding (must be byte-identical across validators): - - AccountBalance/LamportBalance → `abi.encode(uint256)` - - ERC20Balance/SPLTokenAccount → `abi.encode(uint256)` - - ContractCall → raw returndata - - StorageSlot → `abi.encode(bytes32)`; RawAccountData → raw account bytes - - on RPC/decode failure after retries → `VoteReadResult(status = ERROR)` -- interaction with per-chain clients: reads target chains uClient already watches (registry-driven); if a supported domain has no chain client, vote ERROR - -### 4. Config (`universalClient/config/`) - -- optional: `read_polling_interval_seconds` per Push chain entry (else reuse `event_polling_interval_seconds`) -- no new per-external-chain config — reuse existing `rpc_urls` - ---- - -## Cross-cutting decisions / open questions - -- **Height pinning source**: plan uses ChainMeta (gas oracle) height at request time; confirm ChainMeta exists for all chains that will be `supportedDomains` on the contract (contract-side whitelist and core-side ChainMeta must stay in sync — no core check enforces this) -- **`maxAgeSeconds`**: with pinned-height reads, "freshness" = pinned height recency; ChainMeta staleness already bounds this — decide whether core must additionally reject requests when ChainMeta is older than `maxAgeSeconds` -- **Solana determinism**: account data can change between slots and `getAccountInfo` can't query an exact past slot; `minContextSlot` gives ≥ semantics, so identical-bytes quorum may need slot-tolerant ballot design (e.g. vote on value only, drop observed slot from ballot key) — flag for design review -- **`expireExternalRead` vs ERROR quorum**: both route to expiry on the contract; keep both (EndBlocker for timeout, ERROR ballot for definitively-failing queries) or simplify to timeout-only -- **Module address**: `UNIVERSAL_EXECUTOR_MODULE` is hardcoded in the contract — verify against `authtypes.NewModuleAddress(uexecutortypes.ModuleName)` EVM mapping before deploy -- **Fee flow**: fully contract-side (protocol fee → VaultPC, refunds); core only triggers callbacks — no bank/fee logic needed in module -- **Nomenclature**: PDFs say `x/UCallback` as a separate module; plan puts everything in `x/uexecutor` (reuses EVM hooks, module nonce, ballot plumbing, existing AuthZ grants) — confirm - -## Suggested implementation order - -1. Proto + ballot type + codegen -2. Event decode + PendingReadRequest storage + evm_hooks detection -3. ABI + `CallFulfillExternalCallback` / `CallExpireExternalRead` -4. `MsgVoteReadResult` handler + ballot wiring + EndBlocker expiry -5. Queries (gRPC) + autocli -6. uClient: pushcore query + pushsigner vote + read processor + query executor (EVM first, then SVM) -7. E2E test: local chain + mock external RPC (extend `scripts/test_universal.sh`) From 9f674dcba64963e26afa240663c2997e58caf277 Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 28 Jul 2026 19:48:42 +0530 Subject: [PATCH 08/11] change proto to hve targetChain --- universalClient/externalchains/chains.go | 2 +- .../common/{read.go => chain_reader.go} | 16 ------- .../{read_test.go => chain_reader_test.go} | 9 ---- .../externalchains/common/chain_store.go | 8 ++++ .../common/event_processor_test.go | 3 +- universalClient/pushwatcher/client.go | 6 +-- universalClient/pushwatcher/event_listener.go | 42 ++++++++----------- universalClient/uread/types.go | 3 +- 8 files changed, 32 insertions(+), 57 deletions(-) rename universalClient/externalchains/common/{read.go => chain_reader.go} (58%) rename universalClient/externalchains/common/{read_test.go => chain_reader_test.go} (80%) diff --git a/universalClient/externalchains/chains.go b/universalClient/externalchains/chains.go index 7b4f4095..6ed0f60c 100644 --- a/universalClient/externalchains/chains.go +++ b/universalClient/externalchains/chains.go @@ -361,7 +361,7 @@ func (c *Chains) GetClient(chainID string) (common.ChainClient, error) { return client, nil } -// GetStore implements common.ReadStoreResolver: resolves a CAIP-2 chain ID to +// GetStore implements common.ExternalChainStoreResolver: resolves a CAIP-2 chain ID to // that chain's event store, so read requests can be routed into the target // chain's database. func (c *Chains) GetStore(chainID string) (*common.ChainStore, error) { diff --git a/universalClient/externalchains/common/read.go b/universalClient/externalchains/common/chain_reader.go similarity index 58% rename from universalClient/externalchains/common/read.go rename to universalClient/externalchains/common/chain_reader.go index 58203b10..17affadb 100644 --- a/universalClient/externalchains/common/read.go +++ b/universalClient/externalchains/common/chain_reader.go @@ -14,22 +14,6 @@ type ChainReader interface { ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) } -// ReadStoreResolver resolves a CAIP-2 chain ID to that chain's event store, so -// READ_REQUEST events can be routed into the target chain's own database. -// Implemented by externalchains.Chains. -type ReadStoreResolver interface { - GetStore(chainID string) (*ChainStore, error) -} - -// CAIP2 joins a ReadSpec domain (chainNamespace, chainId) into the CAIP-2 key -// used by the chains registry, e.g. ("eip155", "1") -> "eip155:1". -func CAIP2(chainNamespace, chainID string) (string, error) { - if chainNamespace == "" || chainID == "" { - return "", fmt.Errorf("empty chain namespace or id") - } - return chainNamespace + ":" + chainID, nil -} - // EncodeUint256Result canonically encodes a balance/amount as abi.encode(uint256). func EncodeUint256Result(v *big.Int) ([]byte, error) { if v == nil { diff --git a/universalClient/externalchains/common/read_test.go b/universalClient/externalchains/common/chain_reader_test.go similarity index 80% rename from universalClient/externalchains/common/read_test.go rename to universalClient/externalchains/common/chain_reader_test.go index 63087580..9195428b 100644 --- a/universalClient/externalchains/common/read_test.go +++ b/universalClient/externalchains/common/chain_reader_test.go @@ -31,12 +31,3 @@ func TestEncodeBytes32Result(t *testing.T) { require.Len(t, out, 32) assert.Equal(t, v[:], out) } - -func TestCAIP2(t *testing.T) { - got, err := CAIP2("eip155", "1") - require.NoError(t, err) - assert.Equal(t, "eip155:1", got) - - _, err = CAIP2("", "1") - assert.Error(t, err) -} diff --git a/universalClient/externalchains/common/chain_store.go b/universalClient/externalchains/common/chain_store.go index b67bd1f8..cad71d9f 100644 --- a/universalClient/externalchains/common/chain_store.go +++ b/universalClient/externalchains/common/chain_store.go @@ -22,6 +22,14 @@ func NewChainStore(database *db.DB) *ChainStore { } } +// ExternalChainStoreResolver resolves a CAIP-2 chain ID to that chain's event +// store, so events destined for an external chain (READ_REQUEST today, e.g. +// SIGN events in the future) can be routed into that chain's own database. +// Implemented by externalchains.Chains. +type ExternalChainStoreResolver interface { + GetStore(chainID string) (*ChainStore, error) +} + // GetChainHeight returns the last processed block height for the chain. // Creates a new entry with height 0 if one doesn't exist (atomic via FirstOrCreate). func (cs *ChainStore) GetChainHeight() (uint64, error) { diff --git a/universalClient/externalchains/common/event_processor_test.go b/universalClient/externalchains/common/event_processor_test.go index 24fd52d8..d8d5f602 100644 --- a/universalClient/externalchains/common/event_processor_test.go +++ b/universalClient/externalchains/common/event_processor_test.go @@ -60,8 +60,7 @@ func (f *fakeChainReader) ExecuteRead(ctx context.Context, req *uread.ReadReques func testReadRequest() *uread.ReadRequest { return &uread.ReadRequest{ RequestID: "0xabc123", - ChainNamespace: "eip155", - ChainID: "11155111", + TargetChain: "eip155:11155111", Query: []byte{0x01}, MinConfirmations: 1, PinnedBlockHeight: 100, diff --git a/universalClient/pushwatcher/client.go b/universalClient/pushwatcher/client.go index beaf5c77..19122245 100644 --- a/universalClient/pushwatcher/client.go +++ b/universalClient/pushwatcher/client.go @@ -25,14 +25,14 @@ type Client struct { } // NewClient creates a new Push chain client. -// readStoreResolver may be nil; the listener then skips read request polling. +// storeResolver may be nil; the listener then skips read request polling. func NewClient( database *db.DB, chainConfig *config.ChainSpecificConfig, pushCore *pushcore.Client, chainID string, logger zerolog.Logger, - readStoreResolver common.ReadStoreResolver, + storeResolver common.ExternalChainStoreResolver, ) (*Client, error) { // Normalize nil config so downstream uses don't need nil guards. if chainConfig == nil { @@ -45,7 +45,7 @@ func NewClient( database, logger, chainConfig, - readStoreResolver, + storeResolver, ) if err != nil { return nil, fmt.Errorf("failed to create event listener: %w", err) diff --git a/universalClient/pushwatcher/event_listener.go b/universalClient/pushwatcher/event_listener.go index ec8f35be..e0ae3b20 100644 --- a/universalClient/pushwatcher/event_listener.go +++ b/universalClient/pushwatcher/event_listener.go @@ -31,14 +31,14 @@ type Config struct { // EventListener polls Push chain for active TSS events, pending outbounds and // pending read requests via gRPC, converts them to store.Events, and inserts // them into the local DB. Read request events are routed into the target -// chain's DB (via readStoreResolver) so that chain's own event processor +// chain's DB (via storeResolver) so that chain's own event processor // executes and votes them. type EventListener struct { - pushCore *pushcore.Client - chainStore *common.ChainStore - readStoreResolver common.ReadStoreResolver - cfg Config - logger zerolog.Logger + pushCore *pushcore.Client + chainStore *common.ChainStore + storeResolver common.ExternalChainStoreResolver + cfg Config + logger zerolog.Logger mu sync.Mutex running bool @@ -47,13 +47,13 @@ type EventListener struct { } // NewEventListener creates a new Push event listener. -// readStoreResolver may be nil; read request polling is skipped without it. +// storeResolver may be nil; read request polling is skipped without it. func NewEventListener( pushCore *pushcore.Client, database *db.DB, logger zerolog.Logger, chainConfig *config.ChainSpecificConfig, - readStoreResolver common.ReadStoreResolver, + storeResolver common.ExternalChainStoreResolver, ) (*EventListener, error) { if pushCore == nil { return nil, ErrNilClient @@ -68,11 +68,11 @@ func NewEventListener( } return &EventListener{ - pushCore: pushCore, - chainStore: common.NewChainStore(database), - readStoreResolver: readStoreResolver, - cfg: Config{PollInterval: pollInterval}, - logger: logger.With().Str("component", "push_event_listener").Logger(), + pushCore: pushCore, + chainStore: common.NewChainStore(database), + storeResolver: storeResolver, + cfg: Config{PollInterval: pollInterval}, + logger: logger.With().Str("component", "push_event_listener").Logger(), }, nil } @@ -250,7 +250,7 @@ func (el *EventListener) pollFundMigrationEvents(ctx context.Context) int { // retried next poll (core keeps returning them until fulfilled or expired). // Returns new event count. func (el *EventListener) pollReadRequestEvents(ctx context.Context) int { - if el.readStoreResolver == nil { + if el.storeResolver == nil { return 0 } @@ -266,15 +266,9 @@ func (el *EventListener) pollReadRequestEvents(ctx context.Context) int { var newCount int for _, req := range requests { - caip2, err := common.CAIP2(req.ChainNamespace, req.ChainID) + targetStore, err := el.storeResolver.GetStore(req.TargetChain) if err != nil { - el.logger.Warn().Err(err).Str("request_id", req.RequestID).Msg("invalid read request domain") - continue - } - - targetStore, err := el.readStoreResolver.GetStore(caip2) - if err != nil { - el.logger.Debug().Err(err).Str("request_id", req.RequestID).Str("target_chain", caip2).Msg("target chain not served; skipping read request") + el.logger.Debug().Err(err).Str("request_id", req.RequestID).Str("target_chain", req.TargetChain).Msg("target chain not served; skipping read request") continue } @@ -286,13 +280,13 @@ func (el *EventListener) pollReadRequestEvents(ctx context.Context) int { stored, err := targetStore.InsertEventIfNotExists(event) if err != nil { - el.logger.Error().Err(err).Str("event_id", event.EventID).Str("target_chain", caip2).Msg("failed to store read request") + el.logger.Error().Err(err).Str("event_id", event.EventID).Str("target_chain", req.TargetChain).Msg("failed to store read request") continue } if stored { el.logger.Debug(). Str("event_id", event.EventID). - Str("target_chain", caip2). + Str("target_chain", req.TargetChain). Msg("routed read request to target chain") newCount++ } diff --git a/universalClient/uread/types.go b/universalClient/uread/types.go index a7b2ca34..e9bcaabc 100644 --- a/universalClient/uread/types.go +++ b/universalClient/uread/types.go @@ -8,8 +8,7 @@ package uread // ReadRequest mirrors the pending read request tracked by x/uexecutor. type ReadRequest struct { RequestID string // uint256 as 0x-prefixed hex (from ReadRequested event) - ChainNamespace string // e.g. "eip155", "solana" - ChainID string // e.g. "1", "42161", "mainnet-beta" + TargetChain string // CAIP-2, e.g. "eip155:1", "solana:mainnet-beta" Owner []byte // ReadSpec.account.owner (20-byte addr / 32-byte pubkey) Query []byte // chain-specific envelope, abi.encode(...) MinConfirmations uint16 From 01830bad22f1148a45cb91770ad76167413bc351 Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 28 Jul 2026 20:01:33 +0530 Subject: [PATCH 09/11] refactor(uclient): route external events via ChainClient.AddEvent Chain clients own writes to their DB: pushwatcher resolves the target client via GetClient (same as tss consumers) and hands it the event, instead of writing into the chain's store directly. Drops the ExternalChainStoreResolver interface and the chainDBs bookkeeping in the chains manager. --- universalClient/externalchains/chains.go | 22 ----------------- universalClient/externalchains/chains_test.go | 8 ++++--- .../externalchains/common/chain_store.go | 7 ------ .../externalchains/common/types.go | 6 +++++ universalClient/externalchains/evm/client.go | 6 +++++ universalClient/externalchains/svm/client.go | 6 +++++ universalClient/pushwatcher/client.go | 6 ++--- universalClient/pushwatcher/event_listener.go | 24 ++++++++++++------- .../tss/coordinator/coordinator_test.go | 7 +++--- .../tss/txbroadcaster/broadcaster_test.go | 9 +++---- .../tss/txresolver/resolver_test.go | 9 +++---- 11 files changed, 55 insertions(+), 55 deletions(-) diff --git a/universalClient/externalchains/chains.go b/universalClient/externalchains/chains.go index 6ed0f60c..12c1a907 100644 --- a/universalClient/externalchains/chains.go +++ b/universalClient/externalchains/chains.go @@ -28,7 +28,6 @@ type Chains struct { // Chain client management chains map[string]common.ChainClient // key: CAIP-2 chain ID chainConfigs map[string]*uregistrytypes.ChainConfig // key: CAIP-2 chain ID - chainDBs map[string]*db.DB // key: CAIP-2 chain ID chainsMu sync.RWMutex pushChainID string // Push chain ID (always present) @@ -58,7 +57,6 @@ func NewChains( logger: logger.With().Str("component", "chains").Logger(), chains: make(map[string]common.ChainClient), chainConfigs: make(map[string]*uregistrytypes.ChainConfig), - chainDBs: make(map[string]*db.DB), pushChainID: cfg.PushChainID, } } @@ -288,7 +286,6 @@ func (c *Chains) addChain(ctx context.Context, cfg *uregistrytypes.ChainConfig) c.chainsMu.Lock() c.chains[cfg.Chain] = client c.chainConfigs[cfg.Chain] = cfg - c.chainDBs[cfg.Chain] = chainDB c.chainsMu.Unlock() c.logger.Info(). @@ -317,7 +314,6 @@ func (c *Chains) removeChain(chainID string) error { delete(c.chains, chainID) delete(c.chainConfigs, chainID) - delete(c.chainDBs, chainID) c.logger.Info(). Str("chain", chainID). @@ -345,7 +341,6 @@ func (c *Chains) StopAll() { // Clear the registry c.chains = make(map[string]common.ChainClient) c.chainConfigs = make(map[string]*uregistrytypes.ChainConfig) - c.chainDBs = make(map[string]*db.DB) } // GetClient returns the chain client for the specified chain ID @@ -361,23 +356,6 @@ func (c *Chains) GetClient(chainID string) (common.ChainClient, error) { return client, nil } -// GetStore implements common.ExternalChainStoreResolver: resolves a CAIP-2 chain ID to -// that chain's event store, so read requests can be routed into the target -// chain's database. -func (c *Chains) GetStore(chainID string) (*common.ChainStore, error) { - if chainID == c.pushChainID { - return nil, fmt.Errorf("read requests cannot target push chain itself") - } - - c.chainsMu.RLock() - defer c.chainsMu.RUnlock() - - chainDB, exists := c.chainDBs[chainID] - if !exists { - return nil, fmt.Errorf("no database for chain %s", chainID) - } - return common.NewChainStore(chainDB), nil -} // IsEVMChain returns true if the chain uses EVM (e.g. Ethereum, BSC). Used by coordinator for nonce behaviour. func (c *Chains) IsEVMChain(chainID string) bool { diff --git a/universalClient/externalchains/chains_test.go b/universalClient/externalchains/chains_test.go index 20ed9d84..4989fa22 100644 --- a/universalClient/externalchains/chains_test.go +++ b/universalClient/externalchains/chains_test.go @@ -12,6 +12,7 @@ import ( "github.com/pushchain/push-chain-node/universalClient/config" "github.com/pushchain/push-chain-node/universalClient/externalchains/common" + "github.com/pushchain/push-chain-node/universalClient/store" uregistrytypes "github.com/pushchain/push-chain-node/x/uregistry/types" ) @@ -410,9 +411,10 @@ type mockChainClient struct { stopErr error } -func (m *mockChainClient) Start(ctx context.Context) error { m.startCalled = true; return nil } -func (m *mockChainClient) Stop() error { m.stopCalled = true; return m.stopErr } -func (m *mockChainClient) IsHealthy() bool { return true } +func (m *mockChainClient) Start(ctx context.Context) error { m.startCalled = true; return nil } +func (m *mockChainClient) Stop() error { m.stopCalled = true; return m.stopErr } +func (m *mockChainClient) IsHealthy() bool { return true } +func (m *mockChainClient) AddEvent(event *store.Event) (bool, error) { return true, nil } func (m *mockChainClient) GetTxBuilder() (common.TxBuilder, error) { return nil, nil } diff --git a/universalClient/externalchains/common/chain_store.go b/universalClient/externalchains/common/chain_store.go index cad71d9f..a7005741 100644 --- a/universalClient/externalchains/common/chain_store.go +++ b/universalClient/externalchains/common/chain_store.go @@ -22,13 +22,6 @@ func NewChainStore(database *db.DB) *ChainStore { } } -// ExternalChainStoreResolver resolves a CAIP-2 chain ID to that chain's event -// store, so events destined for an external chain (READ_REQUEST today, e.g. -// SIGN events in the future) can be routed into that chain's own database. -// Implemented by externalchains.Chains. -type ExternalChainStoreResolver interface { - GetStore(chainID string) (*ChainStore, error) -} // GetChainHeight returns the last processed block height for the chain. // Creates a new entry with height 0 if one doesn't exist (atomic via FirstOrCreate). diff --git a/universalClient/externalchains/common/types.go b/universalClient/externalchains/common/types.go index f3b2317c..bcd466e3 100644 --- a/universalClient/externalchains/common/types.go +++ b/universalClient/externalchains/common/types.go @@ -4,6 +4,7 @@ import ( "context" "math/big" + "github.com/pushchain/push-chain-node/universalClient/store" uetypes "github.com/pushchain/push-chain-node/x/uexecutor/types" ) @@ -21,6 +22,11 @@ type ChainClient interface { // GetTxBuilder returns the TxBuilder for this chain // Returns an error if txBuilder is not supported for this chain (e.g., Push chain) GetTxBuilder() (TxBuilder, error) + + // AddEvent stores an externally-produced event (e.g. a READ_REQUEST routed + // by the push watcher) in this chain's database for its event processor. + // Returns false if the event already exists. + AddEvent(event *store.Event) (bool, error) } // FundMigrationData contains the data needed to build a fund migration transaction. diff --git a/universalClient/externalchains/evm/client.go b/universalClient/externalchains/evm/client.go index 7df8117b..b36cd282 100644 --- a/universalClient/externalchains/evm/client.go +++ b/universalClient/externalchains/evm/client.go @@ -14,6 +14,7 @@ import ( "github.com/pushchain/push-chain-node/universalClient/db" "github.com/pushchain/push-chain-node/universalClient/externalchains/common" "github.com/pushchain/push-chain-node/universalClient/pushsigner" + "github.com/pushchain/push-chain-node/universalClient/store" uregistrytypes "github.com/pushchain/push-chain-node/x/uregistry/types" ) @@ -202,6 +203,11 @@ func (c *Client) GetTxBuilder() (common.TxBuilder, error) { return c.txBuilder, nil } +// AddEvent stores an externally-produced event in this chain's database. +func (c *Client) AddEvent(event *store.Event) (bool, error) { + return common.NewChainStore(c.database).InsertEventIfNotExists(event) +} + // initializeComponents creates all components that require the RPC client func (c *Client) initializeComponents() error { // Create event listener if gateway is configured diff --git a/universalClient/externalchains/svm/client.go b/universalClient/externalchains/svm/client.go index 8bd0233b..a73140a2 100644 --- a/universalClient/externalchains/svm/client.go +++ b/universalClient/externalchains/svm/client.go @@ -12,6 +12,7 @@ import ( "github.com/pushchain/push-chain-node/universalClient/db" "github.com/pushchain/push-chain-node/universalClient/externalchains/common" "github.com/pushchain/push-chain-node/universalClient/pushsigner" + "github.com/pushchain/push-chain-node/universalClient/store" uregistrytypes "github.com/pushchain/push-chain-node/x/uregistry/types" ) @@ -212,6 +213,11 @@ func (c *Client) GetTxBuilder() (common.TxBuilder, error) { return c.txBuilder, nil } +// AddEvent stores an externally-produced event in this chain's database. +func (c *Client) AddEvent(event *store.Event) (bool, error) { + return common.NewChainStore(c.database).InsertEventIfNotExists(event) +} + // initializeComponents creates all components that require the RPC client func (c *Client) initializeComponents() error { // Create event listener if gateway is configured diff --git a/universalClient/pushwatcher/client.go b/universalClient/pushwatcher/client.go index 19122245..85e2d74a 100644 --- a/universalClient/pushwatcher/client.go +++ b/universalClient/pushwatcher/client.go @@ -25,14 +25,14 @@ type Client struct { } // NewClient creates a new Push chain client. -// storeResolver may be nil; the listener then skips read request polling. +// chainResolver may be nil; the listener then skips read request polling. func NewClient( database *db.DB, chainConfig *config.ChainSpecificConfig, pushCore *pushcore.Client, chainID string, logger zerolog.Logger, - storeResolver common.ExternalChainStoreResolver, + chainResolver ExternalChainResolver, ) (*Client, error) { // Normalize nil config so downstream uses don't need nil guards. if chainConfig == nil { @@ -45,7 +45,7 @@ func NewClient( database, logger, chainConfig, - storeResolver, + chainResolver, ) if err != nil { return nil, fmt.Errorf("failed to create event listener: %w", err) diff --git a/universalClient/pushwatcher/event_listener.go b/universalClient/pushwatcher/event_listener.go index e0ae3b20..83683999 100644 --- a/universalClient/pushwatcher/event_listener.go +++ b/universalClient/pushwatcher/event_listener.go @@ -28,15 +28,21 @@ type Config struct { PollInterval time.Duration } +// ExternalChainResolver resolves a CAIP-2 chain ID to its chain client. +// Satisfied by externalchains.Chains. +type ExternalChainResolver interface { + GetClient(chainID string) (common.ChainClient, error) +} + // EventListener polls Push chain for active TSS events, pending outbounds and // pending read requests via gRPC, converts them to store.Events, and inserts // them into the local DB. Read request events are routed into the target -// chain's DB (via storeResolver) so that chain's own event processor -// executes and votes them. +// chain's DB (via chainResolver) so that chain's own event processor executes +// and votes them. type EventListener struct { pushCore *pushcore.Client chainStore *common.ChainStore - storeResolver common.ExternalChainStoreResolver + chainResolver ExternalChainResolver cfg Config logger zerolog.Logger @@ -47,13 +53,13 @@ type EventListener struct { } // NewEventListener creates a new Push event listener. -// storeResolver may be nil; read request polling is skipped without it. +// chainResolver may be nil; read request polling is skipped without it. func NewEventListener( pushCore *pushcore.Client, database *db.DB, logger zerolog.Logger, chainConfig *config.ChainSpecificConfig, - storeResolver common.ExternalChainStoreResolver, + chainResolver ExternalChainResolver, ) (*EventListener, error) { if pushCore == nil { return nil, ErrNilClient @@ -70,7 +76,7 @@ func NewEventListener( return &EventListener{ pushCore: pushCore, chainStore: common.NewChainStore(database), - storeResolver: storeResolver, + chainResolver: chainResolver, cfg: Config{PollInterval: pollInterval}, logger: logger.With().Str("component", "push_event_listener").Logger(), }, nil @@ -250,7 +256,7 @@ func (el *EventListener) pollFundMigrationEvents(ctx context.Context) int { // retried next poll (core keeps returning them until fulfilled or expired). // Returns new event count. func (el *EventListener) pollReadRequestEvents(ctx context.Context) int { - if el.storeResolver == nil { + if el.chainResolver == nil { return 0 } @@ -266,7 +272,7 @@ func (el *EventListener) pollReadRequestEvents(ctx context.Context) int { var newCount int for _, req := range requests { - targetStore, err := el.storeResolver.GetStore(req.TargetChain) + targetClient, err := el.chainResolver.GetClient(req.TargetChain) if err != nil { el.logger.Debug().Err(err).Str("request_id", req.RequestID).Str("target_chain", req.TargetChain).Msg("target chain not served; skipping read request") continue @@ -278,7 +284,7 @@ func (el *EventListener) pollReadRequestEvents(ctx context.Context) int { continue } - stored, err := targetStore.InsertEventIfNotExists(event) + stored, err := targetClient.AddEvent(event) if err != nil { el.logger.Error().Err(err).Str("event_id", event.EventID).Str("target_chain", req.TargetChain).Msg("failed to store read request") continue diff --git a/universalClient/tss/coordinator/coordinator_test.go b/universalClient/tss/coordinator/coordinator_test.go index 69cf0f26..de65f326 100644 --- a/universalClient/tss/coordinator/coordinator_test.go +++ b/universalClient/tss/coordinator/coordinator_test.go @@ -81,9 +81,10 @@ type coordMockChainClient struct { builderErr error } -func (m *coordMockChainClient) Start(context.Context) error { return nil } -func (m *coordMockChainClient) Stop() error { return nil } -func (m *coordMockChainClient) IsHealthy() bool { return true } +func (m *coordMockChainClient) Start(context.Context) error { return nil } +func (m *coordMockChainClient) Stop() error { return nil } +func (m *coordMockChainClient) IsHealthy() bool { return true } +func (m *coordMockChainClient) AddEvent(event *store.Event) (bool, error) { return true, nil } func (m *coordMockChainClient) GetTxBuilder() (common.TxBuilder, error) { if m.builderErr != nil { return nil, m.builderErr diff --git a/universalClient/tss/txbroadcaster/broadcaster_test.go b/universalClient/tss/txbroadcaster/broadcaster_test.go index 653d13d0..bc693f72 100644 --- a/universalClient/tss/txbroadcaster/broadcaster_test.go +++ b/universalClient/tss/txbroadcaster/broadcaster_test.go @@ -79,10 +79,11 @@ func (m *mockTxBuilder) BroadcastFundMigrationTx(ctx context.Context, req *commo type mockChainClient struct{ builder *mockTxBuilder } -func (m *mockChainClient) Start(context.Context) error { return nil } -func (m *mockChainClient) Stop() error { return nil } -func (m *mockChainClient) IsHealthy() bool { return true } -func (m *mockChainClient) GetTxBuilder() (common.TxBuilder, error) { return m.builder, nil } +func (m *mockChainClient) Start(context.Context) error { return nil } +func (m *mockChainClient) Stop() error { return nil } +func (m *mockChainClient) IsHealthy() bool { return true } +func (m *mockChainClient) AddEvent(event *store.Event) (bool, error) { return true, nil } +func (m *mockChainClient) GetTxBuilder() (common.TxBuilder, error) { return m.builder, nil } func setupTestDB(t *testing.T) (*eventstore.Store, *gorm.DB) { t.Helper() diff --git a/universalClient/tss/txresolver/resolver_test.go b/universalClient/tss/txresolver/resolver_test.go index b7df913f..beb79aa1 100644 --- a/universalClient/tss/txresolver/resolver_test.go +++ b/universalClient/tss/txresolver/resolver_test.go @@ -76,10 +76,11 @@ func (m *mockTxBuilder) BroadcastFundMigrationTx(ctx context.Context, req *commo type mockChainClient struct{ builder *mockTxBuilder } -func (m *mockChainClient) Start(context.Context) error { return nil } -func (m *mockChainClient) Stop() error { return nil } -func (m *mockChainClient) IsHealthy() bool { return true } -func (m *mockChainClient) GetTxBuilder() (common.TxBuilder, error) { return m.builder, nil } +func (m *mockChainClient) Start(context.Context) error { return nil } +func (m *mockChainClient) Stop() error { return nil } +func (m *mockChainClient) IsHealthy() bool { return true } +func (m *mockChainClient) AddEvent(event *store.Event) (bool, error) { return true, nil } +func (m *mockChainClient) GetTxBuilder() (common.TxBuilder, error) { return m.builder, nil } func setupTestDB(t *testing.T) (*eventstore.Store, *gorm.DB) { t.Helper() From e996af6a62601754cb82a82aec51d9d4d8039916 Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 28 Jul 2026 20:09:11 +0530 Subject: [PATCH 10/11] refactor(uclient): fold chain_reader.go into event_processor and types ChainReader moves next to its consumer (EventProcessor), the uint256 canonical encoder moves to types.go, and the near-no-op bytes32 encoder is inlined at its single call site. --- .../externalchains/common/chain_reader.go | 35 ------------------- .../common/chain_reader_test.go | 33 ----------------- .../externalchains/common/event_processor.go | 6 ++++ .../common/event_processor_test.go | 15 ++++++++ .../externalchains/common/types.go | 17 +++++++++ .../externalchains/evm/read_executor.go | 2 +- 6 files changed, 39 insertions(+), 69 deletions(-) delete mode 100644 universalClient/externalchains/common/chain_reader.go delete mode 100644 universalClient/externalchains/common/chain_reader_test.go diff --git a/universalClient/externalchains/common/chain_reader.go b/universalClient/externalchains/common/chain_reader.go deleted file mode 100644 index 17affadb..00000000 --- a/universalClient/externalchains/common/chain_reader.go +++ /dev/null @@ -1,35 +0,0 @@ -package common - -import ( - "context" - "fmt" - "math/big" - - "github.com/pushchain/push-chain-node/universalClient/uread" -) - -// ChainReader executes an external read request against one chain. -// Implemented by chains/evm.Client and chains/svm.Client. -type ChainReader interface { - ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) -} - -// EncodeUint256Result canonically encodes a balance/amount as abi.encode(uint256). -func EncodeUint256Result(v *big.Int) ([]byte, error) { - if v == nil { - v = big.NewInt(0) - } - if v.Sign() < 0 || v.BitLen() > 256 { - return nil, fmt.Errorf("value out of uint256 range") - } - out := make([]byte, 32) - v.FillBytes(out) - return out, nil -} - -// EncodeBytes32Result canonically encodes a storage slot value as abi.encode(bytes32). -func EncodeBytes32Result(v [32]byte) ([]byte, error) { - out := make([]byte, 32) - copy(out, v[:]) - return out, nil -} diff --git a/universalClient/externalchains/common/chain_reader_test.go b/universalClient/externalchains/common/chain_reader_test.go deleted file mode 100644 index 9195428b..00000000 --- a/universalClient/externalchains/common/chain_reader_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package common - -import ( - "bytes" - "math/big" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestEncodeUint256Result(t *testing.T) { - out, err := EncodeUint256Result(big.NewInt(1_000_000)) - require.NoError(t, err) - require.Len(t, out, 32) - assert.Equal(t, big.NewInt(1_000_000), new(big.Int).SetBytes(out)) - - out, err = EncodeUint256Result(nil) - require.NoError(t, err) - assert.True(t, bytes.Equal(out, make([]byte, 32))) - - _, err = EncodeUint256Result(big.NewInt(-1)) - assert.Error(t, err) -} - -func TestEncodeBytes32Result(t *testing.T) { - var v [32]byte - v[31] = 0xff - out, err := EncodeBytes32Result(v) - require.NoError(t, err) - require.Len(t, out, 32) - assert.Equal(t, v[:], out) -} diff --git a/universalClient/externalchains/common/event_processor.go b/universalClient/externalchains/common/event_processor.go index bd059727..2c082303 100644 --- a/universalClient/externalchains/common/event_processor.go +++ b/universalClient/externalchains/common/event_processor.go @@ -26,6 +26,12 @@ type VoteSigner interface { VoteReadResult(ctx context.Context, requestID string, result *uread.ReadResult) (string, error) } +// ChainReader executes an external read request against one chain. +// Implemented by the evm and svm chain clients. +type ChainReader interface { + ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) +} + // EventProcessor processes events from the chain's database and votes on them type EventProcessor struct { signer VoteSigner diff --git a/universalClient/externalchains/common/event_processor_test.go b/universalClient/externalchains/common/event_processor_test.go index d8d5f602..fd6c802c 100644 --- a/universalClient/externalchains/common/event_processor_test.go +++ b/universalClient/externalchains/common/event_processor_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "math/big" "testing" "time" @@ -1182,3 +1183,17 @@ func TestProcessConfirmedEventsEnabledFlags(t *testing.T) { assert.Equal(t, store.StatusConfirmed, inboundEvt.Status) }) } + +func TestEncodeUint256Result(t *testing.T) { + out, err := EncodeUint256Result(big.NewInt(1_000_000)) + require.NoError(t, err) + require.Len(t, out, 32) + assert.Equal(t, big.NewInt(1_000_000), new(big.Int).SetBytes(out)) + + out, err = EncodeUint256Result(nil) + require.NoError(t, err) + assert.Equal(t, make([]byte, 32), out) + + _, err = EncodeUint256Result(big.NewInt(-1)) + assert.Error(t, err) +} diff --git a/universalClient/externalchains/common/types.go b/universalClient/externalchains/common/types.go index bcd466e3..a5bf6026 100644 --- a/universalClient/externalchains/common/types.go +++ b/universalClient/externalchains/common/types.go @@ -2,12 +2,29 @@ package common import ( "context" + "fmt" "math/big" "github.com/pushchain/push-chain-node/universalClient/store" uetypes "github.com/pushchain/push-chain-node/x/uexecutor/types" ) +// EncodeUint256Result canonically encodes a balance/amount as abi.encode(uint256) +// so read results are byte-identical across validators and decodable by the +// requesting contract. The bounds check guards against a malicious RPC value +// that would not fit (FillBytes panics on overflow). +func EncodeUint256Result(v *big.Int) ([]byte, error) { + if v == nil { + v = big.NewInt(0) + } + if v.Sign() < 0 || v.BitLen() > 256 { + return nil, fmt.Errorf("value out of uint256 range") + } + out := make([]byte, 32) + v.FillBytes(out) + return out, nil +} + // ChainClient defines the interface for chain-specific implementations type ChainClient interface { // Start initializes and starts the chain client diff --git a/universalClient/externalchains/evm/read_executor.go b/universalClient/externalchains/evm/read_executor.go index 3f1a950b..a2057760 100644 --- a/universalClient/externalchains/evm/read_executor.go +++ b/universalClient/externalchains/evm/read_executor.go @@ -85,7 +85,7 @@ func (c *Client) ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*urea } var slotValue [32]byte copy(slotValue[32-min(len(value), 32):], value) - resultData, err = common.EncodeBytes32Result(slotValue) + resultData = slotValue[:] default: return uread.NewErrorResult(fmt.Errorf("unknown EvmQueryType %d", env.QueryType)), nil From 56a32092b6fe6e98e470fc1346569ce6e186070b Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 28 Jul 2026 20:21:13 +0530 Subject: [PATCH 11/11] test(uclient): cover evm/svm read executors via json-rpc fakes All query types plus failure modes: eth_call revert, invalid envelopes, transient RPC failures, slot/height constraints, SPL account validation. --- .../externalchains/evm/read_executor_test.go | 255 ++++++++++++++++++ .../externalchains/svm/read_executor_test.go | 210 +++++++++++++++ 2 files changed, 465 insertions(+) create mode 100644 universalClient/externalchains/evm/read_executor_test.go create mode 100644 universalClient/externalchains/svm/read_executor_test.go diff --git a/universalClient/externalchains/evm/read_executor_test.go b/universalClient/externalchains/evm/read_executor_test.go new file mode 100644 index 00000000..661c083e --- /dev/null +++ b/universalClient/externalchains/evm/read_executor_test.go @@ -0,0 +1,255 @@ +package evm + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "testing" + + ethcommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pushchain/push-chain-node/universalClient/uread" +) + +// fakeHeader is a minimal valid block header JSON accepted by types.Header. +func fakeHeader(number uint64) map[string]any { + zeroHash := "0x0000000000000000000000000000000000000000000000000000000000000000" + return map[string]any{ + "parentHash": zeroHash, + "sha3Uncles": zeroHash, + "miner": "0x0000000000000000000000000000000000000000", + "stateRoot": zeroHash, + "transactionsRoot": zeroHash, + "receiptsRoot": zeroHash, + "logsBloom": "0x" + fmt.Sprintf("%0512x", 0), + "difficulty": "0x0", + "number": fmt.Sprintf("0x%x", number), + "gasLimit": "0x0", + "gasUsed": "0x0", + "timestamp": "0x0", + "extraData": "0x", + "mixHash": zeroHash, + "nonce": "0x0000000000000000", + } +} + +type rpcFault struct { + code int + message string +} + +// newReadTestClient spins up a JSON-RPC server answering from results/faults +// keyed by method name, and returns a Client wired to it. +func newReadTestClient(t *testing.T, results map[string]any, faults map[string]rpcFault) *Client { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + + resp := map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(req.ID)} + if fault, ok := faults[req.Method]; ok { + resp["error"] = map[string]any{"code": fault.code, "message": fault.message} + } else if result, ok := results[req.Method]; ok { + resp["result"] = result + } else { + t.Errorf("unexpected RPC method %s", req.Method) + resp["error"] = map[string]any{"code": -32601, "message": "method not found"} + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) + })) + t.Cleanup(srv.Close) + + ethClient, err := ethclient.Dial(srv.URL) + require.NoError(t, err) + t.Cleanup(ethClient.Close) + + return &Client{ + logger: zerolog.Nop(), + rpcClient: &RPCClient{clients: []*ethclient.Client{ethClient}, logger: zerolog.Nop()}, + } +} + +func evmReadRequest(t *testing.T, queryType uint8, blockNumber uint64, payload []byte) *uread.ReadRequest { + t.Helper() + return &uread.ReadRequest{ + RequestID: "0xreq1", + TargetChain: "eip155:11155111", + Query: packEvmEnvelope(t, queryType, 0, blockNumber, payload), + MinConfirmations: 1, + PinnedBlockHeight: 100, + } +} + +func TestExecuteRead_AccountBalance(t *testing.T) { + target := ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") + payload, err := addressArgs.Pack(target) + require.NoError(t, err) + + client := newReadTestClient(t, map[string]any{ + "eth_getBlockByNumber": fakeHeader(100), + "eth_getBalance": "0xf4240", // 1_000_000 + }, nil) + + result, err := client.ExecuteRead(context.Background(), evmReadRequest(t, uint8(evmQueryAccountBalance), 0, payload)) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusSuccess, result.Status) + assert.Equal(t, big.NewInt(1_000_000), new(big.Int).SetBytes(result.ResultData)) + assert.Equal(t, uint64(100), result.ObservedBlockHeight) + assert.Len(t, result.ObservedBlockHash, 32) +} + +func TestExecuteRead_ERC20Balance(t *testing.T) { + token := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + owner := ethcommon.HexToAddress("0x3333333333333333333333333333333333333333") + payload, err := addressPairArgs.Pack(token, owner) + require.NoError(t, err) + + client := newReadTestClient(t, map[string]any{ + "eth_getBlockByNumber": fakeHeader(100), + "eth_call": "0x" + fmt.Sprintf("%064x", 42), + }, nil) + + result, err := client.ExecuteRead(context.Background(), evmReadRequest(t, uint8(evmQueryERC20Balance), 0, payload)) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusSuccess, result.Status) + assert.Equal(t, big.NewInt(42), new(big.Int).SetBytes(result.ResultData)) +} + +func TestExecuteRead_ContractCall(t *testing.T) { + target := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + payload, err := addressBytesArgs.Pack(target, []byte{0xde, 0xad}) + require.NoError(t, err) + + t.Run("returns raw returndata", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "eth_getBlockByNumber": fakeHeader(100), + "eth_call": "0xcafebabe", + }, nil) + + result, err := client.ExecuteRead(context.Background(), evmReadRequest(t, uint8(evmQueryContractCall), 0, payload)) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusSuccess, result.Status) + assert.Equal(t, []byte{0xca, 0xfe, 0xba, 0xbe}, result.ResultData) + }) + + t.Run("revert is a votable ERROR observation", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "eth_getBlockByNumber": fakeHeader(100), + }, map[string]rpcFault{ + "eth_call": {code: 3, message: "execution reverted"}, + }) + + result, err := client.ExecuteRead(context.Background(), evmReadRequest(t, uint8(evmQueryContractCall), 0, payload)) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + assert.Empty(t, result.ResultData) + }) +} + +func TestExecuteRead_StorageSlot(t *testing.T) { + target := ethcommon.HexToAddress("0x2222222222222222222222222222222222222222") + payload, err := addressBytes32Args.Pack(target, [32]byte{0x01}) + require.NoError(t, err) + + client := newReadTestClient(t, map[string]any{ + "eth_getBlockByNumber": fakeHeader(100), + "eth_getStorageAt": "0x" + fmt.Sprintf("%064x", 7), + }, nil) + + result, err := client.ExecuteRead(context.Background(), evmReadRequest(t, uint8(evmQueryStorageSlot), 0, payload)) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusSuccess, result.Status) + require.Len(t, result.ResultData, 32) + assert.Equal(t, big.NewInt(7), new(big.Int).SetBytes(result.ResultData)) +} + +func TestExecuteRead_InvalidEnvelope(t *testing.T) { + client := newReadTestClient(t, nil, nil) + + result, err := client.ExecuteRead(context.Background(), &uread.ReadRequest{ + RequestID: "0xreq1", + Query: []byte{0x01, 0x02}, + PinnedBlockHeight: 100, + }) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) +} + +func TestExecuteRead_RPCFailureIsTransient(t *testing.T) { + target := ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") + payload, err := addressArgs.Pack(target) + require.NoError(t, err) + + client := newReadTestClient(t, nil, map[string]rpcFault{ + "eth_getBlockByNumber": {code: -32000, message: "node is syncing"}, + }) + + result, err := client.ExecuteRead(context.Background(), evmReadRequest(t, uint8(evmQueryAccountBalance), 0, payload)) + require.Error(t, err) + assert.Nil(t, result) +} + +func TestExecuteRead_EnvelopeBlockNumberFallback(t *testing.T) { + target := ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") + payload, err := addressArgs.Pack(target) + require.NoError(t, err) + + client := newReadTestClient(t, map[string]any{ + "eth_getBlockByNumber": fakeHeader(55), + "eth_getBalance": "0x1", + }, nil) + + req := evmReadRequest(t, uint8(evmQueryAccountBalance), 55, payload) + req.PinnedBlockHeight = 0 // TODO(core): fallback removed once core always pins + + result, err := client.ExecuteRead(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, uint64(55), result.ObservedBlockHeight) +} + +func TestExecuteRead_LatestHeightFallback(t *testing.T) { + target := ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") + payload, err := addressArgs.Pack(target) + require.NoError(t, err) + + // TODO(core): delete along with the latest-minConfirmations fallback. + t.Run("uses latest minus min confirmations", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "eth_blockNumber": "0x64", // 100 + "eth_getBlockByNumber": fakeHeader(99), + "eth_getBalance": "0x1", + }, nil) + + req := evmReadRequest(t, uint8(evmQueryAccountBalance), 0, payload) + req.PinnedBlockHeight = 0 + + result, err := client.ExecuteRead(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, uint64(99), result.ObservedBlockHeight) + }) + + t.Run("chain height below min confirmations is transient", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "eth_blockNumber": "0x1", + }, nil) + + req := evmReadRequest(t, uint8(evmQueryAccountBalance), 0, payload) + req.PinnedBlockHeight = 0 + req.MinConfirmations = 5 + + result, err := client.ExecuteRead(context.Background(), req) + require.Error(t, err) + assert.Nil(t, result) + }) +} diff --git a/universalClient/externalchains/svm/read_executor_test.go b/universalClient/externalchains/svm/read_executor_test.go new file mode 100644 index 00000000..b462b190 --- /dev/null +++ b/universalClient/externalchains/svm/read_executor_test.go @@ -0,0 +1,210 @@ +package svm + +import ( + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gagliardetto/solana-go" + solrpc "github.com/gagliardetto/solana-go/rpc" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pushchain/push-chain-node/universalClient/uread" +) + +// accountInfoResult builds a getAccountInfo result with base64 data. +func accountInfoResult(slot uint64, owner solana.PublicKey, data []byte) map[string]any { + return map[string]any{ + "context": map[string]any{"slot": slot}, + "value": map[string]any{ + "data": []any{base64.StdEncoding.EncodeToString(data), "base64"}, + "executable": false, + "lamports": 1, + "owner": owner.String(), + "rentEpoch": 0, + }, + } +} + +// newReadTestClient spins up a JSON-RPC server answering from results keyed by +// method name, and returns a Client wired to it. +func newReadTestClient(t *testing.T, results map[string]any) *Client { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + + resp := map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(req.ID)} + if result, ok := results[req.Method]; ok { + resp["result"] = result + } else { + t.Errorf("unexpected RPC method %s", req.Method) + resp["error"] = map[string]any{"code": -32601, "message": "method not found"} + } + require.NoError(t, json.NewEncoder(w).Encode(resp)) + })) + t.Cleanup(srv.Close) + + return &Client{ + logger: zerolog.Nop(), + rpcClient: &RPCClient{clients: []*solrpc.Client{solrpc.New(srv.URL)}, logger: zerolog.Nop()}, + } +} + +func svmReadRequest(t *testing.T, queryType uint8, minSlot uint64, owner []byte) *uread.ReadRequest { + t.Helper() + query, err := svmEnvelopeArgs.Pack(rawSvmEnvelope{ + QueryType: queryType, + SlotRef: struct { + MinSlot uint64 + }{minSlot}, + }) + require.NoError(t, err) + return &uread.ReadRequest{ + RequestID: "0xreq1", + TargetChain: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + Owner: owner, + Query: query, + } +} + +func testAccount() solana.PublicKey { + return solana.MustPublicKeyFromBase58("So11111111111111111111111111111111111111112") +} + +func TestExecuteRead_LamportBalance(t *testing.T) { + account := testAccount() + + t.Run("success", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "getBalance": map[string]any{ + "context": map[string]any{"slot": 900}, + "value": 5_000_000, + }, + }) + + result, err := client.ExecuteRead(context.Background(), svmReadRequest(t, uint8(solanaQueryLamportBalance), 800, account.Bytes())) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusSuccess, result.Status) + assert.Equal(t, big.NewInt(5_000_000), new(big.Int).SetBytes(result.ResultData)) + assert.Equal(t, uint64(900), result.ObservedBlockHeight) + }) + + t.Run("observed slot below min slot is transient", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "getBalance": map[string]any{ + "context": map[string]any{"slot": 700}, + "value": 5_000_000, + }, + }) + + result, err := client.ExecuteRead(context.Background(), svmReadRequest(t, uint8(solanaQueryLamportBalance), 800, account.Bytes())) + require.Error(t, err) + assert.Nil(t, result) + }) +} + +func TestExecuteRead_SPLTokenAccount(t *testing.T) { + account := testAccount() + + tokenAccountData := func(amount uint64) []byte { + data := make([]byte, 165) + binary.LittleEndian.PutUint64(data[splTokenAmountOffset:], amount) + return data + } + + t.Run("success", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "getAccountInfo": accountInfoResult(900, solana.TokenProgramID, tokenAccountData(777)), + }) + + result, err := client.ExecuteRead(context.Background(), svmReadRequest(t, uint8(solanaQuerySPLTokenAccount), 800, account.Bytes())) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusSuccess, result.Status) + assert.Equal(t, big.NewInt(777), new(big.Int).SetBytes(result.ResultData)) + assert.Equal(t, uint64(900), result.ObservedBlockHeight) + }) + + t.Run("non token-program owner is a votable ERROR", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "getAccountInfo": accountInfoResult(900, solana.SystemProgramID, tokenAccountData(777)), + }) + + result, err := client.ExecuteRead(context.Background(), svmReadRequest(t, uint8(solanaQuerySPLTokenAccount), 0, account.Bytes())) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) + + t.Run("truncated account data is a votable ERROR", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "getAccountInfo": accountInfoResult(900, solana.TokenProgramID, make([]byte, 10)), + }) + + result, err := client.ExecuteRead(context.Background(), svmReadRequest(t, uint8(solanaQuerySPLTokenAccount), 0, account.Bytes())) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) + + t.Run("missing account is a votable ERROR", func(t *testing.T) { + client := newReadTestClient(t, map[string]any{ + "getAccountInfo": map[string]any{ + "context": map[string]any{"slot": 900}, + "value": nil, + }, + }) + + result, err := client.ExecuteRead(context.Background(), svmReadRequest(t, uint8(solanaQuerySPLTokenAccount), 0, account.Bytes())) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) +} + +func TestExecuteRead_RawAccountData(t *testing.T) { + account := testAccount() + raw := []byte{0x01, 0x02, 0x03} + + client := newReadTestClient(t, map[string]any{ + "getAccountInfo": accountInfoResult(900, solana.SystemProgramID, raw), + }) + + result, err := client.ExecuteRead(context.Background(), svmReadRequest(t, uint8(solanaQueryRawAccountData), 0, account.Bytes())) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusSuccess, result.Status) + assert.Equal(t, raw, result.ResultData) + assert.Equal(t, uint64(900), result.ObservedBlockHeight) +} + +func TestExecuteRead_InvalidInputs(t *testing.T) { + account := testAccount() + + t.Run("invalid envelope is a votable ERROR", func(t *testing.T) { + client := newReadTestClient(t, nil) + + result, err := client.ExecuteRead(context.Background(), &uread.ReadRequest{ + RequestID: "0xreq1", + Owner: account.Bytes(), + Query: []byte{0x01}, + }) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) + + t.Run("owner not 32 bytes is a votable ERROR", func(t *testing.T) { + client := newReadTestClient(t, nil) + + result, err := client.ExecuteRead(context.Background(), svmReadRequest(t, uint8(solanaQueryLamportBalance), 0, []byte{0x01, 0x02})) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) +}