From f18ebe17960b9510146aeb6966029c47bf211810 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 13 May 2026 14:24:31 +0800 Subject: [PATCH 1/7] Add precompile-backed bank query client --- docs/rfc/README.md | 4 +- ...rfc-002-precompile-backed-query-clients.md | 450 +++++++++++++++ precompiles/addr/addr.go | 6 +- precompiles/bank/Bank.sol | 42 ++ precompiles/bank/abi.json | 410 ++++++++++++- precompiles/bank/bank.go | 235 +++++++- precompiles/bank/bank_test.go | 126 ++++ precompiles/bank/query/registry.go | 538 ++++++++++++++++++ precompiles/bank/query/registry_test.go | 132 +++++ precompiles/query/address.go | 100 ++++ precompiles/query/address_test.go | 87 +++ precompiles/query/binding.go | 108 ++++ precompiles/query/conn.go | 143 +++++ precompiles/query/conn_test.go | 108 ++++ precompiles/utils/expected_keepers.go | 3 + sei-cosmos/x/bank/client/cli/query.go | 45 +- 16 files changed, 2503 insertions(+), 34 deletions(-) create mode 100644 docs/rfc/rfc-002-precompile-backed-query-clients.md create mode 100644 precompiles/bank/query/registry.go create mode 100644 precompiles/bank/query/registry_test.go create mode 100644 precompiles/query/address.go create mode 100644 precompiles/query/address_test.go create mode 100644 precompiles/query/binding.go create mode 100644 precompiles/query/conn.go create mode 100644 precompiles/query/conn_test.go diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 973204333a..eecc74302a 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -36,4 +36,6 @@ The [rfc-template.md](./rfc-template.md) file includes placeholders for these sections. ## Table of Contents -- [RFC-000: Optimistic Proposal Processing](./rfc-000-optimistic-proposal-processing.md) \ No newline at end of file +- [RFC-000: Optimistic Proposal Processing](./rfc-000-optimistic-proposal-processing.md) +- [RFC-001: Parallel Transaction Message Processing](./rfc-001-parallel-tx-processing.md) +- [RFC-002: Precompile-Backed Query Clients](./rfc-002-precompile-backed-query-clients.md) diff --git a/docs/rfc/rfc-002-precompile-backed-query-clients.md b/docs/rfc/rfc-002-precompile-backed-query-clients.md new file mode 100644 index 0000000000..6f0e26d361 --- /dev/null +++ b/docs/rfc/rfc-002-precompile-backed-query-clients.md @@ -0,0 +1,450 @@ +# RFC 002: Precompile-Backed Query Clients + +## Changelog + +- 2026-05-13: Initial draft + +## Abstract + +This RFC proposes a shared precompile query bridge for module query clients +during the migration away from Cosmos gRPC and REST query surfaces. Generated +`QueryClient` implementations already route every CLI query through a single +`gogogrpc.ClientConn.Invoke` call, so we can keep the generated clients as a +local compatibility API while replacing the transport beneath them with a +reusable middleware that packs the protobuf request into an EVM precompile +`eth_call` and unpacks the ABI result into the protobuf response. + +Each module still owns the mapping between its protobuf query methods and its +precompile ABI methods, but the boilerplate for routing, EVM RPC calls, ABI +packing, ABI unpacking, height handling, and tests can be shared. The final +external query surface is EVM RPC only. + +## Background + +Today CLI query commands follow this shape: + +```go +clientCtx, err := client.GetClientQueryContext(cmd) +queryClient := types.NewQueryClient(clientCtx) +res, err := queryClient.SomeQuery(context.Background(), req) +``` + +The generated client in `x//types/query.pb.go` implements each method +by calling `cc.Invoke(ctx, fullMethod, req, out, opts...)` on the +`github.com/gogo/protobuf/grpc.ClientConn` interface. `client.Context` +implements that `Invoke` method by issuing an ABCI query to the registered +module `QueryServer`. + +The server side is usually in `keeper/grpc_query*.go` or `keeper/querier.go`. +`keeper/msg_server.go` implements transaction message servers, not query +clients. Any query migration should be explicit about that split: + +- Query migration replaces gRPC/REST query surfaces with EVM RPC precompile + queries. +- Message server deprecation is a separate write-path migration. +- Precompiles may still call existing keepers or message servers internally + until those write paths are migrated. + +Several precompiles already expose query-shaped view methods, such as bank +`balance`, `all_balances`, `supply`; staking `validator`, `delegation`, +`params`; distribution `rewards`; oracle `getExchangeRates` and +`getOracleTwaps` where available. Those methods return Solidity ABI values, +while the existing CLI code expects protobuf query responses. + +## Discussion + +### Goals + +- Keep CLI call sites and generated `QueryClient` APIs stable. +- Avoid hand-writing a full query client implementation for every module. +- Put all common EVM RPC `eth_call` behavior in one package. +- Keep module-specific logic limited to a compact request/response mapping. +- Make unsupported query methods fail clearly unless their precompile binding + exists. +- Make it usable from CLI and tests without relying on Cosmos gRPC or REST. +- Put all module binding registries under `precompiles/`. + +### Non-goals + +- This does not remove generated protobuf clients. +- This does not remove `MsgServer` implementations. +- This does not require every module query to have an immediate precompile + equivalent. +- This does not redefine precompile ABIs. Missing ABI coverage should be added + to the module precompile before routing that protobuf query through the bridge. +- This does not preserve gRPC or REST as long-term external query APIs. +- This does not add `QueryServer` shims over precompile calls. + +### Proposed package + +Add a shared package, for example: + +```text +precompiles/query/ + conn.go // gogogrpc.ClientConn middleware + binding.go // typed protobuf <-> ABI binding helpers + eth_call.go // Ethereum JSON-RPC eth_call adapter + address.go // Sei/EVM address conversion helpers + +precompiles//query/ + registry.go // module protobuf query <-> module precompile bindings + convert.go // module-specific ABI/protobuf conversion helpers +``` + +The central type is a `gogogrpc.ClientConn` wrapper: + +```go +type Conn struct { + caller EVMCaller + bindings Registry +} + +func NewConn(caller EVMCaller, bindings Registry, opts ...Option) *Conn + +func (c *Conn) Invoke( + ctx context.Context, + method string, + req any, + reply any, + opts ...grpc.CallOption, +) error +``` + +`Invoke` checks the registry by generated gRPC full method name. If a binding +exists, it executes an EVM RPC `eth_call` to the precompile and fills `reply`. +If no binding exists, it returns an unsupported-query error. `NewStream` should +return a clear unsupported error because generated module query clients +currently use unary queries. + +The caller is backed by Ethereum JSON-RPC: + +```go +type EVMCaller interface { + CallContract( + ctx context.Context, + msg ethereum.CallMsg, + blockNumber *big.Int, + ) ([]byte, error) +} +``` + +For CLI usage, this caller is normally `ethclient.Client`, connected to the +configured EVM RPC endpoint. It must not be backed by x/evm `StaticCall` or any +Cosmos gRPC/REST query path. Tests can use an in-process fake `EVMCaller`. +`Env.EthCall` builds an `ethereum.CallMsg` with the configured default caller, +precompile address, and ABI input, then passes the selected block number to +`CallContract`; bindings can override the caller if a query's semantics depend +on it. + +### Binding model + +Each module contributes a small registry. A binding says: + +- Which protobuf full method it handles, for example + `/cosmos.bank.v1beta1.Query/Balance`. +- Which precompile address and ABI method it calls, for example bank + `balance(address,string)`. +- How to pack the protobuf request into ABI arguments. +- How to unpack ABI outputs into the protobuf response. +- Whether the response is byte-for-byte equivalent to the old protobuf query + response, or documents an intentional variation. + +Sketch: + +```go +type Binding[Req any, Resp any] struct { + FullMethod string + Precompile common.Address + ABI abi.ABI + ABIMethod string + Pack func(context.Context, *Env, *Req) ([]any, error) + Unpack func(context.Context, *Env, *Req, []any, *Resp) error + ABIForHeight func(height int64) abi.ABI + ResponseShape ResponseShape +} +``` + +The generic binding performs the mechanical work: + +```go +func (b Binding[Req, Resp]) Invoke(ctx context.Context, env *Env, req any, reply any) error { + typedReq, ok := req.(*Req) + if !ok { + return fmt.Errorf("expected %T, got %T", (*Req)(nil), req) + } + typedReply, ok := reply.(*Resp) + if !ok { + return fmt.Errorf("expected %T, got %T", (*Resp)(nil), reply) + } + + args, err := b.Pack(ctx, env, typedReq) + if err != nil { + return err + } + input, err := b.ABI.Pack(b.ABIMethod, args...) + if err != nil { + return err + } + output, err := env.EthCall(ctx, b.Precompile, input) + if err != nil { + return err + } + values, err := b.ABI.Unpack(b.ABIMethod, output) + if err != nil { + return err + } + return b.Unpack(ctx, env, typedReq, values, typedReply) +} +``` + +This leaves every module with only the semantic translation. + +### Example: bank balance + +The bank query client method: + +```go +Balance(ctx, &banktypes.QueryBalanceRequest{ + Address: "sei1...", + Denom: "usei", +}) +``` + +can map to the bank precompile: + +```solidity +function balance(address acc, string memory denom) + external view returns (uint256 amount); +``` + +The binding is roughly: + +```go +query.Bind[ + banktypes.QueryBalanceRequest, + banktypes.QueryBalanceResponse, +]( + "/cosmos.bank.v1beta1.Query/Balance", + common.HexToAddress(bank.BankAddress), + bank.GetABI(), + bank.BalanceMethod, + func(ctx context.Context, env *query.Env, req *banktypes.QueryBalanceRequest) ([]any, error) { + evmAddr, err := env.EVMAddressForSeiAddress(ctx, req.Address, query.AllowCastAddress) + if err != nil { + return nil, err + } + return []any{evmAddr, req.Denom}, nil + }, + func(_ context.Context, _ *query.Env, req *banktypes.QueryBalanceRequest, out []any, resp *banktypes.QueryBalanceResponse) error { + amount := sdk.NewIntFromBigInt(out[0].(*big.Int)) + resp.Balance = &sdk.Coin{Denom: req.Denom, Amount: amount} + return nil + }, +) +``` + +All other bank bindings follow the same pattern. The only bespoke code is +address conversion and struct conversion. + +### Response shape + +The default expectation is that precompile-backed queries reconstruct the old +protobuf responses exactly. This makes CLI behavior predictable and gives +parity tests a simple equality assertion. + +Some variation is acceptable when exact reconstruction would make the +precompile ABI or conversion logic disproportionately complicated. Those cases +must be explicit in the binding registry: + +```go +type ResponseShape uint8 + +const ( + ExactProtobufShape ResponseShape = iota + DocumentedVariation +) +``` + +A binding that uses `DocumentedVariation` should include a short note in the +module registry explaining the difference. Examples might include omitted +pagination metadata when the EVM-facing query intentionally exposes a simpler +iterator model, or formatting differences where the old protobuf field was an +implementation detail rather than part of the retained query contract. + +### Address conversion + +Precompile ABIs frequently use EVM `address` values while protobuf queries +frequently use Bech32 Sei addresses. The bridge should centralize this to avoid +subtle inconsistency. + +`Env` should expose: + +```go +type AddressPolicy uint8 + +const ( + RequireAssociation AddressPolicy = iota + AllowCastAddress +) + +func (e *Env) EVMAddressForSeiAddress(ctx context.Context, sei string, policy AddressPolicy) (common.Address, error) +func (e *Env) SeiAddressForEVMAddress(ctx context.Context, evm common.Address, policy AddressPolicy) (sdk.AccAddress, error) +``` + +The implementation must use the `addr` precompile through EVM RPC when +association is required: + +- `getEvmAddr(string)` for Sei Bech32 to EVM address. +- `getSeiAddr(address)` for EVM address to Sei Bech32. + +When `AllowCastAddress` is requested and no association exists, the helper can +mirror `x/evm/keeper/address.go` locally by casting the 20-byte address. Each +binding chooses the policy that matches the precompile method it calls. + +### Height and version handling + +The current `client.Context.Invoke` honors the gRPC block-height metadata header +and sets the ABCI query height. The replacement bridge should translate the +same CLI height selection into the `eth_call` block number parameter. If no +height is selected, it should use latest block semantics. + +Historical precompile ABI differences should be handled in one of two ways: + +- Prefer using the same EVM RPC block number for both the `eth_call` execution + and the binding's `ABIForHeight(height int64)` decision. +- If a binding does not support an old height, return an explicit unsupported + height error rather than falling back to keeper queries. + +This avoids x/evm `StaticCall` entirely and keeps the query path on the EVM RPC +surface that will remain after gRPC and REST are deprecated. + +### Error and fallback policy + +Failure behavior should be deterministic and explicit: + +- No binding: return an unsupported-query error. +- Binding marks the queried height unsupported: return an unsupported-height + error. +- Type mismatch, ABI pack failure, ABI unpack failure, or precompile revert: + return the error. + +There should be no production fallback to keeper-backed gRPC/REST queries. For +dual-run rollout tests, a separate test harness can call the old query server +and compare responses, but the precompile-backed client should not silently +fall back. + +### CLI integration + +Module CLI code can stay almost identical. Each module changes only its query +client construction and receives an EVM RPC endpoint: + +```go +clientCtx, err := client.GetClientQueryContext(cmd) +if err != nil { + return err +} + +rpcURL, err := cmd.Flags().GetString(evmcli.FlagRPC) +if err != nil { + return err +} + +evmClient, err := ethclient.DialContext(cmd.Context(), rpcURL) +if err != nil { + return err +} + +queryClient := banktypes.NewQueryClient( + query.NewConn( + evmClient, + bankprecompilequery.Registry(), + query.WithDefaultBlockNumber(clientCtx.Height), + ), +) +``` + +To avoid repeating that in every command, the module can expose: + +```go +func NewPrecompileBackedQueryClient( + clientCtx client.Context, + evmClient query.EVMCaller, +) banktypes.QueryClient { + return banktypes.NewQueryClient(query.NewConn( + evmClient, + Registry(), + query.WithDefaultBlockNumber(clientCtx.Height), + )) +} +``` + +Then each CLI command only changes: + +```go +queryClient := bankquery.NewPrecompileBackedQueryClient(clientCtx, evmClient) +``` + +Each module should add an `--evm-rpc` query flag, reusing the existing EVM CLI +default of `http://:8545` where practical. Cosmos node RPC +flags can remain during the transition only for command plumbing and output +formatting; retained query execution should use EVM RPC. + +### Testing strategy + +The shared package should have table tests for: + +- Routing to a binding by full method. +- Returning unsupported-query errors when no binding exists. +- Translating CLI height selection into the `eth_call` block number. +- Returning pack, call, and unpack errors without fallback. +- Type mismatch diagnostics. + +Each module registry should have parity tests: + +- Seed keeper state. +- Query via the existing generated client against the existing query server. +- Query via the precompile-backed generated client against EVM RPC `eth_call`. +- Compare protobuf responses for `ExactProtobufShape` bindings. +- Assert documented differences for `DocumentedVariation` bindings. + +The parity tests should start with low-cardinality queries such as bank +`Balance`, bank `Supply`, staking `Params`, staking `Pool`, and distribution +`DelegationTotalRewards`, then move to paginated and struct-heavy queries. + +### Rollout plan + +1. Add the shared `precompiles/query` bridge with no module behavior changes. +2. Add or complete precompile query methods for the module queries that should + remain available after gRPC/REST deprecation. +3. Add bank bindings under `precompiles/bank/query` and parity tests, because + bank has simple read methods and good existing query coverage. +4. Switch bank CLI construction to the EVM RPC-backed helper. +5. Add staking and distribution bindings, paying special attention to + pagination and nested struct conversion. +6. Repeat until every retained module query has a precompile-backed binding. +7. Remove or stop registering deprecated gRPC/REST query surfaces when retained + query coverage has moved to EVM RPC. +8. Only after query parity is proven, design the separate write-path migration + for `MsgServer`. + +### Decisions + +- Query execution uses EVM RPC `eth_call`, not x/evm `StaticCall` or any + Cosmos gRPC/REST endpoint. +- gRPC and REST are not long-term compatibility surfaces for these queries. +- Retained queries should be implemented in precompiles; no retained query + should remain keeper-backed in the final state. +- Binding registries live under `precompiles//query`. +- Responses should match old protobuf responses by default. Documented + variations are acceptable when exact parity would overcomplicate the + precompile ABI or conversion logic. + +### References + +- `sei-cosmos/client/grpc_query.go` +- `evmrpc/simulate.go` +- `precompiles/addr/Addr.sol` +- `precompiles/bank/Bank.sol` +- `precompiles/staking/Staking.sol` +- `precompiles/distribution/Distribution.sol` +- `precompiles/oracle/Oracle.sol` diff --git a/precompiles/addr/addr.go b/precompiles/addr/addr.go index bef0ecf447..0d8d6874ce 100644 --- a/precompiles/addr/addr.go +++ b/precompiles/addr/addr.go @@ -45,6 +45,10 @@ const ( //go:embed abi.json var f embed.FS +func GetABI() abi.ABI { + return pcommon.MustGetABI(f, "abi.json") +} + type PrecompileExecutor struct { evmKeeper putils.EVMKeeper bankKeeper putils.BankKeeper @@ -58,7 +62,7 @@ type PrecompileExecutor struct { func NewPrecompile(keepers putils.Keepers) (*pcommon.DynamicGasPrecompile, error) { - newAbi := pcommon.MustGetABI(f, "abi.json") + newAbi := GetABI() p := &PrecompileExecutor{ evmKeeper: keepers.EVMK(), diff --git a/precompiles/bank/Bank.sol b/precompiles/bank/Bank.sol index e57a083efe..1be3e89803 100644 --- a/precompiles/bank/Bank.sol +++ b/precompiles/bank/Bank.sol @@ -35,6 +35,13 @@ interface IBank { address acc ) external view returns (Coin[] memory response); + function spendable_balances( + address acc + ) external view returns (Coin[] memory response); + + function total_supply() + external view returns (Coin[] memory response); + function name( string memory denom ) external view returns (string memory response); @@ -50,4 +57,39 @@ interface IBank { function supply( string memory denom ) external view returns (uint256 response); + + function denom_metadata( + string memory denom + ) external view returns (Metadata memory response); + + function denoms_metadata() + external view returns (Metadata[] memory response); + + function params() + external view returns (Params memory response); + + struct DenomUnit { + string denom; + uint32 exponent; + string[] aliases; + } + + struct Metadata { + string description; + DenomUnit[] denomUnits; + string base; + string display; + string name; + string symbol; + } + + struct SendEnabled { + string denom; + bool enabled; + } + + struct Params { + SendEnabled[] sendEnabled; + bool defaultSendEnabled; + } } diff --git a/precompiles/bank/abi.json b/precompiles/bank/abi.json index 844ac48943..687425aaa6 100644 --- a/precompiles/bank/abi.json +++ b/precompiles/bank/abi.json @@ -1 +1,409 @@ -[{"inputs":[{"internalType":"address","name":"acc","type":"address"}],"name":"all_balances","outputs":[{"components":[{"internalType":"uint256","name":"amount","type":"uint256"},{"internalType":"string","name":"denom","type":"string"}],"internalType":"struct IBank.Coin[]","name":"response","type":"tuple[]"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"acc","type":"address"},{"internalType":"string","name":"denom","type":"string"}],"name":"balance","outputs":[{"internalType":"uint256","name":"amount","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"string","name":"denom","type":"string"}],"name":"decimals","outputs":[{"internalType":"uint8","name":"response","type":"uint8"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"string","name":"denom","type":"string"}],"name":"name","outputs":[{"internalType":"string","name":"response","type":"string"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"fromAddress","type":"address"},{"internalType":"address","name":"toAddress","type":"address"},{"internalType":"string","name":"denom","type":"string"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"send","outputs":[{"internalType":"bool","name":"success","type":"bool"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"string","name":"toNativeAddress","type":"string"}],"name":"sendNative","outputs":[{"internalType":"bool","name":"success","type":"bool"}],"stateMutability":"payable","type":"function"},{"inputs":[{"internalType":"string","name":"denom","type":"string"}],"name":"supply","outputs":[{"internalType":"uint256","name":"response","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"string","name":"denom","type":"string"}],"name":"symbol","outputs":[{"internalType":"string","name":"response","type":"string"}],"stateMutability":"view","type":"function"}] \ No newline at end of file +[ + { + "inputs": [ + { + "internalType": "address", + "name": "acc", + "type": "address" + } + ], + "name": "all_balances", + "outputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "string", + "name": "denom", + "type": "string" + } + ], + "internalType": "struct IBank.Coin[]", + "name": "response", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "acc", + "type": "address" + }, + { + "internalType": "string", + "name": "denom", + "type": "string" + } + ], + "name": "balance", + "outputs": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "denom", + "type": "string" + } + ], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "response", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "denom", + "type": "string" + } + ], + "name": "denom_metadata", + "outputs": [ + { + "components": [ + { + "internalType": "string", + "name": "description", + "type": "string" + }, + { + "components": [ + { + "internalType": "string", + "name": "denom", + "type": "string" + }, + { + "internalType": "uint32", + "name": "exponent", + "type": "uint32" + }, + { + "internalType": "string[]", + "name": "aliases", + "type": "string[]" + } + ], + "internalType": "struct IBank.DenomUnit[]", + "name": "denomUnits", + "type": "tuple[]" + }, + { + "internalType": "string", + "name": "base", + "type": "string" + }, + { + "internalType": "string", + "name": "display", + "type": "string" + }, + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "string", + "name": "symbol", + "type": "string" + } + ], + "internalType": "struct IBank.Metadata", + "name": "response", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "denoms_metadata", + "outputs": [ + { + "components": [ + { + "internalType": "string", + "name": "description", + "type": "string" + }, + { + "components": [ + { + "internalType": "string", + "name": "denom", + "type": "string" + }, + { + "internalType": "uint32", + "name": "exponent", + "type": "uint32" + }, + { + "internalType": "string[]", + "name": "aliases", + "type": "string[]" + } + ], + "internalType": "struct IBank.DenomUnit[]", + "name": "denomUnits", + "type": "tuple[]" + }, + { + "internalType": "string", + "name": "base", + "type": "string" + }, + { + "internalType": "string", + "name": "display", + "type": "string" + }, + { + "internalType": "string", + "name": "name", + "type": "string" + }, + { + "internalType": "string", + "name": "symbol", + "type": "string" + } + ], + "internalType": "struct IBank.Metadata[]", + "name": "response", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "denom", + "type": "string" + } + ], + "name": "name", + "outputs": [ + { + "internalType": "string", + "name": "response", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "params", + "outputs": [ + { + "components": [ + { + "components": [ + { + "internalType": "string", + "name": "denom", + "type": "string" + }, + { + "internalType": "bool", + "name": "enabled", + "type": "bool" + } + ], + "internalType": "struct IBank.SendEnabled[]", + "name": "sendEnabled", + "type": "tuple[]" + }, + { + "internalType": "bool", + "name": "defaultSendEnabled", + "type": "bool" + } + ], + "internalType": "struct IBank.Params", + "name": "response", + "type": "tuple" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "fromAddress", + "type": "address" + }, + { + "internalType": "address", + "name": "toAddress", + "type": "address" + }, + { + "internalType": "string", + "name": "denom", + "type": "string" + }, + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "name": "send", + "outputs": [ + { + "internalType": "bool", + "name": "success", + "type": "bool" + } + ], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "toNativeAddress", + "type": "string" + } + ], + "name": "sendNative", + "outputs": [ + { + "internalType": "bool", + "name": "success", + "type": "bool" + } + ], + "stateMutability": "payable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "acc", + "type": "address" + } + ], + "name": "spendable_balances", + "outputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "string", + "name": "denom", + "type": "string" + } + ], + "internalType": "struct IBank.Coin[]", + "name": "response", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "denom", + "type": "string" + } + ], + "name": "supply", + "outputs": [ + { + "internalType": "uint256", + "name": "response", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "denom", + "type": "string" + } + ], + "name": "symbol", + "outputs": [ + { + "internalType": "string", + "name": "response", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "total_supply", + "outputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "string", + "name": "denom", + "type": "string" + } + ], + "internalType": "struct IBank.Coin[]", + "name": "response", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/precompiles/bank/bank.go b/precompiles/bank/bank.go index ff79766c0e..b076c38e32 100644 --- a/precompiles/bank/bank.go +++ b/precompiles/bank/bank.go @@ -19,14 +19,19 @@ import ( ) const ( - SendMethod = "send" - SendNativeMethod = "sendNative" - BalanceMethod = "balance" - AllBalancesMethod = "all_balances" - NameMethod = "name" - SymbolMethod = "symbol" - DecimalsMethod = "decimals" - SupplyMethod = "supply" + SendMethod = "send" + SendNativeMethod = "sendNative" + BalanceMethod = "balance" + AllBalancesMethod = "all_balances" + SpendableBalancesMethod = "spendable_balances" + TotalSupplyMethod = "total_supply" + NameMethod = "name" + SymbolMethod = "symbol" + DecimalsMethod = "decimals" + SupplyMethod = "supply" + DenomMetadataMethod = "denom_metadata" + DenomsMetadataMethod = "denoms_metadata" + ParamsMethod = "params" ) const ( @@ -45,14 +50,19 @@ type PrecompileExecutor struct { evmKeeper putils.EVMKeeper address common.Address - SendID []byte - SendNativeID []byte - BalanceID []byte - AllBalancesID []byte - NameID []byte - SymbolID []byte - DecimalsID []byte - SupplyID []byte + SendID []byte + SendNativeID []byte + BalanceID []byte + AllBalancesID []byte + SpendableBalancesID []byte + TotalSupplyID []byte + NameID []byte + SymbolID []byte + DecimalsID []byte + SupplyID []byte + DenomMetadataID []byte + DenomsMetadataID []byte + ParamsID []byte } type CoinBalance struct { @@ -60,6 +70,31 @@ type CoinBalance struct { Denom string } +type DenomUnit struct { + Denom string + Exponent uint32 + Aliases []string +} + +type Metadata struct { + Description string + DenomUnits []DenomUnit + Base string + Display string + Name string + Symbol string +} + +type SendEnabled struct { + Denom string + Enabled bool +} + +type Params struct { + SendEnabled []SendEnabled + DefaultSendEnabled bool +} + func GetABI() abi.ABI { return pcommon.MustGetABI(f, "abi.json") } @@ -84,6 +119,10 @@ func NewPrecompile(keepers putils.Keepers) (*pcommon.DynamicGasPrecompile, error p.BalanceID = m.ID case AllBalancesMethod: p.AllBalancesID = m.ID + case SpendableBalancesMethod: + p.SpendableBalancesID = m.ID + case TotalSupplyMethod: + p.TotalSupplyID = m.ID case NameMethod: p.NameID = m.ID case SymbolMethod: @@ -92,6 +131,12 @@ func NewPrecompile(keepers putils.Keepers) (*pcommon.DynamicGasPrecompile, error p.DecimalsID = m.ID case SupplyMethod: p.SupplyID = m.ID + case DenomMetadataMethod: + p.DenomMetadataID = m.ID + case DenomsMetadataMethod: + p.DenomsMetadataID = m.ID + case ParamsMethod: + p.ParamsID = m.ID } } @@ -119,6 +164,10 @@ func (p PrecompileExecutor) Execute(ctx sdk.Context, method *abi.Method, caller return p.balance(ctx, method, args, value) case AllBalancesMethod: return p.all_balances(ctx, method, args, value) + case SpendableBalancesMethod: + return p.spendableBalances(ctx, method, args, value) + case TotalSupplyMethod: + return p.totalSupply(ctx, method, args, value) case NameMethod: return p.name(ctx, method, args, value) case SymbolMethod: @@ -126,7 +175,13 @@ func (p PrecompileExecutor) Execute(ctx sdk.Context, method *abi.Method, caller case DecimalsMethod: return p.decimals(ctx, method, args, value) case SupplyMethod: - return p.totalSupply(ctx, method, args, value) + return p.supply(ctx, method, args, value) + case DenomMetadataMethod: + return p.denomMetadata(ctx, method, args, value) + case DenomsMetadataMethod: + return p.denomsMetadata(ctx, method, args, value) + case ParamsMethod: + return p.params(ctx, method, args, value) } return } @@ -280,19 +335,46 @@ func (p PrecompileExecutor) all_balances(ctx sdk.Context, method *abi.Method, ar return nil, 0, err } - coins := p.bankKeeper.GetAllBalances(ctx, addr) + coinBalances := coinsToCoinBalances(p.bankKeeper.GetAllBalances(ctx, addr)) - // convert to coin balance structs - coinBalances := make([]CoinBalance, 0, len(coins)) + bz, err := method.Outputs.Pack(coinBalances) + return bz, pcommon.GetRemainingGas(ctx, p.evmKeeper), err +} - for _, coin := range coins { - coinBalances = append(coinBalances, CoinBalance{ - Amount: coin.Amount.BigInt(), - Denom: coin.Denom, - }) +func (p PrecompileExecutor) spendableBalances(ctx sdk.Context, method *abi.Method, args []interface{}, value *big.Int) ([]byte, uint64, error) { + if err := pcommon.ValidateNonPayable(value); err != nil { + return nil, 0, err } - bz, err := method.Outputs.Pack(coinBalances) + if err := pcommon.ValidateArgsLength(args, 1); err != nil { + return nil, 0, err + } + + addr, err := p.accAddressFromArg(ctx, args[0]) + if err != nil { + return nil, 0, err + } + + bz, err := method.Outputs.Pack(coinsToCoinBalances(p.bankKeeper.SpendableCoins(ctx, addr))) + return bz, pcommon.GetRemainingGas(ctx, p.evmKeeper), err +} + +func (p PrecompileExecutor) totalSupply(ctx sdk.Context, method *abi.Method, args []interface{}, value *big.Int) ([]byte, uint64, error) { + if err := pcommon.ValidateNonPayable(value); err != nil { + return nil, 0, err + } + + if err := pcommon.ValidateArgsLength(args, 0); err != nil { + return nil, 0, err + } + + coins := sdk.NewCoins() + p.bankKeeper.IterateTotalSupply(ctx, func(coin sdk.Coin) bool { + coins = coins.Add(coin) + return false + }) + + bz, err := method.Outputs.Pack(coinsToCoinBalances(coins)) return bz, pcommon.GetRemainingGas(ctx, p.evmKeeper), err } @@ -342,7 +424,7 @@ func (p PrecompileExecutor) decimals(ctx sdk.Context, method *abi.Method, _ []in return bz, pcommon.GetRemainingGas(ctx, p.evmKeeper), err } -func (p PrecompileExecutor) totalSupply(ctx sdk.Context, method *abi.Method, args []interface{}, value *big.Int) ([]byte, uint64, error) { +func (p PrecompileExecutor) supply(ctx sdk.Context, method *abi.Method, args []interface{}, value *big.Int) ([]byte, uint64, error) { if err := pcommon.ValidateNonPayable(value); err != nil { return nil, 0, err } @@ -357,6 +439,55 @@ func (p PrecompileExecutor) totalSupply(ctx sdk.Context, method *abi.Method, arg return bz, pcommon.GetRemainingGas(ctx, p.evmKeeper), err } +func (p PrecompileExecutor) denomMetadata(ctx sdk.Context, method *abi.Method, args []interface{}, value *big.Int) ([]byte, uint64, error) { + if err := pcommon.ValidateNonPayable(value); err != nil { + return nil, 0, err + } + + if err := pcommon.ValidateArgsLength(args, 1); err != nil { + return nil, 0, err + } + + denom := args[0].(string) + metadata, found := p.bankKeeper.GetDenomMetaData(ctx, denom) + if !found { + return nil, 0, fmt.Errorf("denom %s not found", denom) + } + bz, err := method.Outputs.Pack(metadataToOutput(metadata)) + return bz, pcommon.GetRemainingGas(ctx, p.evmKeeper), err +} + +func (p PrecompileExecutor) denomsMetadata(ctx sdk.Context, method *abi.Method, args []interface{}, value *big.Int) ([]byte, uint64, error) { + if err := pcommon.ValidateNonPayable(value); err != nil { + return nil, 0, err + } + + if err := pcommon.ValidateArgsLength(args, 0); err != nil { + return nil, 0, err + } + + metadatas := []Metadata{} + p.bankKeeper.IterateAllDenomMetaData(ctx, func(metadata banktypes.Metadata) bool { + metadatas = append(metadatas, metadataToOutput(metadata)) + return false + }) + bz, err := method.Outputs.Pack(metadatas) + return bz, pcommon.GetRemainingGas(ctx, p.evmKeeper), err +} + +func (p PrecompileExecutor) params(ctx sdk.Context, method *abi.Method, args []interface{}, value *big.Int) ([]byte, uint64, error) { + if err := pcommon.ValidateNonPayable(value); err != nil { + return nil, 0, err + } + + if err := pcommon.ValidateArgsLength(args, 0); err != nil { + return nil, 0, err + } + + bz, err := method.Outputs.Pack(paramsToOutput(p.bankKeeper.GetParams(ctx))) + return bz, pcommon.GetRemainingGas(ctx, p.evmKeeper), err +} + func (p PrecompileExecutor) accAddressFromArg(ctx sdk.Context, arg interface{}) (sdk.AccAddress, error) { addr := arg.(common.Address) if addr == (common.Address{}) { @@ -370,6 +501,56 @@ func (p PrecompileExecutor) accAddressFromArg(ctx sdk.Context, arg interface{}) return seiAddr, nil } +func coinsToCoinBalances(coins sdk.Coins) []CoinBalance { + coinBalances := make([]CoinBalance, 0, len(coins)) + for _, coin := range coins { + coinBalances = append(coinBalances, CoinBalance{ + Amount: coin.Amount.BigInt(), + Denom: coin.Denom, + }) + } + return coinBalances +} + +func metadataToOutput(metadata banktypes.Metadata) Metadata { + denomUnits := make([]DenomUnit, 0, len(metadata.DenomUnits)) + for _, unit := range metadata.DenomUnits { + if unit == nil { + continue + } + denomUnits = append(denomUnits, DenomUnit{ + Denom: unit.Denom, + Exponent: unit.Exponent, + Aliases: append([]string(nil), unit.Aliases...), + }) + } + return Metadata{ + Description: metadata.Description, + DenomUnits: denomUnits, + Base: metadata.Base, + Display: metadata.Display, + Name: metadata.Name, + Symbol: metadata.Symbol, + } +} + +func paramsToOutput(params banktypes.Params) Params { + sendEnabled := make([]SendEnabled, 0, len(params.SendEnabled)) + for _, sendEnabledParam := range params.SendEnabled { + if sendEnabledParam == nil { + continue + } + sendEnabled = append(sendEnabled, SendEnabled{ + Denom: sendEnabledParam.Denom, + Enabled: sendEnabledParam.Enabled, + }) + } + return Params{ + SendEnabled: sendEnabled, + DefaultSendEnabled: params.DefaultSendEnabled, + } +} + func (PrecompileExecutor) IsTransaction(method string) bool { switch method { case SendMethod: diff --git a/precompiles/bank/bank_test.go b/precompiles/bank/bank_test.go index a1cae321e8..b52413f1e2 100644 --- a/precompiles/bank/bank_test.go +++ b/precompiles/bank/bank_test.go @@ -3,6 +3,7 @@ package bank_test import ( "embed" "encoding/hex" + "encoding/json" "fmt" "math/big" "strings" @@ -392,8 +393,133 @@ func TestMetadata(t *testing.T) { require.Equal(t, uint8(0), outputs[0]) } +func TestAdditionalQueryMethods(t *testing.T) { + testApp := testkeeper.EVMTestApp + ctx := testApp.NewContext(false, tmtypes.Header{}).WithBlockHeight(2).WithBlockTime(time.Now()) + k := &testApp.EvmKeeper + + seiAddr, evmAddr := testkeeper.MockAddressPair() + k.SetAddressMapping(ctx, seiAddr, evmAddr) + queryCoins := sdk.NewCoins( + sdk.NewCoin("uquerya", sdk.NewInt(19)), + sdk.NewCoin("uqueryb", sdk.NewInt(23)), + ) + require.NoError(t, k.BankKeeper().MintCoins(ctx, types.ModuleName, queryCoins)) + require.NoError(t, k.BankKeeper().SendCoinsFromModuleToAccount(ctx, types.ModuleName, seiAddr, queryCoins)) + + k.BankKeeper().SetDenomMetaData(ctx, banktypes.Metadata{ + Description: "query denom", + DenomUnits: []*banktypes.DenomUnit{ + {Denom: "uquerya", Exponent: 0, Aliases: []string{"microquery"}}, + {Denom: "query", Exponent: 6}, + }, + Base: "uquerya", + Display: "query", + Name: "Query Coin", + Symbol: "QRY", + }) + k.BankKeeper().SetParams(ctx, banktypes.Params{ + SendEnabled: []*banktypes.SendEnabled{{Denom: "uquerya", Enabled: true}}, + DefaultSendEnabled: false, + }) + + p, err := bank.NewPrecompile(testApp.GetPrecompileKeepers()) + require.NoError(t, err) + evm := vm.EVM{StateDB: state.NewDBImpl(ctx, k, true)} + executor := p.GetExecutor().(*bank.PrecompileExecutor) + + spendable := callBankView(t, p, &evm, executor.SpendableBalancesID, evmAddr) + requireCoinBalance(t, unpackCoinBalances(t, spendable), "uquerya", big.NewInt(19)) + requireCoinBalance(t, unpackCoinBalances(t, spendable), "uqueryb", big.NewInt(23)) + + totalSupply := callBankView(t, p, &evm, executor.TotalSupplyID) + requireCoinBalance(t, unpackCoinBalances(t, totalSupply), "uquerya", big.NewInt(19)) + requireCoinBalance(t, unpackCoinBalances(t, totalSupply), "uqueryb", big.NewInt(23)) + + metadata := callBankView(t, p, &evm, executor.DenomMetadataID, "uquerya") + requireJSONEq(t, `{ + "description": "query denom", + "denomUnits": [ + {"denom": "uquerya", "exponent": 0, "aliases": ["microquery"]}, + {"denom": "query", "exponent": 6, "aliases": []} + ], + "base": "uquerya", + "display": "query", + "name": "Query Coin", + "symbol": "QRY" + }`, metadata[0]) + + allMetadata := callBankView(t, p, &evm, executor.DenomsMetadataID) + allMetadataJSON, err := json.Marshal(allMetadata[0]) + require.NoError(t, err) + require.Contains(t, string(allMetadataJSON), `"base":"uquerya"`) + + params := callBankView(t, p, &evm, executor.ParamsID) + requireJSONEq(t, `{ + "sendEnabled": [{"denom": "uquerya", "enabled": true}], + "defaultSendEnabled": false + }`, params[0]) +} + func TestAddress(t *testing.T) { p, err := bank.NewPrecompile(testkeeper.EVMTestApp.GetPrecompileKeepers()) require.Nil(t, err) require.Equal(t, common.HexToAddress(bank.BankAddress), p.Address()) } + +type abiCoinBalance struct { + Amount *big.Int `json:"amount"` + Denom string `json:"denom"` +} + +func callBankView(t *testing.T, p *pcommon.DynamicGasPrecompile, evm *vm.EVM, methodID []byte, args ...interface{}) []interface{} { + t.Helper() + + method, err := p.ABI.MethodById(methodID) + require.NoError(t, err) + packedArgs, err := method.Inputs.Pack(args...) + require.NoError(t, err) + input := append([]byte{}, method.ID...) + input = append(input, packedArgs...) + res, _, err := p.RunAndCalculateGas(evm, common.Address{}, common.Address{}, input, 100000, nil, nil, false, false) + require.NoError(t, err) + outputs, err := method.Outputs.Unpack(res) + require.NoError(t, err) + require.Len(t, outputs, 1) + return outputs +} + +func unpackCoinBalances(t *testing.T, outputs []interface{}) []abiCoinBalance { + t.Helper() + + coins, ok := outputs[0].([]struct { + Amount *big.Int `json:"amount"` + Denom string `json:"denom"` + }) + require.True(t, ok) + coinBalances := make([]abiCoinBalance, 0, len(coins)) + for _, coin := range coins { + coinBalances = append(coinBalances, abiCoinBalance(coin)) + } + return coinBalances +} + +func requireCoinBalance(t *testing.T, coins []abiCoinBalance, denom string, amount *big.Int) { + t.Helper() + + for _, coin := range coins { + if coin.Denom == denom { + require.Equal(t, amount, coin.Amount) + return + } + } + require.Failf(t, "missing coin", "missing %s in %v", denom, coins) +} + +func requireJSONEq(t *testing.T, expected string, actual interface{}) { + t.Helper() + + bz, err := json.Marshal(actual) + require.NoError(t, err) + require.JSONEq(t, expected, string(bz)) +} diff --git a/precompiles/bank/query/registry.go b/precompiles/bank/query/registry.go new file mode 100644 index 0000000000..7216b48723 --- /dev/null +++ b/precompiles/bank/query/registry.go @@ -0,0 +1,538 @@ +package bankquery + +import ( + "bytes" + "context" + "errors" + "fmt" + "math" + "math/big" + "reflect" + "slices" + + "github.com/ethereum/go-ethereum/common" + "github.com/sei-protocol/sei-chain/precompiles/bank" + pquery "github.com/sei-protocol/sei-chain/precompiles/query" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + sdkquery "github.com/sei-protocol/sei-chain/sei-cosmos/types/query" + banktypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/bank/types" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const ( + balanceMethod = "/cosmos.bank.v1beta1.Query/Balance" + allBalancesMethod = "/cosmos.bank.v1beta1.Query/AllBalances" + spendableBalancesMethod = "/cosmos.bank.v1beta1.Query/SpendableBalances" + totalSupplyMethod = "/cosmos.bank.v1beta1.Query/TotalSupply" + supplyOfMethod = "/cosmos.bank.v1beta1.Query/SupplyOf" + paramsMethod = "/cosmos.bank.v1beta1.Query/Params" + denomMetadataMethod = "/cosmos.bank.v1beta1.Query/DenomMetadata" + denomsMetadataMethod = "/cosmos.bank.v1beta1.Query/DenomsMetadata" +) + +func Registry() pquery.Registry { + abi := bank.GetABI() + address := common.HexToAddress(bank.BankAddress) + return pquery.NewRegistry( + pquery.Bind(pquery.Binding[banktypes.QueryBalanceRequest, banktypes.QueryBalanceResponse]{ + FullMethod: balanceMethod, + Precompile: address, + ABI: abi, + ABIMethod: bank.BalanceMethod, + Pack: packBalance, + Unpack: unpackBalance, + ResponseShape: pquery.ExactProtobufShape, + }), + pquery.Bind(pquery.Binding[banktypes.QueryAllBalancesRequest, banktypes.QueryAllBalancesResponse]{ + FullMethod: allBalancesMethod, + Precompile: address, + ABI: abi, + ABIMethod: bank.AllBalancesMethod, + Pack: packAllBalances, + Unpack: unpackAllBalances, + ResponseShape: pquery.ExactProtobufShape, + }), + pquery.Bind(pquery.Binding[banktypes.QuerySpendableBalancesRequest, banktypes.QuerySpendableBalancesResponse]{ + FullMethod: spendableBalancesMethod, + Precompile: address, + ABI: abi, + ABIMethod: bank.SpendableBalancesMethod, + Pack: packSpendableBalances, + Unpack: unpackSpendableBalances, + ResponseShape: pquery.ExactProtobufShape, + }), + pquery.Bind(pquery.Binding[banktypes.QueryTotalSupplyRequest, banktypes.QueryTotalSupplyResponse]{ + FullMethod: totalSupplyMethod, + Precompile: address, + ABI: abi, + ABIMethod: bank.TotalSupplyMethod, + Pack: packNoArgs[banktypes.QueryTotalSupplyRequest], + Unpack: unpackTotalSupply, + ResponseShape: pquery.ExactProtobufShape, + }), + pquery.Bind(pquery.Binding[banktypes.QuerySupplyOfRequest, banktypes.QuerySupplyOfResponse]{ + FullMethod: supplyOfMethod, + Precompile: address, + ABI: abi, + ABIMethod: bank.SupplyMethod, + Pack: packSupplyOf, + Unpack: unpackSupplyOf, + ResponseShape: pquery.ExactProtobufShape, + }), + pquery.Bind(pquery.Binding[banktypes.QueryParamsRequest, banktypes.QueryParamsResponse]{ + FullMethod: paramsMethod, + Precompile: address, + ABI: abi, + ABIMethod: bank.ParamsMethod, + Pack: packNoArgs[banktypes.QueryParamsRequest], + Unpack: unpackParams, + ResponseShape: pquery.ExactProtobufShape, + }), + pquery.Bind(pquery.Binding[banktypes.QueryDenomMetadataRequest, banktypes.QueryDenomMetadataResponse]{ + FullMethod: denomMetadataMethod, + Precompile: address, + ABI: abi, + ABIMethod: bank.DenomMetadataMethod, + Pack: packDenomMetadata, + Unpack: unpackDenomMetadata, + ResponseShape: pquery.ExactProtobufShape, + }), + pquery.Bind(pquery.Binding[banktypes.QueryDenomsMetadataRequest, banktypes.QueryDenomsMetadataResponse]{ + FullMethod: denomsMetadataMethod, + Precompile: address, + ABI: abi, + ABIMethod: bank.DenomsMetadataMethod, + Pack: packNoArgs[banktypes.QueryDenomsMetadataRequest], + Unpack: unpackDenomsMetadata, + ResponseShape: pquery.ExactProtobufShape, + }), + ) +} + +func packBalance(ctx context.Context, env *pquery.Env, req *banktypes.QueryBalanceRequest) ([]interface{}, error) { + if req.Address == "" { + return nil, status.Error(codes.InvalidArgument, "address cannot be empty") + } + if req.Denom == "" { + return nil, status.Error(codes.InvalidArgument, "invalid denom") + } + evmAddr, err := env.EVMAddressForSeiAddress(ctx, req.Address, pquery.AllowCastAddress) + if err != nil { + return nil, err + } + return []interface{}{evmAddr, req.Denom}, nil +} + +func unpackBalance(_ context.Context, _ *pquery.Env, req *banktypes.QueryBalanceRequest, out []interface{}, resp *banktypes.QueryBalanceResponse) error { + amount, err := singleBigInt(out) + if err != nil { + return err + } + coin := sdk.NewCoin(req.Denom, sdk.NewIntFromBigInt(amount)) + resp.Balance = &coin + return nil +} + +func packAllBalances(ctx context.Context, env *pquery.Env, req *banktypes.QueryAllBalancesRequest) ([]interface{}, error) { + if req.Address == "" { + return nil, status.Error(codes.InvalidArgument, "address cannot be empty") + } + evmAddr, err := env.EVMAddressForSeiAddress(ctx, req.Address, pquery.AllowCastAddress) + if err != nil { + return nil, err + } + return []interface{}{evmAddr}, nil +} + +func unpackAllBalances(_ context.Context, _ *pquery.Env, req *banktypes.QueryAllBalancesRequest, out []interface{}, resp *banktypes.QueryAllBalancesResponse) error { + coins, err := coinsFromOutput(out) + if err != nil { + return err + } + paged, pageRes, err := paginateCoins(coins, req.Pagination) + if err != nil { + return err + } + resp.Balances = paged + resp.Pagination = pageRes + return nil +} + +func packSpendableBalances(ctx context.Context, env *pquery.Env, req *banktypes.QuerySpendableBalancesRequest) ([]interface{}, error) { + if req.Address == "" { + return nil, status.Error(codes.InvalidArgument, "address cannot be empty") + } + evmAddr, err := env.EVMAddressForSeiAddress(ctx, req.Address, pquery.AllowCastAddress) + if err != nil { + return nil, err + } + return []interface{}{evmAddr}, nil +} + +func unpackSpendableBalances(_ context.Context, _ *pquery.Env, req *banktypes.QuerySpendableBalancesRequest, out []interface{}, resp *banktypes.QuerySpendableBalancesResponse) error { + coins, err := coinsFromOutput(out) + if err != nil { + return err + } + paged, pageRes, err := paginateCoins(coins, req.Pagination) + if err != nil { + return err + } + resp.Balances = paged + resp.Pagination = pageRes + return nil +} + +func packSupplyOf(_ context.Context, _ *pquery.Env, req *banktypes.QuerySupplyOfRequest) ([]interface{}, error) { + if req.Denom == "" { + return nil, status.Error(codes.InvalidArgument, "invalid denom") + } + return []interface{}{req.Denom}, nil +} + +func unpackSupplyOf(_ context.Context, _ *pquery.Env, req *banktypes.QuerySupplyOfRequest, out []interface{}, resp *banktypes.QuerySupplyOfResponse) error { + amount, err := singleBigInt(out) + if err != nil { + return err + } + resp.Amount = sdk.NewCoin(req.Denom, sdk.NewIntFromBigInt(amount)) + return nil +} + +func unpackTotalSupply(_ context.Context, _ *pquery.Env, req *banktypes.QueryTotalSupplyRequest, out []interface{}, resp *banktypes.QueryTotalSupplyResponse) error { + coins, err := coinsFromOutput(out) + if err != nil { + return err + } + paged, pageRes, err := paginateCoins(coins, req.Pagination) + if err != nil { + return err + } + resp.Supply = paged + resp.Pagination = pageRes + return nil +} + +func unpackParams(_ context.Context, _ *pquery.Env, _ *banktypes.QueryParamsRequest, out []interface{}, resp *banktypes.QueryParamsResponse) error { + if len(out) != 1 { + return fmt.Errorf("expected 1 params output but got %d", len(out)) + } + params, err := paramsFromValue(reflect.ValueOf(out[0])) + if err != nil { + return err + } + resp.Params = params + return nil +} + +func packDenomMetadata(_ context.Context, _ *pquery.Env, req *banktypes.QueryDenomMetadataRequest) ([]interface{}, error) { + if req.Denom == "" { + return nil, status.Error(codes.InvalidArgument, "invalid denom") + } + return []interface{}{req.Denom}, nil +} + +func unpackDenomMetadata(_ context.Context, _ *pquery.Env, _ *banktypes.QueryDenomMetadataRequest, out []interface{}, resp *banktypes.QueryDenomMetadataResponse) error { + if len(out) != 1 { + return fmt.Errorf("expected 1 metadata output but got %d", len(out)) + } + metadata, err := metadataFromValue(reflect.ValueOf(out[0])) + if err != nil { + return err + } + resp.Metadata = metadata + return nil +} + +func unpackDenomsMetadata(_ context.Context, _ *pquery.Env, req *banktypes.QueryDenomsMetadataRequest, out []interface{}, resp *banktypes.QueryDenomsMetadataResponse) error { + if len(out) != 1 { + return fmt.Errorf("expected 1 metadatas output but got %d", len(out)) + } + metadatas, err := metadatasFromValue(reflect.ValueOf(out[0])) + if err != nil { + return err + } + paged, pageRes, err := paginateMetadata(metadatas, req.Pagination) + if err != nil { + return err + } + resp.Metadatas = paged + resp.Pagination = pageRes + return nil +} + +func packNoArgs[Req any](_ context.Context, _ *pquery.Env, _ *Req) ([]interface{}, error) { + return nil, nil +} + +func singleBigInt(out []interface{}) (*big.Int, error) { + if len(out) != 1 { + return nil, fmt.Errorf("expected 1 output but got %d", len(out)) + } + amount, ok := out[0].(*big.Int) + if !ok { + return nil, fmt.Errorf("expected *big.Int output but got %T", out[0]) + } + return amount, nil +} + +func coinsFromOutput(out []interface{}) (sdk.Coins, error) { + if len(out) != 1 { + return nil, fmt.Errorf("expected 1 coin output but got %d", len(out)) + } + value := reflect.ValueOf(out[0]) + if value.Kind() != reflect.Slice { + return nil, fmt.Errorf("expected coin slice but got %T", out[0]) + } + coins := make(sdk.Coins, 0, value.Len()) + for i := 0; i < value.Len(); i++ { + amount, err := fieldBigInt(value.Index(i), "Amount") + if err != nil { + return nil, err + } + denom, err := fieldString(value.Index(i), "Denom") + if err != nil { + return nil, err + } + coins = append(coins, sdk.NewCoin(denom, sdk.NewIntFromBigInt(amount))) + } + return sdk.NewCoins(coins...), nil +} + +func metadatasFromValue(value reflect.Value) ([]banktypes.Metadata, error) { + if value.Kind() != reflect.Slice { + return nil, fmt.Errorf("expected metadata slice but got %s", value.Kind()) + } + metadatas := make([]banktypes.Metadata, 0, value.Len()) + for i := 0; i < value.Len(); i++ { + metadata, err := metadataFromValue(value.Index(i)) + if err != nil { + return nil, err + } + metadatas = append(metadatas, metadata) + } + return metadatas, nil +} + +func metadataFromValue(value reflect.Value) (banktypes.Metadata, error) { + description, err := fieldString(value, "Description") + if err != nil { + return banktypes.Metadata{}, err + } + base, err := fieldString(value, "Base") + if err != nil { + return banktypes.Metadata{}, err + } + display, err := fieldString(value, "Display") + if err != nil { + return banktypes.Metadata{}, err + } + name, err := fieldString(value, "Name") + if err != nil { + return banktypes.Metadata{}, err + } + symbol, err := fieldString(value, "Symbol") + if err != nil { + return banktypes.Metadata{}, err + } + denomUnitsValue := field(value, "DenomUnits") + if !denomUnitsValue.IsValid() { + return banktypes.Metadata{}, errors.New("metadata missing DenomUnits") + } + if denomUnitsValue.Kind() != reflect.Slice { + return banktypes.Metadata{}, fmt.Errorf("expected DenomUnits slice but got %s", denomUnitsValue.Kind()) + } + denomUnits := make([]*banktypes.DenomUnit, 0, denomUnitsValue.Len()) + for i := 0; i < denomUnitsValue.Len(); i++ { + unitValue := denomUnitsValue.Index(i) + denom, err := fieldString(unitValue, "Denom") + if err != nil { + return banktypes.Metadata{}, err + } + exponent, err := fieldUint32(unitValue, "Exponent") + if err != nil { + return banktypes.Metadata{}, err + } + aliases, err := fieldStringSlice(unitValue, "Aliases") + if err != nil { + return banktypes.Metadata{}, err + } + denomUnits = append(denomUnits, &banktypes.DenomUnit{ + Denom: denom, + Exponent: exponent, + Aliases: aliases, + }) + } + return banktypes.Metadata{ + Description: description, + DenomUnits: denomUnits, + Base: base, + Display: display, + Name: name, + Symbol: symbol, + }, nil +} + +func paramsFromValue(value reflect.Value) (banktypes.Params, error) { + defaultSendEnabled, err := fieldBool(value, "DefaultSendEnabled") + if err != nil { + return banktypes.Params{}, err + } + sendEnabledValue := field(value, "SendEnabled") + if !sendEnabledValue.IsValid() { + return banktypes.Params{}, errors.New("params missing SendEnabled") + } + if sendEnabledValue.Kind() != reflect.Slice { + return banktypes.Params{}, fmt.Errorf("expected SendEnabled slice but got %s", sendEnabledValue.Kind()) + } + sendEnabled := make([]*banktypes.SendEnabled, 0, sendEnabledValue.Len()) + for i := 0; i < sendEnabledValue.Len(); i++ { + item := sendEnabledValue.Index(i) + denom, err := fieldString(item, "Denom") + if err != nil { + return banktypes.Params{}, err + } + enabled, err := fieldBool(item, "Enabled") + if err != nil { + return banktypes.Params{}, err + } + sendEnabled = append(sendEnabled, &banktypes.SendEnabled{Denom: denom, Enabled: enabled}) + } + return banktypes.Params{SendEnabled: sendEnabled, DefaultSendEnabled: defaultSendEnabled}, nil +} + +func field(value reflect.Value, name string) reflect.Value { + if value.Kind() == reflect.Pointer { + value = value.Elem() + } + if value.Kind() != reflect.Struct { + return reflect.Value{} + } + return value.FieldByName(name) +} + +func fieldString(value reflect.Value, name string) (string, error) { + field := field(value, name) + if !field.IsValid() || field.Kind() != reflect.String { + return "", fmt.Errorf("expected string field %s", name) + } + return field.String(), nil +} + +func fieldBool(value reflect.Value, name string) (bool, error) { + field := field(value, name) + if !field.IsValid() || field.Kind() != reflect.Bool { + return false, fmt.Errorf("expected bool field %s", name) + } + return field.Bool(), nil +} + +func fieldUint32(value reflect.Value, name string) (uint32, error) { + field := field(value, name) + if !field.IsValid() { + return 0, fmt.Errorf("expected uint32 field %s", name) + } + if field.Kind() < reflect.Uint || field.Kind() > reflect.Uint64 { + return 0, fmt.Errorf("expected uint field %s", name) + } + if field.Uint() > math.MaxUint32 { + return 0, fmt.Errorf("field %s overflows uint32", name) + } + return uint32(field.Uint()), nil +} + +func fieldBigInt(value reflect.Value, name string) (*big.Int, error) { + field := field(value, name) + if !field.IsValid() { + return nil, fmt.Errorf("expected *big.Int field %s", name) + } + amount, ok := field.Interface().(*big.Int) + if !ok { + return nil, fmt.Errorf("expected *big.Int field %s", name) + } + return amount, nil +} + +func fieldStringSlice(value reflect.Value, name string) ([]string, error) { + field := field(value, name) + if !field.IsValid() || field.Kind() != reflect.Slice { + return nil, fmt.Errorf("expected string slice field %s", name) + } + aliases := make([]string, 0, field.Len()) + for i := 0; i < field.Len(); i++ { + if field.Index(i).Kind() != reflect.String { + return nil, fmt.Errorf("expected string element in field %s", name) + } + aliases = append(aliases, field.Index(i).String()) + } + return aliases, nil +} + +func paginateCoins(coins sdk.Coins, req *sdkquery.PageRequest) (sdk.Coins, *sdkquery.PageResponse, error) { + items, pageRes, err := paginate(coins, req, func(coin sdk.Coin) []byte { + return []byte(coin.Denom) + }) + if err != nil { + return nil, nil, err + } + return sdk.Coins(items), pageRes, nil +} + +func paginateMetadata(metadatas []banktypes.Metadata, req *sdkquery.PageRequest) ([]banktypes.Metadata, *sdkquery.PageResponse, error) { + return paginate(metadatas, req, func(metadata banktypes.Metadata) []byte { + return []byte(metadata.Base) + }) +} + +func paginate[T any](items []T, req *sdkquery.PageRequest, keyFn func(T) []byte) ([]T, *sdkquery.PageResponse, error) { + if req == nil { + req = &sdkquery.PageRequest{} + } + if req.Offset > 0 && len(req.Key) > 0 { + return nil, nil, fmt.Errorf("invalid request, either offset or key is expected, got both") + } + + ordered := slices.Clone(items) + if req.Reverse { + slices.Reverse(ordered) + } + + limit := req.Limit + countTotal := req.CountTotal + if limit == 0 { + limit = sdkquery.DefaultLimit + countTotal = true + } + + start := req.Offset + keyPagination := len(req.Key) > 0 + if keyPagination { + start = uint64(len(ordered)) + for i, item := range ordered { + if bytes.Equal(keyFn(item), req.Key) { + start = uint64(i) + break + } + } + } + + if start > uint64(len(ordered)) { + start = uint64(len(ordered)) + } + end := start + limit + if end < start || end > uint64(len(ordered)) { + end = uint64(len(ordered)) + } + + var nextKey []byte + if end < uint64(len(ordered)) { + nextKey = keyFn(ordered[end]) + } + pageRes := &sdkquery.PageResponse{NextKey: nextKey} + if countTotal && !keyPagination { + pageRes.Total = uint64(len(ordered)) + } + + return ordered[int(start):int(end)], pageRes, nil +} diff --git a/precompiles/bank/query/registry_test.go b/precompiles/bank/query/registry_test.go new file mode 100644 index 0000000000..92a29af6ac --- /dev/null +++ b/precompiles/bank/query/registry_test.go @@ -0,0 +1,132 @@ +package bankquery + +import ( + "bytes" + "context" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + addrprecompile "github.com/sei-protocol/sei-chain/precompiles/addr" + "github.com/sei-protocol/sei-chain/precompiles/bank" + pquery "github.com/sei-protocol/sei-chain/precompiles/query" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + sdkquery "github.com/sei-protocol/sei-chain/sei-cosmos/types/query" + banktypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/bank/types" + "github.com/stretchr/testify/require" +) + +type fakeEVMCaller struct { + t *testing.T + wantBlock *big.Int + wantAddress common.Address +} + +func (f fakeEVMCaller) CallContract(_ context.Context, msg ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) { + require.NotNil(f.t, msg.To) + require.Equal(f.t, f.wantBlock, blockNumber) + + if *msg.To == common.HexToAddress(addrprecompile.AddrAddress) { + contractABI := addrprecompile.GetABI() + method, err := contractABI.MethodById(msg.Data[:4]) + require.NoError(f.t, err) + require.Equal(f.t, addrprecompile.GetEvmAddressMethod, method.Name) + args, err := method.Inputs.Unpack(msg.Data[4:]) + require.NoError(f.t, err) + require.Len(f.t, args, 1) + return method.Outputs.Pack(f.wantAddress) + } + + require.Equal(f.t, common.HexToAddress(bank.BankAddress), *msg.To) + contractABI := bank.GetABI() + method, err := contractABI.MethodById(msg.Data[:4]) + require.NoError(f.t, err) + args, err := method.Inputs.Unpack(msg.Data[4:]) + require.NoError(f.t, err) + + switch method.Name { + case bank.BalanceMethod: + require.Equal(f.t, []interface{}{f.wantAddress, "usei"}, args) + return method.Outputs.Pack(big.NewInt(123)) + case bank.AllBalancesMethod: + require.Equal(f.t, []interface{}{f.wantAddress}, args) + return method.Outputs.Pack([]bank.CoinBalance{ + {Amount: big.NewInt(7), Denom: "uatom"}, + {Amount: big.NewInt(11), Denom: "usei"}, + }) + case bank.DenomMetadataMethod: + require.Equal(f.t, []interface{}{"usei"}, args) + return method.Outputs.Pack(bank.Metadata{ + Description: "Sei base denom", + DenomUnits: []bank.DenomUnit{ + {Denom: "usei", Exponent: 0, Aliases: []string{"microsei"}}, + {Denom: "sei", Exponent: 6, Aliases: []string{}}, + }, + Base: "usei", + Display: "sei", + Name: "Sei", + Symbol: "SEI", + }) + case bank.ParamsMethod: + return method.Outputs.Pack(bank.Params{ + SendEnabled: []bank.SendEnabled{{Denom: "usei", Enabled: true}}, + DefaultSendEnabled: true, + }) + default: + f.t.Fatalf("unexpected method %s", method.Name) + return nil, nil + } +} + +func TestGeneratedQueryClientUsesBankPrecompileBindings(t *testing.T) { + seiAddr := sdk.AccAddress(bytes.Repeat([]byte{1}, 20)) + evmAddr := common.BytesToAddress(seiAddr) + caller := fakeEVMCaller{ + t: t, + wantBlock: big.NewInt(99), + wantAddress: evmAddr, + } + client := banktypes.NewQueryClient(pquery.NewConn( + caller, + Registry(), + pquery.WithDefaultBlockNumber(99), + )) + + balance, err := client.Balance(context.Background(), &banktypes.QueryBalanceRequest{ + Address: seiAddr.String(), + Denom: "usei", + }) + require.NoError(t, err) + require.Equal(t, sdk.NewCoin("usei", sdk.NewInt(123)), *balance.Balance) + + allBalances, err := client.AllBalances(context.Background(), &banktypes.QueryAllBalancesRequest{ + Address: seiAddr.String(), + Pagination: &sdkquery.PageRequest{Limit: 1, CountTotal: true}, + }) + require.NoError(t, err) + require.Equal(t, sdk.NewCoins(sdk.NewCoin("uatom", sdk.NewInt(7))), allBalances.Balances) + require.Equal(t, []byte("usei"), allBalances.Pagination.NextKey) + require.Equal(t, uint64(2), allBalances.Pagination.Total) + + metadata, err := client.DenomMetadata(context.Background(), &banktypes.QueryDenomMetadataRequest{Denom: "usei"}) + require.NoError(t, err) + require.Equal(t, banktypes.Metadata{ + Description: "Sei base denom", + DenomUnits: []*banktypes.DenomUnit{ + {Denom: "usei", Exponent: 0, Aliases: []string{"microsei"}}, + {Denom: "sei", Exponent: 6, Aliases: []string{}}, + }, + Base: "usei", + Display: "sei", + Name: "Sei", + Symbol: "SEI", + }, metadata.Metadata) + + params, err := client.Params(context.Background(), &banktypes.QueryParamsRequest{}) + require.NoError(t, err) + require.Equal(t, banktypes.Params{ + SendEnabled: []*banktypes.SendEnabled{{Denom: "usei", Enabled: true}}, + DefaultSendEnabled: true, + }, params.Params) +} diff --git a/precompiles/query/address.go b/precompiles/query/address.go new file mode 100644 index 0000000000..05b5d66cc3 --- /dev/null +++ b/precompiles/query/address.go @@ -0,0 +1,100 @@ +package query + +import ( + "context" + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/common" + addrprecompile "github.com/sei-protocol/sei-chain/precompiles/addr" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" +) + +type AddressPolicy uint8 + +const ( + RequireAssociation AddressPolicy = iota + AllowCastAddress +) + +func (e *Env) EVMAddressForSeiAddress(ctx context.Context, sei string, policy AddressPolicy) (common.Address, error) { + seiAddr, err := sdk.AccAddressFromBech32(sei) + if err != nil { + return common.Address{}, err + } + evmAddr, err := e.associatedEVMAddress(ctx, sei) + if err == nil { + return evmAddr, nil + } + if policy == RequireAssociation || !isAssociationMissing(err) { + return common.Address{}, err + } + return common.BytesToAddress(seiAddr), nil +} + +func (e *Env) SeiAddressForEVMAddress(ctx context.Context, evm common.Address, policy AddressPolicy) (sdk.AccAddress, error) { + seiAddr, err := e.associatedSeiAddress(ctx, evm) + if err == nil { + return seiAddr, nil + } + if policy == RequireAssociation || !isAssociationMissing(err) { + return nil, err + } + return sdk.AccAddress(evm[:]), nil +} + +func (e *Env) associatedEVMAddress(ctx context.Context, sei string) (common.Address, error) { + contractABI := addrprecompile.GetABI() + input, err := contractABI.Pack(addrprecompile.GetEvmAddressMethod, sei) + if err != nil { + return common.Address{}, err + } + output, err := e.EthCall(ctx, common.HexToAddress(addrprecompile.AddrAddress), input) + if err != nil { + return common.Address{}, err + } + values, err := contractABI.Unpack(addrprecompile.GetEvmAddressMethod, output) + if err != nil { + return common.Address{}, err + } + if len(values) != 1 { + return common.Address{}, fmt.Errorf("expected 1 addr precompile output but got %d", len(values)) + } + evmAddr, ok := values[0].(common.Address) + if !ok { + return common.Address{}, fmt.Errorf("expected common.Address addr precompile output but got %T", values[0]) + } + return evmAddr, nil +} + +func (e *Env) associatedSeiAddress(ctx context.Context, evm common.Address) (sdk.AccAddress, error) { + contractABI := addrprecompile.GetABI() + input, err := contractABI.Pack(addrprecompile.GetSeiAddressMethod, evm) + if err != nil { + return nil, err + } + output, err := e.EthCall(ctx, common.HexToAddress(addrprecompile.AddrAddress), input) + if err != nil { + return nil, err + } + values, err := contractABI.Unpack(addrprecompile.GetSeiAddressMethod, output) + if err != nil { + return nil, err + } + if len(values) != 1 { + return nil, fmt.Errorf("expected 1 addr precompile output but got %d", len(values)) + } + seiAddr, ok := values[0].(string) + if !ok { + return nil, fmt.Errorf("expected string addr precompile output but got %T", values[0]) + } + return sdk.AccAddressFromBech32(seiAddr) +} + +func isAssociationMissing(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "not associated") || strings.Contains(msg, "not linked") +} diff --git a/precompiles/query/address_test.go b/precompiles/query/address_test.go new file mode 100644 index 0000000000..b3874f8a20 --- /dev/null +++ b/precompiles/query/address_test.go @@ -0,0 +1,87 @@ +package query + +import ( + "bytes" + "context" + "errors" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + addrprecompile "github.com/sei-protocol/sei-chain/precompiles/addr" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + "github.com/stretchr/testify/require" +) + +type fakeAddressCaller struct { + t *testing.T + evmAddr common.Address + seiAddr sdk.AccAddress + err error +} + +func (f fakeAddressCaller) CallContract(_ context.Context, msg ethereum.CallMsg, _ *big.Int) ([]byte, error) { + require.Equal(f.t, common.HexToAddress(addrprecompile.AddrAddress), *msg.To) + if f.err != nil { + return nil, f.err + } + + contractABI := addrprecompile.GetABI() + method, err := contractABI.MethodById(msg.Data[:4]) + require.NoError(f.t, err) + switch method.Name { + case addrprecompile.GetEvmAddressMethod: + return method.Outputs.Pack(f.evmAddr) + case addrprecompile.GetSeiAddressMethod: + return method.Outputs.Pack(f.seiAddr.String()) + default: + f.t.Fatalf("unexpected method %s", method.Name) + return nil, nil + } +} + +func TestAddressHelpersUseAssociationPrecompile(t *testing.T) { + seiAddr := sdk.AccAddress(bytes.Repeat([]byte{1}, 20)) + evmAddr := common.HexToAddress("0x0000000000000000000000000000000000000042") + env := &Env{caller: fakeAddressCaller{t: t, evmAddr: evmAddr, seiAddr: seiAddr}} + + gotEVM, err := env.EVMAddressForSeiAddress(context.Background(), seiAddr.String(), AllowCastAddress) + require.NoError(t, err) + require.Equal(t, evmAddr, gotEVM) + + gotSei, err := env.SeiAddressForEVMAddress(context.Background(), evmAddr, AllowCastAddress) + require.NoError(t, err) + require.Equal(t, seiAddr, gotSei) +} + +func TestAddressHelpersCastOnlyWhenAllowed(t *testing.T) { + seiAddr := sdk.AccAddress(bytes.Repeat([]byte{2}, 20)) + evmAddr := common.BytesToAddress(bytes.Repeat([]byte{3}, 20)) + env := &Env{caller: fakeAddressCaller{t: t, err: errors.New("not associated")}} + + gotEVM, err := env.EVMAddressForSeiAddress(context.Background(), seiAddr.String(), AllowCastAddress) + require.NoError(t, err) + require.Equal(t, common.BytesToAddress(seiAddr), gotEVM) + + gotSei, err := env.SeiAddressForEVMAddress(context.Background(), evmAddr, AllowCastAddress) + require.NoError(t, err) + require.Equal(t, sdk.AccAddress(evmAddr[:]), gotSei) + + _, err = env.EVMAddressForSeiAddress(context.Background(), seiAddr.String(), RequireAssociation) + require.Error(t, err) + _, err = env.SeiAddressForEVMAddress(context.Background(), evmAddr, RequireAssociation) + require.Error(t, err) +} + +func TestAddressHelpersDoNotCastUnexpectedLookupErrors(t *testing.T) { + seiAddr := sdk.AccAddress(bytes.Repeat([]byte{4}, 20)) + evmAddr := common.BytesToAddress(bytes.Repeat([]byte{5}, 20)) + env := &Env{caller: fakeAddressCaller{t: t, err: errors.New("rpc unavailable")}} + + _, err := env.EVMAddressForSeiAddress(context.Background(), seiAddr.String(), AllowCastAddress) + require.ErrorContains(t, err, "rpc unavailable") + + _, err = env.SeiAddressForEVMAddress(context.Background(), evmAddr, AllowCastAddress) + require.ErrorContains(t, err, "rpc unavailable") +} diff --git a/precompiles/query/binding.go b/precompiles/query/binding.go new file mode 100644 index 0000000000..25e8da122e --- /dev/null +++ b/precompiles/query/binding.go @@ -0,0 +1,108 @@ +package query + +import ( + "context" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +type ResponseShape uint8 + +const ( + ExactProtobufShape ResponseShape = iota + DocumentedVariation +) + +type Invoker interface { + Method() string + Invoke(ctx context.Context, env *Env, req, reply interface{}) error +} + +type Registry map[string]Invoker + +func NewRegistry(bindings ...Invoker) Registry { + registry := make(Registry, len(bindings)) + for _, binding := range bindings { + registry[binding.Method()] = binding + } + return registry +} + +type Binding[Req any, Resp any] struct { + FullMethod string + Precompile common.Address + ABI abi.ABI + ABIMethod string + Pack func(context.Context, *Env, *Req) ([]interface{}, error) + Unpack func(context.Context, *Env, *Req, []interface{}, *Resp) error + ABIForHeight func(height int64) abi.ABI + ResponseShape ResponseShape + Variation string +} + +func Bind[Req any, Resp any](binding Binding[Req, Resp]) Invoker { + return binding +} + +func (b Binding[Req, Resp]) Method() string { + return b.FullMethod +} + +func (b Binding[Req, Resp]) Invoke(ctx context.Context, env *Env, req, reply interface{}) error { + typedReq, ok := req.(*Req) + if !ok { + return status.Error(codes.InvalidArgument, typeMismatch((*Req)(nil), req).Error()) + } + if typedReq == nil { + return status.Error(codes.InvalidArgument, "request cannot be nil") + } + typedReply, ok := reply.(*Resp) + if !ok { + return status.Error(codes.InvalidArgument, typeMismatch((*Resp)(nil), reply).Error()) + } + if typedReply == nil { + return status.Error(codes.InvalidArgument, "reply cannot be nil") + } + if b.Pack == nil || b.Unpack == nil { + return status.Errorf(codes.FailedPrecondition, "precompile query binding for %s is incomplete", b.FullMethod) + } + + contractABI := b.ABI + if b.ABIForHeight != nil { + height := int64(0) + if blockNumber := env.BlockNumber(); blockNumber != nil { + if !blockNumber.IsInt64() { + return status.Errorf(codes.InvalidArgument, "block height %s overflows int64", blockNumber.String()) + } + height = blockNumber.Int64() + } + contractABI = b.ABIForHeight(height) + } + + args, err := b.Pack(ctx, env, typedReq) + if err != nil { + return err + } + input, err := contractABI.Pack(b.ABIMethod, args...) + if err != nil { + return err + } + output, err := env.EthCall(ctx, b.Precompile, input) + if err != nil { + return err + } + values, err := contractABI.Unpack(b.ABIMethod, output) + if err != nil { + return err + } + return b.Unpack(ctx, env, typedReq, values, typedReply) +} + +func BigInt(value interface{}) (*big.Int, bool) { + v, ok := value.(*big.Int) + return v, ok +} diff --git a/precompiles/query/conn.go b/precompiles/query/conn.go new file mode 100644 index 0000000000..1a47736ccb --- /dev/null +++ b/precompiles/query/conn.go @@ -0,0 +1,143 @@ +package query + +import ( + "context" + "fmt" + "math/big" + "strconv" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + gogogrpc "github.com/gogo/protobuf/grpc" + grpctypes "github.com/sei-protocol/sei-chain/sei-cosmos/types/grpc" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +var _ gogogrpc.ClientConn = (*Conn)(nil) + +// EVMCaller is the ethclient.Client subset needed to execute precompile queries. +type EVMCaller interface { + CallContract(ctx context.Context, msg ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) +} + +type Conn struct { + caller EVMCaller + registry Registry + defaultBlockNumber *big.Int + defaultFrom common.Address +} + +type Option func(*Conn) + +func WithDefaultBlockNumber(height int64) Option { + return func(c *Conn) { + if height > 0 { + c.defaultBlockNumber = big.NewInt(height) + } + } +} + +func WithDefaultFrom(addr common.Address) Option { + return func(c *Conn) { + c.defaultFrom = addr + } +} + +func NewConn(caller EVMCaller, registry Registry, opts ...Option) *Conn { + c := &Conn{ + caller: caller, + registry: registry, + } + for _, opt := range opts { + opt(c) + } + return c +} + +func (c *Conn) Invoke(ctx context.Context, method string, req, reply interface{}, opts ...grpc.CallOption) error { + if req == nil { + return status.Error(codes.InvalidArgument, "request cannot be nil") + } + if reply == nil { + return status.Error(codes.InvalidArgument, "reply cannot be nil") + } + if c.caller == nil { + return status.Error(codes.FailedPrecondition, "EVM caller is not configured") + } + binding, ok := c.registry[method] + if !ok { + return status.Errorf(codes.Unimplemented, "precompile query binding for %s is not implemented", method) + } + + env := &Env{ + caller: c.caller, + blockNumber: c.blockNumber(ctx), + defaultFrom: c.defaultFrom, + } + return binding.Invoke(ctx, env, req, reply) +} + +func (c *Conn) NewStream(context.Context, *grpc.StreamDesc, string, ...grpc.CallOption) (grpc.ClientStream, error) { + return nil, status.Error(codes.Unimplemented, "streaming precompile queries are not supported") +} + +func (c *Conn) blockNumber(ctx context.Context) *big.Int { + if md, ok := metadata.FromOutgoingContext(ctx); ok { + heights := md.Get(grpctypes.GRPCBlockHeightHeader) + if len(heights) > 0 { + height, err := strconv.ParseInt(heights[0], 10, 64) + if err == nil && height > 0 { + return big.NewInt(height) + } + } + } + if c.defaultBlockNumber == nil { + return nil + } + return new(big.Int).Set(c.defaultBlockNumber) +} + +type Env struct { + caller EVMCaller + blockNumber *big.Int + defaultFrom common.Address +} + +func (e *Env) BlockNumber() *big.Int { + if e.blockNumber == nil { + return nil + } + return new(big.Int).Set(e.blockNumber) +} + +func (e *Env) EthCall(ctx context.Context, to common.Address, input []byte, opts ...CallOption) ([]byte, error) { + call := callOptions{from: e.defaultFrom} + for _, opt := range opts { + opt(&call) + } + msg := ethereum.CallMsg{ + From: call.from, + To: &to, + Data: input, + } + return e.caller.CallContract(ctx, msg, e.BlockNumber()) +} + +type callOptions struct { + from common.Address +} + +type CallOption func(*callOptions) + +func WithFrom(from common.Address) CallOption { + return func(o *callOptions) { + o.from = from + } +} + +func typeMismatch(expected interface{}, got interface{}) error { + return fmt.Errorf("expected %T, got %T", expected, got) +} diff --git a/precompiles/query/conn_test.go b/precompiles/query/conn_test.go new file mode 100644 index 0000000000..31b3a3005d --- /dev/null +++ b/precompiles/query/conn_test.go @@ -0,0 +1,108 @@ +package query_test + +import ( + "context" + "math/big" + "strings" + "testing" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + pquery "github.com/sei-protocol/sei-chain/precompiles/query" + grpctypes "github.com/sei-protocol/sei-chain/sei-cosmos/types/grpc" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +const echoABI = `[ + { + "name": "echo", + "type": "function", + "stateMutability": "view", + "inputs": [{"name": "value", "type": "uint256"}], + "outputs": [{"name": "response", "type": "uint256"}] + } +]` + +type echoReq struct { + Value *big.Int +} + +type echoResp struct { + Value *big.Int +} + +type fakeCaller struct { + t *testing.T + abi abi.ABI + to common.Address + from common.Address + block *big.Int + callCount int +} + +func (f *fakeCaller) CallContract(_ context.Context, msg ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) { + f.callCount++ + require.Equal(f.t, f.to, *msg.To) + require.Equal(f.t, f.from, msg.From) + require.Equal(f.t, f.block, blockNumber) + + method, err := f.abi.MethodById(msg.Data[:4]) + require.NoError(f.t, err) + require.Equal(f.t, "echo", method.Name) + args, err := method.Inputs.Unpack(msg.Data[4:]) + require.NoError(f.t, err) + require.Len(f.t, args, 1) + value := args[0].(*big.Int) + return method.Outputs.Pack(new(big.Int).Mul(value, big.NewInt(2))) +} + +func TestConnInvokeRoutesBindingThroughEthCall(t *testing.T) { + contractABI, err := abi.JSON(strings.NewReader(echoABI)) + require.NoError(t, err) + precompile := common.HexToAddress("0x0000000000000000000000000000000000000420") + from := common.HexToAddress("0x0000000000000000000000000000000000000042") + caller := &fakeCaller{ + t: t, + abi: contractABI, + to: precompile, + from: from, + block: big.NewInt(77), + } + + conn := pquery.NewConn( + caller, + pquery.NewRegistry(pquery.Bind(pquery.Binding[echoReq, echoResp]{ + FullMethod: "/test.Query/Echo", + Precompile: precompile, + ABI: contractABI, + ABIMethod: "echo", + Pack: func(_ context.Context, _ *pquery.Env, req *echoReq) ([]interface{}, error) { + return []interface{}{req.Value}, nil + }, + Unpack: func(_ context.Context, _ *pquery.Env, _ *echoReq, out []interface{}, resp *echoResp) error { + resp.Value = out[0].(*big.Int) + return nil + }, + ResponseShape: pquery.ExactProtobufShape, + })), + pquery.WithDefaultBlockNumber(55), + pquery.WithDefaultFrom(from), + ) + + ctx := metadata.AppendToOutgoingContext(context.Background(), grpctypes.GRPCBlockHeightHeader, "77") + resp := &echoResp{} + err = conn.Invoke(ctx, "/test.Query/Echo", &echoReq{Value: big.NewInt(21)}, resp) + require.NoError(t, err) + require.Equal(t, big.NewInt(42), resp.Value) + require.Equal(t, 1, caller.callCount) +} + +func TestConnInvokeRejectsUnsupportedBinding(t *testing.T) { + conn := pquery.NewConn(&fakeCaller{}, pquery.NewRegistry()) + err := conn.Invoke(context.Background(), "/test.Query/Missing", &echoReq{}, &echoResp{}) + require.Equal(t, codes.Unimplemented, status.Code(err)) +} diff --git a/precompiles/utils/expected_keepers.go b/precompiles/utils/expected_keepers.go index 905f5ec053..8e4694cbc1 100644 --- a/precompiles/utils/expected_keepers.go +++ b/precompiles/utils/expected_keepers.go @@ -70,7 +70,10 @@ type BankKeeper interface { GetAllBalances(ctx sdk.Context, addr sdk.AccAddress) sdk.Coins GetWeiBalance(ctx sdk.Context, addr sdk.AccAddress) sdk.Int GetDenomMetaData(ctx sdk.Context, denom string) (banktypes.Metadata, bool) + IterateAllDenomMetaData(ctx sdk.Context, cb func(banktypes.Metadata) bool) GetSupply(ctx sdk.Context, denom string) sdk.Coin + IterateTotalSupply(ctx sdk.Context, cb func(sdk.Coin) bool) + GetParams(ctx sdk.Context) banktypes.Params LockedCoins(ctx sdk.Context, addr sdk.AccAddress) sdk.Coins SpendableCoins(ctx sdk.Context, addr sdk.AccAddress) sdk.Coins } diff --git a/sei-cosmos/x/bank/client/cli/query.go b/sei-cosmos/x/bank/client/cli/query.go index 0d67ad9c5c..f4038c0fbb 100644 --- a/sei-cosmos/x/bank/client/cli/query.go +++ b/sei-cosmos/x/bank/client/cli/query.go @@ -4,8 +4,11 @@ import ( "fmt" "strings" + "github.com/ethereum/go-ethereum/ethclient" "github.com/spf13/cobra" + bankprecompilequery "github.com/sei-protocol/sei-chain/precompiles/bank/query" + precompilequery "github.com/sei-protocol/sei-chain/precompiles/query" "github.com/sei-protocol/sei-chain/sei-cosmos/client" "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" @@ -14,7 +17,9 @@ import ( ) const ( - FlagDenom = "denom" + FlagDenom = "denom" + FlagEVMRPC = "evm-rpc" + defaultEVMRPCURL = "http://0.0.0.0:8545" ) // GetQueryCmd returns the parent command for all x/bank CLi query commands. The @@ -63,7 +68,10 @@ Example: return err } - queryClient := types.NewQueryClient(clientCtx) + queryClient, err := NewPrecompileBackedQueryClient(cmd, clientCtx) + if err != nil { + return err + } addr, err := sdk.AccAddressFromBech32(args[0]) if err != nil { @@ -95,6 +103,7 @@ Example: } cmd.Flags().String(FlagDenom, "", "The specific balance denomination to query for") + addEVMRPCFlag(cmd) flags.AddQueryFlagsToCmd(cmd) flags.AddPaginationFlagsToCmd(cmd, "all balances") @@ -129,7 +138,10 @@ To query for the client metadata of a specific coin denomination use: return err } - queryClient := types.NewQueryClient(clientCtx) + queryClient, err := NewPrecompileBackedQueryClient(cmd, clientCtx) + if err != nil { + return err + } if denom == "" { res, err := queryClient.DenomsMetadata(cmd.Context(), &types.QueryDenomsMetadataRequest{}) @@ -150,6 +162,7 @@ To query for the client metadata of a specific coin denomination use: } cmd.Flags().String(FlagDenom, "", "The specific denomination to query client metadata for") + addEVMRPCFlag(cmd) flags.AddQueryFlagsToCmd(cmd) return cmd @@ -181,7 +194,10 @@ To query for the total supply of a specific coin denomination use: return err } - queryClient := types.NewQueryClient(clientCtx) + queryClient, err := NewPrecompileBackedQueryClient(cmd, clientCtx) + if err != nil { + return err + } ctx := cmd.Context() pageReq, err := client.ReadPageRequest(cmd.Flags()) @@ -207,8 +223,29 @@ To query for the total supply of a specific coin denomination use: } cmd.Flags().String(FlagDenom, "", "The specific balance denomination to query for") + addEVMRPCFlag(cmd) flags.AddQueryFlagsToCmd(cmd) flags.AddPaginationFlagsToCmd(cmd, "all supply totals") return cmd } + +func NewPrecompileBackedQueryClient(cmd *cobra.Command, clientCtx client.Context) (types.QueryClient, error) { + rpcURL, err := cmd.Flags().GetString(FlagEVMRPC) + if err != nil { + return nil, err + } + evmClient, err := ethclient.DialContext(cmd.Context(), rpcURL) + if err != nil { + return nil, err + } + return types.NewQueryClient(precompilequery.NewConn( + evmClient, + bankprecompilequery.Registry(), + precompilequery.WithDefaultBlockNumber(clientCtx.Height), + )), nil +} + +func addEVMRPCFlag(cmd *cobra.Command) { + cmd.Flags().String(FlagEVMRPC, defaultEVMRPCURL, "EVM RPC endpoint for precompile-backed bank queries") +} From f39e7b7f9c67e7d5964f0c1292b2ef8bd78f0e43 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 13 May 2026 17:14:38 +0800 Subject: [PATCH 2/7] Fix bank query precompile address handling --- precompiles/bank/Bank.sol | 13 +++ precompiles/bank/abi.json | 86 +++++++++++++++ precompiles/bank/bank.go | 138 +++++++++++++++++++----- precompiles/bank/bank_test.go | 11 ++ precompiles/bank/query/registry.go | 54 +++++----- precompiles/bank/query/registry_test.go | 46 ++++---- 6 files changed, 271 insertions(+), 77 deletions(-) diff --git a/precompiles/bank/Bank.sol b/precompiles/bank/Bank.sol index 1be3e89803..143b6448bf 100644 --- a/precompiles/bank/Bank.sol +++ b/precompiles/bank/Bank.sol @@ -26,6 +26,11 @@ interface IBank { string memory denom ) external view returns (uint256 amount); + function balance_for_address( + string memory acc, + string memory denom + ) external view returns (uint256 amount); + struct Coin { uint256 amount; string denom; @@ -35,10 +40,18 @@ interface IBank { address acc ) external view returns (Coin[] memory response); + function all_balances_for_address( + string memory acc + ) external view returns (Coin[] memory response); + function spendable_balances( address acc ) external view returns (Coin[] memory response); + function spendable_balances_for_address( + string memory acc + ) external view returns (Coin[] memory response); + function total_supply() external view returns (Coin[] memory response); diff --git a/precompiles/bank/abi.json b/precompiles/bank/abi.json index 687425aaa6..e2d9ddf31f 100644 --- a/precompiles/bank/abi.json +++ b/precompiles/bank/abi.json @@ -30,6 +30,68 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "string", + "name": "acc", + "type": "string" + } + ], + "name": "spendable_balances_for_address", + "outputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "string", + "name": "denom", + "type": "string" + } + ], + "internalType": "struct IBank.Coin[]", + "name": "response", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "string", + "name": "acc", + "type": "string" + } + ], + "name": "all_balances_for_address", + "outputs": [ + { + "components": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + }, + { + "internalType": "string", + "name": "denom", + "type": "string" + } + ], + "internalType": "struct IBank.Coin[]", + "name": "response", + "type": "tuple[]" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { @@ -54,6 +116,30 @@ "stateMutability": "view", "type": "function" }, + { + "inputs": [ + { + "internalType": "string", + "name": "acc", + "type": "string" + }, + { + "internalType": "string", + "name": "denom", + "type": "string" + } + ], + "name": "balance_for_address", + "outputs": [ + { + "internalType": "uint256", + "name": "amount", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, { "inputs": [ { diff --git a/precompiles/bank/bank.go b/precompiles/bank/bank.go index b076c38e32..5428735062 100644 --- a/precompiles/bank/bank.go +++ b/precompiles/bank/bank.go @@ -19,19 +19,22 @@ import ( ) const ( - SendMethod = "send" - SendNativeMethod = "sendNative" - BalanceMethod = "balance" - AllBalancesMethod = "all_balances" - SpendableBalancesMethod = "spendable_balances" - TotalSupplyMethod = "total_supply" - NameMethod = "name" - SymbolMethod = "symbol" - DecimalsMethod = "decimals" - SupplyMethod = "supply" - DenomMetadataMethod = "denom_metadata" - DenomsMetadataMethod = "denoms_metadata" - ParamsMethod = "params" + SendMethod = "send" + SendNativeMethod = "sendNative" + BalanceMethod = "balance" + BalanceForAddressMethod = "balance_for_address" + AllBalancesMethod = "all_balances" + AllBalancesForAddressMethod = "all_balances_for_address" + SpendableBalancesMethod = "spendable_balances" + SpendableBalancesForAddressMethod = "spendable_balances_for_address" + TotalSupplyMethod = "total_supply" + NameMethod = "name" + SymbolMethod = "symbol" + DecimalsMethod = "decimals" + SupplyMethod = "supply" + DenomMetadataMethod = "denom_metadata" + DenomsMetadataMethod = "denoms_metadata" + ParamsMethod = "params" ) const ( @@ -50,19 +53,22 @@ type PrecompileExecutor struct { evmKeeper putils.EVMKeeper address common.Address - SendID []byte - SendNativeID []byte - BalanceID []byte - AllBalancesID []byte - SpendableBalancesID []byte - TotalSupplyID []byte - NameID []byte - SymbolID []byte - DecimalsID []byte - SupplyID []byte - DenomMetadataID []byte - DenomsMetadataID []byte - ParamsID []byte + SendID []byte + SendNativeID []byte + BalanceID []byte + BalanceForAddressID []byte + AllBalancesID []byte + AllBalancesForAddressID []byte + SpendableBalancesID []byte + SpendableBalancesForAddressID []byte + TotalSupplyID []byte + NameID []byte + SymbolID []byte + DecimalsID []byte + SupplyID []byte + DenomMetadataID []byte + DenomsMetadataID []byte + ParamsID []byte } type CoinBalance struct { @@ -117,10 +123,16 @@ func NewPrecompile(keepers putils.Keepers) (*pcommon.DynamicGasPrecompile, error p.SendNativeID = m.ID case BalanceMethod: p.BalanceID = m.ID + case BalanceForAddressMethod: + p.BalanceForAddressID = m.ID case AllBalancesMethod: p.AllBalancesID = m.ID + case AllBalancesForAddressMethod: + p.AllBalancesForAddressID = m.ID case SpendableBalancesMethod: p.SpendableBalancesID = m.ID + case SpendableBalancesForAddressMethod: + p.SpendableBalancesForAddressID = m.ID case TotalSupplyMethod: p.TotalSupplyID = m.ID case NameMethod: @@ -162,10 +174,16 @@ func (p PrecompileExecutor) Execute(ctx sdk.Context, method *abi.Method, caller return p.sendNative(ctx, method, args, caller, callingContract, value, readOnly, hooks, evm) case BalanceMethod: return p.balance(ctx, method, args, value) + case BalanceForAddressMethod: + return p.balanceForAddress(ctx, method, args, value) case AllBalancesMethod: return p.all_balances(ctx, method, args, value) + case AllBalancesForAddressMethod: + return p.allBalancesForAddress(ctx, method, args, value) case SpendableBalancesMethod: return p.spendableBalances(ctx, method, args, value) + case SpendableBalancesForAddressMethod: + return p.spendableBalancesForAddress(ctx, method, args, value) case TotalSupplyMethod: return p.totalSupply(ctx, method, args, value) case NameMethod: @@ -321,6 +339,28 @@ func (p PrecompileExecutor) balance(ctx sdk.Context, method *abi.Method, args [] return bz, pcommon.GetRemainingGas(ctx, p.evmKeeper), err } +func (p PrecompileExecutor) balanceForAddress(ctx sdk.Context, method *abi.Method, args []interface{}, value *big.Int) ([]byte, uint64, error) { + if err := pcommon.ValidateNonPayable(value); err != nil { + return nil, 0, err + } + + if err := pcommon.ValidateArgsLength(args, 2); err != nil { + return nil, 0, err + } + + addr, err := accAddressFromStringArg(args[0]) + if err != nil { + return nil, 0, err + } + denom := args[1].(string) + if denom == "" { + return nil, 0, errors.New("invalid denom") + } + + bz, err := method.Outputs.Pack(p.bankKeeper.GetBalance(ctx, addr, denom).Amount.BigInt()) + return bz, pcommon.GetRemainingGas(ctx, p.evmKeeper), err +} + func (p PrecompileExecutor) all_balances(ctx sdk.Context, method *abi.Method, args []interface{}, value *big.Int) ([]byte, uint64, error) { if err := pcommon.ValidateNonPayable(value); err != nil { return nil, 0, err @@ -341,6 +381,26 @@ func (p PrecompileExecutor) all_balances(ctx sdk.Context, method *abi.Method, ar return bz, pcommon.GetRemainingGas(ctx, p.evmKeeper), err } +func (p PrecompileExecutor) allBalancesForAddress(ctx sdk.Context, method *abi.Method, args []interface{}, value *big.Int) ([]byte, uint64, error) { + if err := pcommon.ValidateNonPayable(value); err != nil { + return nil, 0, err + } + + if err := pcommon.ValidateArgsLength(args, 1); err != nil { + return nil, 0, err + } + + addr, err := accAddressFromStringArg(args[0]) + if err != nil { + return nil, 0, err + } + + coinBalances := coinsToCoinBalances(p.bankKeeper.GetAllBalances(ctx, addr)) + + bz, err := method.Outputs.Pack(coinBalances) + return bz, pcommon.GetRemainingGas(ctx, p.evmKeeper), err +} + func (p PrecompileExecutor) spendableBalances(ctx sdk.Context, method *abi.Method, args []interface{}, value *big.Int) ([]byte, uint64, error) { if err := pcommon.ValidateNonPayable(value); err != nil { return nil, 0, err @@ -359,6 +419,24 @@ func (p PrecompileExecutor) spendableBalances(ctx sdk.Context, method *abi.Metho return bz, pcommon.GetRemainingGas(ctx, p.evmKeeper), err } +func (p PrecompileExecutor) spendableBalancesForAddress(ctx sdk.Context, method *abi.Method, args []interface{}, value *big.Int) ([]byte, uint64, error) { + if err := pcommon.ValidateNonPayable(value); err != nil { + return nil, 0, err + } + + if err := pcommon.ValidateArgsLength(args, 1); err != nil { + return nil, 0, err + } + + addr, err := accAddressFromStringArg(args[0]) + if err != nil { + return nil, 0, err + } + + bz, err := method.Outputs.Pack(coinsToCoinBalances(p.bankKeeper.SpendableCoins(ctx, addr))) + return bz, pcommon.GetRemainingGas(ctx, p.evmKeeper), err +} + func (p PrecompileExecutor) totalSupply(ctx sdk.Context, method *abi.Method, args []interface{}, value *big.Int) ([]byte, uint64, error) { if err := pcommon.ValidateNonPayable(value); err != nil { return nil, 0, err @@ -501,6 +579,14 @@ func (p PrecompileExecutor) accAddressFromArg(ctx sdk.Context, arg interface{}) return seiAddr, nil } +func accAddressFromStringArg(arg interface{}) (sdk.AccAddress, error) { + addr, ok := arg.(string) + if !ok || addr == "" { + return nil, errors.New("invalid addr") + } + return sdk.AccAddressFromBech32(addr) +} + func coinsToCoinBalances(coins sdk.Coins) []CoinBalance { coinBalances := make([]CoinBalance, 0, len(coins)) for _, coin := range coins { diff --git a/precompiles/bank/bank_test.go b/precompiles/bank/bank_test.go index b52413f1e2..9af26d5485 100644 --- a/precompiles/bank/bank_test.go +++ b/precompiles/bank/bank_test.go @@ -432,6 +432,17 @@ func TestAdditionalQueryMethods(t *testing.T) { requireCoinBalance(t, unpackCoinBalances(t, spendable), "uquerya", big.NewInt(19)) requireCoinBalance(t, unpackCoinBalances(t, spendable), "uqueryb", big.NewInt(23)) + balanceForAddress := callBankView(t, p, &evm, executor.BalanceForAddressID, seiAddr.String(), "uquerya") + require.Equal(t, big.NewInt(19), balanceForAddress[0]) + + allBalancesForAddress := callBankView(t, p, &evm, executor.AllBalancesForAddressID, seiAddr.String()) + requireCoinBalance(t, unpackCoinBalances(t, allBalancesForAddress), "uquerya", big.NewInt(19)) + requireCoinBalance(t, unpackCoinBalances(t, allBalancesForAddress), "uqueryb", big.NewInt(23)) + + spendableForAddress := callBankView(t, p, &evm, executor.SpendableBalancesForAddressID, seiAddr.String()) + requireCoinBalance(t, unpackCoinBalances(t, spendableForAddress), "uquerya", big.NewInt(19)) + requireCoinBalance(t, unpackCoinBalances(t, spendableForAddress), "uqueryb", big.NewInt(23)) + totalSupply := callBankView(t, p, &evm, executor.TotalSupplyID) requireCoinBalance(t, unpackCoinBalances(t, totalSupply), "uquerya", big.NewInt(19)) requireCoinBalance(t, unpackCoinBalances(t, totalSupply), "uqueryb", big.NewInt(23)) diff --git a/precompiles/bank/query/registry.go b/precompiles/bank/query/registry.go index 7216b48723..dabe2425f6 100644 --- a/precompiles/bank/query/registry.go +++ b/precompiles/bank/query/registry.go @@ -39,7 +39,7 @@ func Registry() pquery.Registry { FullMethod: balanceMethod, Precompile: address, ABI: abi, - ABIMethod: bank.BalanceMethod, + ABIMethod: bank.BalanceForAddressMethod, Pack: packBalance, Unpack: unpackBalance, ResponseShape: pquery.ExactProtobufShape, @@ -48,7 +48,7 @@ func Registry() pquery.Registry { FullMethod: allBalancesMethod, Precompile: address, ABI: abi, - ABIMethod: bank.AllBalancesMethod, + ABIMethod: bank.AllBalancesForAddressMethod, Pack: packAllBalances, Unpack: unpackAllBalances, ResponseShape: pquery.ExactProtobufShape, @@ -57,7 +57,7 @@ func Registry() pquery.Registry { FullMethod: spendableBalancesMethod, Precompile: address, ABI: abi, - ABIMethod: bank.SpendableBalancesMethod, + ABIMethod: bank.SpendableBalancesForAddressMethod, Pack: packSpendableBalances, Unpack: unpackSpendableBalances, ResponseShape: pquery.ExactProtobufShape, @@ -110,18 +110,17 @@ func Registry() pquery.Registry { ) } -func packBalance(ctx context.Context, env *pquery.Env, req *banktypes.QueryBalanceRequest) ([]interface{}, error) { +func packBalance(_ context.Context, _ *pquery.Env, req *banktypes.QueryBalanceRequest) ([]interface{}, error) { if req.Address == "" { return nil, status.Error(codes.InvalidArgument, "address cannot be empty") } if req.Denom == "" { return nil, status.Error(codes.InvalidArgument, "invalid denom") } - evmAddr, err := env.EVMAddressForSeiAddress(ctx, req.Address, pquery.AllowCastAddress) - if err != nil { - return nil, err + if _, err := sdk.AccAddressFromBech32(req.Address); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) } - return []interface{}{evmAddr, req.Denom}, nil + return []interface{}{req.Address, req.Denom}, nil } func unpackBalance(_ context.Context, _ *pquery.Env, req *banktypes.QueryBalanceRequest, out []interface{}, resp *banktypes.QueryBalanceResponse) error { @@ -134,15 +133,14 @@ func unpackBalance(_ context.Context, _ *pquery.Env, req *banktypes.QueryBalance return nil } -func packAllBalances(ctx context.Context, env *pquery.Env, req *banktypes.QueryAllBalancesRequest) ([]interface{}, error) { +func packAllBalances(_ context.Context, _ *pquery.Env, req *banktypes.QueryAllBalancesRequest) ([]interface{}, error) { if req.Address == "" { return nil, status.Error(codes.InvalidArgument, "address cannot be empty") } - evmAddr, err := env.EVMAddressForSeiAddress(ctx, req.Address, pquery.AllowCastAddress) - if err != nil { - return nil, err + if _, err := sdk.AccAddressFromBech32(req.Address); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) } - return []interface{}{evmAddr}, nil + return []interface{}{req.Address}, nil } func unpackAllBalances(_ context.Context, _ *pquery.Env, req *banktypes.QueryAllBalancesRequest, out []interface{}, resp *banktypes.QueryAllBalancesResponse) error { @@ -159,15 +157,14 @@ func unpackAllBalances(_ context.Context, _ *pquery.Env, req *banktypes.QueryAll return nil } -func packSpendableBalances(ctx context.Context, env *pquery.Env, req *banktypes.QuerySpendableBalancesRequest) ([]interface{}, error) { +func packSpendableBalances(_ context.Context, _ *pquery.Env, req *banktypes.QuerySpendableBalancesRequest) ([]interface{}, error) { if req.Address == "" { return nil, status.Error(codes.InvalidArgument, "address cannot be empty") } - evmAddr, err := env.EVMAddressForSeiAddress(ctx, req.Address, pquery.AllowCastAddress) - if err != nil { - return nil, err + if _, err := sdk.AccAddressFromBech32(req.Address); err != nil { + return nil, status.Error(codes.InvalidArgument, err.Error()) } - return []interface{}{evmAddr}, nil + return []interface{}{req.Address}, nil } func unpackSpendableBalances(_ context.Context, _ *pquery.Env, req *banktypes.QuerySpendableBalancesRequest, out []interface{}, resp *banktypes.QuerySpendableBalancesResponse) error { @@ -439,7 +436,7 @@ func fieldUint32(value reflect.Value, name string) (uint32, error) { if field.Uint() > math.MaxUint32 { return 0, fmt.Errorf("field %s overflows uint32", name) } - return uint32(field.Uint()), nil + return uint32(field.Uint()), nil //nolint:gosec // bounded by MaxUint32 check above } func fieldBigInt(value reflect.Value, name string) (*big.Int, error) { @@ -497,6 +494,7 @@ func paginate[T any](items []T, req *sdkquery.PageRequest, keyFn func(T) []byte) if req.Reverse { slices.Reverse(ordered) } + orderedLen := uint64(len(ordered)) //nolint:gosec // len is non-negative and used only as an in-memory page bound limit := req.Limit countTotal := req.CountTotal @@ -508,31 +506,31 @@ func paginate[T any](items []T, req *sdkquery.PageRequest, keyFn func(T) []byte) start := req.Offset keyPagination := len(req.Key) > 0 if keyPagination { - start = uint64(len(ordered)) + start = orderedLen for i, item := range ordered { if bytes.Equal(keyFn(item), req.Key) { - start = uint64(i) + start = uint64(i) //nolint:gosec // i is bounded by len(ordered) break } } } - if start > uint64(len(ordered)) { - start = uint64(len(ordered)) + if start > orderedLen { + start = orderedLen } end := start + limit - if end < start || end > uint64(len(ordered)) { - end = uint64(len(ordered)) + if end < start || end > orderedLen { + end = orderedLen } var nextKey []byte - if end < uint64(len(ordered)) { + if end < orderedLen { nextKey = keyFn(ordered[end]) } pageRes := &sdkquery.PageResponse{NextKey: nextKey} if countTotal && !keyPagination { - pageRes.Total = uint64(len(ordered)) + pageRes.Total = orderedLen } - return ordered[int(start):int(end)], pageRes, nil + return ordered[int(start):int(end)], pageRes, nil //nolint:gosec // start and end are bounded by len(ordered) } diff --git a/precompiles/bank/query/registry_test.go b/precompiles/bank/query/registry_test.go index 92a29af6ac..0bd67961a8 100644 --- a/precompiles/bank/query/registry_test.go +++ b/precompiles/bank/query/registry_test.go @@ -8,7 +8,6 @@ import ( "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" - addrprecompile "github.com/sei-protocol/sei-chain/precompiles/addr" "github.com/sei-protocol/sei-chain/precompiles/bank" pquery "github.com/sei-protocol/sei-chain/precompiles/query" sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" @@ -18,26 +17,15 @@ import ( ) type fakeEVMCaller struct { - t *testing.T - wantBlock *big.Int - wantAddress common.Address + t *testing.T + wantBlock *big.Int + wantSeiAddress string } func (f fakeEVMCaller) CallContract(_ context.Context, msg ethereum.CallMsg, blockNumber *big.Int) ([]byte, error) { require.NotNil(f.t, msg.To) require.Equal(f.t, f.wantBlock, blockNumber) - if *msg.To == common.HexToAddress(addrprecompile.AddrAddress) { - contractABI := addrprecompile.GetABI() - method, err := contractABI.MethodById(msg.Data[:4]) - require.NoError(f.t, err) - require.Equal(f.t, addrprecompile.GetEvmAddressMethod, method.Name) - args, err := method.Inputs.Unpack(msg.Data[4:]) - require.NoError(f.t, err) - require.Len(f.t, args, 1) - return method.Outputs.Pack(f.wantAddress) - } - require.Equal(f.t, common.HexToAddress(bank.BankAddress), *msg.To) contractABI := bank.GetABI() method, err := contractABI.MethodById(msg.Data[:4]) @@ -46,11 +34,17 @@ func (f fakeEVMCaller) CallContract(_ context.Context, msg ethereum.CallMsg, blo require.NoError(f.t, err) switch method.Name { - case bank.BalanceMethod: - require.Equal(f.t, []interface{}{f.wantAddress, "usei"}, args) + case bank.BalanceForAddressMethod: + require.Equal(f.t, []interface{}{f.wantSeiAddress, "usei"}, args) return method.Outputs.Pack(big.NewInt(123)) - case bank.AllBalancesMethod: - require.Equal(f.t, []interface{}{f.wantAddress}, args) + case bank.AllBalancesForAddressMethod: + require.Equal(f.t, []interface{}{f.wantSeiAddress}, args) + return method.Outputs.Pack([]bank.CoinBalance{ + {Amount: big.NewInt(7), Denom: "uatom"}, + {Amount: big.NewInt(11), Denom: "usei"}, + }) + case bank.SpendableBalancesForAddressMethod: + require.Equal(f.t, []interface{}{f.wantSeiAddress}, args) return method.Outputs.Pack([]bank.CoinBalance{ {Amount: big.NewInt(7), Denom: "uatom"}, {Amount: big.NewInt(11), Denom: "usei"}, @@ -81,11 +75,10 @@ func (f fakeEVMCaller) CallContract(_ context.Context, msg ethereum.CallMsg, blo func TestGeneratedQueryClientUsesBankPrecompileBindings(t *testing.T) { seiAddr := sdk.AccAddress(bytes.Repeat([]byte{1}, 20)) - evmAddr := common.BytesToAddress(seiAddr) caller := fakeEVMCaller{ - t: t, - wantBlock: big.NewInt(99), - wantAddress: evmAddr, + t: t, + wantBlock: big.NewInt(99), + wantSeiAddress: seiAddr.String(), } client := banktypes.NewQueryClient(pquery.NewConn( caller, @@ -109,6 +102,13 @@ func TestGeneratedQueryClientUsesBankPrecompileBindings(t *testing.T) { require.Equal(t, []byte("usei"), allBalances.Pagination.NextKey) require.Equal(t, uint64(2), allBalances.Pagination.Total) + spendableBalances, err := client.SpendableBalances(context.Background(), &banktypes.QuerySpendableBalancesRequest{ + Address: seiAddr.String(), + Pagination: &sdkquery.PageRequest{Offset: 1}, + }) + require.NoError(t, err) + require.Equal(t, sdk.NewCoins(sdk.NewCoin("usei", sdk.NewInt(11))), spendableBalances.Balances) + metadata, err := client.DenomMetadata(context.Background(), &banktypes.QueryDenomMetadataRequest{Denom: "usei"}) require.NoError(t, err) require.Equal(t, banktypes.Metadata{ From 4f394bd18e4c33226f9384e0ec72c2f6bc5c8906 Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 13 May 2026 18:33:45 +0800 Subject: [PATCH 3/7] Resolve bank query client review comments --- precompiles/query/binding.go | 6 ------ sei-cosmos/x/bank/client/cli/query.go | 19 +++++++++++-------- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/precompiles/query/binding.go b/precompiles/query/binding.go index 25e8da122e..417c16a2a1 100644 --- a/precompiles/query/binding.go +++ b/precompiles/query/binding.go @@ -2,7 +2,6 @@ package query import ( "context" - "math/big" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" @@ -101,8 +100,3 @@ func (b Binding[Req, Resp]) Invoke(ctx context.Context, env *Env, req, reply int } return b.Unpack(ctx, env, typedReq, values, typedReply) } - -func BigInt(value interface{}) (*big.Int, bool) { - v, ok := value.(*big.Int) - return v, ok -} diff --git a/sei-cosmos/x/bank/client/cli/query.go b/sei-cosmos/x/bank/client/cli/query.go index f4038c0fbb..02f7688bac 100644 --- a/sei-cosmos/x/bank/client/cli/query.go +++ b/sei-cosmos/x/bank/client/cli/query.go @@ -19,7 +19,7 @@ import ( const ( FlagDenom = "denom" FlagEVMRPC = "evm-rpc" - defaultEVMRPCURL = "http://0.0.0.0:8545" + defaultEVMRPCURL = "http://localhost:8545" ) // GetQueryCmd returns the parent command for all x/bank CLi query commands. The @@ -68,10 +68,11 @@ Example: return err } - queryClient, err := NewPrecompileBackedQueryClient(cmd, clientCtx) + queryClient, closeQueryClient, err := NewPrecompileBackedQueryClient(cmd, clientCtx) if err != nil { return err } + defer closeQueryClient() addr, err := sdk.AccAddressFromBech32(args[0]) if err != nil { @@ -138,10 +139,11 @@ To query for the client metadata of a specific coin denomination use: return err } - queryClient, err := NewPrecompileBackedQueryClient(cmd, clientCtx) + queryClient, closeQueryClient, err := NewPrecompileBackedQueryClient(cmd, clientCtx) if err != nil { return err } + defer closeQueryClient() if denom == "" { res, err := queryClient.DenomsMetadata(cmd.Context(), &types.QueryDenomsMetadataRequest{}) @@ -194,10 +196,11 @@ To query for the total supply of a specific coin denomination use: return err } - queryClient, err := NewPrecompileBackedQueryClient(cmd, clientCtx) + queryClient, closeQueryClient, err := NewPrecompileBackedQueryClient(cmd, clientCtx) if err != nil { return err } + defer closeQueryClient() ctx := cmd.Context() pageReq, err := client.ReadPageRequest(cmd.Flags()) @@ -230,20 +233,20 @@ To query for the total supply of a specific coin denomination use: return cmd } -func NewPrecompileBackedQueryClient(cmd *cobra.Command, clientCtx client.Context) (types.QueryClient, error) { +func NewPrecompileBackedQueryClient(cmd *cobra.Command, clientCtx client.Context) (types.QueryClient, func(), error) { rpcURL, err := cmd.Flags().GetString(FlagEVMRPC) if err != nil { - return nil, err + return nil, nil, err } evmClient, err := ethclient.DialContext(cmd.Context(), rpcURL) if err != nil { - return nil, err + return nil, nil, err } return types.NewQueryClient(precompilequery.NewConn( evmClient, bankprecompilequery.Registry(), precompilequery.WithDefaultBlockNumber(clientCtx.Height), - )), nil + )), evmClient.Close, nil } func addEVMRPCFlag(cmd *cobra.Command) { From 4688aa03ca8a1f8a32baf8d3d23cb8b65fd6575b Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Wed, 13 May 2026 20:21:24 +0800 Subject: [PATCH 4/7] Optimize bank precompile total supply accumulation --- precompiles/bank/bank.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/precompiles/bank/bank.go b/precompiles/bank/bank.go index 5428735062..b4e53fdb5c 100644 --- a/precompiles/bank/bank.go +++ b/precompiles/bank/bank.go @@ -446,9 +446,11 @@ func (p PrecompileExecutor) totalSupply(ctx sdk.Context, method *abi.Method, arg return nil, 0, err } - coins := sdk.NewCoins() + coins := sdk.Coins{} p.bankKeeper.IterateTotalSupply(ctx, func(coin sdk.Coin) bool { - coins = coins.Add(coin) + if coin.IsPositive() { + coins = append(coins, coin) + } return false }) From b9825691c000f7677e17552fa2e9d44b4bd1177c Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Mon, 18 May 2026 11:25:05 +0800 Subject: [PATCH 5/7] Move reusable query registry helpers --- precompiles/bank/query/registry.go | 222 ++++------------------------- precompiles/query/helpers.go | 193 +++++++++++++++++++++++++ precompiles/query/helpers_test.go | 74 ++++++++++ 3 files changed, 292 insertions(+), 197 deletions(-) create mode 100644 precompiles/query/helpers.go create mode 100644 precompiles/query/helpers_test.go diff --git a/precompiles/bank/query/registry.go b/precompiles/bank/query/registry.go index dabe2425f6..0dcf687f35 100644 --- a/precompiles/bank/query/registry.go +++ b/precompiles/bank/query/registry.go @@ -1,14 +1,10 @@ package bankquery import ( - "bytes" "context" "errors" "fmt" - "math" - "math/big" "reflect" - "slices" "github.com/ethereum/go-ethereum/common" "github.com/sei-protocol/sei-chain/precompiles/bank" @@ -67,7 +63,7 @@ func Registry() pquery.Registry { Precompile: address, ABI: abi, ABIMethod: bank.TotalSupplyMethod, - Pack: packNoArgs[banktypes.QueryTotalSupplyRequest], + Pack: pquery.PackNoArgs[banktypes.QueryTotalSupplyRequest], Unpack: unpackTotalSupply, ResponseShape: pquery.ExactProtobufShape, }), @@ -85,7 +81,7 @@ func Registry() pquery.Registry { Precompile: address, ABI: abi, ABIMethod: bank.ParamsMethod, - Pack: packNoArgs[banktypes.QueryParamsRequest], + Pack: pquery.PackNoArgs[banktypes.QueryParamsRequest], Unpack: unpackParams, ResponseShape: pquery.ExactProtobufShape, }), @@ -103,7 +99,7 @@ func Registry() pquery.Registry { Precompile: address, ABI: abi, ABIMethod: bank.DenomsMetadataMethod, - Pack: packNoArgs[banktypes.QueryDenomsMetadataRequest], + Pack: pquery.PackNoArgs[banktypes.QueryDenomsMetadataRequest], Unpack: unpackDenomsMetadata, ResponseShape: pquery.ExactProtobufShape, }), @@ -124,7 +120,7 @@ func packBalance(_ context.Context, _ *pquery.Env, req *banktypes.QueryBalanceRe } func unpackBalance(_ context.Context, _ *pquery.Env, req *banktypes.QueryBalanceRequest, out []interface{}, resp *banktypes.QueryBalanceResponse) error { - amount, err := singleBigInt(out) + amount, err := pquery.SingleBigInt(out) if err != nil { return err } @@ -144,11 +140,11 @@ func packAllBalances(_ context.Context, _ *pquery.Env, req *banktypes.QueryAllBa } func unpackAllBalances(_ context.Context, _ *pquery.Env, req *banktypes.QueryAllBalancesRequest, out []interface{}, resp *banktypes.QueryAllBalancesResponse) error { - coins, err := coinsFromOutput(out) + coins, err := pquery.CoinsFromOutput(out) if err != nil { return err } - paged, pageRes, err := paginateCoins(coins, req.Pagination) + paged, pageRes, err := pquery.PaginateCoins(coins, req.Pagination) if err != nil { return err } @@ -168,11 +164,11 @@ func packSpendableBalances(_ context.Context, _ *pquery.Env, req *banktypes.Quer } func unpackSpendableBalances(_ context.Context, _ *pquery.Env, req *banktypes.QuerySpendableBalancesRequest, out []interface{}, resp *banktypes.QuerySpendableBalancesResponse) error { - coins, err := coinsFromOutput(out) + coins, err := pquery.CoinsFromOutput(out) if err != nil { return err } - paged, pageRes, err := paginateCoins(coins, req.Pagination) + paged, pageRes, err := pquery.PaginateCoins(coins, req.Pagination) if err != nil { return err } @@ -189,7 +185,7 @@ func packSupplyOf(_ context.Context, _ *pquery.Env, req *banktypes.QuerySupplyOf } func unpackSupplyOf(_ context.Context, _ *pquery.Env, req *banktypes.QuerySupplyOfRequest, out []interface{}, resp *banktypes.QuerySupplyOfResponse) error { - amount, err := singleBigInt(out) + amount, err := pquery.SingleBigInt(out) if err != nil { return err } @@ -198,11 +194,11 @@ func unpackSupplyOf(_ context.Context, _ *pquery.Env, req *banktypes.QuerySupply } func unpackTotalSupply(_ context.Context, _ *pquery.Env, req *banktypes.QueryTotalSupplyRequest, out []interface{}, resp *banktypes.QueryTotalSupplyResponse) error { - coins, err := coinsFromOutput(out) + coins, err := pquery.CoinsFromOutput(out) if err != nil { return err } - paged, pageRes, err := paginateCoins(coins, req.Pagination) + paged, pageRes, err := pquery.PaginateCoins(coins, req.Pagination) if err != nil { return err } @@ -259,44 +255,6 @@ func unpackDenomsMetadata(_ context.Context, _ *pquery.Env, req *banktypes.Query return nil } -func packNoArgs[Req any](_ context.Context, _ *pquery.Env, _ *Req) ([]interface{}, error) { - return nil, nil -} - -func singleBigInt(out []interface{}) (*big.Int, error) { - if len(out) != 1 { - return nil, fmt.Errorf("expected 1 output but got %d", len(out)) - } - amount, ok := out[0].(*big.Int) - if !ok { - return nil, fmt.Errorf("expected *big.Int output but got %T", out[0]) - } - return amount, nil -} - -func coinsFromOutput(out []interface{}) (sdk.Coins, error) { - if len(out) != 1 { - return nil, fmt.Errorf("expected 1 coin output but got %d", len(out)) - } - value := reflect.ValueOf(out[0]) - if value.Kind() != reflect.Slice { - return nil, fmt.Errorf("expected coin slice but got %T", out[0]) - } - coins := make(sdk.Coins, 0, value.Len()) - for i := 0; i < value.Len(); i++ { - amount, err := fieldBigInt(value.Index(i), "Amount") - if err != nil { - return nil, err - } - denom, err := fieldString(value.Index(i), "Denom") - if err != nil { - return nil, err - } - coins = append(coins, sdk.NewCoin(denom, sdk.NewIntFromBigInt(amount))) - } - return sdk.NewCoins(coins...), nil -} - func metadatasFromValue(value reflect.Value) ([]banktypes.Metadata, error) { if value.Kind() != reflect.Slice { return nil, fmt.Errorf("expected metadata slice but got %s", value.Kind()) @@ -313,27 +271,27 @@ func metadatasFromValue(value reflect.Value) ([]banktypes.Metadata, error) { } func metadataFromValue(value reflect.Value) (banktypes.Metadata, error) { - description, err := fieldString(value, "Description") + description, err := pquery.FieldString(value, "Description") if err != nil { return banktypes.Metadata{}, err } - base, err := fieldString(value, "Base") + base, err := pquery.FieldString(value, "Base") if err != nil { return banktypes.Metadata{}, err } - display, err := fieldString(value, "Display") + display, err := pquery.FieldString(value, "Display") if err != nil { return banktypes.Metadata{}, err } - name, err := fieldString(value, "Name") + name, err := pquery.FieldString(value, "Name") if err != nil { return banktypes.Metadata{}, err } - symbol, err := fieldString(value, "Symbol") + symbol, err := pquery.FieldString(value, "Symbol") if err != nil { return banktypes.Metadata{}, err } - denomUnitsValue := field(value, "DenomUnits") + denomUnitsValue := pquery.Field(value, "DenomUnits") if !denomUnitsValue.IsValid() { return banktypes.Metadata{}, errors.New("metadata missing DenomUnits") } @@ -343,15 +301,15 @@ func metadataFromValue(value reflect.Value) (banktypes.Metadata, error) { denomUnits := make([]*banktypes.DenomUnit, 0, denomUnitsValue.Len()) for i := 0; i < denomUnitsValue.Len(); i++ { unitValue := denomUnitsValue.Index(i) - denom, err := fieldString(unitValue, "Denom") + denom, err := pquery.FieldString(unitValue, "Denom") if err != nil { return banktypes.Metadata{}, err } - exponent, err := fieldUint32(unitValue, "Exponent") + exponent, err := pquery.FieldUint32(unitValue, "Exponent") if err != nil { return banktypes.Metadata{}, err } - aliases, err := fieldStringSlice(unitValue, "Aliases") + aliases, err := pquery.FieldStringSlice(unitValue, "Aliases") if err != nil { return banktypes.Metadata{}, err } @@ -372,11 +330,11 @@ func metadataFromValue(value reflect.Value) (banktypes.Metadata, error) { } func paramsFromValue(value reflect.Value) (banktypes.Params, error) { - defaultSendEnabled, err := fieldBool(value, "DefaultSendEnabled") + defaultSendEnabled, err := pquery.FieldBool(value, "DefaultSendEnabled") if err != nil { return banktypes.Params{}, err } - sendEnabledValue := field(value, "SendEnabled") + sendEnabledValue := pquery.Field(value, "SendEnabled") if !sendEnabledValue.IsValid() { return banktypes.Params{}, errors.New("params missing SendEnabled") } @@ -386,11 +344,11 @@ func paramsFromValue(value reflect.Value) (banktypes.Params, error) { sendEnabled := make([]*banktypes.SendEnabled, 0, sendEnabledValue.Len()) for i := 0; i < sendEnabledValue.Len(); i++ { item := sendEnabledValue.Index(i) - denom, err := fieldString(item, "Denom") + denom, err := pquery.FieldString(item, "Denom") if err != nil { return banktypes.Params{}, err } - enabled, err := fieldBool(item, "Enabled") + enabled, err := pquery.FieldBool(item, "Enabled") if err != nil { return banktypes.Params{}, err } @@ -399,138 +357,8 @@ func paramsFromValue(value reflect.Value) (banktypes.Params, error) { return banktypes.Params{SendEnabled: sendEnabled, DefaultSendEnabled: defaultSendEnabled}, nil } -func field(value reflect.Value, name string) reflect.Value { - if value.Kind() == reflect.Pointer { - value = value.Elem() - } - if value.Kind() != reflect.Struct { - return reflect.Value{} - } - return value.FieldByName(name) -} - -func fieldString(value reflect.Value, name string) (string, error) { - field := field(value, name) - if !field.IsValid() || field.Kind() != reflect.String { - return "", fmt.Errorf("expected string field %s", name) - } - return field.String(), nil -} - -func fieldBool(value reflect.Value, name string) (bool, error) { - field := field(value, name) - if !field.IsValid() || field.Kind() != reflect.Bool { - return false, fmt.Errorf("expected bool field %s", name) - } - return field.Bool(), nil -} - -func fieldUint32(value reflect.Value, name string) (uint32, error) { - field := field(value, name) - if !field.IsValid() { - return 0, fmt.Errorf("expected uint32 field %s", name) - } - if field.Kind() < reflect.Uint || field.Kind() > reflect.Uint64 { - return 0, fmt.Errorf("expected uint field %s", name) - } - if field.Uint() > math.MaxUint32 { - return 0, fmt.Errorf("field %s overflows uint32", name) - } - return uint32(field.Uint()), nil //nolint:gosec // bounded by MaxUint32 check above -} - -func fieldBigInt(value reflect.Value, name string) (*big.Int, error) { - field := field(value, name) - if !field.IsValid() { - return nil, fmt.Errorf("expected *big.Int field %s", name) - } - amount, ok := field.Interface().(*big.Int) - if !ok { - return nil, fmt.Errorf("expected *big.Int field %s", name) - } - return amount, nil -} - -func fieldStringSlice(value reflect.Value, name string) ([]string, error) { - field := field(value, name) - if !field.IsValid() || field.Kind() != reflect.Slice { - return nil, fmt.Errorf("expected string slice field %s", name) - } - aliases := make([]string, 0, field.Len()) - for i := 0; i < field.Len(); i++ { - if field.Index(i).Kind() != reflect.String { - return nil, fmt.Errorf("expected string element in field %s", name) - } - aliases = append(aliases, field.Index(i).String()) - } - return aliases, nil -} - -func paginateCoins(coins sdk.Coins, req *sdkquery.PageRequest) (sdk.Coins, *sdkquery.PageResponse, error) { - items, pageRes, err := paginate(coins, req, func(coin sdk.Coin) []byte { - return []byte(coin.Denom) - }) - if err != nil { - return nil, nil, err - } - return sdk.Coins(items), pageRes, nil -} - func paginateMetadata(metadatas []banktypes.Metadata, req *sdkquery.PageRequest) ([]banktypes.Metadata, *sdkquery.PageResponse, error) { - return paginate(metadatas, req, func(metadata banktypes.Metadata) []byte { + return pquery.Paginate(metadatas, req, func(metadata banktypes.Metadata) []byte { return []byte(metadata.Base) }) } - -func paginate[T any](items []T, req *sdkquery.PageRequest, keyFn func(T) []byte) ([]T, *sdkquery.PageResponse, error) { - if req == nil { - req = &sdkquery.PageRequest{} - } - if req.Offset > 0 && len(req.Key) > 0 { - return nil, nil, fmt.Errorf("invalid request, either offset or key is expected, got both") - } - - ordered := slices.Clone(items) - if req.Reverse { - slices.Reverse(ordered) - } - orderedLen := uint64(len(ordered)) //nolint:gosec // len is non-negative and used only as an in-memory page bound - - limit := req.Limit - countTotal := req.CountTotal - if limit == 0 { - limit = sdkquery.DefaultLimit - countTotal = true - } - - start := req.Offset - keyPagination := len(req.Key) > 0 - if keyPagination { - start = orderedLen - for i, item := range ordered { - if bytes.Equal(keyFn(item), req.Key) { - start = uint64(i) //nolint:gosec // i is bounded by len(ordered) - break - } - } - } - - if start > orderedLen { - start = orderedLen - } - end := start + limit - if end < start || end > orderedLen { - end = orderedLen - } - - var nextKey []byte - if end < orderedLen { - nextKey = keyFn(ordered[end]) - } - pageRes := &sdkquery.PageResponse{NextKey: nextKey} - if countTotal && !keyPagination { - pageRes.Total = orderedLen - } - - return ordered[int(start):int(end)], pageRes, nil //nolint:gosec // start and end are bounded by len(ordered) -} diff --git a/precompiles/query/helpers.go b/precompiles/query/helpers.go new file mode 100644 index 0000000000..7a436dc50b --- /dev/null +++ b/precompiles/query/helpers.go @@ -0,0 +1,193 @@ +package query + +import ( + "bytes" + "context" + "fmt" + "math" + "math/big" + "reflect" + "slices" + + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + sdkquery "github.com/sei-protocol/sei-chain/sei-cosmos/types/query" +) + +// PackNoArgs adapts no-argument precompile query methods to a binding packer. +func PackNoArgs[Req any](_ context.Context, _ *Env, _ *Req) ([]interface{}, error) { + return nil, nil +} + +// SingleBigInt extracts a single *big.Int ABI output. +func SingleBigInt(out []interface{}) (*big.Int, error) { + if len(out) != 1 { + return nil, fmt.Errorf("expected 1 output but got %d", len(out)) + } + amount, ok := out[0].(*big.Int) + if !ok { + return nil, fmt.Errorf("expected *big.Int output but got %T", out[0]) + } + return amount, nil +} + +// CoinsFromOutput decodes a single ABI output slice containing Amount and Denom fields. +func CoinsFromOutput(out []interface{}) (sdk.Coins, error) { + if len(out) != 1 { + return nil, fmt.Errorf("expected 1 coin output but got %d", len(out)) + } + value := reflect.ValueOf(out[0]) + if value.Kind() != reflect.Slice { + return nil, fmt.Errorf("expected coin slice but got %T", out[0]) + } + coins := make(sdk.Coins, 0, value.Len()) + for i := 0; i < value.Len(); i++ { + amount, err := FieldBigInt(value.Index(i), "Amount") + if err != nil { + return nil, err + } + denom, err := FieldString(value.Index(i), "Denom") + if err != nil { + return nil, err + } + coins = append(coins, sdk.NewCoin(denom, sdk.NewIntFromBigInt(amount))) + } + return sdk.NewCoins(coins...), nil +} + +// Field returns a named field from a struct or pointer to a struct. +func Field(value reflect.Value, name string) reflect.Value { + if value.Kind() == reflect.Pointer { + value = value.Elem() + } + if value.Kind() != reflect.Struct { + return reflect.Value{} + } + return value.FieldByName(name) +} + +// FieldString returns a named string field from a struct-like value. +func FieldString(value reflect.Value, name string) (string, error) { + field := Field(value, name) + if !field.IsValid() || field.Kind() != reflect.String { + return "", fmt.Errorf("expected string field %s", name) + } + return field.String(), nil +} + +// FieldBool returns a named bool field from a struct-like value. +func FieldBool(value reflect.Value, name string) (bool, error) { + field := Field(value, name) + if !field.IsValid() || field.Kind() != reflect.Bool { + return false, fmt.Errorf("expected bool field %s", name) + } + return field.Bool(), nil +} + +// FieldUint32 returns a named unsigned integer field narrowed to uint32. +func FieldUint32(value reflect.Value, name string) (uint32, error) { + field := Field(value, name) + if !field.IsValid() { + return 0, fmt.Errorf("expected uint32 field %s", name) + } + if field.Kind() < reflect.Uint || field.Kind() > reflect.Uint64 { + return 0, fmt.Errorf("expected uint field %s", name) + } + if field.Uint() > math.MaxUint32 { + return 0, fmt.Errorf("field %s overflows uint32", name) + } + return uint32(field.Uint()), nil //nolint:gosec // bounded by MaxUint32 check above +} + +// FieldBigInt returns a named *big.Int field from a struct-like value. +func FieldBigInt(value reflect.Value, name string) (*big.Int, error) { + field := Field(value, name) + if !field.IsValid() { + return nil, fmt.Errorf("expected *big.Int field %s", name) + } + amount, ok := field.Interface().(*big.Int) + if !ok { + return nil, fmt.Errorf("expected *big.Int field %s", name) + } + return amount, nil +} + +// FieldStringSlice returns a named []string field from a struct-like value. +func FieldStringSlice(value reflect.Value, name string) ([]string, error) { + field := Field(value, name) + if !field.IsValid() || field.Kind() != reflect.Slice { + return nil, fmt.Errorf("expected string slice field %s", name) + } + aliases := make([]string, 0, field.Len()) + for i := 0; i < field.Len(); i++ { + if field.Index(i).Kind() != reflect.String { + return nil, fmt.Errorf("expected string element in field %s", name) + } + aliases = append(aliases, field.Index(i).String()) + } + return aliases, nil +} + +// PaginateCoins applies Cosmos query pagination to an in-memory coin set. +func PaginateCoins(coins sdk.Coins, req *sdkquery.PageRequest) (sdk.Coins, *sdkquery.PageResponse, error) { + items, pageRes, err := Paginate(coins, req, func(coin sdk.Coin) []byte { + return []byte(coin.Denom) + }) + if err != nil { + return nil, nil, err + } + return sdk.Coins(items), pageRes, nil +} + +// Paginate applies Cosmos query pagination to an in-memory ordered slice. +func Paginate[T any](items []T, req *sdkquery.PageRequest, keyFn func(T) []byte) ([]T, *sdkquery.PageResponse, error) { + if req == nil { + req = &sdkquery.PageRequest{} + } + if req.Offset > 0 && len(req.Key) > 0 { + return nil, nil, fmt.Errorf("invalid request, either offset or key is expected, got both") + } + + ordered := slices.Clone(items) + if req.Reverse { + slices.Reverse(ordered) + } + orderedLen := uint64(len(ordered)) //nolint:gosec // len is non-negative and used only as an in-memory page bound + + limit := req.Limit + countTotal := req.CountTotal + if limit == 0 { + limit = sdkquery.DefaultLimit + countTotal = true + } + + start := req.Offset + keyPagination := len(req.Key) > 0 + if keyPagination { + start = orderedLen + for i, item := range ordered { + if bytes.Equal(keyFn(item), req.Key) { + start = uint64(i) //nolint:gosec // i is bounded by len(ordered) + break + } + } + } + + if start > orderedLen { + start = orderedLen + } + end := start + limit + if end < start || end > orderedLen { + end = orderedLen + } + + var nextKey []byte + if end < orderedLen { + nextKey = keyFn(ordered[end]) + } + pageRes := &sdkquery.PageResponse{NextKey: nextKey} + if countTotal && !keyPagination { + pageRes.Total = orderedLen + } + + return ordered[int(start):int(end)], pageRes, nil //nolint:gosec // start and end are bounded by len(ordered) +} diff --git a/precompiles/query/helpers_test.go b/precompiles/query/helpers_test.go new file mode 100644 index 0000000000..e930a1cdac --- /dev/null +++ b/precompiles/query/helpers_test.go @@ -0,0 +1,74 @@ +package query_test + +import ( + "math/big" + "reflect" + "testing" + + pquery "github.com/sei-protocol/sei-chain/precompiles/query" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + sdkquery "github.com/sei-protocol/sei-chain/sei-cosmos/types/query" + "github.com/stretchr/testify/require" +) + +type abiCoin struct { + Amount *big.Int + Denom string +} + +type helperFields struct { + Amount *big.Int + Aliases []string + Enabled bool + Exponent uint8 + Name string +} + +func TestCoinsFromOutputAndPaginateCoins(t *testing.T) { + coins, err := pquery.CoinsFromOutput([]interface{}{[]abiCoin{ + {Amount: big.NewInt(11), Denom: "usei"}, + {Amount: big.NewInt(7), Denom: "uatom"}, + }}) + require.NoError(t, err) + require.Equal(t, sdk.NewCoins( + sdk.NewCoin("uatom", sdk.NewInt(7)), + sdk.NewCoin("usei", sdk.NewInt(11)), + ), coins) + + paged, pageRes, err := pquery.PaginateCoins(coins, &sdkquery.PageRequest{Limit: 1, CountTotal: true}) + require.NoError(t, err) + require.Equal(t, sdk.NewCoins(sdk.NewCoin("uatom", sdk.NewInt(7))), paged) + require.Equal(t, []byte("usei"), pageRes.NextKey) + require.Equal(t, uint64(2), pageRes.Total) +} + +func TestFieldHelpers(t *testing.T) { + fields := helperFields{ + Amount: big.NewInt(42), + Aliases: []string{"microsei"}, + Enabled: true, + Exponent: 6, + Name: "usei", + } + value := reflect.ValueOf(&fields) + + name, err := pquery.FieldString(value, "Name") + require.NoError(t, err) + require.Equal(t, "usei", name) + + enabled, err := pquery.FieldBool(value, "Enabled") + require.NoError(t, err) + require.True(t, enabled) + + exponent, err := pquery.FieldUint32(value, "Exponent") + require.NoError(t, err) + require.Equal(t, uint32(6), exponent) + + amount, err := pquery.FieldBigInt(value, "Amount") + require.NoError(t, err) + require.Equal(t, big.NewInt(42), amount) + + aliases, err := pquery.FieldStringSlice(value, "Aliases") + require.NoError(t, err) + require.Equal(t, []string{"microsei"}, aliases) +} From fd63377656d8da20a959b21972206837120b1b1f Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Mon, 18 May 2026 11:44:12 +0800 Subject: [PATCH 6/7] Add bank CLI backend comparison tests --- precompiles/bank/query/registry.go | 2 +- precompiles/query/helpers.go | 2 +- precompiles/query/helpers_test.go | 3 + sei-cosmos/x/bank/client/cli/query.go | 38 +- .../bank/client/cli/query_integration_test.go | 592 ++++++++++++++++++ .../x/bank/client/testutil/cli_helpers.go | 1 + sei-cosmos/x/bank/client/testutil/suite.go | 12 +- 7 files changed, 639 insertions(+), 11 deletions(-) create mode 100644 sei-cosmos/x/bank/client/cli/query_integration_test.go diff --git a/precompiles/bank/query/registry.go b/precompiles/bank/query/registry.go index 0dcf687f35..4c061eb43d 100644 --- a/precompiles/bank/query/registry.go +++ b/precompiles/bank/query/registry.go @@ -202,7 +202,7 @@ func unpackTotalSupply(_ context.Context, _ *pquery.Env, req *banktypes.QueryTot if err != nil { return err } - resp.Supply = paged + resp.Supply = paged.Sort() resp.Pagination = pageRes return nil } diff --git a/precompiles/query/helpers.go b/precompiles/query/helpers.go index 7a436dc50b..6a2ed29eda 100644 --- a/precompiles/query/helpers.go +++ b/precompiles/query/helpers.go @@ -143,7 +143,7 @@ func Paginate[T any](items []T, req *sdkquery.PageRequest, keyFn func(T) []byte) if req == nil { req = &sdkquery.PageRequest{} } - if req.Offset > 0 && len(req.Key) > 0 { + if req.Offset > 0 && req.Key != nil { return nil, nil, fmt.Errorf("invalid request, either offset or key is expected, got both") } diff --git a/precompiles/query/helpers_test.go b/precompiles/query/helpers_test.go index e930a1cdac..b6314b7273 100644 --- a/precompiles/query/helpers_test.go +++ b/precompiles/query/helpers_test.go @@ -40,6 +40,9 @@ func TestCoinsFromOutputAndPaginateCoins(t *testing.T) { require.Equal(t, sdk.NewCoins(sdk.NewCoin("uatom", sdk.NewInt(7))), paged) require.Equal(t, []byte("usei"), pageRes.NextKey) require.Equal(t, uint64(2), pageRes.Total) + + _, _, err = pquery.PaginateCoins(coins, &sdkquery.PageRequest{Offset: 1, Key: []byte{}}) + require.ErrorContains(t, err, "either offset or key is expected") } func TestFieldHelpers(t *testing.T) { diff --git a/sei-cosmos/x/bank/client/cli/query.go b/sei-cosmos/x/bank/client/cli/query.go index 02f7688bac..7ff4438c7b 100644 --- a/sei-cosmos/x/bank/client/cli/query.go +++ b/sei-cosmos/x/bank/client/cli/query.go @@ -17,9 +17,12 @@ import ( ) const ( - FlagDenom = "denom" - FlagEVMRPC = "evm-rpc" - defaultEVMRPCURL = "http://localhost:8545" + FlagDenom = "denom" + FlagEVMRPC = "evm-rpc" + FlagQueryClientBackend = "query-client-backend" + defaultEVMRPCURL = "http://localhost:8545" + QueryClientPrecompile = "precompile" + QueryClientLegacy = "legacy" ) // GetQueryCmd returns the parent command for all x/bank CLi query commands. The @@ -68,7 +71,7 @@ Example: return err } - queryClient, closeQueryClient, err := NewPrecompileBackedQueryClient(cmd, clientCtx) + queryClient, closeQueryClient, err := NewQueryClient(cmd, clientCtx) if err != nil { return err } @@ -105,6 +108,7 @@ Example: cmd.Flags().String(FlagDenom, "", "The specific balance denomination to query for") addEVMRPCFlag(cmd) + addQueryClientBackendFlag(cmd) flags.AddQueryFlagsToCmd(cmd) flags.AddPaginationFlagsToCmd(cmd, "all balances") @@ -139,7 +143,7 @@ To query for the client metadata of a specific coin denomination use: return err } - queryClient, closeQueryClient, err := NewPrecompileBackedQueryClient(cmd, clientCtx) + queryClient, closeQueryClient, err := NewQueryClient(cmd, clientCtx) if err != nil { return err } @@ -165,6 +169,7 @@ To query for the client metadata of a specific coin denomination use: cmd.Flags().String(FlagDenom, "", "The specific denomination to query client metadata for") addEVMRPCFlag(cmd) + addQueryClientBackendFlag(cmd) flags.AddQueryFlagsToCmd(cmd) return cmd @@ -196,7 +201,7 @@ To query for the total supply of a specific coin denomination use: return err } - queryClient, closeQueryClient, err := NewPrecompileBackedQueryClient(cmd, clientCtx) + queryClient, closeQueryClient, err := NewQueryClient(cmd, clientCtx) if err != nil { return err } @@ -227,12 +232,28 @@ To query for the total supply of a specific coin denomination use: cmd.Flags().String(FlagDenom, "", "The specific balance denomination to query for") addEVMRPCFlag(cmd) + addQueryClientBackendFlag(cmd) flags.AddQueryFlagsToCmd(cmd) flags.AddPaginationFlagsToCmd(cmd, "all supply totals") return cmd } +func NewQueryClient(cmd *cobra.Command, clientCtx client.Context) (types.QueryClient, func(), error) { + backend, err := cmd.Flags().GetString(FlagQueryClientBackend) + if err != nil { + return nil, nil, err + } + switch backend { + case QueryClientPrecompile: + return NewPrecompileBackedQueryClient(cmd, clientCtx) + case QueryClientLegacy: + return types.NewQueryClient(clientCtx), func() {}, nil + default: + return nil, nil, fmt.Errorf("unsupported bank query client backend %q", backend) + } +} + func NewPrecompileBackedQueryClient(cmd *cobra.Command, clientCtx client.Context) (types.QueryClient, func(), error) { rpcURL, err := cmd.Flags().GetString(FlagEVMRPC) if err != nil { @@ -252,3 +273,8 @@ func NewPrecompileBackedQueryClient(cmd *cobra.Command, clientCtx client.Context func addEVMRPCFlag(cmd *cobra.Command) { cmd.Flags().String(FlagEVMRPC, defaultEVMRPCURL, "EVM RPC endpoint for precompile-backed bank queries") } + +func addQueryClientBackendFlag(cmd *cobra.Command) { + cmd.Flags().String(FlagQueryClientBackend, QueryClientPrecompile, "bank query client backend") + _ = cmd.Flags().MarkHidden(FlagQueryClientBackend) +} diff --git a/sei-cosmos/x/bank/client/cli/query_integration_test.go b/sei-cosmos/x/bank/client/cli/query_integration_test.go new file mode 100644 index 0000000000..73fb8467f5 --- /dev/null +++ b/sei-cosmos/x/bank/client/cli/query_integration_test.go @@ -0,0 +1,592 @@ +package cli_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/vm" + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" + + seiapp "github.com/sei-protocol/sei-chain/app" + bankprecompile "github.com/sei-protocol/sei-chain/precompiles/bank" + "github.com/sei-protocol/sei-chain/sei-cosmos/baseapp" + "github.com/sei-protocol/sei-chain/sei-cosmos/client" + "github.com/sei-protocol/sei-chain/sei-cosmos/client/flags" + clitestutil "github.com/sei-protocol/sei-chain/sei-cosmos/testutil/cli" + sdk "github.com/sei-protocol/sei-chain/sei-cosmos/types" + sdkerrors "github.com/sei-protocol/sei-chain/sei-cosmos/types/errors" + bankcli "github.com/sei-protocol/sei-chain/sei-cosmos/x/bank/client/cli" + banktypes "github.com/sei-protocol/sei-chain/sei-cosmos/x/bank/types" + abci "github.com/sei-protocol/sei-chain/sei-tendermint/abci/types" + tmbytes "github.com/sei-protocol/sei-chain/sei-tendermint/libs/bytes" + tmcli "github.com/sei-protocol/sei-chain/sei-tendermint/libs/cli" + tmproto "github.com/sei-protocol/sei-chain/sei-tendermint/proto/tendermint/types" + rpcclient "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/client" + rpcclientmock "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/client/mock" + "github.com/sei-protocol/sei-chain/sei-tendermint/rpc/coretypes" + evmstate "github.com/sei-protocol/sei-chain/x/evm/state" + minttypes "github.com/sei-protocol/sei-chain/x/mint/types" +) + +func TestBankQueryCLIBackendsMatch(t *testing.T) { + fixture := newBankQueryCLIFixture(t) + outputJSON := fmt.Sprintf("--%s=json", tmcli.OutputFlag) + + testCases := []struct { + name string + command func() *cobra.Command + args []string + expectErr bool + }{ + { + name: "balances all", + command: bankcli.GetBalancesCmd, + args: []string{fixture.queryAddr.String(), outputJSON}, + }, + { + name: "balances single denom", + command: bankcli.GetBalancesCmd, + args: []string{fixture.queryAddr.String(), outputJSON, fmt.Sprintf("--%s=usei", bankcli.FlagDenom)}, + }, + { + name: "balances missing denom returns zero coin", + command: bankcli.GetBalancesCmd, + args: []string{fixture.queryAddr.String(), outputJSON, fmt.Sprintf("--%s=umissing", bankcli.FlagDenom)}, + }, + { + name: "balances paginated", + command: bankcli.GetBalancesCmd, + args: []string{ + fixture.queryAddr.String(), + outputJSON, + fmt.Sprintf("--%s=1", flags.FlagLimit), + fmt.Sprintf("--%s=true", flags.FlagCountTotal), + }, + }, + { + name: "balances page key", + command: bankcli.GetBalancesCmd, + args: []string{ + fixture.queryAddr.String(), + outputJSON, + fmt.Sprintf("--%s=uhist", flags.FlagPageKey), + fmt.Sprintf("--%s=1", flags.FlagLimit), + }, + }, + { + name: "balances reverse", + command: bankcli.GetBalancesCmd, + args: []string{ + fixture.queryAddr.String(), + outputJSON, + fmt.Sprintf("--%s=2", flags.FlagLimit), + fmt.Sprintf("--%s=true", flags.FlagCountTotal), + fmt.Sprintf("--%s=true", flags.FlagReverse), + }, + }, + { + name: "balances invalid address", + command: bankcli.GetBalancesCmd, + args: []string{"not-a-sei-address", outputJSON}, + expectErr: true, + }, + { + name: "balances invalid offset and key pagination", + command: bankcli.GetBalancesCmd, + args: []string{ + fixture.queryAddr.String(), + outputJSON, + fmt.Sprintf("--%s=1", flags.FlagOffset), + fmt.Sprintf("--%s=usei", flags.FlagPageKey), + }, + expectErr: true, + }, + { + name: "total supply", + command: bankcli.GetCmdQueryTotalSupply, + args: []string{outputJSON}, + }, + { + name: "total supply single denom", + command: bankcli.GetCmdQueryTotalSupply, + args: []string{outputJSON, fmt.Sprintf("--%s=usei", bankcli.FlagDenom)}, + }, + { + name: "total supply missing denom returns zero coin", + command: bankcli.GetCmdQueryTotalSupply, + args: []string{outputJSON, fmt.Sprintf("--%s=umissing", bankcli.FlagDenom)}, + }, + { + name: "total supply paginated reverse", + command: bankcli.GetCmdQueryTotalSupply, + args: []string{ + outputJSON, + fmt.Sprintf("--%s=2", flags.FlagLimit), + fmt.Sprintf("--%s=true", flags.FlagCountTotal), + fmt.Sprintf("--%s=true", flags.FlagReverse), + }, + }, + { + name: "total supply page key", + command: bankcli.GetCmdQueryTotalSupply, + args: []string{ + outputJSON, + fmt.Sprintf("--%s=uhist", flags.FlagPageKey), + fmt.Sprintf("--%s=1", flags.FlagLimit), + }, + }, + { + name: "total supply invalid offset and key pagination", + command: bankcli.GetCmdQueryTotalSupply, + args: []string{ + outputJSON, + fmt.Sprintf("--%s=1", flags.FlagOffset), + fmt.Sprintf("--%s=usei", flags.FlagPageKey), + }, + expectErr: true, + }, + { + name: "denom metadata all", + command: bankcli.GetCmdDenomsMetadata, + args: []string{outputJSON}, + }, + { + name: "denom metadata single denom", + command: bankcli.GetCmdDenomsMetadata, + args: []string{outputJSON, fmt.Sprintf("--%s=usei", bankcli.FlagDenom)}, + }, + { + name: "denom metadata missing denom", + command: bankcli.GetCmdDenomsMetadata, + args: []string{outputJSON, fmt.Sprintf("--%s=umissing", bankcli.FlagDenom)}, + expectErr: true, + }, + } + + heightModes := []struct { + name string + height int64 + args []string + }{ + {name: "latest", height: fixture.latestHeight}, + { + name: "with height", + height: fixture.historicalHeight, + args: []string{fmt.Sprintf("--%s=%d", flags.FlagHeight, fixture.historicalHeight)}, + }, + } + + for _, tc := range testCases { + tc := tc + for _, mode := range heightModes { + mode := mode + t.Run(tc.name+"/"+mode.name, func(t *testing.T) { + args := append([]string{}, tc.args...) + args = append(args, mode.args...) + + fixture.legacyRPC.reset() + legacyOut, legacyErr := fixture.exec(t, tc.command, bankcli.QueryClientLegacy, args) + + fixture.precompileRPC.reset() + precompileOut, precompileErr := fixture.exec(t, tc.command, bankcli.QueryClientPrecompile, args) + + if tc.expectErr { + require.Error(t, legacyErr) + require.Error(t, precompileErr) + return + } + + require.NoError(t, legacyErr) + require.NoError(t, precompileErr) + require.JSONEq(t, legacyOut, precompileOut) + require.Equal(t, mode.height, fixture.legacyRPC.lastHeight(t)) + require.Equal(t, mode.height, fixture.precompileRPC.lastHeight(t)) + }) + } + } +} + +func TestBankQueryCLIRejectsUnknownBackend(t *testing.T) { + fixture := newBankQueryCLIFixture(t) + out, err := fixture.exec(t, bankcli.GetBalancesCmd, "wat", []string{ + fixture.queryAddr.String(), + fmt.Sprintf("--%s=json", tmcli.OutputFlag), + }) + require.ErrorContains(t, err, `unsupported bank query client backend "wat"`) + require.Contains(t, out, `unsupported bank query client backend "wat"`) +} + +type bankQueryCLIFixture struct { + clientCtx client.Context + queryAddr sdk.AccAddress + legacyRPC *bankQueryRPC + precompileRPC *bankPrecompileRPC + historicalHeight int64 + latestHeight int64 +} + +func newBankQueryCLIFixture(t *testing.T) *bankQueryCLIFixture { + t.Helper() + + encodingConfig := seiapp.MakeEncodingConfig() + queryAddr := sdk.AccAddress(bytes.Repeat([]byte{1}, 20)) + historicalHeight := int64(7) + latestHeight := int64(11) + + states := map[int64]*bankCLIState{ + historicalHeight: newBankCLIState(t, historicalHeight, queryAddr, sdk.NewCoins( + sdk.NewCoin("uhist", sdk.NewInt(10)), + sdk.NewCoin("usei", sdk.NewInt(20)), + ), []banktypes.Metadata{ + metadata("uhist", "hist", "Historical Coin", "HIST"), + metadata("usei", "sei", "Sei", "SEI"), + }), + latestHeight: newBankCLIState(t, latestHeight, queryAddr, sdk.NewCoins( + sdk.NewCoin("uhist", sdk.NewInt(30)), + sdk.NewCoin("ulatest", sdk.NewInt(40)), + sdk.NewCoin("usei", sdk.NewInt(50)), + ), []banktypes.Metadata{ + metadata("uhist", "hist", "Historical Coin", "HIST"), + metadata("ulatest", "latest", "Latest Coin", "LATEST"), + metadata("usei", "sei", "Sei", "SEI"), + }), + } + + legacyRPC := &bankQueryRPC{ + Client: rpcclientmock.New(), + states: states, + latestHeight: latestHeight, + } + clientCtx := client.Context{}. + WithChainID("sei-test"). + WithCodec(encodingConfig.Marshaler). + WithLegacyAmino(encodingConfig.Amino). + WithInterfaceRegistry(encodingConfig.InterfaceRegistry). + WithTxConfig(encodingConfig.TxConfig). + WithClient(legacyRPC) + + return &bankQueryCLIFixture{ + clientCtx: clientCtx, + queryAddr: queryAddr, + legacyRPC: legacyRPC, + precompileRPC: newBankPrecompileRPC(t, states, latestHeight), + historicalHeight: historicalHeight, + latestHeight: latestHeight, + } +} + +func (f *bankQueryCLIFixture) exec(t *testing.T, command func() *cobra.Command, backend string, args []string) (string, error) { + t.Helper() + + fullArgs := append([]string{}, args...) + fullArgs = append(fullArgs, fmt.Sprintf("--%s=%s", bankcli.FlagQueryClientBackend, backend)) + if backend == bankcli.QueryClientPrecompile { + fullArgs = append(fullArgs, fmt.Sprintf("--%s=%s", bankcli.FlagEVMRPC, f.precompileRPC.URL())) + } + + out, err := clitestutil.ExecTestCLICmd(f.clientCtx, command(), fullArgs) + return out.String(), err +} + +type bankCLIState struct { + app *seiapp.App + ctx sdk.Context +} + +func newBankCLIState(t *testing.T, height int64, queryAddr sdk.AccAddress, coins sdk.Coins, metadatas []banktypes.Metadata) *bankCLIState { + t.Helper() + + testApp := seiapp.Setup(t, false, false, false) + ctx := testApp.BaseApp.NewContext(false, tmproto.Header{ + ChainID: "sei-test", + Height: height, + Time: time.Unix(height, 0), + }).WithEventManager(sdk.NewEventManager()) + + require.NoError(t, testApp.BankKeeper.MintCoins(ctx, minttypes.ModuleName, coins)) + require.NoError(t, testApp.BankKeeper.SendCoinsFromModuleToAccount(ctx, minttypes.ModuleName, queryAddr, coins)) + for _, denomMetadata := range metadatas { + testApp.BankKeeper.SetDenomMetaData(ctx, denomMetadata) + } + testApp.BankKeeper.SetParams(ctx, banktypes.Params{ + SendEnabled: []*banktypes.SendEnabled{{Denom: "usei", Enabled: true}}, + DefaultSendEnabled: false, + }) + + return &bankCLIState{app: testApp, ctx: ctx} +} + +func metadata(base, display, name, symbol string) banktypes.Metadata { + return banktypes.Metadata{ + Description: name + " description", + DenomUnits: []*banktypes.DenomUnit{ + {Denom: base, Exponent: 0, Aliases: []string{"micro" + display}}, + {Denom: display, Exponent: 6, Aliases: []string{symbol}}, + }, + Base: base, + Display: display, + Name: name, + Symbol: symbol, + } +} + +type bankQueryRPC struct { + rpcclientmock.Client + + states map[int64]*bankCLIState + latestHeight int64 + + mu sync.Mutex + heights []int64 +} + +func (r *bankQueryRPC) ABCIQueryWithOptions( + _ context.Context, + path string, + data tmbytes.HexBytes, + opts rpcclient.ABCIQueryOptions, +) (*coretypes.ResultABCIQuery, error) { + height := opts.Height + if height == 0 { + height = r.latestHeight + } + r.recordHeight(height) + + state, ok := r.states[height] + if !ok { + return r.errorResponse(height, fmt.Errorf("no test state for height %d", height)), nil + } + + queryHelper := baseapp.NewQueryServerTestHelper(state.ctx, state.app.InterfaceRegistry()) + banktypes.RegisterQueryServer(queryHelper, state.app.BankKeeper) + querier := queryHelper.Route(path) + if querier == nil { + return r.errorResponse(height, fmt.Errorf("handler not found for %s", path)), nil + } + + res, err := querier(state.ctx, abci.RequestQuery{Path: path, Data: data, Height: height}) + if err != nil { + return r.errorResponse(height, err), nil + } + + return &coretypes.ResultABCIQuery{Response: abci.ResponseQuery{ + Code: 0, + Height: height, + Value: res.Value, + }}, nil +} + +func (r *bankQueryRPC) errorResponse(height int64, err error) *coretypes.ResultABCIQuery { + return &coretypes.ResultABCIQuery{Response: abci.ResponseQuery{ + Code: sdkerrors.ErrInvalidRequest.ABCICode(), + Height: height, + Log: err.Error(), + }} +} + +func (r *bankQueryRPC) reset() { + r.mu.Lock() + defer r.mu.Unlock() + r.heights = nil +} + +func (r *bankQueryRPC) recordHeight(height int64) { + r.mu.Lock() + defer r.mu.Unlock() + r.heights = append(r.heights, height) +} + +func (r *bankQueryRPC) lastHeight(t *testing.T) int64 { + t.Helper() + + r.mu.Lock() + defer r.mu.Unlock() + require.NotEmpty(t, r.heights) + return r.heights[len(r.heights)-1] +} + +type bankPrecompileRPC struct { + server *httptest.Server + + states map[int64]*bankCLIState + latestHeight int64 + + mu sync.Mutex + heights []int64 +} + +func newBankPrecompileRPC(t *testing.T, states map[int64]*bankCLIState, latestHeight int64) *bankPrecompileRPC { + t.Helper() + + rpc := &bankPrecompileRPC{ + states: states, + latestHeight: latestHeight, + } + rpc.server = httptest.NewServer(http.HandlerFunc(rpc.handle)) + t.Cleanup(rpc.server.Close) + return rpc +} + +func (r *bankPrecompileRPC) URL() string { + return r.server.URL +} + +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` +} + +type jsonRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Result interface{} `json:"result,omitempty"` + Error *jsonRPCError `json:"error,omitempty"` +} + +type jsonRPCError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type ethCallArgs struct { + From *common.Address `json:"from,omitempty"` + To *common.Address `json:"to"` + Data *hexutil.Bytes `json:"data"` + Input *hexutil.Bytes `json:"input"` +} + +func (a ethCallArgs) callData() []byte { + if a.Input != nil { + return *a.Input + } + if a.Data != nil { + return *a.Data + } + return nil +} + +func (r *bankPrecompileRPC) handle(w http.ResponseWriter, req *http.Request) { + defer func() { _ = req.Body.Close() }() + w.Header().Set("Content-Type", "application/json") + + var rpcReq jsonRPCRequest + if err := json.NewDecoder(req.Body).Decode(&rpcReq); err != nil { + _ = json.NewEncoder(w).Encode(jsonRPCResponse{JSONRPC: "2.0", Error: &jsonRPCError{Code: -32700, Message: err.Error()}}) + return + } + if rpcReq.Method != "eth_call" { + r.writeError(w, rpcReq.ID, fmt.Errorf("unexpected JSON-RPC method %s", rpcReq.Method)) + return + } + if len(rpcReq.Params) == 0 { + r.writeError(w, rpcReq.ID, fmt.Errorf("missing eth_call params")) + return + } + + var call ethCallArgs + if err := json.Unmarshal(rpcReq.Params[0], &call); err != nil { + r.writeError(w, rpcReq.ID, err) + return + } + height, err := r.blockHeight(rpcReq.Params) + if err != nil { + r.writeError(w, rpcReq.ID, err) + return + } + r.recordHeight(height) + + ret, err := r.callBankPrecompile(height, call) + if err != nil { + r.writeError(w, rpcReq.ID, err) + return + } + _ = json.NewEncoder(w).Encode(jsonRPCResponse{JSONRPC: "2.0", ID: rpcReq.ID, Result: hexutil.Encode(ret)}) +} + +func (r *bankPrecompileRPC) blockHeight(params []json.RawMessage) (int64, error) { + if len(params) < 2 || string(params[1]) == "null" { + return r.latestHeight, nil + } + + var block string + if err := json.Unmarshal(params[1], &block); err != nil { + return 0, err + } + switch block { + case "", "latest", "pending", "safe", "finalized": + return r.latestHeight, nil + default: + height, err := hexutil.DecodeBig(block) + if err != nil { + return 0, err + } + if !height.IsInt64() { + return 0, fmt.Errorf("block height %s overflows int64", block) + } + return height.Int64(), nil + } +} + +func (r *bankPrecompileRPC) callBankPrecompile(height int64, call ethCallArgs) ([]byte, error) { + if call.To == nil || *call.To != common.HexToAddress(bankprecompile.BankAddress) { + return nil, fmt.Errorf("unexpected eth_call target %v", call.To) + } + state, ok := r.states[height] + if !ok { + return nil, fmt.Errorf("no test state for height %d", height) + } + + precompile, err := bankprecompile.NewPrecompile(state.app.GetPrecompileKeepers()) + if err != nil { + return nil, err + } + from := common.Address{} + if call.From != nil { + from = *call.From + } + evm := vm.EVM{StateDB: evmstate.NewDBImpl(state.ctx, &state.app.EvmKeeper, true)} + ret, _, err := precompile.RunAndCalculateGas(&evm, from, from, call.callData(), 10_000_000, (*big.Int)(nil), nil, true, false) + return ret, err +} + +func (r *bankPrecompileRPC) writeError(w http.ResponseWriter, id json.RawMessage, err error) { + _ = json.NewEncoder(w).Encode(jsonRPCResponse{ + JSONRPC: "2.0", + ID: id, + Error: &jsonRPCError{Code: -32000, Message: err.Error()}, + }) +} + +func (r *bankPrecompileRPC) reset() { + r.mu.Lock() + defer r.mu.Unlock() + r.heights = nil +} + +func (r *bankPrecompileRPC) recordHeight(height int64) { + r.mu.Lock() + defer r.mu.Unlock() + r.heights = append(r.heights, height) +} + +func (r *bankPrecompileRPC) lastHeight(t *testing.T) int64 { + t.Helper() + + r.mu.Lock() + defer r.mu.Unlock() + require.NotEmpty(t, r.heights) + return r.heights[len(r.heights)-1] +} diff --git a/sei-cosmos/x/bank/client/testutil/cli_helpers.go b/sei-cosmos/x/bank/client/testutil/cli_helpers.go index 2d37e8d158..15c135c9ad 100644 --- a/sei-cosmos/x/bank/client/testutil/cli_helpers.go +++ b/sei-cosmos/x/bank/client/testutil/cli_helpers.go @@ -22,6 +22,7 @@ func MsgSendExec(clientCtx client.Context, from, to, amount fmt.Stringer, extraA func QueryBalancesExec(clientCtx client.Context, address fmt.Stringer, extraArgs ...string) (testutil.BufferWriter, error) { args := make([]string, 0, 2+len(extraArgs)) args = append(args, address.String(), fmt.Sprintf("--%s=json", cli.OutputFlag)) + args = append(args, fmt.Sprintf("--%s=%s", bankcli.FlagQueryClientBackend, bankcli.QueryClientLegacy)) args = append(args, extraArgs...) return clitestutil.ExecTestCLICmd(clientCtx, bankcli.GetBalancesCmd(), args) diff --git a/sei-cosmos/x/bank/client/testutil/suite.go b/sei-cosmos/x/bank/client/testutil/suite.go index 4796a9ab22..e2f61ef5f6 100644 --- a/sei-cosmos/x/bank/client/testutil/suite.go +++ b/sei-cosmos/x/bank/client/testutil/suite.go @@ -149,7 +149,9 @@ func (s *IntegrationTestSuite) TestGetBalancesCmd() { s.Run(tc.name, func() { cmd := cli.GetBalancesCmd() - out, err := clitestutil.ExecTestCLICmd(val.ClientCtx, cmd, tc.args) + args := append([]string{}, tc.args...) + args = append(args, fmt.Sprintf("--%s=%s", cli.FlagQueryClientBackend, cli.QueryClientLegacy)) + out, err := clitestutil.ExecTestCLICmd(val.ClientCtx, cmd, args) if tc.expectErr { s.Require().Error(err) @@ -222,7 +224,9 @@ func (s *IntegrationTestSuite) TestGetCmdQueryTotalSupply() { cmd := cli.GetCmdQueryTotalSupply() clientCtx := val.ClientCtx - out, err := clitestutil.ExecTestCLICmd(clientCtx, cmd, tc.args) + args := append([]string{}, tc.args...) + args = append(args, fmt.Sprintf("--%s=%s", cli.FlagQueryClientBackend, cli.QueryClientLegacy)) + out, err := clitestutil.ExecTestCLICmd(clientCtx, cmd, args) if tc.expectErr { s.Require().Error(err) } else { @@ -349,7 +353,9 @@ func (s *IntegrationTestSuite) TestGetCmdQueryDenomsMetadata() { cmd := cli.GetCmdDenomsMetadata() clientCtx := val.ClientCtx - out, err := clitestutil.ExecTestCLICmd(clientCtx, cmd, tc.args) + args := append([]string{}, tc.args...) + args = append(args, fmt.Sprintf("--%s=%s", cli.FlagQueryClientBackend, cli.QueryClientLegacy)) + out, err := clitestutil.ExecTestCLICmd(clientCtx, cmd, args) if tc.expectErr { s.Require().Error(err) } else { From 279b713f148e1c8bc0a462b65b735d9c011a4acf Mon Sep 17 00:00:00 2001 From: Tony Chen Date: Mon, 18 May 2026 11:49:42 +0800 Subject: [PATCH 7/7] Document precompile query module implementation context --- .../query/IMPLEMENTING_MODULE_QUERIES.md | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 precompiles/query/IMPLEMENTING_MODULE_QUERIES.md diff --git a/precompiles/query/IMPLEMENTING_MODULE_QUERIES.md b/precompiles/query/IMPLEMENTING_MODULE_QUERIES.md new file mode 100644 index 0000000000..37ce19dd82 --- /dev/null +++ b/precompiles/query/IMPLEMENTING_MODULE_QUERIES.md @@ -0,0 +1,167 @@ +# Implementing Precompile-Backed Module Query Clients + +This note captures the context needed to generate implementations for additional +modules after the bank module example. The goal is to preserve existing query +CLI behavior while moving the implementation behind EVM RPC calls to module +precompiles. + +## Architecture Context + +- Query CLI commands live under `x//client/cli/query.go`. +- The protobuf `QueryClient` interface for each module is generated in + `x//types/query.pb.go`. +- New client implementations should be backed by the module's EVM precompile + under `precompiles//`. +- Binding registries belong under `precompiles//query/`. +- Shared binding, pagination, ABI decoding, and address helpers belong under + `precompiles/query/`. +- Do not add server-side gRPC or REST routing. The long-term goal is to + deprecate gRPC and REST and preserve EVM RPC endpoints. +- Do not route through `x/evm` `StaticCall` from server code. CLI clients should + use EVM RPC `eth_call`. +- No query intended to survive this migration should remain keeper-backed at the + CLI client layer. If a precompile query is missing, implement the precompile + view first, then bind the CLI query to it. + +## Existing Flow + +The reusable connection in `precompiles/query` implements the gRPC +`ClientConn` shape expected by generated `types.NewQueryClient`. + +The flow is: + +1. CLI command builds a module query client. +2. The precompile-backed client calls `types.NewQueryClient(precompilequery.NewConn(...))`. +3. `Conn.Invoke` looks up the full protobuf method name in a module registry. +4. The binding validates and packs the protobuf request into ABI call arguments. +5. `Env.EthCall` executes `eth_call` against the module precompile. +6. The binding unpacks ABI output into the generated protobuf response type. + +Bank is the reference implementation: + +- `precompiles/bank/query/registry.go` +- `sei-cosmos/x/bank/client/cli/query.go` +- `sei-cosmos/x/bank/client/cli/query_integration_test.go` + +## Implementation Checklist For A New Module + +1. Read the module's current query CLI commands and generated query interface: + `x//client/cli/query.go` and `x//types/query.pb.go`. +2. Read the current keeper query implementation only to mirror request + validation, response shape, ordering, pagination, missing-value behavior, and + error behavior. +3. Check the module precompile ABI and implementation under + `precompiles//`. Add any missing read-only precompile methods needed + by the CLIs. +4. Create `precompiles//query/registry.go`. +5. Define one `pquery.Binding[Request, Response]` per protobuf query method the + module should keep. +6. Use full protobuf method strings from `query.pb.go`, for example + `/cosmos.bank.v1beta1.Query/Balance`. +7. In each `Pack` function, validate the request the same way the old query path + did, then return ABI arguments. +8. In each `Unpack` function, reconstruct the generated protobuf response. Aim + for exact JSON/protobuf parity. If exact parity would make the implementation + much more complex, document the variation in the binding with + `DocumentedVariation` and `Variation`. +9. Keep generic decoding and pagination helpers in `precompiles/query`, not in + module-specific registries. +10. Update the module CLI to construct the precompile-backed query client by + default. +11. Add a hidden backend-selection flag while comparing against legacy behavior + in tests. Bank uses `--query-client-backend=precompile|legacy`. +12. Ensure `--height` is respected by passing `clientCtx.Height` into + `precompilequery.WithDefaultBlockNumber`. + +## Height Handling + +`precompilequery.Conn` chooses the EVM block number in this order: + +1. gRPC block height metadata on the outgoing context, if present. +2. `WithDefaultBlockNumber(clientCtx.Height)`, when the CLI passed `--height`. +3. `nil`, which lets the EVM RPC endpoint use latest. + +Integration tests should assert both legacy and precompile backends observe the +same height for: + +- no `--height` +- explicit `--height=` + +## Pagination And Ordering + +Many precompiles return whole in-memory collections, then the binding applies +Cosmos pagination locally with helpers from `precompiles/query`. + +Important details: + +- Match `sei-cosmos/types/query.Paginate`, including the case where `Offset > 0` + and `Key != nil`. An empty page-key byte slice still conflicts with offset. +- Preserve keeper ordering semantics. For example, bank balances preserve reverse + iterator order, but bank total supply normalizes the returned page through + `sdk.Coins` sorting because the keeper path builds the response through + `Coins.Add`. +- Compare JSON CLI output, not just Go structs, because the user-facing contract + is the CLI response. +- Include page-key, limit, count-total, reverse, invalid offset+key, empty + collection, and missing item cases when relevant. + +## Address Handling + +If a precompile expects EVM addresses but the old CLI accepts Sei bech32 +addresses, use `Env` helpers from `precompiles/query/address.go`: + +- `EVMAddressForSeiAddress` +- `SeiAddressForEVMAddress` + +Choose `RequireAssociation` or `AllowCastAddress` deliberately based on the old +query semantics and the precompile's expected contract. + +## Test Expectations + +For each module, add tests that actually execute the Cobra CLI commands. The +test shape should mirror bank's `query_integration_test.go`: + +- Build controlled app states for latest and historical heights. +- Provide a fake legacy Tendermint RPC client that routes protobuf queries to + the old keeper query server. +- Provide a fake JSON-RPC server that handles `eth_call`, chooses the requested + block height, and executes the module precompile. +- Run each CLI case twice, once with the legacy backend and once with the + precompile backend. +- Compare successful outputs with `require.JSONEq`. +- For expected errors, assert both backends fail. +- Cover every query CLI command in the module, with and without `--height`. +- Include edge cases around missing denoms/IDs, invalid addresses, pagination, + reverse order, page-key, default latest height, and historical height. + +Existing older tests that assume Tendermint RPC/gRPC should opt into the legacy +backend until they are rewritten or removed. + +## Future AI Prompt Context + +When asking an AI agent to implement another module, include this context: + +```text +Implement precompile-backed query clients for x/. + +Architectural constraints: +- Query CLIs are in x//client/cli/query.go. +- Generated QueryClient is in x//types/query.pb.go. +- Bindings must live under precompiles//query. +- Reusable helpers must live under precompiles/query. +- Do not add server-side gRPC/REST integration and do not route through x/evm StaticCall. +- The CLI should default to the precompile-backed QueryClient. +- Add a hidden backend flag only for tests so outputs can be compared against the old QueryClient. +- Respect --height by passing clientCtx.Height to precompilequery.WithDefaultBlockNumber. +- Implement missing read-only precompile methods needed by the query CLIs. +- No query being migrated should remain keeper-backed in the new client. + +Use bank as the reference: +- precompiles/bank/query/registry.go +- sei-cosmos/x/bank/client/cli/query.go +- sei-cosmos/x/bank/client/cli/query_integration_test.go + +Add integration tests that execute the actual CLI commands, compare JSON output +between legacy and precompile backends, and cover every query CLI with and +without --height plus pagination and missing/invalid input edge cases. +```