Query your first View
In this tutorial you'll query a live View on the Shinzo testnet from a small TypeScript script. By the end, the script prints the 10 most recent rows of Erc20Event, a registered View that decodes Transfer events from fungible token contracts on Ethereum mainnet. Everything runs against public testnet infrastructure; the only things you install on your machine are npm packages.
The script we're about to create does four things:
- Find the View in ShinzoHub's registry.
- Pick a Host that serves it.
- Sign the GraphQL request.
- POST it to the Host's endpoint.
Prerequisites
- Node.js 22 or later.
- NPM, which comes with Node.js anyway.
You don't need a wallet, tokens, or any local infrastructure.
Set up the project
-
Check your Node.js version:
node --versionv22.22.1 -
Create a project folder and install the dependencies:
mkdir query-view && cd query-view npm init -y npm pkg set type=module npm install viem canonicalize npm install --save-dev tsxviemcreates the signing key, hashes the query, and produces the EIP-712 signature.canonicalizeserializes the query payload into the canonical JSON form the signature commits to.tsxruns TypeScript files directly. Thenpm pkg setline marks the project as ESM so the script can use top-levelawait.
Connect to ShinzoHub
-
Create a file called
query.tswith the imports and the testnet details:import { keccak256, stringToHex } from "viem"; import { generatePrivateKey, privateKeyToAccount } from "viem/accounts"; import canonicalize from "canonicalize"; const SHINZOHUB_REST = "http://testnet.shinzo.network:1317"; const SHINZOHUB_CHAIN_ID = 91273001;The ShinzoHub registry is readable over a public REST API, so plain
fetchcalls are all the discovery client this script needs.SHINZOHUB_CHAIN_IDis the testnet's chain ID; the request signature uses it later to bind the signature to this network.
Find a View
-
Add the discovery step to
query.ts:const viewsResponse = await fetch( `${SHINZOHUB_REST}/shinzonetwork/view/v1/views?pagination.limit=25&include_metadata=true`, ).then((r) => r.json()); const view = viewsResponse.views.find((v) => v.name === "Erc20Event"); if (!view) { throw new Error("Erc20Event is not registered"); } console.log(`View: ${view.name} at ${view.address}`); console.log(`SDL: ${view.metadata?.sdl}`);The views endpoint lists the View registry. Each entry has a
name, a deterministicaddress, and, wheninclude_metadatais set, the parsed bundle: the source query, the SDL, and lens details. The script picksErc20Eventby name and prints its SDL, which lists the fields a query can ask for.You can also browse registered Views in the Shinzo Explorer.
Pick a Host
A View is served by a pool of Hosts. The pools endpoint returns the pools that exist for a View, and a pool becomes active once at least 3 Hosts have joined it. Each registered Host advertises an endpoint_address, the full URL of its GraphQL API.
-
Add the Host selection step:
const poolsResponse = await fetch( `${SHINZOHUB_REST}/shinzonetwork/pool/v1/views/${view.address}/pools`, ).then((r) => r.json()); const details = poolsResponse.details ?? []; const detail = details.find((d) => d.is_active) ?? details[0]; if (!detail) { throw new Error("No pool exists yet for this View"); } const poolAddress = detail.pool.pool_address; const poolHosts = detail.hosts ?? []; console.log(`Pool: ${poolAddress} with ${poolHosts.length} Hosts (active: ${detail.is_active})`); const hostsResponse = await fetch( `${SHINZOHUB_REST}/shinzonetwork/host/v1/hosts?pagination.limit=100`, ).then((r) => r.json()); const members = new Set(poolHosts.map((h) => h.host_address)); const endpoints = hostsResponse.hosts.flatMap((h) => h.endpoint_address ? [{ endpoint: h.endpoint_address, inPool: members.has(h.address) }] : [], ); const candidates = [ ...endpoints.filter((e) => e.inPool), ...endpoints.filter((e) => !e.inPool), ];Pools track their members by Shinzo account address, which is the same bech32 address the Host registry returns, so matching pool members to endpoints is a plain string comparison. The result is a candidate list with pool members first.
NoteRegistered endpoints can go stale on a testnet. The script tries pool members first, then falls back to any other registered Host that answers. Hosts replicate the Views they subscribe to, so the data is the same either way.
Sign the query
Hosts expect every View query to carry a signature. The script hashes your query (the RFC 8785 canonical JSON form of { query, variables }, run through keccak256), builds an EIP-712 QueryRequest over the query hash, a nonce, a timestamp, and the pool address, then asks the signer for a signature. Here the signer is a freshly generated key from viem; in a browser app the same typed data goes to the user's wallet.
-
Add the query and the signing step:
const account = privateKeyToAccount(generatePrivateKey()); const query = `query LatestEvents { Erc20Event(filter: { event: { _eq: "Transfer" } }, limit: 100) { blockNumber event logAddress arguments } }`; const variables = {}; const canonical = canonicalize({ query, variables }) as string; const queryHash = keccak256(stringToHex(canonical)); const nonceBytes = crypto.getRandomValues(new Uint8Array(32)); const nonce = `0x${Array.from(nonceBytes, (b) => b.toString(16).padStart(2, "0")).join("")}`; const timestamp = Math.floor(Date.now() / 1000); const signature = await account.signTypedData({ domain: { name: "ShinzoQueryBilling", version: "1", chainId: SHINZOHUB_CHAIN_ID }, types: { QueryRequest: [ { name: "queryHash", type: "bytes32" }, { name: "nonce", type: "bytes32" }, { name: "timestamp", type: "uint256" }, { name: "pool", type: "address" }, ], }, primaryType: "QueryRequest", message: { queryHash, nonce, timestamp: BigInt(timestamp), pool: poolAddress }, }); const signed = { query, variables, extensions: { request_signature: signature, nonce, query_hash: queryHash, request_timestamp: timestamp, pool_address: poolAddress, fanout: 1, }, }; console.log("Signed request extensions:"); console.log(JSON.stringify(signed.extensions, null, 2));The query asks for the block number, the event name, the contract that emitted the log (
logAddress), andarguments, where the View's lens puts the decoded event parameters. The SDL also listshash,from, andto, but this version of the View leaves those empty.The result is the query plus an
extensionsenvelope:request_signatureis the EIP-712 signature,query_hashbinds the signature to this exact query,nonceandrequest_timestampkeep the request fresh, andpool_addressnames the pool the query bills to.fanoutis only read by the network gateway; Hosts ignore it.NoteQuery billing is not enforced on the testnet yet, so a freshly generated key with no funds is enough here. Signed requests are still the supported interface: once Hosts enforce billing, they will reject unsigned View queries and check the signer's query balance.
Send the query
-
Add the send loop:
let answered = false; for (const candidate of candidates) { try { const response = await fetch(candidate.endpoint, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json" }, body: JSON.stringify({ query: signed.query, variables: signed.variables, extensions: signed.extensions, }), signal: AbortSignal.timeout(15_000), }); if (!response.ok) continue; const body = await response.json(); const rows = body.data?.Erc20Event; if (!rows?.length) continue; console.log(`Answer from ${candidate.endpoint}:`); const latest = rows .sort((a: { blockNumber: number }, b: { blockNumber: number }) => b.blockNumber - a.blockNumber) .slice(0, 10); for (const row of latest) { const arg = (name: string) => row.arguments?.find((a: { name: string }) => a.name === name)?.value ?? ""; console.log(`${row.blockNumber} ${row.event} from=${arg("from")} to=${arg("to")} value=${arg("value")}`); } answered = true; break; } catch { // This endpoint did not answer; try the next one. } } if (!answered) { throw new Error("No Host answered. Wait a minute and run the script again."); }The request body is standard GraphQL-over-HTTP plus the
extensionsenvelope. The script POSTs to each candidate until one answers, sorts the returned rows byblockNumber, and prints the 10 most recent. Sorting locally keeps the request to one bounded page; Query data covers the server-sidefilterandorderarguments if you want the Host to do that work.
Run the script
-
Run it:
npx tsx query.tsView: Erc20Event at 0xEAc245f905e0aAcF3b9Fe27153F2AaF485dc1B48 SDL: type Erc20Event @materialized(if: false) { hash: String blockNumber: Int from: String to: String logAddress: String event: String signature: String arguments: String } Pool: 0xDbc3bE7CBd8Dc8901E3BbbeA1A740BE490dAe23B with 3 Hosts (active: true) Signed request extensions: { "request_signature": "0x7af2957e06e2a8077b881b18bb2d00b2adce8a5f8ebed78f15f5f85ac1684e5a5e2443384d353efac483e3c15dc45d9f2f9c72559c820b5a5a697aaa18521a611c", "nonce": "0xfa7ee71bd179843e04944a083c999ba32805f758cdae2699f604c9c9928c2219", "query_hash": "0xfa3ec6d0ff7e89a1bea76e1042b12d4e89366f13efaf935217f7331c8a44a8c2", "request_timestamp": 1788433155, "pool_address": "0xDbc3bE7CBd8Dc8901E3BbbeA1A740BE490dAe23B", "fanout": 1 } Answer from http://51.178.74.112:9181/api/v0/graphql: 25806062 Transfer from=0xcdb71d4c6b3a0d470201f848e50c7411a521ee04 to=0xc1d13492285eb664951e201bf7c80c7c6318a1b5 value=2000000000 25806062 Transfer from=0xbbbbbbbbbb9cc5e90e3b3af64bdaf62c37eeffcb to=0xd226997439ecfbeff8e110c8c78c8a7eefd19f89 value=102876390302159 25806062 Transfer from=0xd226997439ecfbeff8e110c8c78c8a7eefd19f89 to=0xdcef968d416a41cdac0ed8702fac8128a64241a2 value=421000000 25806062 Transfer from=0x09fc9b7545020f6a51d113e495e0a451597969d3 to=0xf8e349d1d827a6edf17ee673664cfad4ca78c533 value=374880000 25806062 Transfer from=0x9642b23ed1e01df1092b92641051881a322f5d4e to=0x89df61e9ae683899d376ed60964d9e8c3fb27160 value=25265642000 25806062 Transfer from=0x22ec88b9ff78c6f2458ab1a7aa8bb99d84bd4b86 to=0x003896387666c5c11458eeb3f927b72a11b19783 value=2212522960 25805853 Transfer from=0x3416cf6c708da44db2624d63ea0aaef7113527c6 to=0xace0fabed501e819ecc15e67c7ed3a67c2f67e91 value=629288918 25805853 Transfer from=0xace0fabed501e819ecc15e67c7ed3a67c2f67e91 to=0x2c158bc456e027b2affccadf1bdbd9f5fc4c5c8c value=628725538 25805853 Transfer from=0x2c158bc456e027b2affccadf1bdbd9f5fc4c5c8c to=0x3312cc371fe0dd5171878630a1e5cf69778e8fa5 value=628725538 25805153 Transfer from=0xe0554a476a092703abdb3ef35c80e0d76d32939f to=0xa0f1c3ad83e07d97b5e7030e177718be175275ea value=3741969400Registered Hosts, pool membership, and the rows themselves change as the network moves, so your addresses and values will differ.
Where to next
- Create your first View to own the data: build and deploy a View of your own, then query it with this same script.
- Build a local-first app for the embedded version: subscribe to a View and query pushed data locally instead of per request.
- Choosing an app architecture for the trade-offs between the two models.
Need help
- For onboarding and technical support, join the Shinzo Discord.
- To report a documentation bug or request a feature, open an issue in the docs repo.
- For a technical issue with the Host client, open an issue in the shinzo-host-client repo.