Query Guide
How the JSON-RPC and REST APIs fit together, browser and library integration, and troubleshooting
This guide covers what the reference pages do not: how the two API families fit together, how to call them from browsers and libraries, and what to do when a call fails. Every method and endpoint, with parameters and request/response examples, is documented in the JSON-RPC Reference and the REST API Reference.
| Task | Where to look |
|---|---|
Transaction, receipt, logs by 0x hash | JSON-RPC Reference → Transactions |
| Transfer history of one token | JSON-RPC Reference → Token Transfers and Logs (eth_getLogs) |
| All transactions of one address | REST API Reference → Transactions (/cosmos/tx/v1beta1/txs?query=) |
| Balances, blocks, fees | JSON-RPC Reference → Accounts and State, Blocks, Chain and Fees |
| Cosmos tx by HEX hash, token pairs, multi-denom balances | REST API Reference |
| Real-time subscriptions | JSON-RPC Reference → Real-time and Filters |
Explorer-style routes such as /transactions, /token-transfers, or /search belong to the Blockscout API v2,
not to the node. They only exist once a Blockscout instance is indexing the node.
Address Conversion (hex ↔ bech32)
The same key is shown as 0x… on the EVM side and zenanet1… on the Cosmos side. The underlying 20 bytes are identical.
zenad debug addr 0x<address> # → bech32
zenad debug addr zenanet1... # → hexInside a contract, call hexToBech32 / bech32ToHex on the bech32 precompile at
0x0000000000000000000000000000000000000400.
Calling from a Web Frontend
Request and response formats are identical to the curl examples. What changes in a browser is CORS, origin checks on WebSocket, and whether you go through a wallet.
CORS behaviour per server
| Server | Direct browser calls | Node setting |
|---|---|---|
JSON-RPC HTTP :8545 | allowed by default | POST with Content-Type: application/json from any origin. Custom headers need --api.enabled-unsafe-cors |
JSON-RPC WS :8546 | origin whitelist | [json-rpc] ws-origins defaults to ["127.0.0.1", "localhost"]. Add your frontend host, or "*" for all |
Cosmos REST :1317 | blocked by default | [api] enabled-unsafe-cors = true |
CometBFT RPC :26657 | blocked by default | config.toml [rpc] cors_allowed_origins = ["*"] |
On the public mainnet hosts, an nginx reverse proxy terminates TLS and handles CORS, so https://evm.zena-ai.io and
https://api.zena-ai.io are callable from browsers as-is. If you run your own proxy:
- Do not duplicate CORS headers. The JSON-RPC server already sets
Access-Control-Allow-Origin; adding it again in nginx makes browsers reject the response. The REST server sets none, so nginx should add it there. - Handle
OPTIONSpreflight. JSONPOSTtriggers a preflight. The JSON-RPC server answers it, but if youPOSTto REST (for example/cosmos/tx/v1beta1/simulate), nginx must return204forOPTIONS.GETneeds no preflight. - WebSocket path
/ws. Setproxy_set_header Upgrade $http_upgradeandproxy_set_header Connection "upgrade". The browser'sOriginheader reaches the node, so the frontend hostname must be inws-origins. - No mixed content. An HTTPS page cannot call an
http://RPC. Expose the node over HTTPS through the proxy.
fetch
async function rpc<T>(method: string, params: unknown[] = []): Promise<T> {
const res = await fetch("https://evm.zena-ai.io", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }),
});
const json = await res.json();
if (json.error) throw new Error(`${json.error.code}: ${json.error.message}`);
return json.result;
}
const tx = await rpc("eth_getTransactionByHash", ["0x<txhash>"]);
const receipt = await rpc("eth_getTransactionReceipt", ["0x<txhash>"]);
const logs = await rpc("eth_getLogs", [{
address: "0x<token_contract>",
fromBlock: "0x1000", toBlock: "0x3000",
topics: ["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"],
}]);
// Cosmos REST is a plain GET
const balances = await fetch("https://api.zena-ai.io/cosmos/bank/v1beta1/balances/zenanet1...")
.then(r => r.json());Through a wallet provider (MetaMask)
Requests made through window.ethereum go out via the wallet's configured RPC URL, so CORS never applies.
Read-only queries work through this path too.
import { BrowserProvider } from "ethers";
const provider = new BrowserProvider(window.ethereum);
await provider.send("wallet_switchEthereumChain", [{ chainId: "0x3b2e" }]); // 15150
const receipt = await provider.getTransactionReceipt("0x<txhash>");
const logs = await provider.getLogs({ address: "0x<token_contract>", fromBlock: 0x1000, toBlock: 0x3000 });Network values for the wallet are listed in Add to MetaMask.
WebSocket from the browser
const ws = new WebSocket("wss://evm.zena-ai.io/ws");
ws.onopen = () => ws.send(JSON.stringify({
jsonrpc: "2.0", id: 1, method: "eth_subscribe",
params: ["logs", { address: "0x<token_contract>",
topics: ["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"] }],
}));
ws.onmessage = (e) => console.log(JSON.parse(e.data));Browsers attach an Origin header to the upgrade request and the node checks it against ws-origins.
If the host is missing, the connection is closed silently. curl and server-side clients send no Origin, so they pass.
Library Examples
ethers.js v6
import { JsonRpcProvider, Contract, id, zeroPadValue } from "ethers";
const provider = new JsonRpcProvider("https://evm.zena-ai.io", 15150);
// tx / receipt by hash
const tx = await provider.getTransaction("0x<txhash>");
const receipt = await provider.getTransactionReceipt("0x<txhash>");
// Transfer logs of one token, received by one wallet
const TRANSFER = id("Transfer(address,address,uint256)");
const logs = await provider.getLogs({
address: "0x<token_contract>",
fromBlock: 0x1000,
toBlock: 0x3000, // within 10,000 blocks
topics: [TRANSFER, null, zeroPadValue("0x<wallet>", 32)],
});
// ERC-20 balance
const erc20 = new Contract("0x<token_contract>",
["function balanceOf(address) view returns (uint256)"], provider);
const bal = await erc20.balanceOf("0x<wallet>");Foundry cast
export ETH_RPC_URL=https://evm.zena-ai.io
cast tx 0x<txhash>
cast receipt 0x<txhash>
cast logs --from-block 4096 --to-block 12288 \
--address 0x<token_contract> \
'Transfer(address indexed from, address indexed to, uint256 value)'
cast call 0x<token_contract> 'balanceOf(address)(uint256)' 0x<wallet>Troubleshooting
| Symptom | Cause / fix |
|---|---|
eth_getLogs returns block range exceeds cap | Range over 10,000 blocks. Split the call |
eth_getTransactionByHash is slow | Set enable-indexer = true on the node and restart |
Cosmos REST :1317 does not respond | Check [api] enable = true in app.toml |
txpool_* / debug_* return Method not found | Add the namespace to json-rpc.api |
A MsgSend transfer is missing from eth_getLogs | Native transfers emit no logs. Use the bank event search |
/cosmos/tx/v1beta1/txs?events=… returns an error | SDK v0.53 expects query=. Paginate with limit and page |
ethereum_tx.recipient search returns nothing | The value is case-sensitive. Use the EIP-55 checksum form of the address |
eth_call fails on a heavy view function | Gas cap 25,000,000 and 5 s timeout per call by default |
Browser call to REST :1317 fails with CORS | [api] enabled-unsafe-cors = true or handle CORS in the reverse proxy |
| Browser WebSocket disconnects immediately | Add the frontend hostname to ws-origins |
| RPC call fails from an HTTPS page | Mixed content. Expose RPC over HTTPS via the proxy |
| Balance looks wrong | EVM uses 18 decimals (aznnt). Divide by 10^18 for znnt |