From d451fd26edfa832d54bb84d4324249f2583527b8 Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 28 Jul 2026 16:58:51 +0530 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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) + }) +} From d4691e76ef63097ae2bf60e62c226bd42987324b Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 3 Aug 2026 14:29:01 +0530 Subject: [PATCH 12/23] fix: temp types --- universalClient/uread/types.go | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/universalClient/uread/types.go b/universalClient/uread/types.go index e9bcaabc..bc706cb3 100644 --- a/universalClient/uread/types.go +++ b/universalClient/uread/types.go @@ -7,16 +7,14 @@ 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) - 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 - 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 + RequestID string // uint256 as 0x-prefixed hex (from ReadRequested event) + DestinationChain string // CAIP-2, e.g. "eip155:1", "solana:mainnet-beta"; web2 uses "web2:https" + Owner []byte // ReadSpec.account.owner (20-byte addr / 32-byte pubkey) + Query []byte // chain-specific envelope, abi.encode(...) + MinConfirmations uint16 + DestinationBlockHeight uint64 // destination chain height the read is made at; not applicable for web2 + ExpiryBlockHeight uint64 // Push chain height at which the request expires + CreatedAtHeight uint64 // Push chain height at which the request was created } // ReadStatus is the observed outcome a validator votes on. From 8c7d1717386b307bf9e5d8943629e3fde5250d6c Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 3 Aug 2026 14:29:21 +0530 Subject: [PATCH 13/23] fix: pushWatcher acc to types --- universalClient/pushwatcher/event_listener.go | 8 ++++---- universalClient/pushwatcher/event_parser.go | 13 +++++++------ 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/universalClient/pushwatcher/event_listener.go b/universalClient/pushwatcher/event_listener.go index 83683999..7b46d3fa 100644 --- a/universalClient/pushwatcher/event_listener.go +++ b/universalClient/pushwatcher/event_listener.go @@ -272,9 +272,9 @@ func (el *EventListener) pollReadRequestEvents(ctx context.Context) int { var newCount int for _, req := range requests { - targetClient, err := el.chainResolver.GetClient(req.TargetChain) + targetClient, err := el.chainResolver.GetClient(req.DestinationChain) 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") + el.logger.Debug().Err(err).Str("request_id", req.RequestID).Str("destination_chain", req.DestinationChain).Msg("target chain not served; skipping read request") continue } @@ -286,13 +286,13 @@ func (el *EventListener) pollReadRequestEvents(ctx context.Context) int { 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") + el.logger.Error().Err(err).Str("event_id", event.EventID).Str("destination_chain", req.DestinationChain).Msg("failed to store read request") continue } if stored { el.logger.Debug(). Str("event_id", event.EventID). - Str("target_chain", req.TargetChain). + Str("destination_chain", req.DestinationChain). Msg("routed read request to target chain") newCount++ } diff --git a/universalClient/pushwatcher/event_parser.go b/universalClient/pushwatcher/event_parser.go index c6f87960..1abb4a4f 100644 --- a/universalClient/pushwatcher/event_parser.go +++ b/universalClient/pushwatcher/event_parser.go @@ -107,12 +107,13 @@ func convertReadRequestEvent(req *uread.ReadRequest) (*store.Event, error) { } return &store.Event{ - EventID: hashEventID(store.EventTypeReadRequest, req.RequestID), - BlockHeight: req.CreatedAtHeight, - Type: store.EventTypeReadRequest, - ConfirmationType: store.ConfirmationInstant, - Status: store.StatusConfirmed, - EventData: eventData, + EventID: hashEventID(store.EventTypeReadRequest, req.RequestID), + BlockHeight: req.CreatedAtHeight, + ExpiryBlockHeight: req.ExpiryBlockHeight, + Type: store.EventTypeReadRequest, + ConfirmationType: store.ConfirmationInstant, + Status: store.StatusConfirmed, + EventData: eventData, }, nil } From c503e8a8192ae1e142789467c2693cf11f164b93 Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 3 Aug 2026 16:23:56 +0530 Subject: [PATCH 14/23] fix: add read event to pushchain db --- universalClient/core/client.go | 3 +- universalClient/externalchains/chains.go | 15 +++- universalClient/externalchains/chains_test.go | 8 +- .../externalchains/common/event_processor.go | 44 ++++++----- .../common/event_processor_test.go | 54 ++++++++----- .../externalchains/common/types.go | 6 -- universalClient/externalchains/evm/client.go | 9 +-- .../externalchains/evm/read_executor.go | 40 +++++----- .../externalchains/evm/read_executor_test.go | 75 ++++++++++++------- universalClient/externalchains/svm/client.go | 9 +-- .../externalchains/svm/read_executor.go | 11 ++- .../externalchains/svm/read_executor_test.go | 8 +- universalClient/pushwatcher/client.go | 51 ++++++++++--- universalClient/pushwatcher/client_test.go | 32 ++++---- universalClient/pushwatcher/event_listener.go | 60 +++------------ .../pushwatcher/event_listener_test.go | 14 ++-- .../tss/coordinator/coordinator_test.go | 7 +- .../tss/txbroadcaster/broadcaster_test.go | 9 +-- .../tss/txresolver/resolver_test.go | 9 +-- 19 files changed, 249 insertions(+), 215 deletions(-) diff --git a/universalClient/core/client.go b/universalClient/core/client.go index 25fb17c6..f65808f9 100644 --- a/universalClient/core/client.go +++ b/universalClient/core/client.go @@ -76,13 +76,14 @@ func NewUniversalClient(ctx context.Context, cfg *config.Config) (*UniversalClie return nil, err } - // chainsManager routes read request events into target chain DBs. + // chainsManager resolves destination chains for read request execution. pushChain, err := pushwatcher.NewClient( pushDB, cfg.GetChainConfig(cfg.PushChainID), pushCore, cfg.PushChainID, log, + pushSigner, chainsManager, ) if err != nil { diff --git a/universalClient/externalchains/chains.go b/universalClient/externalchains/chains.go index 12c1a907..9df7ee62 100644 --- a/universalClient/externalchains/chains.go +++ b/universalClient/externalchains/chains.go @@ -343,6 +343,20 @@ func (c *Chains) StopAll() { c.chainConfigs = make(map[string]*uregistrytypes.ChainConfig) } +// GetReader implements common.ChainResolver: resolves a CAIP-2 chain ID to a +// chain client that can execute external read requests. +func (c *Chains) GetReader(chainID string) (common.ChainReader, error) { + client, err := c.GetClient(chainID) + if err != nil { + return nil, err + } + reader, ok := client.(common.ChainReader) + if !ok { + return nil, fmt.Errorf("chain client for %s does not support reads", chainID) + } + return reader, nil +} + // GetClient returns the chain client for the specified chain ID func (c *Chains) GetClient(chainID string) (common.ChainClient, error) { c.chainsMu.RLock() @@ -356,7 +370,6 @@ func (c *Chains) GetClient(chainID string) (common.ChainClient, error) { return client, 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/chains_test.go b/universalClient/externalchains/chains_test.go index 4989fa22..20ed9d84 100644 --- a/universalClient/externalchains/chains_test.go +++ b/universalClient/externalchains/chains_test.go @@ -12,7 +12,6 @@ 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" ) @@ -411,10 +410,9 @@ 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) AddEvent(event *store.Event) (bool, error) { return true, nil } +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) GetTxBuilder() (common.TxBuilder, error) { return nil, nil } diff --git a/universalClient/externalchains/common/event_processor.go b/universalClient/externalchains/common/event_processor.go index 2c082303..8a52df7c 100644 --- a/universalClient/externalchains/common/event_processor.go +++ b/universalClient/externalchains/common/event_processor.go @@ -32,6 +32,12 @@ type ChainReader interface { ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) } +// ChainResolver resolves a CAIP-2 chain ID to its ChainReader. +// Implemented by externalchains.Chains. +type ChainResolver interface { + GetReader(chainID string) (ChainReader, error) +} + // EventProcessor processes events from the chain's database and votes on them type EventProcessor struct { signer VoteSigner @@ -40,12 +46,12 @@ type EventProcessor struct { chainID string inboundEnabled bool outboundEnabled bool - // 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 + // readResolver resolves the destination chain of READ_REQUEST events (kept + // in the push chain DB). Nil disables read processing. + readResolver ChainResolver + running bool + stopCh chan struct{} + wg sync.WaitGroup } // NewEventProcessor creates a new event processor @@ -55,7 +61,7 @@ func NewEventProcessor( chainID string, inboundEnabled bool, outboundEnabled bool, - reader ChainReader, + readResolver ChainResolver, logger zerolog.Logger, ) *EventProcessor { return &EventProcessor{ @@ -64,7 +70,7 @@ func NewEventProcessor( chainID: chainID, inboundEnabled: inboundEnabled, outboundEnabled: outboundEnabled, - reader: reader, + readResolver: readResolver, logger: logger.With().Str("component", "event_processor").Str("chain", chainID).Logger(), stopCh: make(chan struct{}), } @@ -163,8 +169,8 @@ func (ep *EventProcessor) processConfirmedEvents(ctx context.Context) error { 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") + if ep.readResolver == nil { + ep.logger.Warn().Str("event_id", event.EventID).Msg("no read resolver configured, skipping read request event processing") continue } if err := ep.processReadRequestEvent(ctx, &event); err != nil { @@ -235,10 +241,11 @@ func (ep *EventProcessor) processInboundEvent(ctx context.Context, event *store. 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). +// processReadRequestEvent executes an external read request against its +// destination chain and votes the observation. Transient failures (chain not +// served, RPC errors, vote failure) keep the event CONFIRMED for retry; +// corrupt requests flip to REVERTED without voting. Expiry is core's job: +// expired requests leave the pending query, so they stop being stored here. func (ep *EventProcessor) processReadRequestEvent(ctx context.Context, event *store.Event) error { var req uread.ReadRequest if err := json.Unmarshal(event.EventData, &req); err != nil { @@ -246,13 +253,14 @@ func (ep *EventProcessor) processReadRequestEvent(ctx context.Context, event *st 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) + reader, err := ep.readResolver.GetReader(req.DestinationChain) + if err != nil { + // destination not served by this validator yet; retry next tick + ep.logger.Debug().Err(err).Str("request_id", req.RequestID).Str("destination_chain", req.DestinationChain).Msg("no reader for destination chain") return nil } - result, err := ep.reader.ExecuteRead(ctx, &req) + result, err := reader.ExecuteRead(ctx, &req) if err != nil { return fmt.Errorf("read execution failed: %w", err) } diff --git a/universalClient/externalchains/common/event_processor_test.go b/universalClient/externalchains/common/event_processor_test.go index fd6c802c..ca3de799 100644 --- a/universalClient/externalchains/common/event_processor_test.go +++ b/universalClient/externalchains/common/event_processor_test.go @@ -58,14 +58,25 @@ func (f *fakeChainReader) ExecuteRead(ctx context.Context, req *uread.ReadReques return f.result, f.err } +type fakeChainResolver struct { + reader ChainReader +} + +func (f *fakeChainResolver) GetReader(chainID string) (ChainReader, error) { + if f.reader == nil { + return nil, fmt.Errorf("no reader for %s", chainID) + } + return f.reader, nil +} + func testReadRequest() *uread.ReadRequest { return &uread.ReadRequest{ - RequestID: "0xabc123", - TargetChain: "eip155:11155111", - Query: []byte{0x01}, - MinConfirmations: 1, - PinnedBlockHeight: 100, - CreatedAtHeight: 7, + RequestID: "0xabc123", + DestinationChain: "eip155:11155111", + Query: []byte{0x01}, + MinConfirmations: 1, + DestinationBlockHeight: 100, + CreatedAtHeight: 7, } } @@ -73,7 +84,11 @@ func newReadTestProcessor(t *testing.T, signer VoteSigner, reader ChainReader) ( t.Helper() database, err := ucdb.OpenInMemoryDB(true) require.NoError(t, err) - ep := NewEventProcessor(signer, database, "eip155:11155111", false, false, reader, zerolog.Nop()) + var resolver ChainResolver + if reader != nil { + resolver = &fakeChainResolver{reader: reader} + } + ep := NewEventProcessor(signer, database, "push_42101-1", false, false, resolver, zerolog.Nop()) return ep, NewChainStore(database) } @@ -136,37 +151,40 @@ func TestProcessReadRequest_VoteFailureKeepsConfirmed(t *testing.T) { assert.Equal(t, store.StatusConfirmed, eventStatus(t, cs, eventID)) } -func TestProcessReadRequest_ExpiredMarkedReverted(t *testing.T) { +func TestProcessReadRequest_ExecutionFailureRetries(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}}) + 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.StatusReverted, eventStatus(t, cs, eventID)) + assert.Equal(t, store.StatusConfirmed, eventStatus(t, cs, eventID)) } -func TestProcessReadRequest_ExecutionFailureRetries(t *testing.T) { +func TestProcessReadRequest_NoResolverSkips(t *testing.T) { req := testReadRequest() signer := &fakeVoteSigner{txHash: "VOTE_TX"} - ep, cs := newReadTestProcessor(t, signer, &fakeChainReader{err: fmt.Errorf("rpc down")}) + // nil resolver -> read events are skipped, left CONFIRMED + ep, cs := newReadTestProcessor(t, signer, nil) 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) { +func TestProcessReadRequest_UnservedChainRetries(t *testing.T) { req := testReadRequest() signer := &fakeVoteSigner{txHash: "VOTE_TX"} - // nil reader -> read events are skipped, left CONFIRMED - ep, cs := newReadTestProcessor(t, signer, nil) + database, err := ucdb.OpenInMemoryDB(true) + require.NoError(t, err) + // resolver present but has no reader for the destination chain + ep := NewEventProcessor(signer, database, "push_42101-1", false, false, &fakeChainResolver{}, zerolog.Nop()) + cs := NewChainStore(database) eventID := seedReadRequest(t, cs, req) require.NoError(t, ep.processConfirmedEvents(context.Background())) @@ -917,7 +935,7 @@ func TestEventProcessorStruct(t *testing.T) { ep := &EventProcessor{} assert.Nil(t, ep.signer) assert.Nil(t, ep.chainStore) - assert.Nil(t, ep.reader) + assert.Nil(t, ep.readResolver) assert.Empty(t, ep.chainID) assert.False(t, ep.running) assert.Nil(t, ep.stopCh) diff --git a/universalClient/externalchains/common/types.go b/universalClient/externalchains/common/types.go index a5bf6026..dcf22023 100644 --- a/universalClient/externalchains/common/types.go +++ b/universalClient/externalchains/common/types.go @@ -5,7 +5,6 @@ import ( "fmt" "math/big" - "github.com/pushchain/push-chain-node/universalClient/store" uetypes "github.com/pushchain/push-chain-node/x/uexecutor/types" ) @@ -39,11 +38,6 @@ 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 b36cd282..908a2c6b 100644 --- a/universalClient/externalchains/evm/client.go +++ b/universalClient/externalchains/evm/client.go @@ -14,7 +14,6 @@ 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" ) @@ -89,15 +88,13 @@ 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, + nil, log, ) } @@ -203,10 +200,6 @@ 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 { diff --git a/universalClient/externalchains/evm/read_executor.go b/universalClient/externalchains/evm/read_executor.go index a2057760..78fa0d7b 100644 --- a/universalClient/externalchains/evm/read_executor.go +++ b/universalClient/externalchains/evm/read_executor.go @@ -15,16 +15,24 @@ import ( 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. +// All validators must produce byte-identical results, so every query runs at the +// height pinned in the request; execution is gated until that height has +// min_confirmations confirmations so a reorg cannot invalidate the read. 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 { + height := req.DestinationBlockHeight + if height == 0 { + height = env.BlockNumber + } + if height == 0 { + return uread.NewErrorResult(fmt.Errorf("read request has no target height")), nil + } + + if err := c.gateHeightConfirmed(ctx, height, uint64(req.MinConfirmations)); err != nil { return nil, err } blockNum := new(big.Int).SetUint64(height) @@ -102,24 +110,16 @@ func (c *Client) ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*urea }, 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 - } +// gateHeightConfirmed blocks execution until the target height has at least +// minConfirmations confirmations. An error is transient: the processor keeps +// the event CONFIRMED and retries next tick. +func (c *Client) gateHeightConfirmed(ctx context.Context, height, minConfirmations uint64) error { latest, err := c.rpcClient.GetLatestBlock(ctx) if err != nil { - return 0, fmt.Errorf("failed to get latest block: %w", err) + return 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) + if latest < height+minConfirmations { + return fmt.Errorf("height %d needs %d confirmations, chain at %d; not final yet", height, minConfirmations, latest) } - return latest - conf, nil + return nil } diff --git a/universalClient/externalchains/evm/read_executor_test.go b/universalClient/externalchains/evm/read_executor_test.go index 661c083e..37c97491 100644 --- a/universalClient/externalchains/evm/read_executor_test.go +++ b/universalClient/externalchains/evm/read_executor_test.go @@ -50,6 +50,16 @@ type rpcFault struct { func newReadTestClient(t *testing.T, results map[string]any, faults map[string]rpcFault) *Client { t.Helper() + // every read is gated on the chain tip; default to a comfortably deep chain + // unless the test overrides eth_blockNumber + if results != nil { + if _, ok := results["eth_blockNumber"]; !ok { + if _, ok := faults["eth_blockNumber"]; !ok { + results["eth_blockNumber"] = "0x1000" + } + } + } + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req struct { ID json.RawMessage `json:"id"` @@ -83,11 +93,11 @@ func newReadTestClient(t *testing.T, results map[string]any, faults map[string]r 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, + RequestID: "0xreq1", + DestinationChain: "eip155:11155111", + Query: packEvmEnvelope(t, queryType, 0, blockNumber, payload), + MinConfirmations: 1, + DestinationBlockHeight: 100, } } @@ -178,9 +188,9 @@ 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, + RequestID: "0xreq1", + Query: []byte{0x01, 0x02}, + DestinationBlockHeight: 100, }) require.NoError(t, err) assert.Equal(t, uread.ReadStatusError, result.Status) @@ -191,7 +201,7 @@ func TestExecuteRead_RPCFailureIsTransient(t *testing.T) { payload, err := addressArgs.Pack(target) require.NoError(t, err) - client := newReadTestClient(t, nil, map[string]rpcFault{ + client := newReadTestClient(t, map[string]any{}, map[string]rpcFault{ "eth_getBlockByNumber": {code: -32000, message: "node is syncing"}, }) @@ -200,7 +210,7 @@ func TestExecuteRead_RPCFailureIsTransient(t *testing.T) { assert.Nil(t, result) } -func TestExecuteRead_EnvelopeBlockNumberFallback(t *testing.T) { +func TestExecuteRead_EnvelopeBlockNumberUsedWhenNotPinned(t *testing.T) { target := ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") payload, err := addressArgs.Pack(target) require.NoError(t, err) @@ -211,45 +221,60 @@ func TestExecuteRead_EnvelopeBlockNumberFallback(t *testing.T) { }, nil) req := evmReadRequest(t, uint8(evmQueryAccountBalance), 55, payload) - req.PinnedBlockHeight = 0 // TODO(core): fallback removed once core always pins + req.DestinationBlockHeight = 0 // client-provided height in the envelope result, err := client.ExecuteRead(context.Background(), req) require.NoError(t, err) assert.Equal(t, uint64(55), result.ObservedBlockHeight) } -func TestExecuteRead_LatestHeightFallback(t *testing.T) { +func TestExecuteRead_MissingHeightIsVotableError(t *testing.T) { + target := ethcommon.HexToAddress("0x1111111111111111111111111111111111111111") + payload, err := addressArgs.Pack(target) + require.NoError(t, err) + + client := newReadTestClient(t, nil, nil) + + req := evmReadRequest(t, uint8(evmQueryAccountBalance), 0, payload) + req.DestinationBlockHeight = 0 + + result, err := client.ExecuteRead(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) +} + +func TestExecuteRead_ConfirmationGate(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) { + t.Run("height not deep enough is transient", func(t *testing.T) { client := newReadTestClient(t, map[string]any{ - "eth_blockNumber": "0x64", // 100 - "eth_getBlockByNumber": fakeHeader(99), - "eth_getBalance": "0x1", + "eth_blockNumber": "0x64", // 100 }, nil) req := evmReadRequest(t, uint8(evmQueryAccountBalance), 0, payload) - req.PinnedBlockHeight = 0 + req.DestinationBlockHeight = 100 + req.MinConfirmations = 5 // needs chain at >= 105 result, err := client.ExecuteRead(context.Background(), req) - require.NoError(t, err) - assert.Equal(t, uint64(99), result.ObservedBlockHeight) + require.Error(t, err) + assert.Nil(t, result) }) - t.Run("chain height below min confirmations is transient", func(t *testing.T) { + t.Run("executes once deep enough", func(t *testing.T) { client := newReadTestClient(t, map[string]any{ - "eth_blockNumber": "0x1", + "eth_blockNumber": "0x69", // 105 + "eth_getBlockByNumber": fakeHeader(100), + "eth_getBalance": "0x1", }, nil) req := evmReadRequest(t, uint8(evmQueryAccountBalance), 0, payload) - req.PinnedBlockHeight = 0 + req.DestinationBlockHeight = 100 req.MinConfirmations = 5 result, err := client.ExecuteRead(context.Background(), req) - require.Error(t, err) - assert.Nil(t, result) + require.NoError(t, err) + assert.Equal(t, uint64(100), result.ObservedBlockHeight) }) } diff --git a/universalClient/externalchains/svm/client.go b/universalClient/externalchains/svm/client.go index a73140a2..6b29c72c 100644 --- a/universalClient/externalchains/svm/client.go +++ b/universalClient/externalchains/svm/client.go @@ -12,7 +12,6 @@ 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" ) @@ -99,15 +98,13 @@ 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, + nil, log, ) } @@ -213,10 +210,6 @@ 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 { diff --git a/universalClient/externalchains/svm/read_executor.go b/universalClient/externalchains/svm/read_executor.go index 4b305e29..d5fc7085 100644 --- a/universalClient/externalchains/svm/read_executor.go +++ b/universalClient/externalchains/svm/read_executor.go @@ -17,11 +17,10 @@ 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. +// Solana cannot query state at an exact past slot, so reads run at finalized +// commitment with minContextSlot as a staleness floor. ObservedBlockHeight (the +// context slot) may differ across validators; core's ballot key covers the +// result value only, never the observed slot. func (c *Client) ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) { env, err := decodeSolanaQueryEnvelope(req.Query) if err != nil { @@ -33,7 +32,7 @@ func (c *Client) ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*urea } account := solana.PublicKeyFromBytes(req.Owner) - minSlot := max(env.MinSlot, req.PinnedBlockHeight) + minSlot := max(env.MinSlot, req.DestinationBlockHeight) switch env.QueryType { case solanaQueryLamportBalance: diff --git a/universalClient/externalchains/svm/read_executor_test.go b/universalClient/externalchains/svm/read_executor_test.go index b462b190..17331ec9 100644 --- a/universalClient/externalchains/svm/read_executor_test.go +++ b/universalClient/externalchains/svm/read_executor_test.go @@ -72,10 +72,10 @@ func svmReadRequest(t *testing.T, queryType uint8, minSlot uint64, owner []byte) }) require.NoError(t, err) return &uread.ReadRequest{ - RequestID: "0xreq1", - TargetChain: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", - Owner: owner, - Query: query, + RequestID: "0xreq1", + DestinationChain: "solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1", + Owner: owner, + Query: query, } } diff --git a/universalClient/pushwatcher/client.go b/universalClient/pushwatcher/client.go index 85e2d74a..503c01bb 100644 --- a/universalClient/pushwatcher/client.go +++ b/universalClient/pushwatcher/client.go @@ -10,29 +10,33 @@ 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/pushcore" + "github.com/pushchain/push-chain-node/universalClient/pushsigner" "github.com/rs/zerolog" ) // Client implements the ChainClient interface for Push chain type Client struct { - logger zerolog.Logger - pushCore *pushcore.Client - database *db.DB - eventListener *EventListener - eventCleaner *common.EventCleaner - ctx context.Context - cancel context.CancelFunc + logger zerolog.Logger + pushCore *pushcore.Client + database *db.DB + eventListener *EventListener + eventCleaner *common.EventCleaner + eventProcessor *common.EventProcessor + ctx context.Context + cancel context.CancelFunc } // NewClient creates a new Push chain client. -// chainResolver may be nil; the listener then skips read request polling. +// pushSigner and readResolver may be nil; the event processor (read request +// execution + voting) is only wired when both are present. func NewClient( database *db.DB, chainConfig *config.ChainSpecificConfig, pushCore *pushcore.Client, chainID string, logger zerolog.Logger, - chainResolver ExternalChainResolver, + pushSigner *pushsigner.Signer, + readResolver common.ChainResolver, ) (*Client, error) { // Normalize nil config so downstream uses don't need nil guards. if chainConfig == nil { @@ -45,7 +49,6 @@ func NewClient( database, logger, chainConfig, - chainResolver, ) if err != nil { return nil, fmt.Errorf("failed to create event listener: %w", err) @@ -67,6 +70,20 @@ func NewClient( eventCleaner: eventCleaner, } + // The push DB holds READ_REQUEST events; the processor executes them on + // their destination chains (via readResolver) and votes the results. + if pushSigner != nil && readResolver != nil { + client.eventProcessor = common.NewEventProcessor( + pushSigner, + database, + chainID, + false, + false, + readResolver, + logger, + ) + } + return client, nil } @@ -88,6 +105,13 @@ func (c *Client) Start(ctx context.Context) error { } } + // Start event processor if wired + if c.eventProcessor != nil { + if err := c.eventProcessor.Start(c.ctx); err != nil { + return fmt.Errorf("failed to start event processor: %w", err) + } + } + c.logger.Info().Msg("Push chain client started successfully") return nil } @@ -113,6 +137,13 @@ func (c *Client) Stop() error { c.eventCleaner.Stop() } + // Stop event processor + if c.eventProcessor != nil { + if err := c.eventProcessor.Stop(); err != nil { + c.logger.Error().Err(err).Str("subsystem", "event_processor").Msg("subsystem failed to stop") + } + } + c.logger.Info().Msg("Push chain client stopped") return nil } diff --git a/universalClient/pushwatcher/client_test.go b/universalClient/pushwatcher/client_test.go index 9f3a0b01..8df232c4 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, nil) + client, err := NewClient(database, nil, pc, "push-chain", logger, nil, 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, nil) + client, err := NewClient(database, cfg, pc, "push-chain", logger, nil, 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, nil) + _, err := NewClient(database, nil, nil, "push-chain", logger, nil, 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, nil) + _, err := NewClient(nil, nil, pc, "push-chain", logger, nil, 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(), nil) + client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop(), nil, 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(), nil) + client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop(), nil, 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(), nil) + client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop(), nil, 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(), nil) + client, err := NewClient(newTestDB(t), cfg, newTestPushCoreClient(), "push-chain", zerolog.Nop(), nil, 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(), nil) + client, err := NewClient(newTestDB(t), nil, newTestPushCoreClient(), "push-chain", zerolog.Nop(), nil, 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, nil) + client, err := NewClient(database, tc.cfg, pc, "push-chain", logger, nil, 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, nil) + client, err := NewClient(database, cfg, pc, "push-chain", logger, nil, 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, nil) + el, err := NewEventListener(pc, database, logger, 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, nil) + el, err := NewEventListener(pc, database, logger, 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, nil) + el, err := NewEventListener(pc, database, logger, 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, nil) + el, err := NewEventListener(pc, database, logger, 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, nil) + el, err := NewEventListener(pc, database, logger, nil) require.NoError(t, err) event := &store.Event{ diff --git a/universalClient/pushwatcher/event_listener.go b/universalClient/pushwatcher/event_listener.go index 7b46d3fa..54dbb19c 100644 --- a/universalClient/pushwatcher/event_listener.go +++ b/universalClient/pushwatcher/event_listener.go @@ -28,23 +28,14 @@ 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 chainResolver) so that chain's own event processor executes -// and votes them. +// them into the local DB. type EventListener struct { - pushCore *pushcore.Client - chainStore *common.ChainStore - chainResolver ExternalChainResolver - cfg Config - logger zerolog.Logger + pushCore *pushcore.Client + chainStore *common.ChainStore + cfg Config + logger zerolog.Logger mu sync.Mutex running bool @@ -53,13 +44,11 @@ type EventListener struct { } // NewEventListener creates a new Push event listener. -// 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, - chainResolver ExternalChainResolver, ) (*EventListener, error) { if pushCore == nil { return nil, ErrNilClient @@ -74,11 +63,10 @@ func NewEventListener( } return &EventListener{ - pushCore: pushCore, - chainStore: common.NewChainStore(database), - chainResolver: chainResolver, - cfg: Config{PollInterval: pollInterval}, - logger: logger.With().Str("component", "push_event_listener").Logger(), + pushCore: pushCore, + chainStore: common.NewChainStore(database), + cfg: Config{PollInterval: pollInterval}, + logger: logger.With().Str("component", "push_event_listener").Logger(), }, nil } @@ -250,16 +238,9 @@ 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. +// pollReadRequestEvents fetches pending external read requests and inserts +// them into the DB. Returns new event count. func (el *EventListener) pollReadRequestEvents(ctx context.Context) int { - if el.chainResolver == nil { - return 0 - } - requests, err := el.pushCore.GetAllPendingReadRequests(ctx) if err != nil { if errors.Is(err, pushcore.ErrReadQueriesNotAvailable) { @@ -272,30 +253,13 @@ func (el *EventListener) pollReadRequestEvents(ctx context.Context) int { var newCount int for _, req := range requests { - targetClient, err := el.chainResolver.GetClient(req.DestinationChain) - if err != nil { - el.logger.Debug().Err(err).Str("request_id", req.RequestID).Str("destination_chain", req.DestinationChain).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 := targetClient.AddEvent(event) - if err != nil { - el.logger.Error().Err(err).Str("event_id", event.EventID).Str("destination_chain", req.DestinationChain).Msg("failed to store read request") - continue - } - if stored { - el.logger.Debug(). - Str("event_id", event.EventID). - Str("destination_chain", req.DestinationChain). - Msg("routed read request to target chain") - newCount++ - } + newCount += el.storeEvent(event) } return newCount diff --git a/universalClient/pushwatcher/event_listener_test.go b/universalClient/pushwatcher/event_listener_test.go index 4805df99..983b3fb3 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, nil) + el, err := NewEventListener(client, db, logger, 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, nil) + _, err := NewEventListener(nil, db, logger, nil) assert.ErrorIs(t, err, ErrNilClient) }) t.Run("nil database", func(t *testing.T) { - _, err := NewEventListener(client, nil, logger, nil, nil) + _, err := NewEventListener(client, nil, logger, 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, nil) + el, err := NewEventListener(client, db, logger, &cfg) 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, nil) + el, err := NewEventListener(client, db, logger, &cfg) 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, nil) + el, err := NewEventListener(newTestPushCoreClient(), newTestDB(t), zerolog.Nop(), 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, nil) + el, err := NewEventListener(newTestPushCoreClient(), newTestDB(t), zerolog.Nop(), nil) require.NoError(t, err) ctx := context.Background() diff --git a/universalClient/tss/coordinator/coordinator_test.go b/universalClient/tss/coordinator/coordinator_test.go index de65f326..69cf0f26 100644 --- a/universalClient/tss/coordinator/coordinator_test.go +++ b/universalClient/tss/coordinator/coordinator_test.go @@ -81,10 +81,9 @@ 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) AddEvent(event *store.Event) (bool, error) { return true, nil } +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) 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 bc693f72..653d13d0 100644 --- a/universalClient/tss/txbroadcaster/broadcaster_test.go +++ b/universalClient/tss/txbroadcaster/broadcaster_test.go @@ -79,11 +79,10 @@ 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) AddEvent(event *store.Event) (bool, error) { return true, nil } -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) 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 beb79aa1..b7df913f 100644 --- a/universalClient/tss/txresolver/resolver_test.go +++ b/universalClient/tss/txresolver/resolver_test.go @@ -76,11 +76,10 @@ 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) AddEvent(event *store.Event) (bool, error) { return true, nil } -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) GetTxBuilder() (common.TxBuilder, error) { return m.builder, nil } func setupTestDB(t *testing.T) (*eventstore.Store, *gorm.DB) { t.Helper() From 4f822fc2b7d9bd9e0dc0f1abd30088dd88813c5b Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 3 Aug 2026 16:53:45 +0530 Subject: [PATCH 15/23] fix: move type to client --- universalClient/externalchains/chains.go | 14 - universalClient/externalchains/chains_test.go | 3 + .../externalchains/common/event_processor.go | 78 +----- .../common/event_processor_test.go | 242 +++--------------- .../externalchains/common/types.go | 12 + universalClient/externalchains/evm/client.go | 9 +- universalClient/externalchains/svm/client.go | 9 +- universalClient/pushwatcher/client.go | 54 ++-- universalClient/pushwatcher/read_processor.go | 215 ++++++++++++++++ .../pushwatcher/read_processor_test.go | 216 ++++++++++++++++ .../tss/coordinator/coordinator_test.go | 3 + .../tss/txbroadcaster/broadcaster_test.go | 9 +- .../tss/txresolver/resolver_test.go | 9 +- 13 files changed, 541 insertions(+), 332 deletions(-) create mode 100644 universalClient/pushwatcher/read_processor.go create mode 100644 universalClient/pushwatcher/read_processor_test.go diff --git a/universalClient/externalchains/chains.go b/universalClient/externalchains/chains.go index 9df7ee62..df25f2b8 100644 --- a/universalClient/externalchains/chains.go +++ b/universalClient/externalchains/chains.go @@ -343,20 +343,6 @@ func (c *Chains) StopAll() { c.chainConfigs = make(map[string]*uregistrytypes.ChainConfig) } -// GetReader implements common.ChainResolver: resolves a CAIP-2 chain ID to a -// chain client that can execute external read requests. -func (c *Chains) GetReader(chainID string) (common.ChainReader, error) { - client, err := c.GetClient(chainID) - if err != nil { - return nil, err - } - reader, ok := client.(common.ChainReader) - if !ok { - return nil, fmt.Errorf("chain client for %s does not support reads", chainID) - } - return reader, nil -} - // GetClient returns the chain client for the specified chain ID func (c *Chains) GetClient(chainID string) (common.ChainClient, error) { c.chainsMu.RLock() diff --git a/universalClient/externalchains/chains_test.go b/universalClient/externalchains/chains_test.go index 20ed9d84..9426e0a8 100644 --- a/universalClient/externalchains/chains_test.go +++ b/universalClient/externalchains/chains_test.go @@ -413,6 +413,9 @@ type mockChainClient struct { 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) GetReadRequestHandler() (common.ReadRequestHandler, error) { + return nil, nil +} func (m *mockChainClient) GetTxBuilder() (common.TxBuilder, error) { return nil, nil } diff --git a/universalClient/externalchains/common/event_processor.go b/universalClient/externalchains/common/event_processor.go index 8a52df7c..c49fcb3d 100644 --- a/universalClient/externalchains/common/event_processor.go +++ b/universalClient/externalchains/common/event_processor.go @@ -13,7 +13,6 @@ import ( "github.com/mr-tron/base58" "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" "github.com/rs/zerolog" ) @@ -23,19 +22,6 @@ import ( 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) -} - -// 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) -} - -// ChainResolver resolves a CAIP-2 chain ID to its ChainReader. -// Implemented by externalchains.Chains. -type ChainResolver interface { - GetReader(chainID string) (ChainReader, error) } // EventProcessor processes events from the chain's database and votes on them @@ -46,12 +32,9 @@ type EventProcessor struct { chainID string inboundEnabled bool outboundEnabled bool - // readResolver resolves the destination chain of READ_REQUEST events (kept - // in the push chain DB). Nil disables read processing. - readResolver ChainResolver - running bool - stopCh chan struct{} - wg sync.WaitGroup + running bool + stopCh chan struct{} + wg sync.WaitGroup } // NewEventProcessor creates a new event processor @@ -61,7 +44,6 @@ func NewEventProcessor( chainID string, inboundEnabled bool, outboundEnabled bool, - readResolver ChainResolver, logger zerolog.Logger, ) *EventProcessor { return &EventProcessor{ @@ -70,7 +52,6 @@ func NewEventProcessor( chainID: chainID, inboundEnabled: inboundEnabled, outboundEnabled: outboundEnabled, - readResolver: readResolver, logger: logger.With().Str("component", "event_processor").Str("chain", chainID).Logger(), stopCh: make(chan struct{}), } @@ -136,7 +117,7 @@ func (ep *EventProcessor) processLoop(ctx context.Context) { } } -// processConfirmedEvents processes confirmed events (inbound, outbound and read requests) +// processConfirmedEvents processes confirmed events (both inbound and outbound) func (ep *EventProcessor) processConfirmedEvents(ctx context.Context) error { events, err := ep.chainStore.GetConfirmedEvents(1000) if err != nil { @@ -168,18 +149,6 @@ func (ep *EventProcessor) processConfirmedEvents(ctx context.Context) error { Msg("failed to vote on outbound event") continue } - } else if event.Type == store.EventTypeReadRequest { - if ep.readResolver == nil { - ep.logger.Warn().Str("event_id", event.EventID).Msg("no read resolver 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 - } } } @@ -241,39 +210,6 @@ func (ep *EventProcessor) processInboundEvent(ctx context.Context, event *store. return ep.markCompleted(event, voteTxHash) } -// processReadRequestEvent executes an external read request against its -// destination chain and votes the observation. Transient failures (chain not -// served, RPC errors, vote failure) keep the event CONFIRMED for retry; -// corrupt requests flip to REVERTED without voting. Expiry is core's job: -// expired requests leave the pending query, so they stop being stored here. -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) - } - - reader, err := ep.readResolver.GetReader(req.DestinationChain) - if err != nil { - // destination not served by this validator yet; retry next tick - ep.logger.Debug().Err(err).Str("request_id", req.RequestID).Str("destination_chain", req.DestinationChain).Msg("no reader for destination chain") - return nil - } - - result, err := 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) @@ -294,12 +230,6 @@ func (ep *EventProcessor) markCompleted(event *store.Event, voteTxHash string) e 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 ca3de799..caf7a44a 100644 --- a/universalClient/externalchains/common/event_processor_test.go +++ b/universalClient/externalchains/common/event_processor_test.go @@ -3,7 +3,6 @@ package common import ( "context" "encoding/json" - "fmt" "math/big" "testing" "time" @@ -14,191 +13,15 @@ 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 -} - -type fakeChainResolver struct { - reader ChainReader -} - -func (f *fakeChainResolver) GetReader(chainID string) (ChainReader, error) { - if f.reader == nil { - return nil, fmt.Errorf("no reader for %s", chainID) - } - return f.reader, nil -} - -func testReadRequest() *uread.ReadRequest { - return &uread.ReadRequest{ - RequestID: "0xabc123", - DestinationChain: "eip155:11155111", - Query: []byte{0x01}, - MinConfirmations: 1, - DestinationBlockHeight: 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) - var resolver ChainResolver - if reader != nil { - resolver = &fakeChainResolver{reader: reader} - } - ep := NewEventProcessor(signer, database, "push_42101-1", false, false, resolver, 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_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_NoResolverSkips(t *testing.T) { - req := testReadRequest() - signer := &fakeVoteSigner{txHash: "VOTE_TX"} - // nil resolver -> 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 TestProcessReadRequest_UnservedChainRetries(t *testing.T) { - req := testReadRequest() - signer := &fakeVoteSigner{txHash: "VOTE_TX"} - database, err := ucdb.OpenInMemoryDB(true) - require.NoError(t, err) - // resolver present but has no reader for the destination chain - ep := NewEventProcessor(signer, database, "push_42101-1", false, false, &fakeChainResolver{}, zerolog.Nop()) - cs := NewChainStore(database) - 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, nil, logger) + processor := NewEventProcessor(nil, nil, chainID, true, true, logger) require.NotNil(t, processor) assert.Equal(t, chainID, processor.chainID) @@ -230,7 +53,7 @@ func TestEventProcessorStop(t *testing.T) { func TestEventProcessorBase58ToHex(t *testing.T) { logger := zerolog.Nop() - processor := NewEventProcessor(nil, nil, "test-chain", true, true, nil, logger) + processor := NewEventProcessor(nil, nil, "test-chain", true, true, logger) t.Run("empty string returns 0x", func(t *testing.T) { result, err := processor.base58ToHex("") @@ -264,7 +87,7 @@ func TestEventProcessorBase58ToHex(t *testing.T) { func TestEventProcessorConstructInbound(t *testing.T) { logger := zerolog.Nop() - processor := NewEventProcessor(nil, nil, "eip155:1", true, true, nil, logger) + processor := NewEventProcessor(nil, nil, "eip155:1", true, true, logger) t.Run("nil event returns error", func(t *testing.T) { inbound, err := processor.constructInbound(nil) @@ -430,7 +253,7 @@ func TestEventProcessorConstructInbound(t *testing.T) { func TestEventProcessorParseOutboundEventData(t *testing.T) { logger := zerolog.Nop() - processor := NewEventProcessor(nil, nil, "eip155:1", true, true, nil, logger) + processor := NewEventProcessor(nil, nil, "eip155:1", true, true, logger) t.Run("nil event returns error", func(t *testing.T) { data, err := processor.parseOutboundEventData(nil) @@ -509,7 +332,7 @@ func TestEventProcessorParseOutboundEventData(t *testing.T) { func TestEventProcessorBuildOutboundObservation(t *testing.T) { logger := zerolog.Nop() - processor := NewEventProcessor(nil, nil, "eip155:1", true, true, nil, logger) + processor := NewEventProcessor(nil, nil, "eip155:1", true, true, logger) t.Run("builds observation with gas fee from parsed data", func(t *testing.T) { outboundData := &OutboundEvent{ @@ -581,7 +404,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, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) event := &store.Event{ EventID: "0xabc:0", @@ -595,7 +418,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, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) event := &store.Event{ EventID: "0xabc:0", @@ -609,7 +432,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, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) event := &store.Event{ EventID: "0xabc:0", @@ -623,7 +446,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, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) eventData, _ := json.Marshal(OutboundEvent{ TxID: "", @@ -641,7 +464,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, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) eventData, _ := json.Marshal(OutboundEvent{ TxID: "0xtxid", @@ -671,7 +494,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, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) event := &store.Event{ EventID: "0xabc:0", @@ -685,7 +508,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, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) event := &store.Event{ EventID: "0xabc:0", @@ -715,7 +538,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, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) @@ -731,7 +554,7 @@ func TestProcessConfirmedEventsRouting(t *testing.T) { }, }) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) @@ -758,7 +581,7 @@ func TestProcessConfirmedEventsRouting(t *testing.T) { }, }) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) // Should not return error - errors on individual events are logged and skipped err := ep.processConfirmedEvents(ctx) @@ -788,7 +611,7 @@ func TestProcessConfirmedEventsRouting(t *testing.T) { }, }) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) @@ -811,7 +634,7 @@ func TestProcessConfirmedEventsRouting(t *testing.T) { }, }) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) @@ -831,7 +654,7 @@ func TestProcessConfirmedEventsRouting(t *testing.T) { }, }) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) @@ -849,7 +672,7 @@ func TestProcessLoopContextCancellation(t *testing.T) { require.NoError(t, err) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) t.Run("processLoop exits promptly on context cancel", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) @@ -885,7 +708,7 @@ func TestProcessLoopStopChannel(t *testing.T) { require.NoError(t, err) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) t.Run("processLoop exits promptly on stop signal", func(t *testing.T) { ctx := context.Background() @@ -935,7 +758,6 @@ func TestEventProcessorStruct(t *testing.T) { ep := &EventProcessor{} assert.Nil(t, ep.signer) assert.Nil(t, ep.chainStore) - assert.Nil(t, ep.readResolver) assert.Empty(t, ep.chainID) assert.False(t, ep.running) assert.Nil(t, ep.stopCh) @@ -948,25 +770,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, nil, logger) + ep := NewEventProcessor(nil, nil, "eip155:1", true, true, 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, nil, logger) + ep := NewEventProcessor(nil, nil, "eip155:1", true, false, 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, nil, logger) + ep := NewEventProcessor(nil, nil, "eip155:1", false, true, 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, nil, logger) + ep := NewEventProcessor(nil, nil, "eip155:1", false, false, logger) assert.False(t, ep.inboundEnabled) assert.False(t, ep.outboundEnabled) }) @@ -978,7 +800,7 @@ func TestEventProcessorStartDoubleStart(t *testing.T) { require.NoError(t, err) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -1005,7 +827,7 @@ func TestEventProcessorStopIdempotent(t *testing.T) { require.NoError(t, err) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -1035,7 +857,7 @@ func TestEventProcessorIsRunningStateTransitions(t *testing.T) { require.NoError(t, err) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) // Initial state: not running assert.False(t, ep.IsRunning()) @@ -1070,7 +892,7 @@ func TestEventProcessorStopViaContextCancel(t *testing.T) { require.NoError(t, err) defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) ctx, cancel := context.WithCancel(context.Background()) @@ -1135,7 +957,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, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", false, false, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) @@ -1148,7 +970,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, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", false, false, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) @@ -1169,7 +991,7 @@ func TestProcessConfirmedEventsEnabledFlags(t *testing.T) { EventData: outboundEventData, }, }) - ep := NewEventProcessor(nil, database, "eip155:1", true, false, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", true, false, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) @@ -1190,7 +1012,7 @@ func TestProcessConfirmedEventsEnabledFlags(t *testing.T) { EventData: inboundEventData, }, }) - ep := NewEventProcessor(nil, database, "eip155:1", false, true, nil, logger) + ep := NewEventProcessor(nil, database, "eip155:1", false, true, logger) err := ep.processConfirmedEvents(ctx) require.NoError(t, err) diff --git a/universalClient/externalchains/common/types.go b/universalClient/externalchains/common/types.go index dcf22023..a389437f 100644 --- a/universalClient/externalchains/common/types.go +++ b/universalClient/externalchains/common/types.go @@ -5,9 +5,16 @@ import ( "fmt" "math/big" + "github.com/pushchain/push-chain-node/universalClient/uread" uetypes "github.com/pushchain/push-chain-node/x/uexecutor/types" ) +// ReadRequestHandler executes a read request on one destination chain. +// Consumed by the push watcher's read processor. +type ReadRequestHandler interface { + ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) +} + // 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 @@ -38,6 +45,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) + + // GetReadRequestHandler returns the handler executing read requests + // destined for this chain + // Returns an error if reads are not available (e.g. client not started) + GetReadRequestHandler() (ReadRequestHandler, 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 908a2c6b..07f83684 100644 --- a/universalClient/externalchains/evm/client.go +++ b/universalClient/externalchains/evm/client.go @@ -94,7 +94,6 @@ func NewClient( chainIDStr, inboundEnabled, outboundEnabled, - nil, log, ) } @@ -200,6 +199,14 @@ func (c *Client) GetTxBuilder() (common.TxBuilder, error) { return c.txBuilder, nil } +// GetReadRequestHandler returns the read request handler for this chain +func (c *Client) GetReadRequestHandler() (common.ReadRequestHandler, error) { + if c.rpcClient == nil { + return nil, fmt.Errorf("read handler not available for chain %s (client not started)", c.chainIDStr) + } + return c, nil +} + // initializeComponents creates all components that require the RPC client func (c *Client) initializeComponents() error { diff --git a/universalClient/externalchains/svm/client.go b/universalClient/externalchains/svm/client.go index 6b29c72c..95e00104 100644 --- a/universalClient/externalchains/svm/client.go +++ b/universalClient/externalchains/svm/client.go @@ -104,7 +104,6 @@ func NewClient( chainIDStr, inboundEnabled, outboundEnabled, - nil, log, ) } @@ -210,6 +209,14 @@ func (c *Client) GetTxBuilder() (common.TxBuilder, error) { return c.txBuilder, nil } +// GetReadRequestHandler returns the read request handler for this chain +func (c *Client) GetReadRequestHandler() (common.ReadRequestHandler, error) { + if c.rpcClient == nil { + return nil, fmt.Errorf("read handler not available for chain %s (client not started)", c.chainIDStr) + } + return c, nil +} + // initializeComponents creates all components that require the RPC client func (c *Client) initializeComponents() error { diff --git a/universalClient/pushwatcher/client.go b/universalClient/pushwatcher/client.go index 503c01bb..318e12b8 100644 --- a/universalClient/pushwatcher/client.go +++ b/universalClient/pushwatcher/client.go @@ -16,18 +16,18 @@ import ( // Client implements the ChainClient interface for Push chain type Client struct { - logger zerolog.Logger - pushCore *pushcore.Client - database *db.DB - eventListener *EventListener - eventCleaner *common.EventCleaner - eventProcessor *common.EventProcessor - ctx context.Context - cancel context.CancelFunc + logger zerolog.Logger + pushCore *pushcore.Client + database *db.DB + eventListener *EventListener + eventCleaner *common.EventCleaner + readProcessor *ReadProcessor + ctx context.Context + cancel context.CancelFunc } // NewClient creates a new Push chain client. -// pushSigner and readResolver may be nil; the event processor (read request +// pushSigner and chainResolver may be nil; the read processor (read request // execution + voting) is only wired when both are present. func NewClient( database *db.DB, @@ -36,7 +36,7 @@ func NewClient( chainID string, logger zerolog.Logger, pushSigner *pushsigner.Signer, - readResolver common.ChainResolver, + chainResolver ChainResolver, ) (*Client, error) { // Normalize nil config so downstream uses don't need nil guards. if chainConfig == nil { @@ -70,18 +70,20 @@ func NewClient( eventCleaner: eventCleaner, } - // The push DB holds READ_REQUEST events; the processor executes them on - // their destination chains (via readResolver) and votes the results. - if pushSigner != nil && readResolver != nil { - client.eventProcessor = common.NewEventProcessor( + // The push DB holds READ_REQUEST events; the read processor executes them + // on their destination chains (via chainResolver) and votes the results. + if pushSigner != nil && chainResolver != nil { + readProcessor, err := NewReadProcessor( pushSigner, + chainResolver, database, - chainID, - false, - false, - readResolver, + eventListener.cfg.PollInterval, logger, ) + if err != nil { + return nil, fmt.Errorf("failed to create read processor: %w", err) + } + client.readProcessor = readProcessor } return client, nil @@ -105,10 +107,10 @@ func (c *Client) Start(ctx context.Context) error { } } - // Start event processor if wired - if c.eventProcessor != nil { - if err := c.eventProcessor.Start(c.ctx); err != nil { - return fmt.Errorf("failed to start event processor: %w", err) + // Start read processor if wired + if c.readProcessor != nil { + if err := c.readProcessor.Start(c.ctx); err != nil { + return fmt.Errorf("failed to start read processor: %w", err) } } @@ -137,10 +139,10 @@ func (c *Client) Stop() error { c.eventCleaner.Stop() } - // Stop event processor - if c.eventProcessor != nil { - if err := c.eventProcessor.Stop(); err != nil { - c.logger.Error().Err(err).Str("subsystem", "event_processor").Msg("subsystem failed to stop") + // Stop read processor + if c.readProcessor != nil { + if err := c.readProcessor.Stop(); err != nil { + c.logger.Error().Err(err).Str("subsystem", "read_processor").Msg("subsystem failed to stop") } } diff --git a/universalClient/pushwatcher/read_processor.go b/universalClient/pushwatcher/read_processor.go new file mode 100644 index 00000000..b9c74b96 --- /dev/null +++ b/universalClient/pushwatcher/read_processor.go @@ -0,0 +1,215 @@ +package pushwatcher + +import ( + "context" + "encoding/json" + "sync" + "time" + + "github.com/pushchain/push-chain-node/universalClient/db" + "github.com/pushchain/push-chain-node/universalClient/externalchains/common" + "github.com/pushchain/push-chain-node/universalClient/store" + "github.com/pushchain/push-chain-node/universalClient/uread" + "github.com/rs/zerolog" +) + +const readProcessBatchSize = 1000 + +// ChainResolver resolves a CAIP-2 chain ID to its chain client. +// Satisfied by externalchains.Chains. +type ChainResolver interface { + GetClient(chainID string) (common.ChainClient, error) +} + +// readVoter submits a read observation vote to Push Chain. +// Satisfied by *pushsigner.Signer. +type readVoter interface { + VoteReadResult(ctx context.Context, requestID string, result *uread.ReadResult) (string, error) +} + +// ReadProcessor consumes READ_REQUEST events from the push chain DB, executes +// each request on its destination chain via the resolved handler, and votes +// the result. Transient failures (destination not served, RPC errors, vote +// failure) keep the event CONFIRMED for retry; corrupt events flip to +// REVERTED. Expiry is core's job: expired requests leave the pending query. +type ReadProcessor struct { + voter readVoter + resolver ChainResolver + chainStore *common.ChainStore + cfg Config + logger zerolog.Logger + + mu sync.Mutex + running bool + cancel context.CancelFunc + wg sync.WaitGroup +} + +// NewReadProcessor creates a new read processor. +func NewReadProcessor( + voter readVoter, + resolver ChainResolver, + database *db.DB, + pollInterval time.Duration, + logger zerolog.Logger, +) (*ReadProcessor, error) { + if database == nil { + return nil, ErrNilDatabase + } + + if pollInterval <= 0 { + pollInterval = DefaultPollInterval + } + + return &ReadProcessor{ + voter: voter, + resolver: resolver, + chainStore: common.NewChainStore(database), + cfg: Config{PollInterval: pollInterval}, + logger: logger.With().Str("component", "push_read_processor").Logger(), + }, nil +} + +// Start begins processing read request events. +func (p *ReadProcessor) Start(ctx context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.running { + return ErrAlreadyRunning + } + + childCtx, cancel := context.WithCancel(ctx) + p.cancel = cancel + p.running = true + + p.logger.Debug(). + Dur("poll_interval", p.cfg.PollInterval). + Msg("starting read processor") + + p.wg.Add(1) + go p.run(childCtx) + + return nil +} + +// Stop gracefully stops the processor. +func (p *ReadProcessor) Stop() error { + p.mu.Lock() + defer p.mu.Unlock() + + if !p.running { + return ErrNotRunning + } + + p.cancel() + p.wg.Wait() + p.running = false + + return nil +} + +func (p *ReadProcessor) run(ctx context.Context) { + defer p.wg.Done() + + p.processConfirmedReads(ctx) + + ticker := time.NewTicker(p.cfg.PollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.processConfirmedReads(ctx) + } + } +} + +// processConfirmedReads executes and votes stored read request events. +func (p *ReadProcessor) processConfirmedReads(ctx context.Context) { + events, err := p.chainStore.GetConfirmedEvents(readProcessBatchSize) + if err != nil { + p.logger.Error().Err(err).Msg("failed to query confirmed events") + return + } + + for _, event := range events { + if event.Type != store.EventTypeReadRequest { + continue + } + + select { + case <-ctx.Done(): + return + default: + } + + if err := p.processOne(ctx, &event); err != nil { + p.logger.Error(). + Err(err). + Str("event_id", event.EventID). + Msg("failed to process read request event") + } + } +} + +func (p *ReadProcessor) processOne(ctx context.Context, event *store.Event) error { + var req uread.ReadRequest + if err := json.Unmarshal(event.EventData, &req); err != nil { + p.markReverted(event.EventID) + return err + } + + log := p.logger.With().Str("request_id", req.RequestID).Logger() + + destClient, err := p.resolver.GetClient(req.DestinationChain) + if err != nil { + // destination not served by this validator yet; retry next tick + log.Debug().Err(err).Str("destination_chain", req.DestinationChain).Msg("destination chain not served") + return nil + } + + handler, err := destClient.GetReadRequestHandler() + if err != nil { + // destination client not ready to serve reads yet; retry next tick + log.Debug().Err(err).Str("destination_chain", req.DestinationChain).Msg("read handler not available") + return nil + } + + result, err := handler.ExecuteRead(ctx, &req) + if err != nil { + log.Debug().Err(err).Str("destination_chain", req.DestinationChain).Msg("read execution failed; will retry") + return nil + } + + voteTxHash, err := p.voter.VoteReadResult(ctx, req.RequestID, result) + if err != nil { + // TODO(core): ErrVoteReadNotAvailable falls through here until MsgVoteReadResult lands. + log.Warn().Err(err).Msg("failed to vote read result; will retry") + return nil + } + + rowsAffected, err := p.chainStore.UpdateStatusAndVoteTxHash(event.EventID, store.StatusConfirmed, store.StatusCompleted, voteTxHash) + if err != nil { + return err + } + if rowsAffected == 0 { + return nil + } + + log.Info(). + Str("vote_tx_hash", voteTxHash). + Int32("status", int32(result.Status)). + Uint64("observed_height", result.ObservedBlockHeight). + Msg("read request voted") + + return nil +} + +func (p *ReadProcessor) markReverted(eventID string) { + if _, err := p.chainStore.UpdateEventStatus(eventID, store.StatusConfirmed, store.StatusReverted); err != nil { + p.logger.Error().Err(err).Str("event_id", eventID).Msg("failed to mark read request reverted") + } +} diff --git a/universalClient/pushwatcher/read_processor_test.go b/universalClient/pushwatcher/read_processor_test.go new file mode 100644 index 00000000..784f0c1c --- /dev/null +++ b/universalClient/pushwatcher/read_processor_test.go @@ -0,0 +1,216 @@ +package pushwatcher + +import ( + "context" + "fmt" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pushchain/push-chain-node/universalClient/externalchains/common" + "github.com/pushchain/push-chain-node/universalClient/store" + "github.com/pushchain/push-chain-node/universalClient/uread" +) + +type fakeReadVoter struct { + votes map[string]*uread.ReadResult + txHash string + err error +} + +func (f *fakeReadVoter) VoteReadResult(ctx context.Context, requestID string, result *uread.ReadResult) (string, error) { + if f.err != nil { + return "", f.err + } + if f.votes == nil { + f.votes = make(map[string]*uread.ReadResult) + } + f.votes[requestID] = result + return f.txHash, nil +} + +type fakeDestClient struct { + result *uread.ReadResult + err error +} + +func (f *fakeDestClient) Start(ctx context.Context) error { return nil } +func (f *fakeDestClient) Stop() error { return nil } +func (f *fakeDestClient) IsHealthy() bool { return true } +func (f *fakeDestClient) GetTxBuilder() (common.TxBuilder, error) { + return nil, fmt.Errorf("not supported") +} +func (f *fakeDestClient) GetReadRequestHandler() (common.ReadRequestHandler, error) { + return f, nil +} +func (f *fakeDestClient) ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) { + return f.result, f.err +} + +type fakeChainResolver struct { + client common.ChainClient +} + +func (f *fakeChainResolver) GetClient(chainID string) (common.ChainClient, error) { + if f.client == nil { + return nil, fmt.Errorf("no client for %s", chainID) + } + return f.client, nil +} + +func testReadRequest() *uread.ReadRequest { + return &uread.ReadRequest{ + RequestID: "0xabc123", + DestinationChain: "eip155:11155111", + Query: []byte{0x01}, + MinConfirmations: 1, + DestinationBlockHeight: 100, + CreatedAtHeight: 7, + } +} + +func newTestReadProcessor(t *testing.T, voter readVoter, destClient common.ChainClient) (*ReadProcessor, *common.ChainStore) { + t.Helper() + database := newTestDB(t) + p, err := NewReadProcessor(voter, &fakeChainResolver{client: destClient}, database, 0, zerolog.Nop()) + require.NoError(t, err) + return p, common.NewChainStore(database) +} + +func seedReadRequest(t *testing.T, cs *common.ChainStore, req *uread.ReadRequest) string { + t.Helper() + event, err := convertReadRequestEvent(req) + require.NoError(t, err) + stored, err := cs.InsertEventIfNotExists(event) + require.NoError(t, err) + require.True(t, stored) + return event.EventID +} + +func eventStatus(t *testing.T, cs *common.ChainStore, eventID string) string { + t.Helper() + events, err := cs.GetConfirmedEvents(100) + require.NoError(t, err) + for i := range events { + if events[i].EventID == eventID { + return events[i].Status + } + } + // not CONFIRMED anymore; caller asserts via CAS probes + return "" +} + +func TestReadProcessor_SuccessFlow(t *testing.T) { + req := testReadRequest() + result := &uread.ReadResult{ + Status: uread.ReadStatusSuccess, + ResultData: []byte{0xaa}, + ObservedBlockHeight: 100, + } + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadProcessor(t, voter, &fakeDestClient{result: result}) + eventID := seedReadRequest(t, cs, req) + + p.processConfirmedReads(context.Background()) + + require.Contains(t, voter.votes, req.RequestID) + assert.Equal(t, result, voter.votes[req.RequestID]) + + // event flipped to COMPLETED with vote tx hash + rows, err := cs.UpdateEventStatus(eventID, store.StatusCompleted, store.StatusCompleted) + require.NoError(t, err) + assert.Equal(t, int64(1), rows) + + // second tick must not re-vote + voter.votes = nil + p.processConfirmedReads(context.Background()) + assert.Empty(t, voter.votes) +} + +func TestReadProcessor_VoteFailureKeepsConfirmed(t *testing.T) { + req := testReadRequest() + voter := &fakeReadVoter{err: fmt.Errorf("MsgVoteReadResult not available")} + p, cs := newTestReadProcessor(t, voter, &fakeDestClient{result: &uread.ReadResult{Status: uread.ReadStatusSuccess}}) + eventID := seedReadRequest(t, cs, req) + + p.processConfirmedReads(context.Background()) + + assert.Equal(t, store.StatusConfirmed, eventStatus(t, cs, eventID)) +} + +func TestReadProcessor_ExecutionFailureRetries(t *testing.T) { + req := testReadRequest() + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadProcessor(t, voter, &fakeDestClient{err: fmt.Errorf("rpc down")}) + eventID := seedReadRequest(t, cs, req) + + p.processConfirmedReads(context.Background()) + + assert.Empty(t, voter.votes) + assert.Equal(t, store.StatusConfirmed, eventStatus(t, cs, eventID)) +} + +func TestReadProcessor_UnservedChainRetries(t *testing.T) { + req := testReadRequest() + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadProcessor(t, voter, nil) + eventID := seedReadRequest(t, cs, req) + + p.processConfirmedReads(context.Background()) + + assert.Empty(t, voter.votes) + assert.Equal(t, store.StatusConfirmed, eventStatus(t, cs, eventID)) +} + +func TestReadProcessor_CorruptEventReverted(t *testing.T) { + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadProcessor(t, voter, &fakeDestClient{result: &uread.ReadResult{Status: uread.ReadStatusSuccess}}) + + stored, err := cs.InsertEventIfNotExists(&store.Event{ + EventID: "corrupt-read", + Type: store.EventTypeReadRequest, + ConfirmationType: store.ConfirmationInstant, + Status: store.StatusConfirmed, + EventData: []byte("not json"), + }) + require.NoError(t, err) + require.True(t, stored) + + p.processConfirmedReads(context.Background()) + + assert.Empty(t, voter.votes) + rows, err := cs.UpdateEventStatus("corrupt-read", store.StatusReverted, store.StatusReverted) + require.NoError(t, err) + assert.Equal(t, int64(1), rows) +} + +func TestReadProcessor_IgnoresOtherEventTypes(t *testing.T) { + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadProcessor(t, voter, &fakeDestClient{result: &uread.ReadResult{Status: uread.ReadStatusSuccess}}) + + stored, err := cs.InsertEventIfNotExists(&store.Event{ + EventID: "tss-event", + Type: store.EventTypeKeygen, + ConfirmationType: store.ConfirmationInstant, + Status: store.StatusConfirmed, + EventData: []byte("{}"), + }) + require.NoError(t, err) + require.True(t, stored) + + p.processConfirmedReads(context.Background()) + + assert.Empty(t, voter.votes) + assert.Equal(t, store.StatusConfirmed, eventStatus(t, cs, "tss-event")) +} + +func TestReadProcessor_StartStop(t *testing.T) { + p, _ := newTestReadProcessor(t, &fakeReadVoter{}, nil) + + require.NoError(t, p.Start(context.Background())) + assert.Equal(t, ErrAlreadyRunning, p.Start(context.Background())) + require.NoError(t, p.Stop()) + assert.Equal(t, ErrNotRunning, p.Stop()) +} diff --git a/universalClient/tss/coordinator/coordinator_test.go b/universalClient/tss/coordinator/coordinator_test.go index 69cf0f26..b4ea3cd4 100644 --- a/universalClient/tss/coordinator/coordinator_test.go +++ b/universalClient/tss/coordinator/coordinator_test.go @@ -84,6 +84,9 @@ type coordMockChainClient struct { 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) GetReadRequestHandler() (common.ReadRequestHandler, error) { + return nil, 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..dae85fb3 100644 --- a/universalClient/tss/txbroadcaster/broadcaster_test.go +++ b/universalClient/tss/txbroadcaster/broadcaster_test.go @@ -79,9 +79,12 @@ 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) Start(context.Context) error { return nil } +func (m *mockChainClient) Stop() error { return nil } +func (m *mockChainClient) IsHealthy() bool { return true } +func (m *mockChainClient) GetReadRequestHandler() (common.ReadRequestHandler, error) { + return nil, nil +} func (m *mockChainClient) GetTxBuilder() (common.TxBuilder, error) { return m.builder, nil } func setupTestDB(t *testing.T) (*eventstore.Store, *gorm.DB) { diff --git a/universalClient/tss/txresolver/resolver_test.go b/universalClient/tss/txresolver/resolver_test.go index b7df913f..b13aec47 100644 --- a/universalClient/tss/txresolver/resolver_test.go +++ b/universalClient/tss/txresolver/resolver_test.go @@ -76,9 +76,12 @@ 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) Start(context.Context) error { return nil } +func (m *mockChainClient) Stop() error { return nil } +func (m *mockChainClient) IsHealthy() bool { return true } +func (m *mockChainClient) GetReadRequestHandler() (common.ReadRequestHandler, error) { + return nil, nil +} func (m *mockChainClient) GetTxBuilder() (common.TxBuilder, error) { return m.builder, nil } func setupTestDB(t *testing.T) (*eventstore.Store, *gorm.DB) { From 7212d2e90d7c9fdf80ea9a7d1fd4c0045b6ef20d Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 3 Aug 2026 17:29:11 +0530 Subject: [PATCH 16/23] fix: read req handling --- universalClient/pushwatcher/client.go | 59 ++--- .../pushwatcher/event_processor.go | 151 ++++++++++++ .../pushwatcher/event_processor_test.go | 90 ++++++++ ...d_processor.go => read_event_processor.go} | 143 +++--------- .../pushwatcher/read_event_processor_test.go | 215 +++++++++++++++++ .../pushwatcher/read_processor_test.go | 216 ------------------ 6 files changed, 517 insertions(+), 357 deletions(-) create mode 100644 universalClient/pushwatcher/event_processor.go create mode 100644 universalClient/pushwatcher/event_processor_test.go rename universalClient/pushwatcher/{read_processor.go => read_event_processor.go} (53%) create mode 100644 universalClient/pushwatcher/read_event_processor_test.go delete mode 100644 universalClient/pushwatcher/read_processor_test.go diff --git a/universalClient/pushwatcher/client.go b/universalClient/pushwatcher/client.go index 318e12b8..297246b2 100644 --- a/universalClient/pushwatcher/client.go +++ b/universalClient/pushwatcher/client.go @@ -11,24 +11,25 @@ import ( "github.com/pushchain/push-chain-node/universalClient/externalchains/common" "github.com/pushchain/push-chain-node/universalClient/pushcore" "github.com/pushchain/push-chain-node/universalClient/pushsigner" + "github.com/pushchain/push-chain-node/universalClient/store" "github.com/rs/zerolog" ) // Client implements the ChainClient interface for Push chain type Client struct { - logger zerolog.Logger - pushCore *pushcore.Client - database *db.DB - eventListener *EventListener - eventCleaner *common.EventCleaner - readProcessor *ReadProcessor - ctx context.Context - cancel context.CancelFunc + logger zerolog.Logger + pushCore *pushcore.Client + database *db.DB + eventListener *EventListener + eventCleaner *common.EventCleaner + eventProcessor *EventProcessor + ctx context.Context + cancel context.CancelFunc } // NewClient creates a new Push chain client. -// pushSigner and chainResolver may be nil; the read processor (read request -// execution + voting) is only wired when both are present. +// pushSigner and chainResolver may be nil; the READ_REQUEST handler is only +// registered when both are present. func NewClient( database *db.DB, chainConfig *config.ChainSpecificConfig, @@ -70,21 +71,21 @@ func NewClient( eventCleaner: eventCleaner, } - // The push DB holds READ_REQUEST events; the read processor executes them - // on their destination chains (via chainResolver) and votes the results. + eventProcessor, err := NewEventProcessor(database, eventListener.cfg.PollInterval, logger) + if err != nil { + return nil, fmt.Errorf("failed to create event processor: %w", err) + } + + // READ_REQUEST events are executed on their destination chains (via + // chainResolver) and the results voted back. if pushSigner != nil && chainResolver != nil { - readProcessor, err := NewReadProcessor( - pushSigner, - chainResolver, - database, - eventListener.cfg.PollInterval, - logger, - ) + readEventProcessor, err := NewReadEventProcessor(pushSigner, chainResolver, database, logger) if err != nil { - return nil, fmt.Errorf("failed to create read processor: %w", err) + return nil, fmt.Errorf("failed to create read event processor: %w", err) } - client.readProcessor = readProcessor + eventProcessor.RegisterHandler(store.EventTypeReadRequest, readEventProcessor) } + client.eventProcessor = eventProcessor return client, nil } @@ -107,10 +108,10 @@ func (c *Client) Start(ctx context.Context) error { } } - // Start read processor if wired - if c.readProcessor != nil { - if err := c.readProcessor.Start(c.ctx); err != nil { - return fmt.Errorf("failed to start read processor: %w", err) + // Start event processor + if c.eventProcessor != nil { + if err := c.eventProcessor.Start(c.ctx); err != nil { + return fmt.Errorf("failed to start event processor: %w", err) } } @@ -139,10 +140,10 @@ func (c *Client) Stop() error { c.eventCleaner.Stop() } - // Stop read processor - if c.readProcessor != nil { - if err := c.readProcessor.Stop(); err != nil { - c.logger.Error().Err(err).Str("subsystem", "read_processor").Msg("subsystem failed to stop") + // Stop event processor + if c.eventProcessor != nil { + if err := c.eventProcessor.Stop(); err != nil { + c.logger.Error().Err(err).Str("subsystem", "event_processor").Msg("subsystem failed to stop") } } diff --git a/universalClient/pushwatcher/event_processor.go b/universalClient/pushwatcher/event_processor.go new file mode 100644 index 00000000..f848ae76 --- /dev/null +++ b/universalClient/pushwatcher/event_processor.go @@ -0,0 +1,151 @@ +package pushwatcher + +import ( + "context" + "sync" + "time" + + "github.com/pushchain/push-chain-node/universalClient/db" + "github.com/pushchain/push-chain-node/universalClient/externalchains/common" + "github.com/pushchain/push-chain-node/universalClient/store" + "github.com/rs/zerolog" +) + +const eventProcessBatchSize = 1000 + +// EventHandler processes one CONFIRMED push chain event of a registered type. +// Handlers own the event's status transitions; a returned error is logged and +// the event is retried next tick. +type EventHandler interface { + HandleEvent(ctx context.Context, event *store.Event) error +} + +// EventProcessor drains CONFIRMED events from the push chain DB and dispatches +// them to the handler registered for their type. Event types without a handler +// are ignored (e.g. TSS events, which are consumed by the TSS subsystem). +type EventProcessor struct { + chainStore *common.ChainStore + handlers map[string]EventHandler + cfg Config + logger zerolog.Logger + + mu sync.Mutex + running bool + cancel context.CancelFunc + wg sync.WaitGroup +} + +// NewEventProcessor creates a new push event processor. Register handlers +// before Start. +func NewEventProcessor( + database *db.DB, + pollInterval time.Duration, + logger zerolog.Logger, +) (*EventProcessor, error) { + if database == nil { + return nil, ErrNilDatabase + } + + if pollInterval <= 0 { + pollInterval = DefaultPollInterval + } + + return &EventProcessor{ + chainStore: common.NewChainStore(database), + handlers: make(map[string]EventHandler), + cfg: Config{PollInterval: pollInterval}, + logger: logger.With().Str("component", "push_event_processor").Logger(), + }, nil +} + +// RegisterHandler registers a handler for an event type. Must be called before Start. +func (p *EventProcessor) RegisterHandler(eventType string, handler EventHandler) { + p.handlers[eventType] = handler +} + +// Start begins processing events. +func (p *EventProcessor) Start(ctx context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + + if p.running { + return ErrAlreadyRunning + } + + childCtx, cancel := context.WithCancel(ctx) + p.cancel = cancel + p.running = true + + p.logger.Debug(). + Dur("poll_interval", p.cfg.PollInterval). + Msg("starting push event processor") + + p.wg.Add(1) + go p.run(childCtx) + + return nil +} + +// Stop gracefully stops the processor. +func (p *EventProcessor) Stop() error { + p.mu.Lock() + defer p.mu.Unlock() + + if !p.running { + return ErrNotRunning + } + + p.cancel() + p.wg.Wait() + p.running = false + + return nil +} + +func (p *EventProcessor) run(ctx context.Context) { + defer p.wg.Done() + + p.processConfirmedEvents(ctx) + + ticker := time.NewTicker(p.cfg.PollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + p.processConfirmedEvents(ctx) + } + } +} + +// processConfirmedEvents dispatches CONFIRMED events to their registered handlers. +func (p *EventProcessor) processConfirmedEvents(ctx context.Context) { + events, err := p.chainStore.GetConfirmedEvents(eventProcessBatchSize) + if err != nil { + p.logger.Error().Err(err).Msg("failed to query confirmed events") + return + } + + for _, event := range events { + handler, ok := p.handlers[event.Type] + if !ok { + continue + } + + select { + case <-ctx.Done(): + return + default: + } + + if err := handler.HandleEvent(ctx, &event); err != nil { + p.logger.Error(). + Err(err). + Str("event_id", event.EventID). + Str("type", event.Type). + Msg("failed to process event") + } + } +} diff --git a/universalClient/pushwatcher/event_processor_test.go b/universalClient/pushwatcher/event_processor_test.go new file mode 100644 index 00000000..92e60bcd --- /dev/null +++ b/universalClient/pushwatcher/event_processor_test.go @@ -0,0 +1,90 @@ +package pushwatcher + +import ( + "context" + "fmt" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pushchain/push-chain-node/universalClient/externalchains/common" + "github.com/pushchain/push-chain-node/universalClient/store" +) + +type fakeEventHandler struct { + handled []string + err error +} + +func (f *fakeEventHandler) HandleEvent(ctx context.Context, event *store.Event) error { + f.handled = append(f.handled, event.EventID) + return f.err +} + +func seedEvent(t *testing.T, cs *common.ChainStore, eventID, eventType string) { + t.Helper() + stored, err := cs.InsertEventIfNotExists(&store.Event{ + EventID: eventID, + Type: eventType, + ConfirmationType: store.ConfirmationInstant, + Status: store.StatusConfirmed, + EventData: []byte("{}"), + }) + require.NoError(t, err) + require.True(t, stored) +} + +func TestEventProcessor_DispatchesByType(t *testing.T) { + database := newTestDB(t) + p, err := NewEventProcessor(database, 0, zerolog.Nop()) + require.NoError(t, err) + cs := common.NewChainStore(database) + + readHandler := &fakeEventHandler{} + p.RegisterHandler(store.EventTypeReadRequest, readHandler) + + seedEvent(t, cs, "read-1", store.EventTypeReadRequest) + seedEvent(t, cs, "tss-1", store.EventTypeKeygen) // no handler registered + + p.processConfirmedEvents(context.Background()) + + assert.Equal(t, []string{"read-1"}, readHandler.handled) +} + +func TestEventProcessor_HandlerErrorKeepsProcessing(t *testing.T) { + database := newTestDB(t) + p, err := NewEventProcessor(database, 0, zerolog.Nop()) + require.NoError(t, err) + cs := common.NewChainStore(database) + + failing := &fakeEventHandler{err: fmt.Errorf("boom")} + p.RegisterHandler(store.EventTypeReadRequest, failing) + + seedEvent(t, cs, "read-1", store.EventTypeReadRequest) + seedEvent(t, cs, "read-2", store.EventTypeReadRequest) + + p.processConfirmedEvents(context.Background()) + + // both attempted despite errors, both still CONFIRMED for retry + assert.Len(t, failing.handled, 2) + events, err := cs.GetConfirmedEvents(10) + require.NoError(t, err) + assert.Len(t, events, 2) +} + +func TestEventProcessor_NilDatabase(t *testing.T) { + _, err := NewEventProcessor(nil, 0, zerolog.Nop()) + assert.ErrorIs(t, err, ErrNilDatabase) +} + +func TestEventProcessor_StartStop(t *testing.T) { + p, err := NewEventProcessor(newTestDB(t), 0, zerolog.Nop()) + require.NoError(t, err) + + require.NoError(t, p.Start(context.Background())) + assert.Equal(t, ErrAlreadyRunning, p.Start(context.Background())) + require.NoError(t, p.Stop()) + assert.Equal(t, ErrNotRunning, p.Stop()) +} diff --git a/universalClient/pushwatcher/read_processor.go b/universalClient/pushwatcher/read_event_processor.go similarity index 53% rename from universalClient/pushwatcher/read_processor.go rename to universalClient/pushwatcher/read_event_processor.go index b9c74b96..1544c91e 100644 --- a/universalClient/pushwatcher/read_processor.go +++ b/universalClient/pushwatcher/read_event_processor.go @@ -3,8 +3,6 @@ package pushwatcher import ( "context" "encoding/json" - "sync" - "time" "github.com/pushchain/push-chain-node/universalClient/db" "github.com/pushchain/push-chain-node/universalClient/externalchains/common" @@ -13,8 +11,6 @@ import ( "github.com/rs/zerolog" ) -const readProcessBatchSize = 1000 - // ChainResolver resolves a CAIP-2 chain ID to its chain client. // Satisfied by externalchains.Chains. type ChainResolver interface { @@ -27,135 +23,45 @@ type readVoter interface { VoteReadResult(ctx context.Context, requestID string, result *uread.ReadResult) (string, error) } -// ReadProcessor consumes READ_REQUEST events from the push chain DB, executes -// each request on its destination chain via the resolved handler, and votes -// the result. Transient failures (destination not served, RPC errors, vote -// failure) keep the event CONFIRMED for retry; corrupt events flip to -// REVERTED. Expiry is core's job: expired requests leave the pending query. -type ReadProcessor struct { +// ReadEventProcessor handles READ_REQUEST events: it executes each request on +// its destination chain via the resolved read handler and votes the result. +// Transient failures (destination not served, RPC errors, vote failure) keep +// the event CONFIRMED for retry; corrupt events flip to REVERTED. Expiry is +// core's job: expired requests leave the pending query. +type ReadEventProcessor struct { voter readVoter resolver ChainResolver chainStore *common.ChainStore - cfg Config logger zerolog.Logger - - mu sync.Mutex - running bool - cancel context.CancelFunc - wg sync.WaitGroup } -// NewReadProcessor creates a new read processor. -func NewReadProcessor( +// NewReadEventProcessor creates the handler for READ_REQUEST events. +func NewReadEventProcessor( voter readVoter, resolver ChainResolver, database *db.DB, - pollInterval time.Duration, logger zerolog.Logger, -) (*ReadProcessor, error) { +) (*ReadEventProcessor, error) { if database == nil { return nil, ErrNilDatabase } - if pollInterval <= 0 { - pollInterval = DefaultPollInterval - } - - return &ReadProcessor{ + return &ReadEventProcessor{ voter: voter, resolver: resolver, chainStore: common.NewChainStore(database), - cfg: Config{PollInterval: pollInterval}, - logger: logger.With().Str("component", "push_read_processor").Logger(), + logger: logger.With().Str("component", "push_read_event_processor").Logger(), }, nil } -// Start begins processing read request events. -func (p *ReadProcessor) Start(ctx context.Context) error { - p.mu.Lock() - defer p.mu.Unlock() - - if p.running { - return ErrAlreadyRunning - } - - childCtx, cancel := context.WithCancel(ctx) - p.cancel = cancel - p.running = true - - p.logger.Debug(). - Dur("poll_interval", p.cfg.PollInterval). - Msg("starting read processor") - - p.wg.Add(1) - go p.run(childCtx) - - return nil -} - -// Stop gracefully stops the processor. -func (p *ReadProcessor) Stop() error { - p.mu.Lock() - defer p.mu.Unlock() - - if !p.running { - return ErrNotRunning - } - - p.cancel() - p.wg.Wait() - p.running = false - - return nil -} - -func (p *ReadProcessor) run(ctx context.Context) { - defer p.wg.Done() - - p.processConfirmedReads(ctx) - - ticker := time.NewTicker(p.cfg.PollInterval) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - p.processConfirmedReads(ctx) - } - } -} - -// processConfirmedReads executes and votes stored read request events. -func (p *ReadProcessor) processConfirmedReads(ctx context.Context) { - events, err := p.chainStore.GetConfirmedEvents(readProcessBatchSize) - if err != nil { - p.logger.Error().Err(err).Msg("failed to query confirmed events") - return - } - - for _, event := range events { - if event.Type != store.EventTypeReadRequest { - continue - } - - select { - case <-ctx.Done(): - return - default: - } - - if err := p.processOne(ctx, &event); err != nil { - p.logger.Error(). - Err(err). - Str("event_id", event.EventID). - Msg("failed to process read request event") - } +// HandleEvent implements EventHandler for READ_REQUEST events. +func (p *ReadEventProcessor) HandleEvent(ctx context.Context, event *store.Event) error { + if p.isExpired(event) { + p.logger.Info().Str("event_id", event.EventID).Msg("read request expired; marking reverted") + p.markReverted(event.EventID) + return nil } -} -func (p *ReadProcessor) processOne(ctx context.Context, event *store.Event) error { var req uread.ReadRequest if err := json.Unmarshal(event.EventData, &req); err != nil { p.markReverted(event.EventID) @@ -208,7 +114,20 @@ func (p *ReadProcessor) processOne(ctx context.Context, event *store.Event) erro return nil } -func (p *ReadProcessor) markReverted(eventID string) { +// isExpired reports whether the request's expiry Push chain height has been +// reached, using the chain height persisted by the event listener. +func (p *ReadEventProcessor) isExpired(event *store.Event) bool { + if event.ExpiryBlockHeight == 0 { + return false + } + pushHeight, err := p.chainStore.GetChainHeight() + if err != nil { + return false + } + return pushHeight >= event.ExpiryBlockHeight +} + +func (p *ReadEventProcessor) markReverted(eventID string) { if _, err := p.chainStore.UpdateEventStatus(eventID, store.StatusConfirmed, store.StatusReverted); err != nil { p.logger.Error().Err(err).Str("event_id", eventID).Msg("failed to mark read request reverted") } diff --git a/universalClient/pushwatcher/read_event_processor_test.go b/universalClient/pushwatcher/read_event_processor_test.go new file mode 100644 index 00000000..e1ad5bd6 --- /dev/null +++ b/universalClient/pushwatcher/read_event_processor_test.go @@ -0,0 +1,215 @@ +package pushwatcher + +import ( + "context" + "fmt" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pushchain/push-chain-node/universalClient/externalchains/common" + "github.com/pushchain/push-chain-node/universalClient/store" + "github.com/pushchain/push-chain-node/universalClient/uread" +) + +type fakeReadVoter struct { + votes map[string]*uread.ReadResult + txHash string + err error +} + +func (f *fakeReadVoter) VoteReadResult(ctx context.Context, requestID string, result *uread.ReadResult) (string, error) { + if f.err != nil { + return "", f.err + } + if f.votes == nil { + f.votes = make(map[string]*uread.ReadResult) + } + f.votes[requestID] = result + return f.txHash, nil +} + +type fakeDestClient struct { + result *uread.ReadResult + err error + notStarted bool +} + +func (f *fakeDestClient) Start(ctx context.Context) error { return nil } +func (f *fakeDestClient) Stop() error { return nil } +func (f *fakeDestClient) IsHealthy() bool { return true } +func (f *fakeDestClient) GetTxBuilder() (common.TxBuilder, error) { + return nil, fmt.Errorf("not supported") +} +func (f *fakeDestClient) GetReadRequestHandler() (common.ReadRequestHandler, error) { + if f.notStarted { + return nil, fmt.Errorf("client not started") + } + return f, nil +} +func (f *fakeDestClient) ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) { + return f.result, f.err +} + +type fakeChainResolver struct { + client common.ChainClient +} + +func (f *fakeChainResolver) GetClient(chainID string) (common.ChainClient, error) { + if f.client == nil { + return nil, fmt.Errorf("no client for %s", chainID) + } + return f.client, nil +} + +func testReadRequest() *uread.ReadRequest { + return &uread.ReadRequest{ + RequestID: "0xabc123", + DestinationChain: "eip155:11155111", + Query: []byte{0x01}, + MinConfirmations: 1, + DestinationBlockHeight: 100, + CreatedAtHeight: 7, + } +} + +func newTestReadEventProcessor(t *testing.T, voter readVoter, destClient common.ChainClient) (*ReadEventProcessor, *common.ChainStore) { + t.Helper() + database := newTestDB(t) + p, err := NewReadEventProcessor(voter, &fakeChainResolver{client: destClient}, database, zerolog.Nop()) + require.NoError(t, err) + return p, common.NewChainStore(database) +} + +func seedReadRequest(t *testing.T, cs *common.ChainStore, req *uread.ReadRequest) *store.Event { + t.Helper() + event, err := convertReadRequestEvent(req) + require.NoError(t, err) + stored, err := cs.InsertEventIfNotExists(event) + require.NoError(t, err) + require.True(t, stored) + return event +} + +func assertStatus(t *testing.T, cs *common.ChainStore, eventID, status string) { + t.Helper() + rows, err := cs.UpdateEventStatus(eventID, status, status) + require.NoError(t, err) + assert.Equal(t, int64(1), rows, "event %s not in status %s", eventID, status) +} + +func TestReadEventProcessor_SuccessFlow(t *testing.T) { + req := testReadRequest() + result := &uread.ReadResult{ + Status: uread.ReadStatusSuccess, + ResultData: []byte{0xaa}, + ObservedBlockHeight: 100, + } + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadEventProcessor(t, voter, &fakeDestClient{result: result}) + event := seedReadRequest(t, cs, req) + + require.NoError(t, p.HandleEvent(context.Background(), event)) + + require.Contains(t, voter.votes, req.RequestID) + assert.Equal(t, result, voter.votes[req.RequestID]) + assertStatus(t, cs, event.EventID, store.StatusCompleted) +} + +func TestReadEventProcessor_VoteFailureKeepsConfirmed(t *testing.T) { + req := testReadRequest() + voter := &fakeReadVoter{err: fmt.Errorf("MsgVoteReadResult not available")} + p, cs := newTestReadEventProcessor(t, voter, &fakeDestClient{result: &uread.ReadResult{Status: uread.ReadStatusSuccess}}) + event := seedReadRequest(t, cs, req) + + require.NoError(t, p.HandleEvent(context.Background(), event)) + + assertStatus(t, cs, event.EventID, store.StatusConfirmed) +} + +func TestReadEventProcessor_ExecutionFailureRetries(t *testing.T) { + req := testReadRequest() + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadEventProcessor(t, voter, &fakeDestClient{err: fmt.Errorf("rpc down")}) + event := seedReadRequest(t, cs, req) + + require.NoError(t, p.HandleEvent(context.Background(), event)) + + assert.Empty(t, voter.votes) + assertStatus(t, cs, event.EventID, store.StatusConfirmed) +} + +func TestReadEventProcessor_UnservedChainRetries(t *testing.T) { + req := testReadRequest() + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadEventProcessor(t, voter, nil) + event := seedReadRequest(t, cs, req) + + require.NoError(t, p.HandleEvent(context.Background(), event)) + + assert.Empty(t, voter.votes) + assertStatus(t, cs, event.EventID, store.StatusConfirmed) +} + +func TestReadEventProcessor_HandlerUnavailableRetries(t *testing.T) { + req := testReadRequest() + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadEventProcessor(t, voter, &fakeDestClient{notStarted: true}) + event := seedReadRequest(t, cs, req) + + require.NoError(t, p.HandleEvent(context.Background(), event)) + + assert.Empty(t, voter.votes) + assertStatus(t, cs, event.EventID, store.StatusConfirmed) +} + +func TestReadEventProcessor_CorruptEventReverted(t *testing.T) { + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadEventProcessor(t, voter, &fakeDestClient{result: &uread.ReadResult{Status: uread.ReadStatusSuccess}}) + + event := &store.Event{ + EventID: "corrupt-read", + Type: store.EventTypeReadRequest, + ConfirmationType: store.ConfirmationInstant, + Status: store.StatusConfirmed, + EventData: []byte("not json"), + } + stored, err := cs.InsertEventIfNotExists(event) + require.NoError(t, err) + require.True(t, stored) + + require.Error(t, p.HandleEvent(context.Background(), event)) + + assert.Empty(t, voter.votes) + assertStatus(t, cs, event.EventID, store.StatusReverted) +} + +func TestReadEventProcessor_ExpiredMarkedReverted(t *testing.T) { + req := testReadRequest() + req.ExpiryBlockHeight = 50 + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadEventProcessor(t, voter, &fakeDestClient{result: &uread.ReadResult{Status: uread.ReadStatusSuccess}}) + require.NoError(t, cs.UpdateChainHeight(100)) // push chain past expiry + event := seedReadRequest(t, cs, req) + + require.NoError(t, p.HandleEvent(context.Background(), event)) + + assert.Empty(t, voter.votes) + assertStatus(t, cs, event.EventID, store.StatusReverted) +} + +func TestReadEventProcessor_NotExpiredProcessesNormally(t *testing.T) { + req := testReadRequest() + req.ExpiryBlockHeight = 200 + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, cs := newTestReadEventProcessor(t, voter, &fakeDestClient{result: &uread.ReadResult{Status: uread.ReadStatusSuccess}}) + require.NoError(t, cs.UpdateChainHeight(100)) + event := seedReadRequest(t, cs, req) + + require.NoError(t, p.HandleEvent(context.Background(), event)) + + require.Contains(t, voter.votes, req.RequestID) + assertStatus(t, cs, event.EventID, store.StatusCompleted) +} diff --git a/universalClient/pushwatcher/read_processor_test.go b/universalClient/pushwatcher/read_processor_test.go deleted file mode 100644 index 784f0c1c..00000000 --- a/universalClient/pushwatcher/read_processor_test.go +++ /dev/null @@ -1,216 +0,0 @@ -package pushwatcher - -import ( - "context" - "fmt" - "testing" - - "github.com/rs/zerolog" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/pushchain/push-chain-node/universalClient/externalchains/common" - "github.com/pushchain/push-chain-node/universalClient/store" - "github.com/pushchain/push-chain-node/universalClient/uread" -) - -type fakeReadVoter struct { - votes map[string]*uread.ReadResult - txHash string - err error -} - -func (f *fakeReadVoter) VoteReadResult(ctx context.Context, requestID string, result *uread.ReadResult) (string, error) { - if f.err != nil { - return "", f.err - } - if f.votes == nil { - f.votes = make(map[string]*uread.ReadResult) - } - f.votes[requestID] = result - return f.txHash, nil -} - -type fakeDestClient struct { - result *uread.ReadResult - err error -} - -func (f *fakeDestClient) Start(ctx context.Context) error { return nil } -func (f *fakeDestClient) Stop() error { return nil } -func (f *fakeDestClient) IsHealthy() bool { return true } -func (f *fakeDestClient) GetTxBuilder() (common.TxBuilder, error) { - return nil, fmt.Errorf("not supported") -} -func (f *fakeDestClient) GetReadRequestHandler() (common.ReadRequestHandler, error) { - return f, nil -} -func (f *fakeDestClient) ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) { - return f.result, f.err -} - -type fakeChainResolver struct { - client common.ChainClient -} - -func (f *fakeChainResolver) GetClient(chainID string) (common.ChainClient, error) { - if f.client == nil { - return nil, fmt.Errorf("no client for %s", chainID) - } - return f.client, nil -} - -func testReadRequest() *uread.ReadRequest { - return &uread.ReadRequest{ - RequestID: "0xabc123", - DestinationChain: "eip155:11155111", - Query: []byte{0x01}, - MinConfirmations: 1, - DestinationBlockHeight: 100, - CreatedAtHeight: 7, - } -} - -func newTestReadProcessor(t *testing.T, voter readVoter, destClient common.ChainClient) (*ReadProcessor, *common.ChainStore) { - t.Helper() - database := newTestDB(t) - p, err := NewReadProcessor(voter, &fakeChainResolver{client: destClient}, database, 0, zerolog.Nop()) - require.NoError(t, err) - return p, common.NewChainStore(database) -} - -func seedReadRequest(t *testing.T, cs *common.ChainStore, req *uread.ReadRequest) string { - t.Helper() - event, err := convertReadRequestEvent(req) - require.NoError(t, err) - stored, err := cs.InsertEventIfNotExists(event) - require.NoError(t, err) - require.True(t, stored) - return event.EventID -} - -func eventStatus(t *testing.T, cs *common.ChainStore, eventID string) string { - t.Helper() - events, err := cs.GetConfirmedEvents(100) - require.NoError(t, err) - for i := range events { - if events[i].EventID == eventID { - return events[i].Status - } - } - // not CONFIRMED anymore; caller asserts via CAS probes - return "" -} - -func TestReadProcessor_SuccessFlow(t *testing.T) { - req := testReadRequest() - result := &uread.ReadResult{ - Status: uread.ReadStatusSuccess, - ResultData: []byte{0xaa}, - ObservedBlockHeight: 100, - } - voter := &fakeReadVoter{txHash: "VOTE_TX"} - p, cs := newTestReadProcessor(t, voter, &fakeDestClient{result: result}) - eventID := seedReadRequest(t, cs, req) - - p.processConfirmedReads(context.Background()) - - require.Contains(t, voter.votes, req.RequestID) - assert.Equal(t, result, voter.votes[req.RequestID]) - - // event flipped to COMPLETED with vote tx hash - rows, err := cs.UpdateEventStatus(eventID, store.StatusCompleted, store.StatusCompleted) - require.NoError(t, err) - assert.Equal(t, int64(1), rows) - - // second tick must not re-vote - voter.votes = nil - p.processConfirmedReads(context.Background()) - assert.Empty(t, voter.votes) -} - -func TestReadProcessor_VoteFailureKeepsConfirmed(t *testing.T) { - req := testReadRequest() - voter := &fakeReadVoter{err: fmt.Errorf("MsgVoteReadResult not available")} - p, cs := newTestReadProcessor(t, voter, &fakeDestClient{result: &uread.ReadResult{Status: uread.ReadStatusSuccess}}) - eventID := seedReadRequest(t, cs, req) - - p.processConfirmedReads(context.Background()) - - assert.Equal(t, store.StatusConfirmed, eventStatus(t, cs, eventID)) -} - -func TestReadProcessor_ExecutionFailureRetries(t *testing.T) { - req := testReadRequest() - voter := &fakeReadVoter{txHash: "VOTE_TX"} - p, cs := newTestReadProcessor(t, voter, &fakeDestClient{err: fmt.Errorf("rpc down")}) - eventID := seedReadRequest(t, cs, req) - - p.processConfirmedReads(context.Background()) - - assert.Empty(t, voter.votes) - assert.Equal(t, store.StatusConfirmed, eventStatus(t, cs, eventID)) -} - -func TestReadProcessor_UnservedChainRetries(t *testing.T) { - req := testReadRequest() - voter := &fakeReadVoter{txHash: "VOTE_TX"} - p, cs := newTestReadProcessor(t, voter, nil) - eventID := seedReadRequest(t, cs, req) - - p.processConfirmedReads(context.Background()) - - assert.Empty(t, voter.votes) - assert.Equal(t, store.StatusConfirmed, eventStatus(t, cs, eventID)) -} - -func TestReadProcessor_CorruptEventReverted(t *testing.T) { - voter := &fakeReadVoter{txHash: "VOTE_TX"} - p, cs := newTestReadProcessor(t, voter, &fakeDestClient{result: &uread.ReadResult{Status: uread.ReadStatusSuccess}}) - - stored, err := cs.InsertEventIfNotExists(&store.Event{ - EventID: "corrupt-read", - Type: store.EventTypeReadRequest, - ConfirmationType: store.ConfirmationInstant, - Status: store.StatusConfirmed, - EventData: []byte("not json"), - }) - require.NoError(t, err) - require.True(t, stored) - - p.processConfirmedReads(context.Background()) - - assert.Empty(t, voter.votes) - rows, err := cs.UpdateEventStatus("corrupt-read", store.StatusReverted, store.StatusReverted) - require.NoError(t, err) - assert.Equal(t, int64(1), rows) -} - -func TestReadProcessor_IgnoresOtherEventTypes(t *testing.T) { - voter := &fakeReadVoter{txHash: "VOTE_TX"} - p, cs := newTestReadProcessor(t, voter, &fakeDestClient{result: &uread.ReadResult{Status: uread.ReadStatusSuccess}}) - - stored, err := cs.InsertEventIfNotExists(&store.Event{ - EventID: "tss-event", - Type: store.EventTypeKeygen, - ConfirmationType: store.ConfirmationInstant, - Status: store.StatusConfirmed, - EventData: []byte("{}"), - }) - require.NoError(t, err) - require.True(t, stored) - - p.processConfirmedReads(context.Background()) - - assert.Empty(t, voter.votes) - assert.Equal(t, store.StatusConfirmed, eventStatus(t, cs, "tss-event")) -} - -func TestReadProcessor_StartStop(t *testing.T) { - p, _ := newTestReadProcessor(t, &fakeReadVoter{}, nil) - - require.NoError(t, p.Start(context.Background())) - assert.Equal(t, ErrAlreadyRunning, p.Start(context.Background())) - require.NoError(t, p.Stop()) - assert.Equal(t, ErrNotRunning, p.Stop()) -} From 03ebd48989d5fbce6d091fa076f6ad309c859641 Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 3 Aug 2026 17:37:38 +0530 Subject: [PATCH 17/23] refactor: naming changes --- universalClient/core/client.go | 21 +++++++++---------- .../externalchains/common/chain_store.go | 1 - 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/universalClient/core/client.go b/universalClient/core/client.go index f65808f9..cf1cdb93 100644 --- a/universalClient/core/client.go +++ b/universalClient/core/client.go @@ -31,7 +31,7 @@ type UniversalClient struct { pushCore *pushcore.Client pushSigner *pushsigner.Signer chains *externalchains.Chains - pushChain *pushwatcher.Client + pushWatcher *pushwatcher.Client tssNode *tss.Node } @@ -70,14 +70,13 @@ func NewUniversalClient(ctx context.Context, cfg *config.Config) (*UniversalClie chainsManager := externalchains.NewChains(pushCore, pushSigner, cfg, log) - // Push chain DB is shared by the push chain client and the TSS node. + // Push chain DB is shared by the push watcher and the TSS node. pushDB, err := openPushDB(cfg) if err != nil { return nil, err } - // chainsManager resolves destination chains for read request execution. - pushChain, err := pushwatcher.NewClient( + pushWatcher, err := pushwatcher.NewClient( pushDB, cfg.GetChainConfig(cfg.PushChainID), pushCore, @@ -87,7 +86,7 @@ func NewUniversalClient(ctx context.Context, cfg *config.Config) (*UniversalClie chainsManager, ) if err != nil { - return nil, fmt.Errorf("failed to create push chain client: %w", err) + return nil, fmt.Errorf("failed to create push watcher: %w", err) } tssNode, err := initTSS(ctx, cfg, pushCore, chainsManager, pushSigner, pushDB, log) @@ -105,7 +104,7 @@ func NewUniversalClient(ctx context.Context, cfg *config.Config) (*UniversalClie pushCore: pushCore, pushSigner: pushSigner, chains: chainsManager, - pushChain: pushChain, + pushWatcher: pushWatcher, tssNode: tssNode, }, nil } @@ -133,8 +132,8 @@ func (uc *UniversalClient) Start() error { return fmt.Errorf("failed to start chains manager: %w", err) } - if err := uc.pushChain.Start(uc.ctx); err != nil { - return fmt.Errorf("failed to start push chain client: %w", err) + if err := uc.pushWatcher.Start(uc.ctx); err != nil { + return fmt.Errorf("failed to start push watcher: %w", err) } if uc.tssNode != nil { @@ -169,9 +168,9 @@ func (uc *UniversalClient) shutdown() { } } - if uc.pushChain != nil { - if err := uc.pushChain.Stop(); err != nil { - uc.log.Error().Err(err).Str("subsystem", "push_chain").Msg("subsystem failed to stop") + if uc.pushWatcher != nil { + if err := uc.pushWatcher.Stop(); err != nil { + uc.log.Error().Err(err).Str("subsystem", "push_watcher").Msg("subsystem failed to stop") } } diff --git a/universalClient/externalchains/common/chain_store.go b/universalClient/externalchains/common/chain_store.go index a7005741..b67bd1f8 100644 --- a/universalClient/externalchains/common/chain_store.go +++ b/universalClient/externalchains/common/chain_store.go @@ -22,7 +22,6 @@ func NewChainStore(database *db.DB) *ChainStore { } } - // 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) { From 5958d430f4652173b77a96257e3c75754cf572b4 Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 3 Aug 2026 17:48:17 +0530 Subject: [PATCH 18/23] refactor: remove unused fn --- .../externalchains/common/chain_store.go | 18 --------------- .../externalchains/common/chain_store_test.go | 22 ------------------- 2 files changed, 40 deletions(-) diff --git a/universalClient/externalchains/common/chain_store.go b/universalClient/externalchains/common/chain_store.go index b67bd1f8..b711087c 100644 --- a/universalClient/externalchains/common/chain_store.go +++ b/universalClient/externalchains/common/chain_store.go @@ -154,24 +154,6 @@ func (cs *ChainStore) UpdateStatusAndEventData(eventID, oldStatus, newStatus str return res.RowsAffected, nil } -// UpdateVoteTxHash updates the vote_tx_hash field for an event -func (cs *ChainStore) UpdateVoteTxHash(eventID string, voteTxHash string) error { - if cs.database == nil { - return fmt.Errorf("database is nil") - } - - result := cs.database.Client(). - Model(&store.Event{}). - Where("event_id = ?", eventID). - Update("vote_tx_hash", voteTxHash) - - if result.Error != nil { - return fmt.Errorf("failed to update vote_tx_hash: %w", result.Error) - } - - return nil -} - // DeleteTerminalEvents deletes events in terminal states (COMPLETED, REVERTED, EXPIRED) // that were updated before the given time func (cs *ChainStore) DeleteTerminalEvents(updatedBefore any) (int64, error) { diff --git a/universalClient/externalchains/common/chain_store_test.go b/universalClient/externalchains/common/chain_store_test.go index a3b80989..461b2608 100644 --- a/universalClient/externalchains/common/chain_store_test.go +++ b/universalClient/externalchains/common/chain_store_test.go @@ -56,11 +56,6 @@ func TestChainStoreNilDatabase(t *testing.T) { assert.Contains(t, err.Error(), "database is nil") }) - t.Run("UpdateVoteTxHash returns error for nil database", func(t *testing.T) { - err := store.UpdateVoteTxHash("event-1", "0x123") - require.Error(t, err) - assert.Contains(t, err.Error(), "database is nil") - }) t.Run("InsertEventIfNotExists returns error for nil database", func(t *testing.T) { inserted, err := store.InsertEventIfNotExists(nil) @@ -222,23 +217,6 @@ func TestChainStore_UpdateStatusAndEventData(t *testing.T) { assert.Equal(t, int64(1), rows) } -func TestChainStore_UpdateVoteTxHash(t *testing.T) { - cs := newTestChainStore(t) - - event := &storemodels.Event{ - EventID: "evt-5", - BlockHeight: 50, - Type: storemodels.EventTypeOutbound, - ConfirmationType: storemodels.ConfirmationStandard, - Status: storemodels.StatusConfirmed, - } - _, err := cs.InsertEventIfNotExists(event) - require.NoError(t, err) - - err = cs.UpdateVoteTxHash("evt-5", "0xvotehash") - require.NoError(t, err) -} - func TestChainStore_GetPendingEventsLimit(t *testing.T) { cs := newTestChainStore(t) From 9b3cbf94946f6a6a57590a8ac7a1da9369517997 Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 3 Aug 2026 18:37:00 +0530 Subject: [PATCH 19/23] refactor: observation event processor --- .../externalchains/common/event_processor.go | 299 +---- .../common/event_processor_test.go | 1090 +++-------------- .../inbound_observation_event_processor.go | 118 ++ ...nbound_observation_event_processor_test.go | 214 ++++ .../outbound_observation_event_processor.go | 105 ++ ...tbound_observation_event_processor_test.go | 191 +++ universalClient/externalchains/evm/client.go | 20 +- universalClient/externalchains/svm/client.go | 20 +- 8 files changed, 864 insertions(+), 1193 deletions(-) create mode 100644 universalClient/externalchains/common/inbound_observation_event_processor.go create mode 100644 universalClient/externalchains/common/inbound_observation_event_processor_test.go create mode 100644 universalClient/externalchains/common/outbound_observation_event_processor.go create mode 100644 universalClient/externalchains/common/outbound_observation_event_processor_test.go diff --git a/universalClient/externalchains/common/event_processor.go b/universalClient/externalchains/common/event_processor.go index c49fcb3d..280ccf40 100644 --- a/universalClient/externalchains/common/event_processor.go +++ b/universalClient/externalchains/common/event_processor.go @@ -3,9 +3,7 @@ package common import ( "context" "encoding/hex" - "encoding/json" "fmt" - "strconv" "strings" "sync" "time" @@ -17,46 +15,55 @@ import ( "github.com/rs/zerolog" ) -// VoteSigner is the subset of pushsigner.Signer used by EventProcessor. +const eventProcessBatchSize = 1000 + +// VoteSigner is the subset of pushsigner.Signer used by the event processors. // 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) } -// EventProcessor processes events from the chain's database and votes on them +// EventHandler processes one CONFIRMED event of a registered type. +// Handlers own the event's status transitions; a returned error is logged and +// the event is retried next tick. +type EventHandler interface { + HandleEvent(ctx context.Context, event *store.Event) error +} + +// EventProcessor drains CONFIRMED events from the chain's database and +// dispatches them to the handler registered for their type. Event types +// without a handler are ignored. type EventProcessor struct { - signer VoteSigner - chainStore *ChainStore - logger zerolog.Logger - chainID string - inboundEnabled bool - outboundEnabled bool - running bool - stopCh chan struct{} - wg sync.WaitGroup + chainStore *ChainStore + handlers map[string]EventHandler + chainID string + logger zerolog.Logger + running bool + stopCh chan struct{} + wg sync.WaitGroup } -// NewEventProcessor creates a new event processor +// NewEventProcessor creates a new event processor. Register handlers before Start. func NewEventProcessor( - signer VoteSigner, database *db.DB, chainID string, - inboundEnabled bool, - outboundEnabled bool, logger zerolog.Logger, ) *EventProcessor { return &EventProcessor{ - signer: signer, - chainStore: NewChainStore(database), - chainID: chainID, - inboundEnabled: inboundEnabled, - outboundEnabled: outboundEnabled, - logger: logger.With().Str("component", "event_processor").Str("chain", chainID).Logger(), - stopCh: make(chan struct{}), + chainStore: NewChainStore(database), + handlers: make(map[string]EventHandler), + chainID: chainID, + logger: logger.With().Str("component", "event_processor").Str("chain", chainID).Logger(), + stopCh: make(chan struct{}), } } +// RegisterHandler registers a handler for an event type. Must be called before Start. +func (ep *EventProcessor) RegisterHandler(eventType string, handler EventHandler) { + ep.handlers[eventType] = handler +} + // Start begins processing events func (ep *EventProcessor) Start(ctx context.Context) error { if ep.running { @@ -109,7 +116,6 @@ func (ep *EventProcessor) processLoop(ctx context.Context) { ep.logger.Debug().Msg("stop signal received, stopping event processor") return case <-ticker.C: - // Fetch 1000 CONFIRMED events and process them if err := ep.processConfirmedEvents(ctx); err != nil { ep.logger.Error().Err(err).Msg("failed to process confirmed events") } @@ -117,102 +123,34 @@ func (ep *EventProcessor) processLoop(ctx context.Context) { } } -// processConfirmedEvents processes confirmed events (both inbound and outbound) +// processConfirmedEvents dispatches CONFIRMED events to their registered handlers. func (ep *EventProcessor) processConfirmedEvents(ctx context.Context) error { - events, err := ep.chainStore.GetConfirmedEvents(1000) + events, err := ep.chainStore.GetConfirmedEvents(eventProcessBatchSize) if err != nil { return fmt.Errorf("failed to get confirmed events: %w", err) } for _, event := range events { - if event.Type == store.EventTypeInbound { - if !ep.inboundEnabled { - ep.logger.Warn().Str("event_id", event.EventID).Msg("inbound disabled, skipping inbound event processing") - continue - } - if err := ep.processInboundEvent(ctx, &event); err != nil { - ep.logger.Error(). - Err(err). - Str("event_id", event.EventID). - Msg("failed to vote on inbound event") - continue - } - } else if event.Type == store.EventTypeOutbound { - if !ep.outboundEnabled { - ep.logger.Warn().Str("event_id", event.EventID).Msg("outbound disabled, skipping outbound event processing") - continue - } - if err := ep.processOutboundEvent(ctx, &event); err != nil { - ep.logger.Error(). - Err(err). - Str("event_id", event.EventID). - Msg("failed to vote on outbound event") - continue - } + handler, ok := ep.handlers[event.Type] + if !ok { + continue } - } - return nil -} - -// processOutboundEvent processes an outbound event by voting on it -func (ep *EventProcessor) processOutboundEvent(ctx context.Context, event *store.Event) error { - ep.logger.Debug(). - Str("event_id", event.EventID). - Msg("processing outbound event") - - // Parse outbound event data once - outboundData, err := ep.parseOutboundEventData(event) - if err != nil { - return fmt.Errorf("failed to parse outbound event data: %w", err) - } - - txID := outboundData.TxID - utxID := outboundData.UniversalTxID - - // Build observation from parsed data - observation, err := ep.buildOutboundObservation(event, outboundData) - if err != nil { - return fmt.Errorf("failed to build outbound observation: %w", err) - } - - // Vote on outbound - voteTxHash, err := ep.signer.VoteOutbound(ctx, txID, utxID, observation) - if err != nil { - return fmt.Errorf("failed to vote on outbound: %w", err) - } - - return ep.markCompleted(event, voteTxHash) -} - -// processInboundEvent processes an inbound event by voting on it and confirming it -func (ep *EventProcessor) processInboundEvent(ctx context.Context, event *store.Event) error { - ep.logger.Debug(). - Str("event_id", event.EventID). - Msg("processing inbound event") - - // Extract inbound data from event - inbound, err := ep.constructInbound(event) - if err != nil { - return fmt.Errorf("failed to construct inbound: %w", err) - } - - // Execute vote on blockchain - voteTxHash, err := ep.signer.VoteInbound(ctx, inbound) - if err != nil { - ep.logger.Error(). - Str("event_id", event.EventID). - Err(err). - Msg("failed to vote on event - keeping status for retry") - return err + if err := handler.HandleEvent(ctx, &event); err != nil { + ep.logger.Error(). + Err(err). + Str("event_id", event.EventID). + Str("type", event.Type). + Msg("failed to process event") + } } - return ep.markCompleted(event, voteTxHash) + return nil } -// 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) +// markEventCompleted atomically records the vote hash and flips CONFIRMED -> COMPLETED. +func markEventCompleted(chainStore *ChainStore, logger zerolog.Logger, event *store.Event, voteTxHash string) error { + rowsAffected, err := 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) } @@ -221,7 +159,7 @@ func (ep *EventProcessor) markCompleted(event *store.Event, voteTxHash string) e return nil // already completed } - ep.logger.Info(). + logger.Info(). Str("event_id", event.EventID). Str("type", event.Type). Str("vote_tx_hash", voteTxHash). @@ -230,86 +168,25 @@ func (ep *EventProcessor) markCompleted(event *store.Event, voteTxHash string) e return nil } -// constructInbound creates an Inbound message from event data -func (ep *EventProcessor) constructInbound(event *store.Event) (*uexecutortypes.Inbound, error) { - var eventData UniversalTx - - if event == nil { - return nil, fmt.Errorf("event is nil") - } - - if event.EventData == nil { - return nil, fmt.Errorf("event data is missing for event_id: %s", event.EventID) - } - - if err := json.Unmarshal(event.EventData, &eventData); err != nil { - return nil, fmt.Errorf("failed to unmarshal event data: %w", err) - } - - // Map txType from eventData to proper enum value - txType := uexecutortypes.TxType_UNSPECIFIED_TX - switch eventData.TxType { - case 0: - txType = uexecutortypes.TxType_GAS - case 1: - txType = uexecutortypes.TxType_GAS_AND_PAYLOAD - case 2: - txType = uexecutortypes.TxType_FUNDS - case 3: - txType = uexecutortypes.TxType_FUNDS_AND_PAYLOAD - default: - txType = uexecutortypes.TxType_UNSPECIFIED_TX - } - - // Extract txHash from EventID (format: "txHash:logIndex") +// eventTxHash extracts the tx hash from an EventID (format: "txHash:logIndex" +// or "signature:logIndex"), converting base58 signatures to 0x-prefixed hex. +// Falls back to the raw value if conversion fails. +func eventTxHash(eventID string) string { txHash := "" - parts := strings.Split(event.EventID, ":") + parts := strings.Split(eventID, ":") if len(parts) > 0 { txHash = parts[0] } - // Convert txHash to hex format if it's in base58 - txHashHex, err := ep.base58ToHex(txHash) + txHashHex, err := base58ToHex(txHash) if err != nil { - ep.logger.Warn(). - Str("tx_hash", txHash). - Err(err). - Msg("failed to convert txHash to hex, using original value") - txHashHex = txHash - } - - inboundMsg := &uexecutortypes.Inbound{ - SourceChain: eventData.SourceChain, - TxHash: txHashHex, - Sender: eventData.Sender, - Recipient: eventData.Recipient, - Amount: eventData.Amount, - AssetAddr: eventData.Token, - LogIndex: strconv.FormatUint(uint64(eventData.LogIndex), 10), - TxType: txType, - IsCEA: eventData.FromCEA, - RawPayload: eventData.RawPayload, - } - - // Set revert instructions if revert fund recipient is present - if eventData.RevertFundRecipient != "" { - inboundMsg.RevertInstructions = &uexecutortypes.RevertInstructions{ - FundRecipient: eventData.RevertFundRecipient, - } + return txHash } - - // Use event's VerificationData if present, otherwise fall back to txHash - if eventData.VerificationData == "" || eventData.VerificationData == "0x" { - inboundMsg.VerificationData = txHashHex - } else { - inboundMsg.VerificationData = eventData.VerificationData - } - - return inboundMsg, nil + return txHashHex } // base58ToHex converts a base58 encoded string to hex format (0x...) -func (ep *EventProcessor) base58ToHex(base58Str string) (string, error) { +func base58ToHex(base58Str string) (string, error) { if base58Str == "" { return "0x", nil } @@ -328,65 +205,3 @@ func (ep *EventProcessor) base58ToHex(base58Str string) (string, error) { // Convert to hex with 0x prefix return "0x" + hex.EncodeToString(decoded), nil } - -// parseOutboundEventData unmarshals event data into an OutboundEvent struct -func (ep *EventProcessor) parseOutboundEventData(event *store.Event) (*OutboundEvent, error) { - if event == nil { - return nil, fmt.Errorf("event is nil") - } - - if len(event.EventData) == 0 { - return nil, fmt.Errorf("event data is empty") - } - - var eventData OutboundEvent - if err := json.Unmarshal(event.EventData, &eventData); err != nil { - return nil, fmt.Errorf("failed to unmarshal event data: %w", err) - } - - if eventData.TxID == "" { - return nil, fmt.Errorf("tx_id not found in event data") - } - - if eventData.UniversalTxID == "" { - return nil, fmt.Errorf("universal_tx_id not found in event data") - } - - return &eventData, nil -} - -// buildOutboundObservation builds an OutboundObservation from event metadata and parsed outbound data -func (ep *EventProcessor) buildOutboundObservation(event *store.Event, outboundData *OutboundEvent) (*uexecutortypes.OutboundObservation, error) { - // Extract txHash from EventID (format: "txHash:logIndex" or "signature:logIndex") - txHash := "" - parts := strings.Split(event.EventID, ":") - if len(parts) > 0 { - txHash = parts[0] - } - - // Convert txHash to hex format if it's in base58 - txHashHex, err := ep.base58ToHex(txHash) - if err != nil { - ep.logger.Warn(). - Str("tx_hash", txHash). - Err(err). - Msg("failed to convert txHash to hex, using original value") - txHashHex = txHash - } - - gasFeeUsed := "0" - if outboundData.GasFeeUsed != "" { - gasFeeUsed = outboundData.GasFeeUsed - } - - observation := &uexecutortypes.OutboundObservation{ - Success: true, - BlockHeight: event.BlockHeight, - TxHash: txHashHex, - ErrorMsg: "", - GasFeeUsed: gasFeeUsed, - Pc20WrapperAddress: outboundData.Pc20WrapperAddress, - } - - return observation, nil -} diff --git a/universalClient/externalchains/common/event_processor_test.go b/universalClient/externalchains/common/event_processor_test.go index caf7a44a..df8a1f12 100644 --- a/universalClient/externalchains/common/event_processor_test.go +++ b/universalClient/externalchains/common/event_processor_test.go @@ -2,7 +2,7 @@ package common import ( "context" - "encoding/json" + "fmt" "math/big" "testing" "time" @@ -16,1012 +16,244 @@ import ( uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" ) -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) - - require.NotNil(t, processor) - assert.Equal(t, chainID, processor.chainID) - assert.False(t, processor.running) - assert.NotNil(t, processor.stopCh) - assert.NotNil(t, processor.chainStore) - }) +type fakeVoteSigner struct { + inboundVotes int + outboundVotes int + txHash string + err error } -func TestEventProcessorIsRunning(t *testing.T) { - t.Run("returns false when not running", func(t *testing.T) { - processor := &EventProcessor{running: false} - assert.False(t, processor.IsRunning()) - }) - - t.Run("returns true when running", func(t *testing.T) { - processor := &EventProcessor{running: true} - assert.True(t, processor.IsRunning()) - }) +func (f *fakeVoteSigner) VoteInbound(ctx context.Context, inbound *uexecutortypes.Inbound) (string, error) { + if f.err != nil { + return "", f.err + } + f.inboundVotes++ + return f.txHash, nil } -func TestEventProcessorStop(t *testing.T) { - t.Run("stop when not running returns nil", func(t *testing.T) { - processor := &EventProcessor{running: false} - err := processor.Stop() - assert.NoError(t, err) - }) +func (f *fakeVoteSigner) VoteOutbound(ctx context.Context, txID string, utxID string, observation *uexecutortypes.OutboundObservation) (string, error) { + if f.err != nil { + return "", f.err + } + f.outboundVotes++ + return f.txHash, nil } -func TestEventProcessorBase58ToHex(t *testing.T) { - logger := zerolog.Nop() - processor := NewEventProcessor(nil, nil, "test-chain", true, true, logger) - - t.Run("empty string returns 0x", func(t *testing.T) { - result, err := processor.base58ToHex("") - require.NoError(t, err) - assert.Equal(t, "0x", result) - }) - - t.Run("already hex returns as is", func(t *testing.T) { - input := "0xabcdef1234567890" - result, err := processor.base58ToHex(input) - require.NoError(t, err) - assert.Equal(t, input, result) - }) - - t.Run("valid base58 converts to hex", func(t *testing.T) { - // "3yZe7d" is base58 for bytes [1, 2, 3, 4] - input := "2VfUX" - result, err := processor.base58ToHex(input) - require.NoError(t, err) - assert.True(t, len(result) > 2) - assert.Equal(t, "0x", result[:2]) - }) - - t.Run("invalid base58 returns error", func(t *testing.T) { - // Base58 doesn't include 0, O, I, l - input := "0OIl" - _, err := processor.base58ToHex(input) - require.Error(t, err) - }) +type fakeEventHandler struct { + handled []string + err error } -func TestEventProcessorConstructInbound(t *testing.T) { - logger := zerolog.Nop() - processor := NewEventProcessor(nil, nil, "eip155:1", true, true, logger) - - t.Run("nil event returns error", func(t *testing.T) { - inbound, err := processor.constructInbound(nil) - require.Error(t, err) - assert.Nil(t, inbound) - assert.Contains(t, err.Error(), "event is nil") - }) - - t.Run("nil event data returns error", func(t *testing.T) { - event := &store.Event{ - EventID: "0x123:0", - EventData: nil, - } - inbound, err := processor.constructInbound(event) - require.Error(t, err) - assert.Nil(t, inbound) - assert.Contains(t, err.Error(), "event data is missing") - }) - - t.Run("invalid JSON returns error", func(t *testing.T) { - event := &store.Event{ - EventID: "0x123:0", - EventData: []byte("invalid json"), - } - inbound, err := processor.constructInbound(event) - require.Error(t, err) - assert.Nil(t, inbound) - }) - - t.Run("valid event data constructs inbound", func(t *testing.T) { - eventData := UniversalTx{ - SourceChain: "eip155:1", - LogIndex: 5, - Sender: "0xsender123", - Recipient: "push1recipient", - Token: "0xtoken", - Amount: "1000000", - TxType: 2, // FUNDS - } - eventDataBytes, _ := json.Marshal(eventData) - - event := &store.Event{ - EventID: "0xabc123:5", - EventData: eventDataBytes, - } - - inbound, err := processor.constructInbound(event) - require.NoError(t, err) - require.NotNil(t, inbound) - assert.Equal(t, "eip155:1", inbound.SourceChain) - assert.Equal(t, "0xsender123", inbound.Sender) - assert.Equal(t, "1000000", inbound.Amount) - assert.Equal(t, uexecutortypes.TxType_FUNDS, inbound.TxType) - }) - - t.Run("passes all fields unconditionally to inbound", func(t *testing.T) { - eventData := UniversalTx{ - SourceChain: "eip155:1", - LogIndex: 3, - Sender: "0xsender", - Recipient: "0xrecipient", - Token: "0xtoken", - Amount: "500", - RawPayload: "0xdeadbeef", - VerificationData: "0xsigdata", - RevertFundRecipient: "0xrevert", - TxType: 3, // FUNDS_AND_PAYLOAD - FromCEA: true, - } - eventDataBytes, _ := json.Marshal(eventData) - - event := &store.Event{ - EventID: "0xtxhash:3", - EventData: eventDataBytes, - } - - inbound, err := processor.constructInbound(event) - require.NoError(t, err) - require.NotNil(t, inbound) - assert.Equal(t, "0xrecipient", inbound.Recipient) - assert.Equal(t, "0xdeadbeef", inbound.RawPayload) - assert.Equal(t, "0xsigdata", inbound.VerificationData) - assert.True(t, inbound.IsCEA) - require.NotNil(t, inbound.RevertInstructions) - assert.Equal(t, "0xrevert", inbound.RevertInstructions.FundRecipient) - }) - - t.Run("passes raw payload and verification data for non-payload tx types", func(t *testing.T) { - // Core will strip these — UV just passes everything through - eventData := UniversalTx{ - SourceChain: "eip155:1", - Sender: "0xsender", - Recipient: "0xrecipient", - Amount: "1000", - RawPayload: "0xcafe", - VerificationData: "0xsig", - TxType: 2, // FUNDS (non-payload type) - } - eventDataBytes, _ := json.Marshal(eventData) - - event := &store.Event{ - EventID: "0xhash:0", - EventData: eventDataBytes, - } - - inbound, err := processor.constructInbound(event) - require.NoError(t, err) - assert.Equal(t, "0xrecipient", inbound.Recipient) - assert.Equal(t, "0xcafe", inbound.RawPayload) - assert.Equal(t, "0xsig", inbound.VerificationData) - }) - - t.Run("no revert instructions when revert recipient is empty", func(t *testing.T) { - eventData := UniversalTx{ - SourceChain: "eip155:1", - Sender: "0xsender", - Amount: "100", - TxType: 0, // GAS - RevertFundRecipient: "", - } - eventDataBytes, _ := json.Marshal(eventData) - - event := &store.Event{ - EventID: "0xhash:0", - EventData: eventDataBytes, - } - - inbound, err := processor.constructInbound(event) - require.NoError(t, err) - assert.Nil(t, inbound.RevertInstructions) - }) - - t.Run("tx type mapping", func(t *testing.T) { - testCases := []struct { - txType uint - expected uexecutortypes.TxType - }{ - {0, uexecutortypes.TxType_GAS}, - {1, uexecutortypes.TxType_GAS_AND_PAYLOAD}, - {2, uexecutortypes.TxType_FUNDS}, - {3, uexecutortypes.TxType_FUNDS_AND_PAYLOAD}, - {99, uexecutortypes.TxType_UNSPECIFIED_TX}, // Unknown defaults to unspecified - } - - for _, tc := range testCases { - eventData := UniversalTx{ - SourceChain: "eip155:1", - TxType: tc.txType, - } - eventDataBytes, _ := json.Marshal(eventData) - - event := &store.Event{ - EventID: "0xabc:0", - EventData: eventDataBytes, - } - - inbound, err := processor.constructInbound(event) - require.NoError(t, err) - assert.Equal(t, tc.expected, inbound.TxType, "TxType %d should map to %v", tc.txType, tc.expected) - } - }) +func (f *fakeEventHandler) HandleEvent(ctx context.Context, event *store.Event) error { + f.handled = append(f.handled, event.EventID) + return f.err } -func TestEventProcessorParseOutboundEventData(t *testing.T) { - logger := zerolog.Nop() - processor := NewEventProcessor(nil, nil, "eip155:1", true, true, logger) - - t.Run("nil event returns error", func(t *testing.T) { - data, err := processor.parseOutboundEventData(nil) - require.Error(t, err) - assert.Nil(t, data) - assert.Contains(t, err.Error(), "event is nil") - }) - - t.Run("empty event data returns error", func(t *testing.T) { - event := &store.Event{ - EventID: "test", - EventData: []byte{}, - } - data, err := processor.parseOutboundEventData(event) - require.Error(t, err) - assert.Nil(t, data) - assert.Contains(t, err.Error(), "event data is empty") - }) - - t.Run("valid outbound event extracts IDs and gas fee", func(t *testing.T) { - eventData := OutboundEvent{ - TxID: "0x1234", - UniversalTxID: "0xabcd", - GasFeeUsed: "42000000000000", - } - eventDataBytes, _ := json.Marshal(eventData) - - event := &store.Event{ - EventID: "test", - EventData: eventDataBytes, - } - - data, err := processor.parseOutboundEventData(event) - require.NoError(t, err) - assert.Equal(t, "0x1234", data.TxID) - assert.Equal(t, "0xabcd", data.UniversalTxID) - assert.Equal(t, "42000000000000", data.GasFeeUsed) - }) - - t.Run("missing tx_id returns error", func(t *testing.T) { - eventData := OutboundEvent{ - TxID: "", - UniversalTxID: "0xabcd", - } - eventDataBytes, _ := json.Marshal(eventData) - - event := &store.Event{ - EventID: "test", - EventData: eventDataBytes, - } - - data, err := processor.parseOutboundEventData(event) - require.Error(t, err) - assert.Nil(t, data) - assert.Contains(t, err.Error(), "tx_id not found") - }) - - t.Run("missing universal_tx_id returns error", func(t *testing.T) { - eventData := OutboundEvent{ - TxID: "0x1234", - UniversalTxID: "", - } - eventDataBytes, _ := json.Marshal(eventData) - - event := &store.Event{ - EventID: "test", - EventData: eventDataBytes, - } - - data, err := processor.parseOutboundEventData(event) - require.Error(t, err) - assert.Nil(t, data) - assert.Contains(t, err.Error(), "universal_tx_id not found") - }) +func newTestDB(t *testing.T) *ucdb.DB { + t.Helper() + database, err := ucdb.OpenInMemoryDB(true) + require.NoError(t, err) + t.Cleanup(func() { _ = database.Close() }) + return database } -func TestEventProcessorBuildOutboundObservation(t *testing.T) { - logger := zerolog.Nop() - processor := NewEventProcessor(nil, nil, "eip155:1", true, true, logger) - - t.Run("builds observation with gas fee from parsed data", func(t *testing.T) { - outboundData := &OutboundEvent{ - TxID: "0x1234", - UniversalTxID: "0xabcd", - GasFeeUsed: "42000000000000", - } - - event := &store.Event{ - EventID: "0xabc123:5", - BlockHeight: 12345, - } - - obs, err := processor.buildOutboundObservation(event, outboundData) - require.NoError(t, err) - require.NotNil(t, obs) - assert.True(t, obs.Success) - assert.Equal(t, uint64(12345), obs.BlockHeight) - assert.Equal(t, "0xabc123", obs.TxHash) - assert.Equal(t, "42000000000000", obs.GasFeeUsed) - }) - - t.Run("missing gas fee defaults to 0", func(t *testing.T) { - outboundData := &OutboundEvent{ - TxID: "0x1234", - UniversalTxID: "0xabcd", - } - - event := &store.Event{ - EventID: "0xabc123:5", - BlockHeight: 12345, - } - - obs, err := processor.buildOutboundObservation(event, outboundData) - require.NoError(t, err) - require.NotNil(t, obs) - assert.Equal(t, "0", obs.GasFeeUsed) - }) - - t.Run("handles base58 tx hash", func(t *testing.T) { - outboundData := &OutboundEvent{ - TxID: "0x1234", - UniversalTxID: "0xabcd", - } - - event := &store.Event{ - EventID: "2VfUX:0", // Base58 encoded - BlockHeight: 100, - } - - obs, err := processor.buildOutboundObservation(event, outboundData) - require.NoError(t, err) - require.NotNil(t, obs) - assert.True(t, len(obs.TxHash) >= 2) +func seedConfirmedEvent(t *testing.T, database *ucdb.DB, eventID, eventType string, eventData []byte) { + t.Helper() + result := database.Client().Create(&store.Event{ + EventID: eventID, + Type: eventType, + ConfirmationType: store.ConfirmationInstant, + Status: store.StatusConfirmed, + EventData: eventData, }) + require.NoError(t, result.Error) } -func TestProcessOutboundEvent(t *testing.T) { - logger := zerolog.Nop() - ctx := context.Background() - - setupDB := func(t *testing.T) *ucdb.DB { - t.Helper() - database, err := ucdb.OpenInMemoryDB(true) - require.NoError(t, err) - return database - } - - 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) - - event := &store.Event{ - EventID: "0xabc:0", - EventData: nil, - } - err := ep.processOutboundEvent(ctx, event) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse outbound event data") - }) - - 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) - - event := &store.Event{ - EventID: "0xabc:0", - EventData: []byte{}, - } - err := ep.processOutboundEvent(ctx, event) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse outbound event data") - }) - - 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) - - event := &store.Event{ - EventID: "0xabc:0", - EventData: []byte("not json"), - } - err := ep.processOutboundEvent(ctx, event) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse outbound event data") - }) - - 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) - - eventData, _ := json.Marshal(OutboundEvent{ - TxID: "", - UniversalTxID: "0xutxid", - }) - event := &store.Event{ - EventID: "0xabc:0", - EventData: eventData, - } - err := ep.processOutboundEvent(ctx, event) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse outbound event data") - }) - - 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) - - eventData, _ := json.Marshal(OutboundEvent{ - TxID: "0xtxid", - UniversalTxID: "", - }) - event := &store.Event{ - EventID: "0xabc:0", - EventData: eventData, - } - err := ep.processOutboundEvent(ctx, event) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to parse outbound event data") - }) +func TestNewEventProcessor(t *testing.T) { + processor := NewEventProcessor(nil, "eip155:1", zerolog.Nop()) + + require.NotNil(t, processor) + assert.Equal(t, "eip155:1", processor.chainID) + assert.False(t, processor.running) + assert.NotNil(t, processor.stopCh) + assert.NotNil(t, processor.chainStore) + assert.Empty(t, processor.handlers) } -func TestProcessInboundEvent(t *testing.T) { - logger := zerolog.Nop() - ctx := context.Background() +func TestEventProcessor_DispatchesByType(t *testing.T) { + database := newTestDB(t) + ep := NewEventProcessor(database, "eip155:1", zerolog.Nop()) - setupDB := func(t *testing.T) *ucdb.DB { - t.Helper() - database, err := ucdb.OpenInMemoryDB(true) - require.NoError(t, err) - return database - } + inboundHandler := &fakeEventHandler{} + ep.RegisterHandler(store.EventTypeInbound, inboundHandler) - 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) + seedConfirmedEvent(t, database, "0xin:0", store.EventTypeInbound, []byte("{}")) + seedConfirmedEvent(t, database, "0xout:0", store.EventTypeOutbound, []byte("{}")) // no handler registered - event := &store.Event{ - EventID: "0xabc:0", - EventData: nil, - } - err := ep.processInboundEvent(ctx, event) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to construct inbound") - }) + require.NoError(t, ep.processConfirmedEvents(context.Background())) - 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) - - event := &store.Event{ - EventID: "0xabc:0", - EventData: []byte("{not valid json}"), - } - err := ep.processInboundEvent(ctx, event) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to construct inbound") - }) + assert.Equal(t, []string{"0xin:0"}, inboundHandler.handled) } -func TestProcessConfirmedEventsRouting(t *testing.T) { - logger := zerolog.Nop() - ctx := context.Background() - - setupDB := func(t *testing.T, events []store.Event) *ucdb.DB { - t.Helper() - database, err := ucdb.OpenInMemoryDB(true) - require.NoError(t, err) - for _, e := range events { - result := database.Client().Create(&e) - require.NoError(t, result.Error) - } - return database - } - - 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) +func TestEventProcessor_HandlerErrorKeepsProcessing(t *testing.T) { + database := newTestDB(t) + ep := NewEventProcessor(database, "eip155:1", zerolog.Nop()) - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) - }) + failing := &fakeEventHandler{err: fmt.Errorf("boom")} + ep.RegisterHandler(store.EventTypeInbound, failing) - t.Run("only pending events are ignored", func(t *testing.T) { - database := setupDB(t, []store.Event{ - { - EventID: "0xpending:0", - Status: store.StatusPending, - Type: store.EventTypeInbound, - EventData: []byte("{}"), - }, - }) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) + seedConfirmedEvent(t, database, "0xin:0", store.EventTypeInbound, []byte("{}")) + seedConfirmedEvent(t, database, "0xin:1", store.EventTypeInbound, []byte("{}")) - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) + require.NoError(t, ep.processConfirmedEvents(context.Background())) - // Event should remain PENDING (not picked up) - var evt store.Event - database.Client().Where("event_id = ?", "0xpending:0").First(&evt) - assert.Equal(t, store.StatusPending, evt.Status) - }) - - t.Run("inbound with bad data fails gracefully and continues to next event", func(t *testing.T) { - database := setupDB(t, []store.Event{ - { - EventID: "0xbad_inbound:0", - Status: store.StatusConfirmed, - Type: store.EventTypeInbound, - EventData: []byte("not json"), - }, - { - EventID: "0xbad_inbound2:0", - Status: store.StatusConfirmed, - Type: store.EventTypeInbound, - EventData: nil, - }, - }) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - // Should not return error - errors on individual events are logged and skipped - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) - - // Both events should remain CONFIRMED (failed to process, not updated) - var evt1, evt2 store.Event - database.Client().Where("event_id = ?", "0xbad_inbound:0").First(&evt1) - assert.Equal(t, store.StatusConfirmed, evt1.Status) - database.Client().Where("event_id = ?", "0xbad_inbound2:0").First(&evt2) - assert.Equal(t, store.StatusConfirmed, evt2.Status) - }) - - t.Run("outbound with bad data fails gracefully and continues to next event", func(t *testing.T) { - database := setupDB(t, []store.Event{ - { - EventID: "0xbad_outbound:0", - Status: store.StatusConfirmed, - Type: store.EventTypeOutbound, - EventData: []byte("not json"), - }, - { - EventID: "0xbad_outbound2:0", - Status: store.StatusConfirmed, - Type: store.EventTypeOutbound, - EventData: []byte{}, - }, - }) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) - - // Both events should remain CONFIRMED - var evt1, evt2 store.Event - database.Client().Where("event_id = ?", "0xbad_outbound:0").First(&evt1) - assert.Equal(t, store.StatusConfirmed, evt1.Status) - database.Client().Where("event_id = ?", "0xbad_outbound2:0").First(&evt2) - assert.Equal(t, store.StatusConfirmed, evt2.Status) - }) - - t.Run("read request without reader is skipped", func(t *testing.T) { - database := setupDB(t, []store.Event{ - { - EventID: "0xread:0", - Status: store.StatusConfirmed, - Type: store.EventTypeReadRequest, - EventData: []byte("{}"), - }, - }) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) - - var evt store.Event - database.Client().Where("event_id = ?", "0xread:0").First(&evt) - assert.Equal(t, store.StatusConfirmed, evt.Status) - }) - - t.Run("unknown event type is silently skipped", func(t *testing.T) { - database := setupDB(t, []store.Event{ - { - EventID: "0xunknown:0", - Status: store.StatusConfirmed, - Type: "UNKNOWN_TYPE", - EventData: []byte("{}"), - }, - }) - defer database.Close() - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) - - // Event should remain CONFIRMED (no handler for this type) - var evt store.Event - database.Client().Where("event_id = ?", "0xunknown:0").First(&evt) - assert.Equal(t, store.StatusConfirmed, evt.Status) - }) -} - -func TestProcessLoopContextCancellation(t *testing.T) { - logger := zerolog.Nop() - database, err := ucdb.OpenInMemoryDB(true) - require.NoError(t, err) - defer database.Close() - - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - t.Run("processLoop exits promptly on context cancel", func(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) - - err := ep.Start(ctx) - require.NoError(t, err) - assert.True(t, ep.IsRunning()) - - // Cancel context and wait for stop - cancel() - - // The wg.Wait inside Stop() will block until processLoop exits - done := make(chan struct{}) - go func() { - ep.Stop() - close(done) - }() - - select { - case <-done: - // processLoop exited within reasonable time - case <-time.After(10 * time.Second): - t.Fatal("processLoop did not exit within 10 seconds after context cancellation") - } - - assert.False(t, ep.IsRunning()) - }) -} - -func TestProcessLoopStopChannel(t *testing.T) { - logger := zerolog.Nop() - database, err := ucdb.OpenInMemoryDB(true) + // both attempted despite errors, both still CONFIRMED for retry + assert.Len(t, failing.handled, 2) + events, err := NewChainStore(database).GetConfirmedEvents(10) require.NoError(t, err) - defer database.Close() - - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - t.Run("processLoop exits promptly on stop signal", func(t *testing.T) { - ctx := context.Background() - - err := ep.Start(ctx) - require.NoError(t, err) - assert.True(t, ep.IsRunning()) - - done := make(chan struct{}) - go func() { - ep.Stop() - close(done) - }() - - select { - case <-done: - // processLoop exited promptly - case <-time.After(10 * time.Second): - t.Fatal("processLoop did not exit within 10 seconds after stop signal") - } - - assert.False(t, ep.IsRunning()) - }) + assert.Len(t, events, 2) } -func TestProcessConfirmedEventsDBError(t *testing.T) { - logger := zerolog.Nop() - ctx := context.Background() +func TestEventProcessor_PendingEventsIgnored(t *testing.T) { + database := newTestDB(t) + ep := NewEventProcessor(database, "eip155:1", zerolog.Nop()) - t.Run("nil database returns error", func(t *testing.T) { - ep := &EventProcessor{ - chainStore: NewChainStore(nil), - logger: logger, - chainID: "eip155:1", - inboundEnabled: true, - outboundEnabled: true, - } + handler := &fakeEventHandler{} + ep.RegisterHandler(store.EventTypeInbound, handler) - err := ep.processConfirmedEvents(ctx) - require.Error(t, err) - assert.Contains(t, err.Error(), "failed to get confirmed events") + result := database.Client().Create(&store.Event{ + EventID: "0xpending:0", + Type: store.EventTypeInbound, + ConfirmationType: store.ConfirmationInstant, + Status: store.StatusPending, + EventData: []byte("{}"), }) -} + require.NoError(t, result.Error) -func TestEventProcessorStruct(t *testing.T) { - t.Run("struct has expected fields", func(t *testing.T) { - ep := &EventProcessor{} - assert.Nil(t, ep.signer) - assert.Nil(t, ep.chainStore) - assert.Empty(t, ep.chainID) - assert.False(t, ep.running) - assert.Nil(t, ep.stopCh) - assert.False(t, ep.inboundEnabled) - assert.False(t, ep.outboundEnabled) - }) -} + require.NoError(t, ep.processConfirmedEvents(context.Background())) -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) - 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) - 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) - 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) - assert.False(t, ep.inboundEnabled) - assert.False(t, ep.outboundEnabled) - }) + assert.Empty(t, handler.handled) } -func TestEventProcessorStartDoubleStart(t *testing.T) { - logger := zerolog.Nop() - database, err := ucdb.OpenInMemoryDB(true) - require.NoError(t, err) - defer database.Close() - - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() +func TestEventProcessor_NilDatabaseErrors(t *testing.T) { + ep := NewEventProcessor(nil, "eip155:1", zerolog.Nop()) + ep.RegisterHandler(store.EventTypeInbound, &fakeEventHandler{}) - // First start should succeed - err = ep.Start(ctx) - require.NoError(t, err) - assert.True(t, ep.IsRunning()) - - // Second start should be rejected - err = ep.Start(ctx) + err := ep.processConfirmedEvents(context.Background()) require.Error(t, err) - assert.Contains(t, err.Error(), "already running") - assert.True(t, ep.IsRunning()) - - // Clean up - err = ep.Stop() - require.NoError(t, err) - assert.False(t, ep.IsRunning()) + assert.Contains(t, err.Error(), "failed to get confirmed events") } -func TestEventProcessorStopIdempotent(t *testing.T) { - logger := zerolog.Nop() - database, err := ucdb.OpenInMemoryDB(true) - require.NoError(t, err) - defer database.Close() - - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) +func TestEventProcessor_Lifecycle(t *testing.T) { + database := newTestDB(t) + ep := NewEventProcessor(database, "eip155:1", zerolog.Nop()) ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Start the processor - err = ep.Start(ctx) - require.NoError(t, err) - assert.True(t, ep.IsRunning()) - - // First stop - err = ep.Stop() - require.NoError(t, err) - assert.False(t, ep.IsRunning()) - - // Second stop should be idempotent (no error, no panic) - err = ep.Stop() - require.NoError(t, err) + // initial state assert.False(t, ep.IsRunning()) - // Third stop also fine - err = ep.Stop() - require.NoError(t, err) -} - -func TestEventProcessorIsRunningStateTransitions(t *testing.T) { - logger := zerolog.Nop() - database, err := ucdb.OpenInMemoryDB(true) - require.NoError(t, err) - defer database.Close() - - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) - - // Initial state: not running - assert.False(t, ep.IsRunning()) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - // After start: running - err = ep.Start(ctx) - require.NoError(t, err) + // start + require.NoError(t, ep.Start(ctx)) assert.True(t, ep.IsRunning()) - // After stop: not running - err = ep.Stop() - require.NoError(t, err) + // double start rejected + err := ep.Start(ctx) + require.Error(t, err) + assert.Contains(t, err.Error(), "already running") + + // stop, idempotent + require.NoError(t, ep.Stop()) assert.False(t, ep.IsRunning()) + require.NoError(t, ep.Stop()) - // Can restart after stop - err = ep.Start(ctx) - require.NoError(t, err) + // restart works + require.NoError(t, ep.Start(ctx)) assert.True(t, ep.IsRunning()) - - // Clean up - err = ep.Stop() - require.NoError(t, err) - assert.False(t, ep.IsRunning()) + require.NoError(t, ep.Stop()) } -func TestEventProcessorStopViaContextCancel(t *testing.T) { - logger := zerolog.Nop() - database, err := ucdb.OpenInMemoryDB(true) - require.NoError(t, err) - defer database.Close() - - ep := NewEventProcessor(nil, database, "eip155:1", true, true, logger) +func TestEventProcessor_StopViaContextCancel(t *testing.T) { + database := newTestDB(t) + ep := NewEventProcessor(database, "eip155:1", zerolog.Nop()) ctx, cancel := context.WithCancel(context.Background()) - - err = ep.Start(ctx) - require.NoError(t, err) + require.NoError(t, ep.Start(ctx)) assert.True(t, ep.IsRunning()) - // Cancel context - the processLoop should exit cancel() - // Stop should still work cleanly after context cancellation - err = ep.Stop() - require.NoError(t, err) + done := make(chan struct{}) + go func() { + _ = ep.Stop() + close(done) + }() + + select { + case <-done: + case <-time.After(10 * time.Second): + t.Fatal("processLoop did not exit after context cancellation") + } assert.False(t, ep.IsRunning()) } -func TestProcessConfirmedEventsEnabledFlags(t *testing.T) { - logger := zerolog.Nop() - ctx := context.Background() - - // Helper to create an in-memory DB and seed confirmed events - setupDB := func(t *testing.T, events []store.Event) *ucdb.DB { - t.Helper() - database, err := ucdb.OpenInMemoryDB(true) +func TestBase58ToHex(t *testing.T) { + t.Run("empty string returns 0x", func(t *testing.T) { + result, err := base58ToHex("") require.NoError(t, err) - for _, e := range events { - result := database.Client().Create(&e) - require.NoError(t, result.Error) - } - return database - } - - inboundEventData, _ := json.Marshal(UniversalTx{ - SourceChain: "eip155:1", - Sender: "0xsender", - Amount: "1000", - TxType: 2, - }) - - outboundEventData, _ := json.Marshal(OutboundEvent{ - TxID: "0xtxid", - UniversalTxID: "0xutxid", + assert.Equal(t, "0x", result) }) - makeEvents := func() []store.Event { - return []store.Event{ - { - EventID: "0xaaa:0", - Status: store.StatusConfirmed, - Type: store.EventTypeInbound, - EventData: inboundEventData, - }, - { - EventID: "0xbbb:0", - Status: store.StatusConfirmed, - Type: store.EventTypeOutbound, - EventData: outboundEventData, - }, - } - } - - 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) - - err := ep.processConfirmedEvents(ctx) + t.Run("already hex returns as is", func(t *testing.T) { + input := "0xabcdef1234567890" + result, err := base58ToHex(input) require.NoError(t, err) - - // Inbound event should still be CONFIRMED (skipped, not processed) - var inboundEvt store.Event - database.Client().Where("event_id = ?", "0xaaa:0").First(&inboundEvt) - assert.Equal(t, store.StatusConfirmed, inboundEvt.Status) + assert.Equal(t, input, result) }) - 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) - - err := ep.processConfirmedEvents(ctx) + t.Run("valid base58 converts to hex", func(t *testing.T) { + result, err := base58ToHex("2VfUX") require.NoError(t, err) + assert.True(t, len(result) > 2) + assert.Equal(t, "0x", result[:2]) + }) - // Outbound event should still be CONFIRMED (skipped, not processed) - var outboundEvt store.Event - database.Client().Where("event_id = ?", "0xbbb:0").First(&outboundEvt) - assert.Equal(t, store.StatusConfirmed, outboundEvt.Status) + t.Run("invalid base58 returns error", func(t *testing.T) { + // Base58 doesn't include 0, O, I, l + _, err := base58ToHex("0OIl") + require.Error(t, err) }) +} - t.Run("inbound enabled but outbound disabled skips only outbound", func(t *testing.T) { - // Seed only outbound events so we don't hit nil signer panic on inbound - database := setupDB(t, []store.Event{ - { - EventID: "0xbbb:0", - Status: store.StatusConfirmed, - Type: store.EventTypeOutbound, - EventData: outboundEventData, - }, - }) - ep := NewEventProcessor(nil, database, "eip155:1", true, false, logger) +func TestEventTxHash(t *testing.T) { + t.Run("hex event id", func(t *testing.T) { + assert.Equal(t, "0xabc123", eventTxHash("0xabc123:5")) + }) - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) + t.Run("base58 event id converts", func(t *testing.T) { + got := eventTxHash("2VfUX:0") + assert.Equal(t, "0x", got[:2]) + }) - // Outbound event should still be CONFIRMED (skipped due to outbound disabled) - var outboundEvt store.Event - database.Client().Where("event_id = ?", "0xbbb:0").First(&outboundEvt) - assert.Equal(t, store.StatusConfirmed, outboundEvt.Status) + t.Run("invalid base58 falls back to raw value", func(t *testing.T) { + assert.Equal(t, "0OIl", eventTxHash("0OIl:0")) }) +} - t.Run("outbound enabled but inbound disabled skips only inbound", func(t *testing.T) { - // Seed only inbound events so we don't hit nil signer panic on outbound - database := setupDB(t, []store.Event{ - { - EventID: "0xaaa:0", - Status: store.StatusConfirmed, - Type: store.EventTypeInbound, - EventData: inboundEventData, - }, - }) - ep := NewEventProcessor(nil, database, "eip155:1", false, true, logger) +func TestMarkEventCompleted(t *testing.T) { + database := newTestDB(t) + cs := NewChainStore(database) + seedConfirmedEvent(t, database, "0xdone:0", store.EventTypeInbound, []byte("{}")) - err := ep.processConfirmedEvents(ctx) - require.NoError(t, err) + event := &store.Event{EventID: "0xdone:0", Type: store.EventTypeInbound} + require.NoError(t, markEventCompleted(cs, zerolog.Nop(), event, "0xvote")) - // Inbound event should still be CONFIRMED (skipped due to inbound disabled) - var inboundEvt store.Event - database.Client().Where("event_id = ?", "0xaaa:0").First(&inboundEvt) - assert.Equal(t, store.StatusConfirmed, inboundEvt.Status) - }) + rows, err := cs.UpdateEventStatus("0xdone:0", store.StatusCompleted, store.StatusCompleted) + require.NoError(t, err) + assert.Equal(t, int64(1), rows) + + // already completed: no-op, no error + require.NoError(t, markEventCompleted(cs, zerolog.Nop(), event, "0xvote2")) } func TestEncodeUint256Result(t *testing.T) { diff --git a/universalClient/externalchains/common/inbound_observation_event_processor.go b/universalClient/externalchains/common/inbound_observation_event_processor.go new file mode 100644 index 00000000..e86272f5 --- /dev/null +++ b/universalClient/externalchains/common/inbound_observation_event_processor.go @@ -0,0 +1,118 @@ +package common + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + + "github.com/pushchain/push-chain-node/universalClient/db" + "github.com/pushchain/push-chain-node/universalClient/store" + uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" + "github.com/rs/zerolog" +) + +// InboundObservationEventProcessor handles INBOUND events: it builds the +// inbound observation from the stored event and votes it on Push chain. +type InboundObservationEventProcessor struct { + signer VoteSigner + chainStore *ChainStore + logger zerolog.Logger +} + +// NewInboundObservationEventProcessor creates the handler for INBOUND events. +func NewInboundObservationEventProcessor( + signer VoteSigner, + database *db.DB, + logger zerolog.Logger, +) *InboundObservationEventProcessor { + return &InboundObservationEventProcessor{ + signer: signer, + chainStore: NewChainStore(database), + logger: logger.With().Str("component", "inbound_observation_event_processor").Logger(), + } +} + +// HandleEvent implements EventHandler for INBOUND events. +func (p *InboundObservationEventProcessor) HandleEvent(ctx context.Context, event *store.Event) error { + p.logger.Debug(). + Str("event_id", event.EventID). + Msg("processing inbound event") + + // Extract inbound data from event + inbound, err := p.buildInboundObservation(event) + if err != nil { + return fmt.Errorf("failed to build inbound observation: %w", err) + } + + // Execute vote on blockchain + voteTxHash, err := p.signer.VoteInbound(ctx, inbound) + if err != nil { + return fmt.Errorf("failed to vote on inbound - keeping status for retry: %w", err) + } + + return markEventCompleted(p.chainStore, p.logger, event, voteTxHash) +} + +// buildInboundObservation builds an Inbound observation from event data +func (p *InboundObservationEventProcessor) buildInboundObservation(event *store.Event) (*uexecutortypes.Inbound, error) { + var eventData UniversalTx + + if event == nil { + return nil, fmt.Errorf("event is nil") + } + + if event.EventData == nil { + return nil, fmt.Errorf("event data is missing for event_id: %s", event.EventID) + } + + if err := json.Unmarshal(event.EventData, &eventData); err != nil { + return nil, fmt.Errorf("failed to unmarshal event data: %w", err) + } + + // Map txType from eventData to proper enum value + txType := uexecutortypes.TxType_UNSPECIFIED_TX + switch eventData.TxType { + case 0: + txType = uexecutortypes.TxType_GAS + case 1: + txType = uexecutortypes.TxType_GAS_AND_PAYLOAD + case 2: + txType = uexecutortypes.TxType_FUNDS + case 3: + txType = uexecutortypes.TxType_FUNDS_AND_PAYLOAD + default: + txType = uexecutortypes.TxType_UNSPECIFIED_TX + } + + txHashHex := eventTxHash(event.EventID) + + inboundMsg := &uexecutortypes.Inbound{ + SourceChain: eventData.SourceChain, + TxHash: txHashHex, + Sender: eventData.Sender, + Recipient: eventData.Recipient, + Amount: eventData.Amount, + AssetAddr: eventData.Token, + LogIndex: strconv.FormatUint(uint64(eventData.LogIndex), 10), + TxType: txType, + IsCEA: eventData.FromCEA, + RawPayload: eventData.RawPayload, + } + + // Set revert instructions if revert fund recipient is present + if eventData.RevertFundRecipient != "" { + inboundMsg.RevertInstructions = &uexecutortypes.RevertInstructions{ + FundRecipient: eventData.RevertFundRecipient, + } + } + + // Use event's VerificationData if present, otherwise fall back to txHash + if eventData.VerificationData == "" || eventData.VerificationData == "0x" { + inboundMsg.VerificationData = txHashHex + } else { + inboundMsg.VerificationData = eventData.VerificationData + } + + return inboundMsg, nil +} diff --git a/universalClient/externalchains/common/inbound_observation_event_processor_test.go b/universalClient/externalchains/common/inbound_observation_event_processor_test.go new file mode 100644 index 00000000..30440e9e --- /dev/null +++ b/universalClient/externalchains/common/inbound_observation_event_processor_test.go @@ -0,0 +1,214 @@ +package common + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pushchain/push-chain-node/universalClient/store" + uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" +) + +func TestInboundBuildInboundObservation(t *testing.T) { + processor := NewInboundObservationEventProcessor(nil, nil, zerolog.Nop()) + + t.Run("nil event returns error", func(t *testing.T) { + inbound, err := processor.buildInboundObservation(nil) + require.Error(t, err) + assert.Nil(t, inbound) + assert.Contains(t, err.Error(), "event is nil") + }) + + t.Run("nil event data returns error", func(t *testing.T) { + event := &store.Event{ + EventID: "0x123:0", + EventData: nil, + } + inbound, err := processor.buildInboundObservation(event) + require.Error(t, err) + assert.Nil(t, inbound) + assert.Contains(t, err.Error(), "event data is missing") + }) + + t.Run("invalid JSON returns error", func(t *testing.T) { + event := &store.Event{ + EventID: "0x123:0", + EventData: []byte("invalid json"), + } + inbound, err := processor.buildInboundObservation(event) + require.Error(t, err) + assert.Nil(t, inbound) + }) + + t.Run("valid event data constructs inbound", func(t *testing.T) { + eventData := UniversalTx{ + SourceChain: "eip155:1", + LogIndex: 5, + Sender: "0xsender123", + Recipient: "push1recipient", + Token: "0xtoken", + Amount: "1000000", + TxType: 2, // FUNDS + } + eventDataBytes, _ := json.Marshal(eventData) + + event := &store.Event{ + EventID: "0xabc123:5", + EventData: eventDataBytes, + } + + inbound, err := processor.buildInboundObservation(event) + require.NoError(t, err) + require.NotNil(t, inbound) + assert.Equal(t, "eip155:1", inbound.SourceChain) + assert.Equal(t, "0xsender123", inbound.Sender) + assert.Equal(t, "1000000", inbound.Amount) + assert.Equal(t, "0xabc123", inbound.TxHash) + assert.Equal(t, uexecutortypes.TxType_FUNDS, inbound.TxType) + }) + + t.Run("passes all fields unconditionally to inbound", func(t *testing.T) { + eventData := UniversalTx{ + SourceChain: "eip155:1", + LogIndex: 3, + Sender: "0xsender", + Recipient: "0xrecipient", + Token: "0xtoken", + Amount: "500", + RawPayload: "0xdeadbeef", + VerificationData: "0xsigdata", + RevertFundRecipient: "0xrevert", + TxType: 3, // FUNDS_AND_PAYLOAD + FromCEA: true, + } + eventDataBytes, _ := json.Marshal(eventData) + + event := &store.Event{ + EventID: "0xtxhash:3", + EventData: eventDataBytes, + } + + inbound, err := processor.buildInboundObservation(event) + require.NoError(t, err) + require.NotNil(t, inbound) + assert.Equal(t, "0xrecipient", inbound.Recipient) + assert.Equal(t, "0xdeadbeef", inbound.RawPayload) + assert.Equal(t, "0xsigdata", inbound.VerificationData) + assert.True(t, inbound.IsCEA) + require.NotNil(t, inbound.RevertInstructions) + assert.Equal(t, "0xrevert", inbound.RevertInstructions.FundRecipient) + }) + + t.Run("no revert instructions when revert recipient is empty", func(t *testing.T) { + eventData := UniversalTx{ + SourceChain: "eip155:1", + Sender: "0xsender", + Amount: "100", + TxType: 0, // GAS + RevertFundRecipient: "", + } + eventDataBytes, _ := json.Marshal(eventData) + + event := &store.Event{ + EventID: "0xhash:0", + EventData: eventDataBytes, + } + + inbound, err := processor.buildInboundObservation(event) + require.NoError(t, err) + assert.Nil(t, inbound.RevertInstructions) + }) + + t.Run("falls back verification data to tx hash", func(t *testing.T) { + eventData := UniversalTx{ + SourceChain: "eip155:1", + VerificationData: "", + TxType: 0, + } + eventDataBytes, _ := json.Marshal(eventData) + + event := &store.Event{ + EventID: "0xhash:0", + EventData: eventDataBytes, + } + + inbound, err := processor.buildInboundObservation(event) + require.NoError(t, err) + assert.Equal(t, "0xhash", inbound.VerificationData) + }) + + t.Run("tx type mapping", func(t *testing.T) { + testCases := []struct { + txType uint + expected uexecutortypes.TxType + }{ + {0, uexecutortypes.TxType_GAS}, + {1, uexecutortypes.TxType_GAS_AND_PAYLOAD}, + {2, uexecutortypes.TxType_FUNDS}, + {3, uexecutortypes.TxType_FUNDS_AND_PAYLOAD}, + {99, uexecutortypes.TxType_UNSPECIFIED_TX}, // Unknown defaults to unspecified + } + + for _, tc := range testCases { + eventData := UniversalTx{ + SourceChain: "eip155:1", + TxType: tc.txType, + } + eventDataBytes, _ := json.Marshal(eventData) + + event := &store.Event{ + EventID: "0xabc:0", + EventData: eventDataBytes, + } + + inbound, err := processor.buildInboundObservation(event) + require.NoError(t, err) + assert.Equal(t, tc.expected, inbound.TxType, "TxType %d should map to %v", tc.txType, tc.expected) + } + }) +} + +func TestInboundHandleEvent(t *testing.T) { + ctx := context.Background() + + t.Run("construct failure returns error, event stays CONFIRMED", func(t *testing.T) { + database := newTestDB(t) + processor := NewInboundObservationEventProcessor(&fakeVoteSigner{txHash: "0xvote"}, database, zerolog.Nop()) + seedConfirmedEvent(t, database, "0xbad:0", store.EventTypeInbound, []byte("not json")) + + err := processor.HandleEvent(ctx, &store.Event{EventID: "0xbad:0", EventData: []byte("not json")}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to build inbound observation") + }) + + t.Run("vote failure returns error", func(t *testing.T) { + database := newTestDB(t) + processor := NewInboundObservationEventProcessor(&fakeVoteSigner{err: fmt.Errorf("broadcast failed")}, database, zerolog.Nop()) + eventData, _ := json.Marshal(UniversalTx{SourceChain: "eip155:1", TxType: 0}) + + err := processor.HandleEvent(ctx, &store.Event{EventID: "0xin:0", EventData: eventData}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to vote on inbound") + }) + + t.Run("successful vote marks event completed", func(t *testing.T) { + database := newTestDB(t) + signer := &fakeVoteSigner{txHash: "0xvote"} + processor := NewInboundObservationEventProcessor(signer, database, zerolog.Nop()) + eventData, _ := json.Marshal(UniversalTx{SourceChain: "eip155:1", TxType: 0}) + seedConfirmedEvent(t, database, "0xin:0", store.EventTypeInbound, eventData) + + err := processor.HandleEvent(ctx, &store.Event{EventID: "0xin:0", Type: store.EventTypeInbound, EventData: eventData}) + require.NoError(t, err) + assert.Equal(t, 1, signer.inboundVotes) + + rows, err := NewChainStore(database).UpdateEventStatus("0xin:0", store.StatusCompleted, store.StatusCompleted) + require.NoError(t, err) + assert.Equal(t, int64(1), rows) + }) +} diff --git a/universalClient/externalchains/common/outbound_observation_event_processor.go b/universalClient/externalchains/common/outbound_observation_event_processor.go new file mode 100644 index 00000000..56974ee6 --- /dev/null +++ b/universalClient/externalchains/common/outbound_observation_event_processor.go @@ -0,0 +1,105 @@ +package common + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/pushchain/push-chain-node/universalClient/db" + "github.com/pushchain/push-chain-node/universalClient/store" + uexecutortypes "github.com/pushchain/push-chain-node/x/uexecutor/types" + "github.com/rs/zerolog" +) + +// OutboundObservationEventProcessor handles OUTBOUND events: it builds the +// outbound observation from the stored event and votes it on Push chain. +type OutboundObservationEventProcessor struct { + signer VoteSigner + chainStore *ChainStore + logger zerolog.Logger +} + +// NewOutboundObservationEventProcessor creates the handler for OUTBOUND events. +func NewOutboundObservationEventProcessor( + signer VoteSigner, + database *db.DB, + logger zerolog.Logger, +) *OutboundObservationEventProcessor { + return &OutboundObservationEventProcessor{ + signer: signer, + chainStore: NewChainStore(database), + logger: logger.With().Str("component", "outbound_observation_event_processor").Logger(), + } +} + +// HandleEvent implements EventHandler for OUTBOUND events. +func (p *OutboundObservationEventProcessor) HandleEvent(ctx context.Context, event *store.Event) error { + p.logger.Debug(). + Str("event_id", event.EventID). + Msg("processing outbound event") + + // Parse outbound event data once + outboundData, err := p.parseOutboundEventData(event) + if err != nil { + return fmt.Errorf("failed to parse outbound event data: %w", err) + } + + // Build observation from parsed data + observation, err := p.buildOutboundObservation(event, outboundData) + if err != nil { + return fmt.Errorf("failed to build outbound observation: %w", err) + } + + // Vote on outbound + voteTxHash, err := p.signer.VoteOutbound(ctx, outboundData.TxID, outboundData.UniversalTxID, observation) + if err != nil { + return fmt.Errorf("failed to vote on outbound: %w", err) + } + + return markEventCompleted(p.chainStore, p.logger, event, voteTxHash) +} + +// parseOutboundEventData unmarshals event data into an OutboundEvent struct +func (p *OutboundObservationEventProcessor) parseOutboundEventData(event *store.Event) (*OutboundEvent, error) { + if event == nil { + return nil, fmt.Errorf("event is nil") + } + + if len(event.EventData) == 0 { + return nil, fmt.Errorf("event data is empty") + } + + var eventData OutboundEvent + if err := json.Unmarshal(event.EventData, &eventData); err != nil { + return nil, fmt.Errorf("failed to unmarshal event data: %w", err) + } + + if eventData.TxID == "" { + return nil, fmt.Errorf("tx_id not found in event data") + } + + if eventData.UniversalTxID == "" { + return nil, fmt.Errorf("universal_tx_id not found in event data") + } + + return &eventData, nil +} + +// buildOutboundObservation builds an OutboundObservation from event metadata and parsed outbound data +func (p *OutboundObservationEventProcessor) buildOutboundObservation(event *store.Event, outboundData *OutboundEvent) (*uexecutortypes.OutboundObservation, error) { + gasFeeUsed := "0" + if outboundData.GasFeeUsed != "" { + gasFeeUsed = outboundData.GasFeeUsed + } + + observation := &uexecutortypes.OutboundObservation{ + Success: true, + BlockHeight: event.BlockHeight, + TxHash: eventTxHash(event.EventID), + ErrorMsg: "", + GasFeeUsed: gasFeeUsed, + Pc20WrapperAddress: outboundData.Pc20WrapperAddress, + } + + return observation, nil +} diff --git a/universalClient/externalchains/common/outbound_observation_event_processor_test.go b/universalClient/externalchains/common/outbound_observation_event_processor_test.go new file mode 100644 index 00000000..8a456a41 --- /dev/null +++ b/universalClient/externalchains/common/outbound_observation_event_processor_test.go @@ -0,0 +1,191 @@ +package common + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pushchain/push-chain-node/universalClient/store" +) + +func TestOutboundParseOutboundEventData(t *testing.T) { + processor := NewOutboundObservationEventProcessor(nil, nil, zerolog.Nop()) + + t.Run("nil event returns error", func(t *testing.T) { + data, err := processor.parseOutboundEventData(nil) + require.Error(t, err) + assert.Nil(t, data) + assert.Contains(t, err.Error(), "event is nil") + }) + + t.Run("empty event data returns error", func(t *testing.T) { + event := &store.Event{ + EventID: "test", + EventData: []byte{}, + } + data, err := processor.parseOutboundEventData(event) + require.Error(t, err) + assert.Nil(t, data) + assert.Contains(t, err.Error(), "event data is empty") + }) + + t.Run("valid outbound event extracts IDs and gas fee", func(t *testing.T) { + eventData := OutboundEvent{ + TxID: "0x1234", + UniversalTxID: "0xabcd", + GasFeeUsed: "42000000000000", + } + eventDataBytes, _ := json.Marshal(eventData) + + event := &store.Event{ + EventID: "test", + EventData: eventDataBytes, + } + + data, err := processor.parseOutboundEventData(event) + require.NoError(t, err) + assert.Equal(t, "0x1234", data.TxID) + assert.Equal(t, "0xabcd", data.UniversalTxID) + assert.Equal(t, "42000000000000", data.GasFeeUsed) + }) + + t.Run("missing tx_id returns error", func(t *testing.T) { + eventData := OutboundEvent{ + TxID: "", + UniversalTxID: "0xabcd", + } + eventDataBytes, _ := json.Marshal(eventData) + + event := &store.Event{ + EventID: "test", + EventData: eventDataBytes, + } + + data, err := processor.parseOutboundEventData(event) + require.Error(t, err) + assert.Nil(t, data) + assert.Contains(t, err.Error(), "tx_id not found") + }) + + t.Run("missing universal_tx_id returns error", func(t *testing.T) { + eventData := OutboundEvent{ + TxID: "0x1234", + UniversalTxID: "", + } + eventDataBytes, _ := json.Marshal(eventData) + + event := &store.Event{ + EventID: "test", + EventData: eventDataBytes, + } + + data, err := processor.parseOutboundEventData(event) + require.Error(t, err) + assert.Nil(t, data) + assert.Contains(t, err.Error(), "universal_tx_id not found") + }) +} + +func TestOutboundBuildOutboundObservation(t *testing.T) { + processor := NewOutboundObservationEventProcessor(nil, nil, zerolog.Nop()) + + t.Run("builds observation with gas fee from parsed data", func(t *testing.T) { + outboundData := &OutboundEvent{ + TxID: "0x1234", + UniversalTxID: "0xabcd", + GasFeeUsed: "42000000000000", + } + + event := &store.Event{ + EventID: "0xabc123:5", + BlockHeight: 12345, + } + + obs, err := processor.buildOutboundObservation(event, outboundData) + require.NoError(t, err) + require.NotNil(t, obs) + assert.True(t, obs.Success) + assert.Equal(t, uint64(12345), obs.BlockHeight) + assert.Equal(t, "0xabc123", obs.TxHash) + assert.Equal(t, "42000000000000", obs.GasFeeUsed) + }) + + t.Run("missing gas fee defaults to 0", func(t *testing.T) { + outboundData := &OutboundEvent{ + TxID: "0x1234", + UniversalTxID: "0xabcd", + } + + event := &store.Event{ + EventID: "0xabc123:5", + BlockHeight: 12345, + } + + obs, err := processor.buildOutboundObservation(event, outboundData) + require.NoError(t, err) + require.NotNil(t, obs) + assert.Equal(t, "0", obs.GasFeeUsed) + }) + + t.Run("handles base58 tx hash", func(t *testing.T) { + outboundData := &OutboundEvent{ + TxID: "0x1234", + UniversalTxID: "0xabcd", + } + + event := &store.Event{ + EventID: "2VfUX:0", // Base58 encoded + BlockHeight: 100, + } + + obs, err := processor.buildOutboundObservation(event, outboundData) + require.NoError(t, err) + require.NotNil(t, obs) + assert.True(t, len(obs.TxHash) >= 2) + assert.Equal(t, "0x", obs.TxHash[:2]) + }) +} + +func TestOutboundHandleEvent(t *testing.T) { + ctx := context.Background() + + t.Run("parse failure returns error", func(t *testing.T) { + database := newTestDB(t) + processor := NewOutboundObservationEventProcessor(&fakeVoteSigner{txHash: "0xvote"}, database, zerolog.Nop()) + + err := processor.HandleEvent(ctx, &store.Event{EventID: "0xbad:0", EventData: []byte("not json")}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to parse outbound event data") + }) + + t.Run("vote failure returns error", func(t *testing.T) { + database := newTestDB(t) + processor := NewOutboundObservationEventProcessor(&fakeVoteSigner{err: fmt.Errorf("broadcast failed")}, database, zerolog.Nop()) + eventData, _ := json.Marshal(OutboundEvent{TxID: "0xtxid", UniversalTxID: "0xutxid"}) + + err := processor.HandleEvent(ctx, &store.Event{EventID: "0xout:0", EventData: eventData}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to vote on outbound") + }) + + t.Run("successful vote marks event completed", func(t *testing.T) { + database := newTestDB(t) + signer := &fakeVoteSigner{txHash: "0xvote"} + processor := NewOutboundObservationEventProcessor(signer, database, zerolog.Nop()) + eventData, _ := json.Marshal(OutboundEvent{TxID: "0xtxid", UniversalTxID: "0xutxid"}) + seedConfirmedEvent(t, database, "0xout:0", store.EventTypeOutbound, eventData) + + err := processor.HandleEvent(ctx, &store.Event{EventID: "0xout:0", Type: store.EventTypeOutbound, EventData: eventData}) + require.NoError(t, err) + assert.Equal(t, 1, signer.outboundVotes) + + rows, err := NewChainStore(database).UpdateEventStatus("0xout:0", store.StatusCompleted, store.StatusCompleted) + require.NoError(t, err) + assert.Equal(t, int64(1), rows) + }) +} diff --git a/universalClient/externalchains/evm/client.go b/universalClient/externalchains/evm/client.go index 07f83684..2b2af36a 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" ) @@ -86,16 +87,14 @@ func NewClient( // Initialize components that don't require RPC client if pushSigner != nil { - inboundEnabled := config.Enabled != nil && config.Enabled.IsInboundEnabled - outboundEnabled := config.Enabled != nil && config.Enabled.IsOutboundEnabled - client.eventProcessor = common.NewEventProcessor( - pushSigner, - database, - chainIDStr, - inboundEnabled, - outboundEnabled, - log, - ) + ep := common.NewEventProcessor(database, chainIDStr, log) + if config.Enabled != nil && config.Enabled.IsInboundEnabled { + ep.RegisterHandler(store.EventTypeInbound, common.NewInboundObservationEventProcessor(pushSigner, database, log)) + } + if config.Enabled != nil && config.Enabled.IsOutboundEnabled { + ep.RegisterHandler(store.EventTypeOutbound, common.NewOutboundObservationEventProcessor(pushSigner, database, log)) + } + client.eventProcessor = ep } return client, nil @@ -207,7 +206,6 @@ func (c *Client) GetReadRequestHandler() (common.ReadRequestHandler, error) { return c, nil } - // 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 95e00104..e0afd3a0 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" ) @@ -96,16 +97,14 @@ func NewClient( // Initialize components that don't require RPC client if pushSigner != nil { - inboundEnabled := config.Enabled != nil && config.Enabled.IsInboundEnabled - outboundEnabled := config.Enabled != nil && config.Enabled.IsOutboundEnabled - client.eventProcessor = common.NewEventProcessor( - pushSigner, - database, - chainIDStr, - inboundEnabled, - outboundEnabled, - log, - ) + ep := common.NewEventProcessor(database, chainIDStr, log) + if config.Enabled != nil && config.Enabled.IsInboundEnabled { + ep.RegisterHandler(store.EventTypeInbound, common.NewInboundObservationEventProcessor(pushSigner, database, log)) + } + if config.Enabled != nil && config.Enabled.IsOutboundEnabled { + ep.RegisterHandler(store.EventTypeOutbound, common.NewOutboundObservationEventProcessor(pushSigner, database, log)) + } + client.eventProcessor = ep } return client, nil @@ -217,7 +216,6 @@ func (c *Client) GetReadRequestHandler() (common.ReadRequestHandler, error) { return c, nil } - // initializeComponents creates all components that require the RPC client func (c *Client) initializeComponents() error { // Create event listener if gateway is configured From b4d539af9bfae45c8ca2d09c48b473de5fe0fd07 Mon Sep 17 00:00:00 2001 From: aman035 Date: Mon, 3 Aug 2026 18:48:54 +0530 Subject: [PATCH 20/23] refactor: observeration events --- .../externalchains/common/chain_store_test.go | 1 - .../inbound_observation_event_processor.go | 17 ++++++++++- ...nbound_observation_event_processor_test.go | 14 ++++----- .../outbound_observation_event_processor.go | 22 +++++++++++--- ...tbound_observation_event_processor_test.go | 16 +++++----- .../externalchains/common/types.go | 30 ------------------- .../externalchains/evm/event_confirmer.go | 2 +- .../evm/event_confirmer_test.go | 4 +-- .../externalchains/evm/event_parser.go | 10 +++---- .../externalchains/evm/event_parser_test.go | 26 ++++++++-------- .../externalchains/svm/event_parser.go | 6 ++-- .../externalchains/svm/event_parser_test.go | 26 ++++++++-------- 12 files changed, 86 insertions(+), 88 deletions(-) diff --git a/universalClient/externalchains/common/chain_store_test.go b/universalClient/externalchains/common/chain_store_test.go index 461b2608..bc413cac 100644 --- a/universalClient/externalchains/common/chain_store_test.go +++ b/universalClient/externalchains/common/chain_store_test.go @@ -56,7 +56,6 @@ func TestChainStoreNilDatabase(t *testing.T) { assert.Contains(t, err.Error(), "database is nil") }) - t.Run("InsertEventIfNotExists returns error for nil database", func(t *testing.T) { inserted, err := store.InsertEventIfNotExists(nil) require.Error(t, err) diff --git a/universalClient/externalchains/common/inbound_observation_event_processor.go b/universalClient/externalchains/common/inbound_observation_event_processor.go index e86272f5..c72f8842 100644 --- a/universalClient/externalchains/common/inbound_observation_event_processor.go +++ b/universalClient/externalchains/common/inbound_observation_event_processor.go @@ -12,6 +12,21 @@ import ( "github.com/rs/zerolog" ) +// InboundObservation is the inbound observation payload stored for INBOUND events +type InboundObservation struct { + SourceChain string `json:"sourceChain"` + LogIndex uint `json:"logIndex"` + Sender string `json:"sender"` + Recipient string `json:"recipient"` + Token string `json:"bridgeToken"` + Amount string `json:"bridgeAmount"` // uint256 as decimal string + RawPayload string `json:"rawPayload,omitempty"` // hex-encoded raw payload bytes from source chain + VerificationData string `json:"verificationData"` + RevertFundRecipient string `json:"revertFundRecipient,omitempty"` + TxType uint `json:"txType"` // enum backing uint as decimal string + FromCEA bool `json:"fromCEA"` // true if inbound is initiated by a CEA +} + // InboundObservationEventProcessor handles INBOUND events: it builds the // inbound observation from the stored event and votes it on Push chain. type InboundObservationEventProcessor struct { @@ -56,7 +71,7 @@ func (p *InboundObservationEventProcessor) HandleEvent(ctx context.Context, even // buildInboundObservation builds an Inbound observation from event data func (p *InboundObservationEventProcessor) buildInboundObservation(event *store.Event) (*uexecutortypes.Inbound, error) { - var eventData UniversalTx + var eventData InboundObservation if event == nil { return nil, fmt.Errorf("event is nil") diff --git a/universalClient/externalchains/common/inbound_observation_event_processor_test.go b/universalClient/externalchains/common/inbound_observation_event_processor_test.go index 30440e9e..4abfe0ab 100644 --- a/universalClient/externalchains/common/inbound_observation_event_processor_test.go +++ b/universalClient/externalchains/common/inbound_observation_event_processor_test.go @@ -46,7 +46,7 @@ func TestInboundBuildInboundObservation(t *testing.T) { }) t.Run("valid event data constructs inbound", func(t *testing.T) { - eventData := UniversalTx{ + eventData := InboundObservation{ SourceChain: "eip155:1", LogIndex: 5, Sender: "0xsender123", @@ -73,7 +73,7 @@ func TestInboundBuildInboundObservation(t *testing.T) { }) t.Run("passes all fields unconditionally to inbound", func(t *testing.T) { - eventData := UniversalTx{ + eventData := InboundObservation{ SourceChain: "eip155:1", LogIndex: 3, Sender: "0xsender", @@ -105,7 +105,7 @@ func TestInboundBuildInboundObservation(t *testing.T) { }) t.Run("no revert instructions when revert recipient is empty", func(t *testing.T) { - eventData := UniversalTx{ + eventData := InboundObservation{ SourceChain: "eip155:1", Sender: "0xsender", Amount: "100", @@ -125,7 +125,7 @@ func TestInboundBuildInboundObservation(t *testing.T) { }) t.Run("falls back verification data to tx hash", func(t *testing.T) { - eventData := UniversalTx{ + eventData := InboundObservation{ SourceChain: "eip155:1", VerificationData: "", TxType: 0, @@ -155,7 +155,7 @@ func TestInboundBuildInboundObservation(t *testing.T) { } for _, tc := range testCases { - eventData := UniversalTx{ + eventData := InboundObservation{ SourceChain: "eip155:1", TxType: tc.txType, } @@ -189,7 +189,7 @@ func TestInboundHandleEvent(t *testing.T) { t.Run("vote failure returns error", func(t *testing.T) { database := newTestDB(t) processor := NewInboundObservationEventProcessor(&fakeVoteSigner{err: fmt.Errorf("broadcast failed")}, database, zerolog.Nop()) - eventData, _ := json.Marshal(UniversalTx{SourceChain: "eip155:1", TxType: 0}) + eventData, _ := json.Marshal(InboundObservation{SourceChain: "eip155:1", TxType: 0}) err := processor.HandleEvent(ctx, &store.Event{EventID: "0xin:0", EventData: eventData}) require.Error(t, err) @@ -200,7 +200,7 @@ func TestInboundHandleEvent(t *testing.T) { database := newTestDB(t) signer := &fakeVoteSigner{txHash: "0xvote"} processor := NewInboundObservationEventProcessor(signer, database, zerolog.Nop()) - eventData, _ := json.Marshal(UniversalTx{SourceChain: "eip155:1", TxType: 0}) + eventData, _ := json.Marshal(InboundObservation{SourceChain: "eip155:1", TxType: 0}) seedConfirmedEvent(t, database, "0xin:0", store.EventTypeInbound, eventData) err := processor.HandleEvent(ctx, &store.Event{EventID: "0xin:0", Type: store.EventTypeInbound, EventData: eventData}) diff --git a/universalClient/externalchains/common/outbound_observation_event_processor.go b/universalClient/externalchains/common/outbound_observation_event_processor.go index 56974ee6..8ba4ee29 100644 --- a/universalClient/externalchains/common/outbound_observation_event_processor.go +++ b/universalClient/externalchains/common/outbound_observation_event_processor.go @@ -11,6 +11,20 @@ import ( "github.com/rs/zerolog" ) +// OutboundObservation is the outbound observation payload stored for OUTBOUND events +// Event structure: +// - txID at 1st indexed position (bytes32) +// - universalTxID at 2nd indexed position (bytes32) +type OutboundObservation struct { + TxID string `json:"tx_id"` // bytes32 hex-encoded (0x...) + UniversalTxID string `json:"universal_tx_id"` // bytes32 hex-encoded (0x...) + GasFeeUsed string `json:"gas_fee_used,omitempty"` // gas fee used in wei (decimal string) + // PC20 export only: wrapper token address deployed/minted on the destination + // at settlement (observed in the finalize event). Core uses it to flip the + // PC20 deploy flag; empty for non-PC20 settlements. + Pc20WrapperAddress string `json:"pc20_wrapper_address,omitempty"` +} + // OutboundObservationEventProcessor handles OUTBOUND events: it builds the // outbound observation from the stored event and votes it on Push chain. type OutboundObservationEventProcessor struct { @@ -59,8 +73,8 @@ func (p *OutboundObservationEventProcessor) HandleEvent(ctx context.Context, eve return markEventCompleted(p.chainStore, p.logger, event, voteTxHash) } -// parseOutboundEventData unmarshals event data into an OutboundEvent struct -func (p *OutboundObservationEventProcessor) parseOutboundEventData(event *store.Event) (*OutboundEvent, error) { +// parseOutboundEventData unmarshals event data into an OutboundObservation struct +func (p *OutboundObservationEventProcessor) parseOutboundEventData(event *store.Event) (*OutboundObservation, error) { if event == nil { return nil, fmt.Errorf("event is nil") } @@ -69,7 +83,7 @@ func (p *OutboundObservationEventProcessor) parseOutboundEventData(event *store. return nil, fmt.Errorf("event data is empty") } - var eventData OutboundEvent + var eventData OutboundObservation if err := json.Unmarshal(event.EventData, &eventData); err != nil { return nil, fmt.Errorf("failed to unmarshal event data: %w", err) } @@ -86,7 +100,7 @@ func (p *OutboundObservationEventProcessor) parseOutboundEventData(event *store. } // buildOutboundObservation builds an OutboundObservation from event metadata and parsed outbound data -func (p *OutboundObservationEventProcessor) buildOutboundObservation(event *store.Event, outboundData *OutboundEvent) (*uexecutortypes.OutboundObservation, error) { +func (p *OutboundObservationEventProcessor) buildOutboundObservation(event *store.Event, outboundData *OutboundObservation) (*uexecutortypes.OutboundObservation, error) { gasFeeUsed := "0" if outboundData.GasFeeUsed != "" { gasFeeUsed = outboundData.GasFeeUsed diff --git a/universalClient/externalchains/common/outbound_observation_event_processor_test.go b/universalClient/externalchains/common/outbound_observation_event_processor_test.go index 8a456a41..af3db22c 100644 --- a/universalClient/externalchains/common/outbound_observation_event_processor_test.go +++ b/universalClient/externalchains/common/outbound_observation_event_processor_test.go @@ -35,7 +35,7 @@ func TestOutboundParseOutboundEventData(t *testing.T) { }) t.Run("valid outbound event extracts IDs and gas fee", func(t *testing.T) { - eventData := OutboundEvent{ + eventData := OutboundObservation{ TxID: "0x1234", UniversalTxID: "0xabcd", GasFeeUsed: "42000000000000", @@ -55,7 +55,7 @@ func TestOutboundParseOutboundEventData(t *testing.T) { }) t.Run("missing tx_id returns error", func(t *testing.T) { - eventData := OutboundEvent{ + eventData := OutboundObservation{ TxID: "", UniversalTxID: "0xabcd", } @@ -73,7 +73,7 @@ func TestOutboundParseOutboundEventData(t *testing.T) { }) t.Run("missing universal_tx_id returns error", func(t *testing.T) { - eventData := OutboundEvent{ + eventData := OutboundObservation{ TxID: "0x1234", UniversalTxID: "", } @@ -95,7 +95,7 @@ func TestOutboundBuildOutboundObservation(t *testing.T) { processor := NewOutboundObservationEventProcessor(nil, nil, zerolog.Nop()) t.Run("builds observation with gas fee from parsed data", func(t *testing.T) { - outboundData := &OutboundEvent{ + outboundData := &OutboundObservation{ TxID: "0x1234", UniversalTxID: "0xabcd", GasFeeUsed: "42000000000000", @@ -116,7 +116,7 @@ func TestOutboundBuildOutboundObservation(t *testing.T) { }) t.Run("missing gas fee defaults to 0", func(t *testing.T) { - outboundData := &OutboundEvent{ + outboundData := &OutboundObservation{ TxID: "0x1234", UniversalTxID: "0xabcd", } @@ -133,7 +133,7 @@ func TestOutboundBuildOutboundObservation(t *testing.T) { }) t.Run("handles base58 tx hash", func(t *testing.T) { - outboundData := &OutboundEvent{ + outboundData := &OutboundObservation{ TxID: "0x1234", UniversalTxID: "0xabcd", } @@ -166,7 +166,7 @@ func TestOutboundHandleEvent(t *testing.T) { t.Run("vote failure returns error", func(t *testing.T) { database := newTestDB(t) processor := NewOutboundObservationEventProcessor(&fakeVoteSigner{err: fmt.Errorf("broadcast failed")}, database, zerolog.Nop()) - eventData, _ := json.Marshal(OutboundEvent{TxID: "0xtxid", UniversalTxID: "0xutxid"}) + eventData, _ := json.Marshal(OutboundObservation{TxID: "0xtxid", UniversalTxID: "0xutxid"}) err := processor.HandleEvent(ctx, &store.Event{EventID: "0xout:0", EventData: eventData}) require.Error(t, err) @@ -177,7 +177,7 @@ func TestOutboundHandleEvent(t *testing.T) { database := newTestDB(t) signer := &fakeVoteSigner{txHash: "0xvote"} processor := NewOutboundObservationEventProcessor(signer, database, zerolog.Nop()) - eventData, _ := json.Marshal(OutboundEvent{TxID: "0xtxid", UniversalTxID: "0xutxid"}) + eventData, _ := json.Marshal(OutboundObservation{TxID: "0xtxid", UniversalTxID: "0xutxid"}) seedConfirmedEvent(t, database, "0xout:0", store.EventTypeOutbound, eventData) err := processor.HandleEvent(ctx, &store.Event{EventID: "0xout:0", Type: store.EventTypeOutbound, EventData: eventData}) diff --git a/universalClient/externalchains/common/types.go b/universalClient/externalchains/common/types.go index a389437f..54a26cee 100644 --- a/universalClient/externalchains/common/types.go +++ b/universalClient/externalchains/common/types.go @@ -119,33 +119,3 @@ type TxBuilder interface { // BroadcastFundMigrationTx assembles and broadcasts a signed fund migration transaction. BroadcastFundMigrationTx(ctx context.Context, req *UnsignedSigningReq, data *FundMigrationData, signature []byte) (string, error) } - -// UniversalTx Payload -type UniversalTx struct { - SourceChain string `json:"sourceChain"` - LogIndex uint `json:"logIndex"` - Sender string `json:"sender"` - Recipient string `json:"recipient"` - Token string `json:"bridgeToken"` - Amount string `json:"bridgeAmount"` // uint256 as decimal string - RawPayload string `json:"rawPayload,omitempty"` // hex-encoded raw payload bytes from source chain - VerificationData string `json:"verificationData"` - RevertFundRecipient string `json:"revertFundRecipient,omitempty"` - TxType uint `json:"txType"` // enum backing uint as decimal string - FromCEA bool `json:"fromCEA"` // true if inbound is initiated by a CEA -} - -// OutboundEvent represents an outbound observation event from the gateway contract -// Event structure: -// - txID at 1st indexed position (bytes32) -// - universalTxID at 2nd indexed position (bytes32) -type OutboundEvent struct { - TxID string `json:"tx_id"` // bytes32 hex-encoded (0x...) - UniversalTxID string `json:"universal_tx_id"` // bytes32 hex-encoded (0x...) - GasFeeUsed string `json:"gas_fee_used,omitempty"` // gas fee used in wei (decimal string) - // PC20 export only: wrapper token address deployed/minted on the destination - // at settlement (observed in the finalize event). Core uses it to flip the - // PC20 deploy flag; empty for non-PC20 settlements. - Pc20WrapperAddress string `json:"pc20_wrapper_address,omitempty"` -} - diff --git a/universalClient/externalchains/evm/event_confirmer.go b/universalClient/externalchains/evm/event_confirmer.go index 45cabc08..b4c73a83 100644 --- a/universalClient/externalchains/evm/event_confirmer.go +++ b/universalClient/externalchains/evm/event_confirmer.go @@ -181,7 +181,7 @@ func (ec *EventConfirmer) processPendingEvents(ctx context.Context) error { gasFeeUsed := new(big.Int).Mul(gasUsed, gasPrice).String() // Unmarshal, set GasFeeUsed, re-marshal - var outboundEvent chaincommon.OutboundEvent + var outboundEvent chaincommon.OutboundObservation if unmarshalErr := json.Unmarshal(event.EventData, &outboundEvent); unmarshalErr != nil { ec.logger.Error(). Err(unmarshalErr). diff --git a/universalClient/externalchains/evm/event_confirmer_test.go b/universalClient/externalchains/evm/event_confirmer_test.go index 022812cb..55ae7f51 100644 --- a/universalClient/externalchains/evm/event_confirmer_test.go +++ b/universalClient/externalchains/evm/event_confirmer_test.go @@ -313,7 +313,7 @@ func TestEventConfirmer_UpdateStatusAndEventData_WithDB(t *testing.T) { _, memDB := newTestEventConfirmerWithDB(t) cs := common.NewChainStore(memDB) - outbound := common.OutboundEvent{ + outbound := common.OutboundObservation{ TxID: "0xtx1", UniversalTxID: "0xuni1", } @@ -347,7 +347,7 @@ func TestEventConfirmer_UpdateStatusAndEventData_WithDB(t *testing.T) { require.Len(t, confirmed, 1) assert.Equal(t, "0xoutbound1:0", confirmed[0].EventID) - var stored common.OutboundEvent + var stored common.OutboundObservation require.NoError(t, json.Unmarshal(confirmed[0].EventData, &stored)) assert.Equal(t, "123456789", stored.GasFeeUsed) } diff --git a/universalClient/externalchains/evm/event_parser.go b/universalClient/externalchains/evm/event_parser.go index 3d44d470..a603f8ad 100644 --- a/universalClient/externalchains/evm/event_parser.go +++ b/universalClient/externalchains/evm/event_parser.go @@ -125,7 +125,7 @@ func parseOutboundObservationEvent(log *types.Log, eventType string, logger zero } // Create OutboundEvent payload - payload := common.OutboundEvent{ + payload := common.OutboundObservation{ TxID: txID, UniversalTxID: universalTxID, Pc20WrapperAddress: wrapperAddr, @@ -168,7 +168,7 @@ func parseUniversalTxEvent(event *store.Event, log *types.Log, chainID string, l return } - payload := common.UniversalTx{ + payload := common.InboundObservation{ SourceChain: chainID, Sender: ethcommon.BytesToAddress(log.Topics[1].Bytes()).Hex(), Recipient: ethcommon.BytesToAddress(log.Topics[2].Bytes()).Hex(), @@ -215,7 +215,7 @@ func readWord(data []byte, i int) []byte { // decodePayload reads the raw payload bytes at the given offset and stores the hex string. // The core validator will decode the universal payload from these raw bytes. -func decodePayload(data []byte, dataOffset uint64, payload *common.UniversalTx, logger zerolog.Logger) { +func decodePayload(data []byte, dataOffset uint64, payload *common.InboundObservation, logger zerolog.Logger) { if dataOffset < uint64(32*5) { return } @@ -240,7 +240,7 @@ func decodeSignatureData(data []byte, w []byte, minOffset uint64) string { } // finalizeEvent marshals the payload and sets confirmation type on the event. -func finalizeEvent(event *store.Event, payload *common.UniversalTx, logger zerolog.Logger) { +func finalizeEvent(event *store.Event, payload *common.InboundObservation, logger zerolog.Logger) { if b, err := json.Marshal(payload); err == nil { event.EventData = b } else { @@ -266,7 +266,7 @@ UniversalTx Event (V2 - upgraded chains): - signatureData (bytes) — Word 5 (offset) - fromCEA (bool) — Word 6 */ -func parseUniversalTx(event *store.Event, log *types.Log, dataOffset uint64, payload *common.UniversalTx, logger zerolog.Logger) { +func parseUniversalTx(event *store.Event, log *types.Log, dataOffset uint64, payload *common.InboundObservation, logger zerolog.Logger) { data := log.Data decodePayload(data, dataOffset, payload, logger) diff --git a/universalClient/externalchains/evm/event_parser_test.go b/universalClient/externalchains/evm/event_parser_test.go index 9fb77e1e..88dbc878 100644 --- a/universalClient/externalchains/evm/event_parser_test.go +++ b/universalClient/externalchains/evm/event_parser_test.go @@ -337,7 +337,7 @@ func TestParseOutboundObservation_PC20Wrapper(t *testing.T) { wrapperOf := func(t *testing.T, e *store.Event) string { t.Helper() - var ob common.OutboundEvent + var ob common.OutboundObservation require.NoError(t, json.Unmarshal(e.EventData, &ob)) return ob.Pc20WrapperAddress } @@ -544,21 +544,21 @@ func TestDecodePayload(t *testing.T) { big.NewInt(int64(len(inner))).FillBytes(data[160:192]) copy(data[192:196], inner) - payload := &common.UniversalTx{} + payload := &common.InboundObservation{} decodePayload(data, 160, payload, logger) assert.Equal(t, "0xdeadbeef", payload.RawPayload) }) t.Run("offset too small is ignored", func(t *testing.T) { data := make([]byte, 256) - payload := &common.UniversalTx{} + payload := &common.InboundObservation{} decodePayload(data, 32, payload, logger) // < 32*5 assert.Empty(t, payload.RawPayload) }) t.Run("offset zero is ignored", func(t *testing.T) { data := make([]byte, 256) - payload := &common.UniversalTx{} + payload := &common.InboundObservation{} decodePayload(data, 0, payload, logger) assert.Empty(t, payload.RawPayload) }) @@ -566,7 +566,7 @@ func TestDecodePayload(t *testing.T) { t.Run("readDynamicBytes fails gracefully", func(t *testing.T) { // Data is too short for the length word at the offset data := make([]byte, 168) // offset 160 + only 8 bytes; need 32 for length - payload := &common.UniversalTx{} + payload := &common.InboundObservation{} decodePayload(data, 160, payload, logger) assert.Empty(t, payload.RawPayload) }) @@ -634,13 +634,13 @@ func TestFinalizeEvent(t *testing.T) { t.Run("txType 0 sets FAST confirmation", func(t *testing.T) { event := &store.Event{} - payload := &common.UniversalTx{TxType: 0, Sender: "0xabc"} + payload := &common.InboundObservation{TxType: 0, Sender: "0xabc"} finalizeEvent(event, payload, logger) assert.Equal(t, store.ConfirmationFast, event.ConfirmationType) assert.NotNil(t, event.EventData) - var decoded common.UniversalTx + var decoded common.InboundObservation err := json.Unmarshal(event.EventData, &decoded) require.NoError(t, err) assert.Equal(t, "0xabc", decoded.Sender) @@ -648,7 +648,7 @@ func TestFinalizeEvent(t *testing.T) { t.Run("txType 1 sets FAST confirmation", func(t *testing.T) { event := &store.Event{} - payload := &common.UniversalTx{TxType: 1} + payload := &common.InboundObservation{TxType: 1} finalizeEvent(event, payload, logger) assert.Equal(t, store.ConfirmationFast, event.ConfirmationType) @@ -656,7 +656,7 @@ func TestFinalizeEvent(t *testing.T) { t.Run("txType 2 sets STANDARD confirmation", func(t *testing.T) { event := &store.Event{} - payload := &common.UniversalTx{TxType: 2} + payload := &common.InboundObservation{TxType: 2} finalizeEvent(event, payload, logger) assert.Equal(t, store.ConfirmationStandard, event.ConfirmationType) @@ -664,7 +664,7 @@ func TestFinalizeEvent(t *testing.T) { t.Run("txType 3 sets STANDARD confirmation", func(t *testing.T) { event := &store.Event{} - payload := &common.UniversalTx{TxType: 3} + payload := &common.InboundObservation{TxType: 3} finalizeEvent(event, payload, logger) assert.Equal(t, store.ConfirmationStandard, event.ConfirmationType) @@ -672,7 +672,7 @@ func TestFinalizeEvent(t *testing.T) { t.Run("high txType sets STANDARD confirmation", func(t *testing.T) { event := &store.Event{} - payload := &common.UniversalTx{TxType: 255} + payload := &common.InboundObservation{TxType: 255} finalizeEvent(event, payload, logger) assert.Equal(t, store.ConfirmationStandard, event.ConfirmationType) @@ -680,7 +680,7 @@ func TestFinalizeEvent(t *testing.T) { t.Run("event data is valid JSON", func(t *testing.T) { event := &store.Event{} - payload := &common.UniversalTx{ + payload := &common.InboundObservation{ SourceChain: "eip155:1", Sender: "0xsender", Recipient: "0xrecipient", @@ -690,7 +690,7 @@ func TestFinalizeEvent(t *testing.T) { } finalizeEvent(event, payload, logger) - var decoded common.UniversalTx + var decoded common.InboundObservation err := json.Unmarshal(event.EventData, &decoded) require.NoError(t, err) assert.Equal(t, "eip155:1", decoded.SourceChain) diff --git a/universalClient/externalchains/svm/event_parser.go b/universalClient/externalchains/svm/event_parser.go index 68f1dd4d..cc65ea4c 100644 --- a/universalClient/externalchains/svm/event_parser.go +++ b/universalClient/externalchains/svm/event_parser.go @@ -177,7 +177,7 @@ func parseOutboundObservationEvent(log string, signature string, slot uint64, lo } // Create OutboundEvent payload - payload := common.OutboundEvent{ + payload := common.OutboundObservation{ TxID: txID, UniversalTxID: universalTxID, GasFeeUsed: fmt.Sprintf("%d", gasUsed), @@ -250,7 +250,7 @@ func parseUniversalTxEvent(event *store.Event, decoded []byte, logIndex uint, ch } // decodeUniversalTxEvent decodes a TxWithFunds event -func decodeUniversalTxEvent(data []byte, logger zerolog.Logger) (*common.UniversalTx, error) { +func decodeUniversalTxEvent(data []byte, logger zerolog.Logger) (*common.InboundObservation, error) { if len(data) < 120 { logger.Warn(). Int("data_len", len(data)). @@ -258,7 +258,7 @@ func decodeUniversalTxEvent(data []byte, logger zerolog.Logger) (*common.Univers } offset := 8 - payload := &common.UniversalTx{} + payload := &common.InboundObservation{} // Parse sender (32 bytes) if len(data) < offset+32 { diff --git a/universalClient/externalchains/svm/event_parser_test.go b/universalClient/externalchains/svm/event_parser_test.go index 88ae4436..f7c6f060 100644 --- a/universalClient/externalchains/svm/event_parser_test.go +++ b/universalClient/externalchains/svm/event_parser_test.go @@ -307,7 +307,7 @@ func TestParseSendFundsEvent(t *testing.T) { assert.Equal(t, store.ConfirmationFast, event.ConfirmationType) // Unmarshal EventData - var utx common.UniversalTx + var utx common.InboundObservation require.NoError(t, json.Unmarshal(event.EventData, &utx)) assert.Equal(t, chainID, utx.SourceChain) @@ -344,7 +344,7 @@ func TestParseSendFundsEvent(t *testing.T) { data := buildSendFundsPayload(s, r, tok, 0, nil, rev, 0, nil, false) event := ParseEvent(wrapAsLog(data), sig, 1, 0, EventTypeSendFunds, chainID, logger) require.NotNil(t, event) - var utx common.UniversalTx + var utx common.InboundObservation require.NoError(t, json.Unmarshal(event.EventData, &utx)) assert.False(t, utx.FromCEA) }) @@ -355,7 +355,7 @@ func TestParseSendFundsEvent(t *testing.T) { data := buildSendFundsPayload(s, r, tok, 0, nil, rev, 0, nil, false) event := ParseEvent(wrapAsLog(data), sig, 1, 0, EventTypeSendFunds, chainID, logger) require.NotNil(t, event) - var utx common.UniversalTx + var utx common.InboundObservation require.NoError(t, json.Unmarshal(event.EventData, &utx)) assert.Empty(t, utx.RawPayload) assert.Empty(t, utx.VerificationData) @@ -368,7 +368,7 @@ func TestParseSendFundsEvent(t *testing.T) { data := buildSendFundsPayload(s, r, tok, maxU64, nil, rev, 0, nil, false) event := ParseEvent(wrapAsLog(data), sig, 1, 0, EventTypeSendFunds, chainID, logger) require.NotNil(t, event) - var utx common.UniversalTx + var utx common.InboundObservation require.NoError(t, json.Unmarshal(event.EventData, &utx)) assert.Equal(t, "18446744073709551615", utx.Amount) }) @@ -423,7 +423,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { assert.Equal(t, store.StatusPending, event.Status) assert.Equal(t, store.ConfirmationStandard, event.ConfirmationType) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Equal(t, "0x"+hex.EncodeToString(txID[:]), outbound.TxID) assert.Equal(t, "0x"+hex.EncodeToString(utxID[:]), outbound.UniversalTxID) @@ -439,7 +439,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { event := ParseEvent(wrapAsLog(data), signature, 1, 0, EventTypeFinalizeUniversalTx, chainID, logger) require.NotNil(t, event) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Equal(t, solana.PublicKeyFromBytes(token[:]).String(), outbound.Pc20WrapperAddress) }) @@ -450,7 +450,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { event := ParseEvent(wrapAsLog(data), signature, 1, 0, EventTypeFinalizeUniversalTx, chainID, logger) require.NotNil(t, event) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Empty(t, outbound.Pc20WrapperAddress) }) @@ -461,7 +461,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { event := ParseEvent(wrapAsLog(data), signature, 1, 0, EventTypeRevertUniversalTx, chainID, logger) require.NotNil(t, event) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Empty(t, outbound.Pc20WrapperAddress) assert.Equal(t, "7777", outbound.GasFeeUsed) @@ -473,7 +473,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { event := ParseEvent(wrapAsLog(data), signature, 1, 0, EventTypeFundsRescued, chainID, logger) require.NotNil(t, event) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Empty(t, outbound.Pc20WrapperAddress) assert.Equal(t, "3333", outbound.GasFeeUsed) @@ -514,7 +514,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { event := ParseEvent(wrapAsLog(data), signature, 100, 0, EventTypeFinalizeUniversalTx, chainID, logger) require.NotNil(t, event) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Contains(t, outbound.TxID, "0x1111") assert.Contains(t, outbound.UniversalTxID, "0x2222") @@ -536,7 +536,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { event := ParseEvent(wrapAsLog(data), signature, 100, 0, EventTypeFinalizeUniversalTx, chainID, logger) require.NotNil(t, event) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Equal(t, "0x"+hex.EncodeToString(txID[:]), outbound.TxID) assert.Equal(t, "0x"+hex.EncodeToString(utxID[:]), outbound.UniversalTxID) @@ -549,7 +549,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { event := ParseEvent(wrapAsLog(data), signature, 100, 0, EventTypeFinalizeUniversalTx, chainID, logger) require.NotNil(t, event) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Equal(t, "0", outbound.GasFeeUsed) }) @@ -560,7 +560,7 @@ func TestParseOutboundObservationEvent(t *testing.T) { event := ParseEvent(wrapAsLog(data), signature, 100, 0, EventTypeFinalizeUniversalTx, chainID, logger) require.NotNil(t, event) - var outbound common.OutboundEvent + var outbound common.OutboundObservation require.NoError(t, json.Unmarshal(event.EventData, &outbound)) assert.Equal(t, "18446744073709551615", outbound.GasFeeUsed) }) From 3afc24c293a0e413d39b4df4c592c910be609daa Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 4 Aug 2026 12:33:14 +0530 Subject: [PATCH 21/23] add: web2 read handler --- .../externalchains/common/types.go | 12 +- .../externalchains/web2/read_envelope.go | 140 ++++++++ .../externalchains/web2/read_executor.go | 324 +++++++++++++++++ .../externalchains/web2/read_executor_test.go | 330 ++++++++++++++++++ 4 files changed, 800 insertions(+), 6 deletions(-) create mode 100644 universalClient/externalchains/web2/read_envelope.go create mode 100644 universalClient/externalchains/web2/read_executor.go create mode 100644 universalClient/externalchains/web2/read_executor_test.go diff --git a/universalClient/externalchains/common/types.go b/universalClient/externalchains/common/types.go index 54a26cee..0dfc59be 100644 --- a/universalClient/externalchains/common/types.go +++ b/universalClient/externalchains/common/types.go @@ -9,12 +9,6 @@ import ( uetypes "github.com/pushchain/push-chain-node/x/uexecutor/types" ) -// ReadRequestHandler executes a read request on one destination chain. -// Consumed by the push watcher's read processor. -type ReadRequestHandler interface { - ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) -} - // 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 @@ -119,3 +113,9 @@ type TxBuilder interface { // BroadcastFundMigrationTx assembles and broadcasts a signed fund migration transaction. BroadcastFundMigrationTx(ctx context.Context, req *UnsignedSigningReq, data *FundMigrationData, signature []byte) (string, error) } + +// ReadRequestHandler executes a read request on one destination chain. +// Consumed by the push watcher's read processor. +type ReadRequestHandler interface { + ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) +} diff --git a/universalClient/externalchains/web2/read_envelope.go b/universalClient/externalchains/web2/read_envelope.go new file mode 100644 index 00000000..b56d1048 --- /dev/null +++ b/universalClient/externalchains/web2/read_envelope.go @@ -0,0 +1,140 @@ +package web2 + +import ( + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi" +) + +// web2Method mirrors the Web2QueryEnvelope method enum from the read spec. +type web2Method uint8 + +const ( + web2MethodGet web2Method = 0 + web2MethodPost web2Method = 1 +) + +// extractValueType mirrors the Web2Extract valueType enum. +type extractValueType uint8 + +const ( + valueTypeUint256 extractValueType = 0 + valueTypeInt256 extractValueType = 1 + valueTypeBool extractValueType = 2 + valueTypeString extractValueType = 3 + valueTypeBytes extractValueType = 4 +) + +// extractMode mirrors the Web2Extract mode enum. +type extractMode uint8 + +// modeIdentical is the only supported aggregation mode: quorum on identical +// result bytes. More modes (e.g. median) need core-side aggregation first. +const modeIdentical extractMode = 0 + +// web2Extract is one declared field to pull out of the JSON response. +type web2Extract struct { + Path string // JSONPath into the response, e.g. "$.data.price" + ValueType extractValueType + Mode extractMode + Decimals uint8 // numeric JSON scaled by 10^decimals before encoding +} + +// web2QueryEnvelope is the decoded abi.encode(Web2QueryEnvelope) query. +type web2QueryEnvelope struct { + Method web2Method + URL string + Headers []byte // canonical JSON object of header name -> value + Body []byte // POST only + TimeoutMs uint64 + Extract []web2Extract +} + +var web2EnvelopeArgs = mustReadArgs(abi.ArgumentMarshaling{Type: "tuple", Components: []abi.ArgumentMarshaling{ + {Name: "method", Type: "uint8"}, + {Name: "url", Type: "string"}, + {Name: "headers", Type: "bytes"}, + {Name: "body", Type: "bytes"}, + {Name: "timeoutMs", Type: "uint64"}, + {Name: "extract", Type: "tuple[]", Components: []abi.ArgumentMarshaling{ + {Name: "path", Type: "string"}, + {Name: "valueType", Type: "uint8"}, + {Name: "mode", Type: "uint8"}, + {Name: "decimals", Type: "uint8"}, + }}, +}}) + +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("web2: invalid abi type %q: %v", m.Type, err)) + } + args = append(args, abi.Argument{Name: m.Name, Type: typ}) + } + return args +} + +type rawWeb2Extract struct { + Path string + ValueType uint8 + Mode uint8 + Decimals uint8 +} + +type rawWeb2Envelope struct { + Method uint8 + Url string + Headers []byte + Body []byte + TimeoutMs uint64 + Extract []rawWeb2Extract +} + +// decodeWeb2QueryEnvelope decodes ReadSpec.query for web2 destinations. +func decodeWeb2QueryEnvelope(query []byte) (*web2QueryEnvelope, error) { + vals, err := web2EnvelopeArgs.Unpack(query) + if err != nil { + return nil, fmt.Errorf("failed to unpack Web2QueryEnvelope: %w", err) + } + raw := *abi.ConvertType(vals[0], new(rawWeb2Envelope)).(*rawWeb2Envelope) + + env := &web2QueryEnvelope{ + Method: web2Method(raw.Method), + URL: raw.Url, + Headers: raw.Headers, + Body: raw.Body, + TimeoutMs: raw.TimeoutMs, + } + for _, e := range raw.Extract { + env.Extract = append(env.Extract, web2Extract{ + Path: e.Path, + ValueType: extractValueType(e.ValueType), + Mode: extractMode(e.Mode), + Decimals: e.Decimals, + }) + } + + if env.Method > web2MethodPost { + return nil, fmt.Errorf("unknown web2 method %d", env.Method) + } + if len(env.Extract) == 0 { + return nil, fmt.Errorf("envelope has no extract entries") + } + if len(env.Extract) > maxExtractEntries { + return nil, fmt.Errorf("envelope has %d extract entries, max %d", len(env.Extract), maxExtractEntries) + } + for _, e := range env.Extract { + if e.ValueType > valueTypeBytes { + return nil, fmt.Errorf("unknown extract value type %d", e.ValueType) + } + if e.Mode != modeIdentical { + return nil, fmt.Errorf("unsupported extract mode %d, only IDENTICAL", e.Mode) + } + } + return env, nil +} diff --git a/universalClient/externalchains/web2/read_executor.go b/universalClient/externalchains/web2/read_executor.go new file mode 100644 index 00000000..f5d92448 --- /dev/null +++ b/universalClient/externalchains/web2/read_executor.go @@ -0,0 +1,324 @@ +// Package web2 executes web2 (HTTP) read requests: it fetches the declared +// endpoint, extracts the declared JSON fields, and canonically encodes them so +// read results are byte-identical across validators. +package web2 + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "math/big" + "net/http" + "strconv" + "strings" + "time" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/rs/zerolog" + + "github.com/pushchain/push-chain-node/universalClient/uread" +) + +const ( + // DestinationPrefix identifies web2 read destinations, e.g. "web2:https". + DestinationPrefix = "web2:" + + maxResponseBytes = 64 * 1024 + maxExtractEntries = 16 + defaultTimeout = 5 * time.Second + maxTimeout = 15 * time.Second +) + +// Executor implements common.ReadRequestHandler for web2 destinations. +type Executor struct { + httpClient *http.Client + logger zerolog.Logger + // allowInsecureURL disables the https-only rule (tests only) + allowInsecureURL bool +} + +// NewExecutor creates a web2 read executor. +func NewExecutor(logger zerolog.Logger) *Executor { + return &Executor{ + httpClient: &http.Client{Timeout: maxTimeout}, + logger: logger.With().Str("component", "web2_read_executor").Logger(), + } +} + +// ExecuteRead fetches the endpoint declared in the envelope, extracts the +// declared fields, and abi-encodes them in extract order. Deterministic +// failures (bad envelope, non-JSON response, missing path, 4xx) are votable +// ERROR observations; transport failures and 5xx are transient errors. +func (e *Executor) ExecuteRead(ctx context.Context, req *uread.ReadRequest) (*uread.ReadResult, error) { + env, err := decodeWeb2QueryEnvelope(req.Query) + if err != nil { + return uread.NewErrorResult(err), nil + } + + if err := e.validateEnvelope(env); err != nil { + return uread.NewErrorResult(err), nil + } + + body, errResult, err := e.fetch(ctx, env) + if err != nil { + return nil, err // transient + } + if errResult != nil { + return errResult, nil + } + + resultData, err := extractAndEncode(body, env.Extract) + if err != nil { + return uread.NewErrorResult(err), nil + } + + // web2 has no block height or hash; the ballot covers result data only + return &uread.ReadResult{ + Status: uread.ReadStatusSuccess, + ResultData: resultData, + }, nil +} + +// validateEnvelope enforces the v1 request constraints. +func (e *Executor) validateEnvelope(env *web2QueryEnvelope) error { + if !e.allowInsecureURL && !strings.HasPrefix(env.URL, "https://") { + return fmt.Errorf("url must be https") + } + if env.Method == web2MethodGet && len(env.Body) > 0 { + return fmt.Errorf("GET request must not have a body") + } + return nil +} + +// fetch performs the HTTP request. Returns (body, nil, nil) on success, +// (nil, errorResult, nil) on deterministic failure, (nil, nil, err) on +// transient failure. +func (e *Executor) fetch(ctx context.Context, env *web2QueryEnvelope) ([]byte, *uread.ReadResult, error) { + timeout := defaultTimeout + if env.TimeoutMs > 0 { + timeout = min(time.Duration(env.TimeoutMs)*time.Millisecond, maxTimeout) + } + reqCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + method := http.MethodGet + var reqBody io.Reader + if env.Method == web2MethodPost { + method = http.MethodPost + reqBody = bytes.NewReader(env.Body) + } + + httpReq, err := http.NewRequestWithContext(reqCtx, method, env.URL, reqBody) + if err != nil { + return nil, uread.NewErrorResult(fmt.Errorf("invalid request: %w", err)), nil + } + + if len(env.Headers) > 0 { + var headers map[string]string + if err := json.Unmarshal(env.Headers, &headers); err != nil { + return nil, uread.NewErrorResult(fmt.Errorf("invalid headers encoding: %w", err)), nil + } + for name, value := range headers { + httpReq.Header.Set(name, value) + } + } + + resp, err := e.httpClient.Do(httpReq) + if err != nil { + return nil, nil, fmt.Errorf("request failed: %w", err) // transient + } + defer func() { _ = resp.Body.Close() }() + + // 4xx is a deterministic answer from the endpoint; 5xx is the endpoint + // having a bad moment + if resp.StatusCode >= 500 { + return nil, nil, fmt.Errorf("endpoint returned status %d", resp.StatusCode) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, uread.NewErrorResult(fmt.Errorf("endpoint returned status %d", resp.StatusCode)), nil + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response: %w", err) // transient + } + if len(body) > maxResponseBytes { + return nil, uread.NewErrorResult(fmt.Errorf("response exceeds %d bytes", maxResponseBytes)), nil + } + + return body, nil, nil +} + +// extractAndEncode applies each extract spec to the JSON response and +// abi-encodes the values in extract order. +func extractAndEncode(body []byte, extracts []web2Extract) ([]byte, error) { + decoder := json.NewDecoder(bytes.NewReader(body)) + decoder.UseNumber() + var root any + if err := decoder.Decode(&root); err != nil { + return nil, fmt.Errorf("response is not valid JSON: %w", err) + } + + args := make(abi.Arguments, 0, len(extracts)) + values := make([]any, 0, len(extracts)) + for _, ex := range extracts { + raw, err := resolveJSONPath(root, ex.Path) + if err != nil { + return nil, err + } + + value, abiType, err := convertValue(raw, ex) + if err != nil { + return nil, fmt.Errorf("path %s: %w", ex.Path, err) + } + args = append(args, abi.Argument{Name: "v", Type: abiType}) + values = append(values, value) + } + + encoded, err := args.Pack(values...) + if err != nil { + return nil, fmt.Errorf("failed to encode result: %w", err) + } + return encoded, nil +} + +var ( + abiUint256, _ = abi.NewType("uint256", "", nil) + abiInt256, _ = abi.NewType("int256", "", nil) + abiBool, _ = abi.NewType("bool", "", nil) + abiString, _ = abi.NewType("string", "", nil) + abiBytes, _ = abi.NewType("bytes", "", nil) +) + +// convertValue converts a JSON value to the declared abi value. +func convertValue(raw any, ex web2Extract) (any, abi.Type, error) { + switch ex.ValueType { + case valueTypeUint256, valueTypeInt256: + num, err := scaledInteger(raw, ex.Decimals) + if err != nil { + return nil, abi.Type{}, err + } + if ex.ValueType == valueTypeUint256 { + if num.Sign() < 0 || num.BitLen() > 256 { + return nil, abi.Type{}, fmt.Errorf("value out of uint256 range") + } + return num, abiUint256, nil + } + if num.BitLen() > 255 { + return nil, abi.Type{}, fmt.Errorf("value out of int256 range") + } + return num, abiInt256, nil + + case valueTypeBool: + b, ok := raw.(bool) + if !ok { + return nil, abi.Type{}, fmt.Errorf("expected bool, got %T", raw) + } + return b, abiBool, nil + + case valueTypeString: + s, ok := raw.(string) + if !ok { + return nil, abi.Type{}, fmt.Errorf("expected string, got %T", raw) + } + return s, abiString, nil + + case valueTypeBytes: + s, ok := raw.(string) + if !ok || !strings.HasPrefix(s, "0x") { + return nil, abi.Type{}, fmt.Errorf("expected 0x-prefixed hex string") + } + decoded, err := hex.DecodeString(strings.TrimPrefix(s, "0x")) + if err != nil { + return nil, abi.Type{}, fmt.Errorf("invalid hex: %w", err) + } + return decoded, abiBytes, nil + + default: + return nil, abi.Type{}, fmt.Errorf("unknown value type %d", ex.ValueType) + } +} + +// scaledInteger parses a JSON number (or numeric string), scales it by +// 10^decimals, and truncates to an integer. big.Rat keeps float-formatted +// JSON exact (e.g. "3512.4471" with 8 decimals -> 351244710000). +func scaledInteger(raw any, decimals uint8) (*big.Int, error) { + var numStr string + switch v := raw.(type) { + case json.Number: + numStr = v.String() + case string: + numStr = v + default: + return nil, fmt.Errorf("expected number, got %T", raw) + } + + rat, ok := new(big.Rat).SetString(numStr) + if !ok { + return nil, fmt.Errorf("invalid number %q", numStr) + } + + scale := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil) + rat.Mul(rat, new(big.Rat).SetInt(scale)) + + return new(big.Int).Quo(rat.Num(), rat.Denom()), nil +} + +// resolveJSONPath resolves a minimal JSONPath subset: "$" root, dot fields and +// array indexes, e.g. "$.data.items[0].price". +func resolveJSONPath(root any, path string) (any, error) { + if !strings.HasPrefix(path, "$") { + return nil, fmt.Errorf("path %s must start with $", path) + } + + current := root + rest := strings.TrimPrefix(path, "$") + for _, segment := range strings.Split(rest, ".") { + if segment == "" { + continue + } + + field := segment + var indexes []int + for strings.HasSuffix(field, "]") { + open := strings.LastIndex(field, "[") + if open < 0 { + return nil, fmt.Errorf("path %s has malformed index in %q", path, segment) + } + idx, err := strconv.Atoi(field[open+1 : len(field)-1]) + if err != nil || idx < 0 { + return nil, fmt.Errorf("path %s has invalid index in %q", path, segment) + } + indexes = append([]int{idx}, indexes...) + field = field[:open] + } + + if field != "" { + obj, ok := current.(map[string]any) + if !ok { + return nil, fmt.Errorf("path %s: %q is not an object", path, field) + } + value, ok := obj[field] + if !ok { + return nil, fmt.Errorf("path %s: field %q not found", path, field) + } + current = value + } + + for _, idx := range indexes { + arr, ok := current.([]any) + if !ok { + return nil, fmt.Errorf("path %s: indexing into non-array", path) + } + if idx >= len(arr) { + return nil, fmt.Errorf("path %s: index %d out of range", path, idx) + } + current = arr[idx] + } + } + + return current, nil +} diff --git a/universalClient/externalchains/web2/read_executor_test.go b/universalClient/externalchains/web2/read_executor_test.go new file mode 100644 index 00000000..0ee27d02 --- /dev/null +++ b/universalClient/externalchains/web2/read_executor_test.go @@ -0,0 +1,330 @@ +package web2 + +import ( + "context" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pushchain/push-chain-node/universalClient/uread" +) + +func packEnvelope(t *testing.T, env rawWeb2Envelope) []byte { + t.Helper() + data, err := web2EnvelopeArgs.Pack(env) + require.NoError(t, err) + return data +} + +func extractSpec(path string, valueType extractValueType, decimals uint8) rawWeb2Extract { + return rawWeb2Extract{Path: path, ValueType: uint8(valueType), Mode: uint8(modeIdentical), Decimals: decimals} +} + +func newTestExecutor(t *testing.T, handler http.HandlerFunc) (*Executor, string) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + + e := NewExecutor(zerolog.Nop()) + e.allowInsecureURL = true // httptest serves plain http + return e, srv.URL +} + +func jsonHandler(t *testing.T, wantMethod string, response any) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, wantMethod, r.Method) + require.NoError(t, json.NewEncoder(w).Encode(response)) + } +} + +func web2Request(t *testing.T, env rawWeb2Envelope) *uread.ReadRequest { + t.Helper() + return &uread.ReadRequest{ + RequestID: "0xreq1", + DestinationChain: "web2:https", + Query: packEnvelope(t, env), + } +} + +func TestExecuteRead_GetIdenticalFields(t *testing.T) { + e, url := newTestExecutor(t, jsonHandler(t, http.MethodGet, map[string]any{ + "status": "FINAL", + "winner": "TeamA", + "score": map[string]any{"a": 3, "b": 1}, + })) + + req := web2Request(t, rawWeb2Envelope{ + Method: uint8(web2MethodGet), + Url: url, + Extract: []rawWeb2Extract{ + extractSpec("$.status", valueTypeString, 0), + extractSpec("$.winner", valueTypeString, 0), + extractSpec("$.score.a", valueTypeUint256, 0), + }, + }) + + result, err := e.ExecuteRead(context.Background(), req) + require.NoError(t, err) + require.Equal(t, uread.ReadStatusSuccess, result.Status) + assert.Zero(t, result.ObservedBlockHeight) + + stringTy, _ := abi.NewType("string", "", nil) + uintTy, _ := abi.NewType("uint256", "", nil) + args := abi.Arguments{{Type: stringTy}, {Type: stringTy}, {Type: uintTy}} + vals, err := args.Unpack(result.ResultData) + require.NoError(t, err) + assert.Equal(t, "FINAL", vals[0]) + assert.Equal(t, "TeamA", vals[1]) + assert.Equal(t, big.NewInt(3), vals[2]) +} + +func TestExecuteRead_DecimalScaling(t *testing.T) { + e, url := newTestExecutor(t, jsonHandler(t, http.MethodGet, map[string]any{ + "price": 3512.4471, + })) + + req := web2Request(t, rawWeb2Envelope{ + Method: uint8(web2MethodGet), + Url: url, + Extract: []rawWeb2Extract{extractSpec("$.price", valueTypeUint256, 8)}, + }) + + result, err := e.ExecuteRead(context.Background(), req) + require.NoError(t, err) + require.Equal(t, uread.ReadStatusSuccess, result.Status) + + assert.Equal(t, big.NewInt(351244710000), new(big.Int).SetBytes(result.ResultData)) +} + +func TestExecuteRead_PostBody(t *testing.T) { + e, url := newTestExecutor(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + var body map[string]string + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "{ token { decimals } }", body["query"]) + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "data": map[string]any{"token": map[string]any{"decimals": 18}}, + })) + }) + + req := web2Request(t, rawWeb2Envelope{ + Method: uint8(web2MethodPost), + Url: url, + Headers: []byte(`{"content-type":"application/json"}`), + Body: []byte(`{"query":"{ token { decimals } }"}`), + Extract: []rawWeb2Extract{extractSpec("$.data.token.decimals", valueTypeUint256, 0)}, + }) + + result, err := e.ExecuteRead(context.Background(), req) + require.NoError(t, err) + require.Equal(t, uread.ReadStatusSuccess, result.Status) + assert.Equal(t, big.NewInt(18), new(big.Int).SetBytes(result.ResultData)) +} + +func TestExecuteRead_ArrayIndexPath(t *testing.T) { + e, url := newTestExecutor(t, jsonHandler(t, http.MethodGet, map[string]any{ + "items": []any{map[string]any{"ok": true}, map[string]any{"ok": false}}, + })) + + req := web2Request(t, rawWeb2Envelope{ + Method: uint8(web2MethodGet), + Url: url, + Extract: []rawWeb2Extract{extractSpec("$.items[1].ok", valueTypeBool, 0)}, + }) + + result, err := e.ExecuteRead(context.Background(), req) + require.NoError(t, err) + require.Equal(t, uread.ReadStatusSuccess, result.Status) + + boolTy, _ := abi.NewType("bool", "", nil) + vals, err := abi.Arguments{{Type: boolTy}}.Unpack(result.ResultData) + require.NoError(t, err) + assert.Equal(t, false, vals[0]) +} + +func TestExecuteRead_VotableErrors(t *testing.T) { + t.Run("invalid envelope", func(t *testing.T) { + e := NewExecutor(zerolog.Nop()) + result, err := e.ExecuteRead(context.Background(), &uread.ReadRequest{Query: []byte{0x01}}) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) + + t.Run("non-https url", func(t *testing.T) { + e := NewExecutor(zerolog.Nop()) + req := web2Request(t, rawWeb2Envelope{ + Method: uint8(web2MethodGet), + Url: "http://insecure.example.com", + Extract: []rawWeb2Extract{extractSpec("$.x", valueTypeString, 0)}, + }) + result, err := e.ExecuteRead(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) + + t.Run("non-identical mode not supported", func(t *testing.T) { + e := NewExecutor(zerolog.Nop()) + req := web2Request(t, rawWeb2Envelope{ + Method: uint8(web2MethodGet), + Url: "https://api.example.com", + Extract: []rawWeb2Extract{ + {Path: "$.price", ValueType: uint8(valueTypeUint256), Mode: 1, Decimals: 8}, + }, + }) + result, err := e.ExecuteRead(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) + + t.Run("GET with body", func(t *testing.T) { + e := NewExecutor(zerolog.Nop()) + req := web2Request(t, rawWeb2Envelope{ + Method: uint8(web2MethodGet), + Url: "https://api.example.com", + Body: []byte("nope"), + Extract: []rawWeb2Extract{extractSpec("$.x", valueTypeString, 0)}, + }) + result, err := e.ExecuteRead(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) + + t.Run("missing path", func(t *testing.T) { + e, url := newTestExecutor(t, jsonHandler(t, http.MethodGet, map[string]any{"a": 1})) + req := web2Request(t, rawWeb2Envelope{ + Method: uint8(web2MethodGet), + Url: url, + Extract: []rawWeb2Extract{extractSpec("$.missing", valueTypeUint256, 0)}, + }) + result, err := e.ExecuteRead(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) + + t.Run("type mismatch", func(t *testing.T) { + e, url := newTestExecutor(t, jsonHandler(t, http.MethodGet, map[string]any{"a": "text"})) + req := web2Request(t, rawWeb2Envelope{ + Method: uint8(web2MethodGet), + Url: url, + Extract: []rawWeb2Extract{extractSpec("$.a", valueTypeBool, 0)}, + }) + result, err := e.ExecuteRead(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) + + t.Run("non-JSON response", func(t *testing.T) { + e, url := newTestExecutor(t, func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("not json")) + }) + req := web2Request(t, rawWeb2Envelope{ + Method: uint8(web2MethodGet), + Url: url, + Extract: []rawWeb2Extract{extractSpec("$.a", valueTypeString, 0)}, + }) + result, err := e.ExecuteRead(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) + + t.Run("404 status", func(t *testing.T) { + e, url := newTestExecutor(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + req := web2Request(t, rawWeb2Envelope{ + Method: uint8(web2MethodGet), + Url: url, + Extract: []rawWeb2Extract{extractSpec("$.a", valueTypeString, 0)}, + }) + result, err := e.ExecuteRead(context.Background(), req) + require.NoError(t, err) + assert.Equal(t, uread.ReadStatusError, result.Status) + }) +} + +func TestExecuteRead_TransientErrors(t *testing.T) { + t.Run("500 status", func(t *testing.T) { + e, url := newTestExecutor(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + req := web2Request(t, rawWeb2Envelope{ + Method: uint8(web2MethodGet), + Url: url, + Extract: []rawWeb2Extract{extractSpec("$.a", valueTypeString, 0)}, + }) + result, err := e.ExecuteRead(context.Background(), req) + require.Error(t, err) + assert.Nil(t, result) + }) + + t.Run("unreachable endpoint", func(t *testing.T) { + e := NewExecutor(zerolog.Nop()) + e.allowInsecureURL = true + req := web2Request(t, rawWeb2Envelope{ + Method: uint8(web2MethodGet), + Url: "http://127.0.0.1:1", + TimeoutMs: 500, + Extract: []rawWeb2Extract{extractSpec("$.a", valueTypeString, 0)}, + }) + result, err := e.ExecuteRead(context.Background(), req) + require.Error(t, err) + assert.Nil(t, result) + }) +} + +func TestScaledInteger(t *testing.T) { + cases := []struct { + in string + decimals uint8 + want string + }{ + {"3512.4471", 8, "351244710000"}, + {"100", 0, "100"}, + {"0.5", 2, "50"}, + {"1.999", 0, "1"}, // truncates + {"-2.5", 1, "-25"}, + } + for _, tc := range cases { + got, err := scaledInteger(json.Number(tc.in), tc.decimals) + require.NoError(t, err, tc.in) + assert.Equal(t, tc.want, got.String(), tc.in) + } + + _, err := scaledInteger(json.Number("not-a-number"), 0) + assert.Error(t, err) + _, err = scaledInteger(true, 0) + assert.Error(t, err) +} + +func TestDecodeWeb2QueryEnvelope_Invalid(t *testing.T) { + t.Run("garbage bytes", func(t *testing.T) { + _, err := decodeWeb2QueryEnvelope([]byte{0x01, 0x02}) + assert.Error(t, err) + }) + + t.Run("no extract entries", func(t *testing.T) { + data, err := web2EnvelopeArgs.Pack(rawWeb2Envelope{Method: 0, Url: "https://x"}) + require.NoError(t, err) + _, err = decodeWeb2QueryEnvelope(data) + assert.Error(t, err) + }) + + t.Run("unknown method", func(t *testing.T) { + data, err := web2EnvelopeArgs.Pack(rawWeb2Envelope{ + Method: 9, + Url: "https://x", + Extract: []rawWeb2Extract{{Path: "$.a"}}, + }) + require.NoError(t, err) + _, err = decodeWeb2QueryEnvelope(data) + assert.Error(t, err) + }) +} From 6d6f773e532b5192f23f92a9c3276f9e9c8d380a Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 4 Aug 2026 12:47:11 +0530 Subject: [PATCH 22/23] fix: harden reads --- .../externalchains/web2/read_executor.go | 102 +++++++++++++++++- .../externalchains/web2/read_executor_test.go | 70 ++++++++++++ 2 files changed, 168 insertions(+), 4 deletions(-) diff --git a/universalClient/externalchains/web2/read_executor.go b/universalClient/externalchains/web2/read_executor.go index f5d92448..f8063fd5 100644 --- a/universalClient/externalchains/web2/read_executor.go +++ b/universalClient/externalchains/web2/read_executor.go @@ -8,9 +8,11 @@ import ( "context" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "math/big" + "net" "net/http" "strconv" "strings" @@ -30,8 +32,21 @@ const ( maxExtractEntries = 16 defaultTimeout = 5 * time.Second maxTimeout = 15 * time.Second + maxRedirects = 5 ) +// errBlockedRequest marks a request rejected by the SSRF guard (private/internal +// address, disallowed redirect). It is deterministic: every honest validator +// rejects the same envelope identically, so it becomes a votable ERROR rather +// than a transient retry. +var errBlockedRequest = errors.New("request blocked by ssrf guard") + +// TODO(core): a web2 read makes every validator fetch an attacker-chosen URL, +// so the fee is the only thing pricing that outbound work. The fee must NEVER +// be fully refunded on failure or no-quorum: a full refund lets an attacker +// drive the whole validator set at any endpoint for only tx gas (griefing / +// reflected load). Charge for execution regardless of read outcome. + // Executor implements common.ReadRequestHandler for web2 destinations. type Executor struct { httpClient *http.Client @@ -40,12 +55,86 @@ type Executor struct { allowInsecureURL bool } -// NewExecutor creates a web2 read executor. +// NewExecutor creates a web2 read executor. Its HTTP client dials through an +// SSRF guard that blocks private/internal addresses on the initial request and +// on every redirect hop, and only connects to the exact IP it vetted (so DNS +// rebinding cannot swap in an internal address between check and dial). func NewExecutor(logger zerolog.Logger) *Executor { - return &Executor{ - httpClient: &http.Client{Timeout: maxTimeout}, - logger: logger.With().Str("component", "web2_read_executor").Logger(), + e := &Executor{ + logger: logger.With().Str("component", "web2_read_executor").Logger(), + } + e.httpClient = &http.Client{ + Timeout: maxTimeout, + Transport: &http.Transport{DialContext: e.dialContext}, + CheckRedirect: e.checkRedirect, + } + return e +} + +// dialContext resolves the target host and refuses any non-public address, then +// dials the vetted IP directly. Tests set allowInsecureURL to reach httptest +// servers on loopback. +func (e *Executor) dialContext(ctx context.Context, network, addr string) (net.Conn, error) { + if e.allowInsecureURL { + return (&net.Dialer{}).DialContext(ctx, network, addr) + } + + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + if len(ips) == 0 { + return nil, fmt.Errorf("no addresses for %s", host) + } + for _, ip := range ips { + if isDisallowedIP(ip.IP) { + return nil, fmt.Errorf("%w: %s resolves to non-public address %s", errBlockedRequest, host, ip.IP) + } } + + dialer := &net.Dialer{} + var lastErr error + for _, ip := range ips { + conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.IP.String(), port)) + if err != nil { + lastErr = err + continue + } + return conn, nil + } + return nil, lastErr +} + +// checkRedirect keeps redirects https-only and bounded. The dialer still vets +// every hop's address; this only rejects scheme downgrades and redirect loops. +func (e *Executor) checkRedirect(req *http.Request, via []*http.Request) error { + if len(via) >= maxRedirects { + return fmt.Errorf("%w: too many redirects", errBlockedRequest) + } + if !e.allowInsecureURL && req.URL.Scheme != "https" { + return fmt.Errorf("%w: redirect to non-https url", errBlockedRequest) + } + return nil +} + +// isDisallowedIP reports whether an IP is one the guard must never connect to: +// loopback, private (RFC1918 / ULA), link-local (incl. 169.254.169.254 cloud +// metadata), carrier-grade NAT, multicast, or the unspecified address. +func isDisallowedIP(ip net.IP) bool { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || + ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsMulticast() { + return true + } + // Carrier-grade NAT 100.64.0.0/10 (RFC 6598) is not covered by IsPrivate. + if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1]&0xc0 == 64 { + return true + } + return false } // ExecuteRead fetches the endpoint declared in the envelope, extracts the @@ -128,6 +217,11 @@ func (e *Executor) fetch(ctx context.Context, env *web2QueryEnvelope) ([]byte, * resp, err := e.httpClient.Do(httpReq) if err != nil { + // A guard rejection is the same for every validator: votable ERROR. + // Any other transport error may be transient. + if errors.Is(err, errBlockedRequest) { + return nil, uread.NewErrorResult(fmt.Errorf("request blocked: %w", err)), nil + } return nil, nil, fmt.Errorf("request failed: %w", err) // transient } defer func() { _ = resp.Body.Close() }() diff --git a/universalClient/externalchains/web2/read_executor_test.go b/universalClient/externalchains/web2/read_executor_test.go index 0ee27d02..d13c8022 100644 --- a/universalClient/externalchains/web2/read_executor_test.go +++ b/universalClient/externalchains/web2/read_executor_test.go @@ -3,7 +3,9 @@ package web2 import ( "context" "encoding/json" + "errors" "math/big" + "net" "net/http" "net/http/httptest" "testing" @@ -280,6 +282,74 @@ func TestExecuteRead_TransientErrors(t *testing.T) { }) } +func TestExecuteRead_SSRFGuard(t *testing.T) { + // guard is active only when allowInsecureURL is false + blocked := []string{ + "https://127.0.0.1/x", + "https://[::1]/x", + "https://169.254.169.254/latest/meta-data/", + "https://10.0.0.1/x", + "https://192.168.1.1/x", + "https://172.16.0.1/x", + "https://100.64.0.1/x", + "https://0.0.0.0/x", + } + for _, target := range blocked { + t.Run(target, func(t *testing.T) { + e := NewExecutor(zerolog.Nop()) + req := web2Request(t, rawWeb2Envelope{ + Method: uint8(web2MethodGet), + Url: target, + TimeoutMs: 1000, + Extract: []rawWeb2Extract{extractSpec("$.x", valueTypeString, 0)}, + }) + result, err := e.ExecuteRead(context.Background(), req) + require.NoError(t, err) // deterministic, not transient + assert.Equal(t, uread.ReadStatusError, result.Status) + }) + } +} + +func TestCheckRedirect(t *testing.T) { + e := NewExecutor(zerolog.Nop()) + + mustReq := func(rawURL string) *http.Request { + r, err := http.NewRequest(http.MethodGet, rawURL, nil) + require.NoError(t, err) + return r + } + + // https redirect within the hop budget is allowed + assert.NoError(t, e.checkRedirect(mustReq("https://example.com/next"), make([]*http.Request, 1))) + + // scheme downgrade to http is blocked + err := e.checkRedirect(mustReq("http://example.com/next"), make([]*http.Request, 1)) + require.Error(t, err) + assert.True(t, errors.Is(err, errBlockedRequest)) + + // too many redirects is blocked + err = e.checkRedirect(mustReq("https://example.com/next"), make([]*http.Request, maxRedirects)) + require.Error(t, err) + assert.True(t, errors.Is(err, errBlockedRequest)) +} + +func TestIsDisallowedIP(t *testing.T) { + disallowed := []string{ + "127.0.0.1", "::1", "10.1.2.3", "172.16.5.5", "192.168.0.1", + "169.254.169.254", "100.64.0.1", "0.0.0.0", "fe80::1", "fc00::1", "224.0.0.1", + // IPv4-mapped IPv6 must not slip past the v4-range checks + "::ffff:127.0.0.1", "::ffff:169.254.169.254", "::ffff:10.0.0.1", + } + for _, s := range disallowed { + assert.True(t, isDisallowedIP(net.ParseIP(s)), "%s should be blocked", s) + } + + allowed := []string{"8.8.8.8", "1.1.1.1", "93.184.216.34", "2606:2800:220:1::1", "100.63.255.255", "100.128.0.1"} + for _, s := range allowed { + assert.False(t, isDisallowedIP(net.ParseIP(s)), "%s should be allowed", s) + } +} + func TestScaledInteger(t *testing.T) { cases := []struct { in string From ab74a70bd30083e718780a6518be64e97415d836 Mon Sep 17 00:00:00 2001 From: aman035 Date: Tue, 4 Aug 2026 12:47:56 +0530 Subject: [PATCH 23/23] plug web2 read executor --- universalClient/pushwatcher/client.go | 3 +- .../pushwatcher/read_event_processor.go | 51 ++++++++++++------- .../pushwatcher/read_event_processor_test.go | 39 +++++++++++++- 3 files changed, 74 insertions(+), 19 deletions(-) diff --git a/universalClient/pushwatcher/client.go b/universalClient/pushwatcher/client.go index 297246b2..875400f8 100644 --- a/universalClient/pushwatcher/client.go +++ b/universalClient/pushwatcher/client.go @@ -9,6 +9,7 @@ import ( "github.com/pushchain/push-chain-node/universalClient/config" "github.com/pushchain/push-chain-node/universalClient/db" "github.com/pushchain/push-chain-node/universalClient/externalchains/common" + "github.com/pushchain/push-chain-node/universalClient/externalchains/web2" "github.com/pushchain/push-chain-node/universalClient/pushcore" "github.com/pushchain/push-chain-node/universalClient/pushsigner" "github.com/pushchain/push-chain-node/universalClient/store" @@ -79,7 +80,7 @@ func NewClient( // READ_REQUEST events are executed on their destination chains (via // chainResolver) and the results voted back. if pushSigner != nil && chainResolver != nil { - readEventProcessor, err := NewReadEventProcessor(pushSigner, chainResolver, database, logger) + readEventProcessor, err := NewReadEventProcessor(pushSigner, chainResolver, web2.NewExecutor(logger), database, logger) if err != nil { return nil, fmt.Errorf("failed to create read event processor: %w", err) } diff --git a/universalClient/pushwatcher/read_event_processor.go b/universalClient/pushwatcher/read_event_processor.go index 1544c91e..9d99edd7 100644 --- a/universalClient/pushwatcher/read_event_processor.go +++ b/universalClient/pushwatcher/read_event_processor.go @@ -3,9 +3,12 @@ package pushwatcher import ( "context" "encoding/json" + "fmt" + "strings" "github.com/pushchain/push-chain-node/universalClient/db" "github.com/pushchain/push-chain-node/universalClient/externalchains/common" + "github.com/pushchain/push-chain-node/universalClient/externalchains/web2" "github.com/pushchain/push-chain-node/universalClient/store" "github.com/pushchain/push-chain-node/universalClient/uread" "github.com/rs/zerolog" @@ -29,16 +32,19 @@ type readVoter interface { // the event CONFIRMED for retry; corrupt events flip to REVERTED. Expiry is // core's job: expired requests leave the pending query. type ReadEventProcessor struct { - voter readVoter - resolver ChainResolver - chainStore *common.ChainStore - logger zerolog.Logger + voter readVoter + resolver ChainResolver + web2Handler common.ReadRequestHandler + chainStore *common.ChainStore + logger zerolog.Logger } // NewReadEventProcessor creates the handler for READ_REQUEST events. +// web2Handler serves web2 destinations; nil means web2 reads are not served. func NewReadEventProcessor( voter readVoter, resolver ChainResolver, + web2Handler common.ReadRequestHandler, database *db.DB, logger zerolog.Logger, ) (*ReadEventProcessor, error) { @@ -47,10 +53,11 @@ func NewReadEventProcessor( } return &ReadEventProcessor{ - voter: voter, - resolver: resolver, - chainStore: common.NewChainStore(database), - logger: logger.With().Str("component", "push_read_event_processor").Logger(), + voter: voter, + resolver: resolver, + web2Handler: web2Handler, + chainStore: common.NewChainStore(database), + logger: logger.With().Str("component", "push_read_event_processor").Logger(), }, nil } @@ -70,17 +77,10 @@ func (p *ReadEventProcessor) HandleEvent(ctx context.Context, event *store.Event log := p.logger.With().Str("request_id", req.RequestID).Logger() - destClient, err := p.resolver.GetClient(req.DestinationChain) + handler, err := p.resolveHandler(req.DestinationChain) if err != nil { // destination not served by this validator yet; retry next tick - log.Debug().Err(err).Str("destination_chain", req.DestinationChain).Msg("destination chain not served") - return nil - } - - handler, err := destClient.GetReadRequestHandler() - if err != nil { - // destination client not ready to serve reads yet; retry next tick - log.Debug().Err(err).Str("destination_chain", req.DestinationChain).Msg("read handler not available") + log.Debug().Err(err).Str("destination_chain", req.DestinationChain).Msg("destination not served") return nil } @@ -114,6 +114,23 @@ func (p *ReadEventProcessor) HandleEvent(ctx context.Context, event *store.Event return nil } +// resolveHandler returns the read handler for a destination: the web2 executor +// for web2 destinations, otherwise the destination chain client's handler. +func (p *ReadEventProcessor) resolveHandler(destination string) (common.ReadRequestHandler, error) { + if strings.HasPrefix(destination, web2.DestinationPrefix) { + if p.web2Handler == nil { + return nil, fmt.Errorf("web2 reads not served") + } + return p.web2Handler, nil + } + + destClient, err := p.resolver.GetClient(destination) + if err != nil { + return nil, err + } + return destClient.GetReadRequestHandler() +} + // isExpired reports whether the request's expiry Push chain height has been // reached, using the chain height persisted by the event listener. func (p *ReadEventProcessor) isExpired(event *store.Event) bool { diff --git a/universalClient/pushwatcher/read_event_processor_test.go b/universalClient/pushwatcher/read_event_processor_test.go index e1ad5bd6..f10cfcad 100644 --- a/universalClient/pushwatcher/read_event_processor_test.go +++ b/universalClient/pushwatcher/read_event_processor_test.go @@ -78,7 +78,7 @@ func testReadRequest() *uread.ReadRequest { func newTestReadEventProcessor(t *testing.T, voter readVoter, destClient common.ChainClient) (*ReadEventProcessor, *common.ChainStore) { t.Helper() database := newTestDB(t) - p, err := NewReadEventProcessor(voter, &fakeChainResolver{client: destClient}, database, zerolog.Nop()) + p, err := NewReadEventProcessor(voter, &fakeChainResolver{client: destClient}, nil, database, zerolog.Nop()) require.NoError(t, err) return p, common.NewChainStore(database) } @@ -213,3 +213,40 @@ func TestReadEventProcessor_NotExpiredProcessesNormally(t *testing.T) { require.Contains(t, voter.votes, req.RequestID) assertStatus(t, cs, event.EventID, store.StatusCompleted) } + + +func TestReadEventProcessor_Web2Dispatch(t *testing.T) { + req := testReadRequest() + req.DestinationChain = "web2:https" + result := &uread.ReadResult{Status: uread.ReadStatusSuccess, ResultData: []byte{0xbb}} + + t.Run("dispatches to web2 handler", func(t *testing.T) { + database := newTestDB(t) + voter := &fakeReadVoter{txHash: "VOTE_TX"} + web2Handler := &fakeDestClient{result: result} + p, err := NewReadEventProcessor(voter, &fakeChainResolver{}, web2Handler, database, zerolog.Nop()) + require.NoError(t, err) + cs := common.NewChainStore(database) + event := seedReadRequest(t, cs, req) + + require.NoError(t, p.HandleEvent(context.Background(), event)) + + require.Contains(t, voter.votes, req.RequestID) + assert.Equal(t, result, voter.votes[req.RequestID]) + assertStatus(t, cs, event.EventID, store.StatusCompleted) + }) + + t.Run("no web2 handler retries", func(t *testing.T) { + database := newTestDB(t) + voter := &fakeReadVoter{txHash: "VOTE_TX"} + p, err := NewReadEventProcessor(voter, &fakeChainResolver{}, nil, database, zerolog.Nop()) + require.NoError(t, err) + cs := common.NewChainStore(database) + event := seedReadRequest(t, cs, req) + + require.NoError(t, p.HandleEvent(context.Background(), event)) + + assert.Empty(t, voter.votes) + assertStatus(t, cs, event.EventID, store.StatusConfirmed) + }) +}