diff --git a/.ai/categories/queries.md b/.ai/categories/queries.md index 4113fc3c0..f0aac1b27 100644 --- a/.ai/categories/queries.md +++ b/.ai/categories/queries.md @@ -5430,6 +5430,552 @@ Demos offer more realistic implementations than tutorials: Wormhole supports a growing number of blockchains. Check out the [Supported Networks by Product](/docs/products/reference/supported-networks/){target=\_blank} page to see which networks are supported for each Wormhole product. +--- + +Page Title: Live Crypto Price Widget + +- Source (raw): https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/main/.ai/pages/products-queries-tutorials-live-crypto-prices.md +- Canonical (HTML): https://wormhole.com/docs/products/queries/tutorials/live-crypto-prices/ +- Summary: Learn how to fetch real-time crypto prices using Wormhole Queries and display them in a live widget powered by secure and verified Witnet data feeds. + +# Live Crypto Price Widget + +:simple-github: [Source code on GitHub](https://github.com/wormhole-foundation/e2e-tutorial-live-crypto-prices){target=\_blank} + +In this tutorial, you'll build a widget that displays live crypto prices using [Wormhole Queries](/docs/products/queries/overview/){target=\_blank} and [Witnet](https://witnet.io/){target=\_blank} data feeds. You'll learn how to request signed price data from the network, verify the response, and show it in a responsive frontend built with [Next.js](https://nextjs.org/){target=\_blank} and [TypeScript](https://www.typescriptlang.org/){target=\_blank}. + +Queries enable fetching verified off-chain data directly on-chain or in web applications without requiring your own oracle infrastructure. Each response is cryptographically signed by the [Wormhole Guardians](/docs/protocol/infrastructure/guardians/){target=\_blank}, ensuring authenticity and preventing tampering. By combining Queries with Witnet's decentralized price feeds, you can access real-time, trustworthy market data through a single API call, without managing relayers or custom backends. + +## Prerequisites + +Before starting, make sure you have the following set up: + + - [Node.js and npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm){target=\_blank} installed on your system + - A [Wormhole Queries API key](/docs/products/queries/get-started/#request-an-api-key){target=\_blank} + - Access to an EVM-compatible [testnet RPC](https://chainlist.org/?testnets=true){target=\_blank}, such as Arbitrum Sepolia + - A [Witnet data feed identifier](https://docs.witnet.io/smart-contracts/witnet-data-feeds/addresses){target=\_blank} (this tutorial uses the ETH/USD feed as an example) + + You can use a different Witnet feed or testnet if you prefer. Make sure to update the environment variables later in this tutorial with the correct values for your setup. + +## Project Setup + +In this section, you will create a new Next.js project, install the required dependencies, and configure the environment variables needed to fetch data from Wormhole Queries. + +1. **Create a new Next.js app**: Enable TypeScript, Tailwind CSS, and the `src/` directory when prompted. You can configure the remaining options as you like. Create your app using the following command: + + ```bash + npx create-next-app@latest live-crypto-prices + cd live-crypto-prices + ``` + +2. **Install dependencies**: Add the required packages. + + ```bash + npm install @wormhole-foundation/wormhole-query-sdk axios ethers + ``` + + - [`@wormhole-foundation/wormhole-query-sdk`](https://www.npmjs.com/package/@wormhole-foundation/wormhole-query-sdk){target=\_blank}: Build, send, and decode Wormhole Queries. + - [`axios`](https://www.npmjs.com/package/axios){target=\_blank}: Make JSON-RPC and Query Proxy requests. + - [`ethers`](https://www.npmjs.com/package/ethers){target=\_blank}: Handle ABI encoding and decoding for Witnet calls. + +3. **Add environment variables**: Create a file named `.env.local` in the project root. + + ```env + # Wormhole Query Proxy + QUERY_URL=https://testnet.query.wormhole.com/v1/query + QUERIES_API_KEY=INSERT_API_KEY + + # Chain and RPC + WORMHOLE_CHAIN_ID=10003 + RPC_URL=https://arbitrum-sepolia.drpc.org + + # Witnet Price Router on Arbitrum Sepolia + CALL_TO=0x1111AbA2164AcdC6D291b08DfB374280035E1111 + + # ETH/USD feed on Witnet, six decimals + FEED_ID4=0x3d15f701 + FEED_DECIMALS=6 + FEED_HEARTBEAT_SEC=86400 + ``` + + !!! warning + Make sure to add the `.env.local` file to your `.gitignore` to exclude it from version control. Never commit API keys to your repository. + + You can use a different Witnet feed or network by updating `CALL_TO`, `FEED_ID4`, `FEED_DECIMALS`, and `WORMHOLE_CHAIN_ID`. These values allow the app to fetch a live ETH/USD price with proper scaling, timestamps, and a signed response. + + +4. **Add a configuration file**: Create `src/lib/config.ts` to access environment variables throughout the app. + + ```ts title="src/lib/config.ts" + export const QUERY_URL = process.env.QUERY_URL!; + export const QUERIES_API_KEY = process.env.QUERIES_API_KEY!; + export const RPC_URL = process.env.RPC_URL!; + + export const DEFAULTS = { + chainId: Number(process.env.WORMHOLE_CHAIN_ID || 0), + to: process.env.CALL_TO || '', + feedId4: process.env.FEED_ID4 || '', + feedDecimals: Number(process.env.FEED_DECIMALS || 0), + feedHeartbeatSec: Number(process.env.FEED_HEARTBEAT_SEC || 0), + }; + + ``` + +## Build the Server Logic + +In this section, you will implement the backend that powers the widget. You will encode the Witnet call, create and send a Wormhole Query, decode the signed response, and expose an API route for the frontend. + +### Encode Witnet Call and Build the Request + +First, encode the function call for Witnet's Price Router using the feed ID and package it into a Wormhole Query request. This query will be anchored to the latest block, ensuring the data you receive is verifiably tied to a recent snapshot of the chain state. This helper will return a serialized request that can be sent to the Wormhole Query Proxy. + +```ts title="src/lib/queries/buildRequest.ts" +import axios from 'axios'; +import { + EthCallQueryRequest, + PerChainQueryRequest, + QueryRequest, +} from '@wormhole-foundation/wormhole-query-sdk'; +import { Interface } from 'ethers'; + +// ABI interface for Witnet's Price Router +const WITNET_IFACE = new Interface([ + // Function signature for reading the latest price feed + 'function latestPrice(bytes4 id) view returns (int256 value, uint256 timestamp, bytes32 drTxHash, uint8 status)', +]); + +// Encode calldata for Witnet Router: latestPrice(bytes4) +export function encodeWitnetLatestPrice(id4: string): string { + // Validate feed ID format (must be a 4-byte hex) + if (!/^0x[0-9a-fA-F]{8}$/.test(id4)) { + throw new Error(`Invalid FEED_ID4: ${id4}`); + } + // Return ABI-encoded call data for latestPrice(bytes4) + return WITNET_IFACE.encodeFunctionData('latestPrice', [id4 as `0x${string}`]); +} + +export async function buildEthCallRequest(params: { + rpcUrl: string; + chainId: number; // Wormhole chain id + to: string; + data: string; // 0x-prefixed calldata +}) { + const { rpcUrl, chainId, to, data } = params; + + // Get the latest block number via JSON-RPC + // Short timeout prevents long hangs in the dev environment + const latestBlock: string = ( + await axios.post( + rpcUrl, + { + method: 'eth_getBlockByNumber', + params: ['latest', false], + id: 1, + jsonrpc: '2.0', + }, + { timeout: 5_000, headers: { 'Content-Type': 'application/json' } } + ) + ).data?.result?.number; + + if (!latestBlock) throw new Error('Failed to fetch latest block'); + + // Build a Wormhole Query that wraps an EthCall to the Witnet contract + const request = new QueryRequest(1, [ + new PerChainQueryRequest( + chainId, + new EthCallQueryRequest(latestBlock, [{ to, data }]) + ), + ]); + + // Serialize to bytes for sending to the Wormhole Query Proxy + return request.serialize(); // Uint8Array +} + +``` + +### Send Request to the Query Proxy + +Next, you will send the serialized query to the Wormhole Query Proxy, which forwards it to the Guardians for verification. The proxy returns a signed response containing the requested data and proof that the Guardians verified it. This step ensures that all the data your app consumes comes from a trusted and authenticated source. + +```ts title="src/lib/queries/client.ts" +import axios from 'axios'; + +export async function postQuery({ + queryUrl, + apiKey, + bytes, + timeoutMs = 25_000, +}: { + queryUrl: string; + apiKey: string; + bytes: Uint8Array; + timeoutMs?: number; +}) { + // Convert the query bytes to hex and POST to the proxy + const res = await axios.post( + queryUrl, + { bytes: Buffer.from(bytes).toString('hex') }, + { + timeout: timeoutMs, + headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' }, + validateStatus: (s) => s === 200, + } + ); + return res.data; // throws on non-200 +} + +``` + +### Decode and Verify Response + +Once you receive the signed response, you will decode it to extract the Witnet price data. +Here, you will use ethers to parse the ABI-encoded return values and scale the raw integer to a readable decimal value based on the feed's configured number of decimals. This function will output a clean result containing the latest price, timestamp, and transaction reference from the Witnet feed. + +```ts title="src/lib/queries/decode.ts" +import { + EthCallQueryResponse, + QueryResponse, +} from '@wormhole-foundation/wormhole-query-sdk'; +import { Interface, Result } from 'ethers'; + +// ABI interface for decoding Witnet's latestPrice response +const WITNET_IFACE = new Interface([ + 'function latestPrice(bytes4 id) view returns (int256 value, uint256 timestamp, bytes32 drTxHash, uint8 status)', +]); + +// Parse the first EthCall result from the proxy's response +export function parseFirstEthCallResult(proxyResponse: { bytes: string }): { + chainResp: EthCallQueryResponse; + raw: string; +} { + // Decode the top-level QueryResponse from Wormhole Guardians + const qr = QueryResponse.from(proxyResponse.bytes); + + // Extract the first chain response and its raw call result + const chainResp = qr.responses[0].response as EthCallQueryResponse; + const raw = chainResp.results[0]; + return { chainResp, raw }; +} + +// Decode Witnet's latestPrice return tuple into readable fields +export function decodeWitnetLatestPrice( + raw: string, + decimals: number +): { price: string; timestampSec: number; drTxHash: string } { + // Decode ABI-encoded result from the router call + const r: Result = WITNET_IFACE.decodeFunctionResult('latestPrice', raw); + const value = BigInt(r[0].toString()); + const timestampSec = Number(r[1].toString()); + const drTxHash = r[2] as string; + + return { + price: scaleBigintToDecimalString(value, decimals), + timestampSec, + drTxHash, + }; +} + +// Convert a bigint price into a human-readable decimal string +function scaleBigintToDecimalString(value: bigint, decimals: number): string { + const zero = BigInt(0); + const neg = value < zero ? '-' : ''; + const v = value < zero ? -value : value; + const s = v.toString().padStart(decimals + 1, '0'); + const i = s.slice(0, -decimals); + const f = s.slice(-decimals).replace(/0+$/, ''); + return neg + (f ? `${i}.${f}` : i); +} + +``` + +### Add Shared Types + +Create a `src/lib/types.ts` file to define the structure of your API responses. These types ensure consistency between the backend and the frontend, keeping the data shape predictable and type-safe. You will import these types in both the API route and the widget to keep your responses aligned across the app. + +```ts title="src/lib/types.ts" +export interface QueryApiSuccess { + ok: true; + blockNumber: string; + blockTimeMicros: string; + price: string; + decimals: number; + updatedAt: string; + stale?: boolean; +} + +export interface QueryApiError { + ok: false; + error: string; +} +export type QueryApiResponse = QueryApiSuccess | QueryApiError; + +``` + +### Add API Route for Frontend + +Finally, expose an API endpoint at `/api/queries`. This route ties everything together: it builds the query, sends it, decodes the response, and returns a structured JSON payload containing the current price, timestamp, block number, and a stale flag indicating whether the feed data is still fresh. The frontend widget will call this endpoint every few seconds to display the live, verified price data. + +```ts title="src/app/api/queries/route.ts" +import { NextResponse } from 'next/server'; +import { + buildEthCallRequest, + encodeWitnetLatestPrice, +} from '@/lib/queries/buildRequest'; +import { postQuery } from '@/lib/queries/client'; +import { QUERY_URL, QUERIES_API_KEY, RPC_URL, DEFAULTS } from '@/lib/config'; +import { + parseFirstEthCallResult, + decodeWitnetLatestPrice, +} from '@/lib/queries/decode'; +import type { QueryApiSuccess, QueryApiError } from '@/lib/types'; + +export async function GET() { + const t0 = Date.now(); + try { + // Encode the call for Witnet’s latestPrice(bytes4) + const data = encodeWitnetLatestPrice(DEFAULTS.feedId4); + + // Build a Wormhole Query request anchored to the latest block + const bytes = await buildEthCallRequest({ + rpcUrl: RPC_URL, + chainId: DEFAULTS.chainId, + to: DEFAULTS.to, + data, + }); + const t1 = Date.now(); + + // Send the query to the Wormhole Query Proxy and await the signed response + const proxyResponse = await postQuery({ + queryUrl: QUERY_URL, + apiKey: QUERIES_API_KEY, + bytes, + timeoutMs: 25_000, + }); + const t2 = Date.now(); + + // Decode the signed Guardian response and extract Witnet data + const { chainResp, raw } = parseFirstEthCallResult(proxyResponse); + const { price, timestampSec } = decodeWitnetLatestPrice( + raw, + DEFAULTS.feedDecimals + ); + + // Log the latency of each leg for debugging + console.log(`RPC ${t1 - t0}ms → Proxy ${t2 - t1}ms`); + + // Mark data as stale if older than the feed’s heartbeat interval + const heartbeat = Number(process.env.FEED_HEARTBEAT_SEC || 0); + const stale = heartbeat > 0 && Date.now() / 1000 - timestampSec > heartbeat; + + // Return a normalized JSON payload for the frontend + const body: QueryApiSuccess = { + ok: true, + blockNumber: chainResp.blockNumber.toString(), + blockTimeMicros: chainResp.blockTime.toString(), + price, + decimals: DEFAULTS.feedDecimals, + updatedAt: new Date(timestampSec * 1000).toISOString(), + stale, + }; + return NextResponse.json(body); + } catch (e: unknown) { + // Catch and return a structured error + const message = e instanceof Error ? e.message : String(e); + console.error('Error in /api/queries:', message); + const body: QueryApiError = { ok: false, error: message }; + return NextResponse.json(body, { status: 500 }); + } +} + +``` + +## Price Widget + +In this section, you will build a client component that fetches the signed price from your API, renders it with a freshness badge, and refreshes on an interval without overlapping requests. + +### Create Widget Component + +Create a client component that calls `/api/queries`, renders the current price, shows the last update time and block number, and displays a freshness badge based on the heartbeat. The component uses a ref to avoid overlapping requests and a timed interval to refresh automatically. + +```ts title="src/components/PriceWidget.tsx" +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +// Expected API success shape from /api/queries +type ApiOk = { + ok: true; + price: string; + updatedAt: number | string; + blockNumber: string; + blockTimeMicros: number; + decimals: number; + stale: boolean; +}; + +// API error shape +type ApiErr = { ok: false; error: string }; + +// Format timestamps for display +function formatTime(ts: number | string) { + let n: number; + if (typeof ts === 'string') { + const numeric = Number(ts); + if (Number.isFinite(numeric)) { + n = numeric; + } else { + const parsed = new Date(ts); + return Number.isNaN(parsed.getTime()) ? '—' : parsed.toLocaleString(); + } + } else { + n = ts; + } + if (!Number.isFinite(n)) return '—'; + // If it looks like seconds, convert to ms + const ms = n < 1_000_000_000_000 ? n * 1000 : n; + const d = new Date(ms); + return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString(); +} + +export default function PriceWidget() { + // UI state: fetched data, loading state, and any errors + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + // Keep track of polling and prevent overlapping requests + const timer = useRef(null); + const inFlight = useRef(false); + + // Fetch price data from the API route + async function fetchPrice() { + if (inFlight.current) return; // avoid concurrent requests + inFlight.current = true; + setLoading(true); + setError(null); + + try { + const res = await fetch('/api/queries', { cache: 'no-store' }); + const json: ApiOk | ApiErr = await res.json(); + if (!json.ok) throw new Error(json.error); + setData(json); + } catch (e: any) { + setError(e?.message || 'Failed to fetch price'); + } finally { + setLoading(false); + inFlight.current = false; + } + } + + // Fetch immediately and refresh every 30 seconds + useEffect(() => { + fetchPrice(); + timer.current = setInterval(fetchPrice, 30_000); + return () => { + if (timer.current) clearInterval(timer.current); + }; + }, []); + + return ( +
+
+

ETH/USD Live Price

+ {data?.stale ? ( + + Stale + + ) : ( + + Fresh + + )} +
+ +
+
+ {loading && !data ? 'Loading…' : data ? data.price : '—'} +
+ +
+ {data ? ( + <> + Updated at {formatTime(data.updatedAt)}, block {data.blockNumber} + + ) : error ? ( + {error} + ) : ( + 'Fetching latest price' + )} +
+
+ +
+ +
+
+ ); +} + +``` + +### Add the Widget to Home Page + +Render the widget on the home page with a simple heading and container so users see the price as soon as they load the app. + +```ts title="src/app/page.tsx" +import PriceWidget from '@/components/PriceWidget'; + +export default function Page() { + return ( +
+

+ Live Crypto Price Widget +

+ +
+ ); +} + +``` + +## Run the App + +Start the development server and confirm that the live widget displays data correctly: + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) to see your app running. You should see the widget display the current ETH/USD price, the last update time, the block number, and a freshness badge indicating whether the data is still within its heartbeat window. + +The price may update only intermittently. Witnet feeds refresh only when a particular time or price deviation threshold is reached to prevent unnecessary network updates. + +Your app should look like this: + +![Frontend of Queries Live Prices Widget](/docs/images/products/queries/tutorials/live-crypto-prices/live-crypto-prices-1.webp){.half} + +???- tip "Troubleshooting" + If you encounter a “Request failed with status code 403” error, it likely means your Queries API key is missing or incorrect. Check the `QUERIES_API_KEY` value in your `.env.local` file and restart the development server after updating it. + +## Resources + +If you'd like to explore the complete project or need a reference while following this tutorial, you can find the complete codebase in the Wormhole's Queries [Tutorial GitHub repository](https://github.com/wormhole-foundation/e2e-tutorial-live-crypto-prices){target=\_blank}. + +## Conclusion + +You've successfully built a live crypto price widget that fetches verified data from Wormhole Queries and Witnet. Your app encodes a feed request, sends it through the Guardian network for verification, and displays the latest signed price in a simple, responsive widget. + +The Queries flow can be extended to fetch other on-chain data or integrate multiple feeds for dashboards and analytics tools. + +Looking for more? Check out the [Wormhole Tutorial Demo repository](https://github.com/wormhole-foundation/demo-tutorials){target=\_blank} for additional examples. + + --- Page Title: Messaging Overview diff --git a/.ai/pages/products-queries-tutorials-live-crypto-prices.md b/.ai/pages/products-queries-tutorials-live-crypto-prices.md new file mode 100644 index 000000000..d0102e37a --- /dev/null +++ b/.ai/pages/products-queries-tutorials-live-crypto-prices.md @@ -0,0 +1,543 @@ +--- +title: Live Crypto Price Widget +description: Learn how to fetch real-time crypto prices using Wormhole Queries and display them in a live widget powered by secure and verified Witnet data feeds. +categories: Queries +url: https://wormhole.com/docs/products/queries/tutorials/live-crypto-prices/ +--- + +# Live Crypto Price Widget + +:simple-github: [Source code on GitHub](https://github.com/wormhole-foundation/e2e-tutorial-live-crypto-prices){target=\_blank} + +In this tutorial, you'll build a widget that displays live crypto prices using [Wormhole Queries](/docs/products/queries/overview/){target=\_blank} and [Witnet](https://witnet.io/){target=\_blank} data feeds. You'll learn how to request signed price data from the network, verify the response, and show it in a responsive frontend built with [Next.js](https://nextjs.org/){target=\_blank} and [TypeScript](https://www.typescriptlang.org/){target=\_blank}. + +Queries enable fetching verified off-chain data directly on-chain or in web applications without requiring your own oracle infrastructure. Each response is cryptographically signed by the [Wormhole Guardians](/docs/protocol/infrastructure/guardians/){target=\_blank}, ensuring authenticity and preventing tampering. By combining Queries with Witnet's decentralized price feeds, you can access real-time, trustworthy market data through a single API call, without managing relayers or custom backends. + +## Prerequisites + +Before starting, make sure you have the following set up: + + - [Node.js and npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm){target=\_blank} installed on your system + - A [Wormhole Queries API key](/docs/products/queries/get-started/#request-an-api-key){target=\_blank} + - Access to an EVM-compatible [testnet RPC](https://chainlist.org/?testnets=true){target=\_blank}, such as Arbitrum Sepolia + - A [Witnet data feed identifier](https://docs.witnet.io/smart-contracts/witnet-data-feeds/addresses){target=\_blank} (this tutorial uses the ETH/USD feed as an example) + + You can use a different Witnet feed or testnet if you prefer. Make sure to update the environment variables later in this tutorial with the correct values for your setup. + +## Project Setup + +In this section, you will create a new Next.js project, install the required dependencies, and configure the environment variables needed to fetch data from Wormhole Queries. + +1. **Create a new Next.js app**: Enable TypeScript, Tailwind CSS, and the `src/` directory when prompted. You can configure the remaining options as you like. Create your app using the following command: + + ```bash + npx create-next-app@latest live-crypto-prices + cd live-crypto-prices + ``` + +2. **Install dependencies**: Add the required packages. + + ```bash + npm install @wormhole-foundation/wormhole-query-sdk axios ethers + ``` + + - [`@wormhole-foundation/wormhole-query-sdk`](https://www.npmjs.com/package/@wormhole-foundation/wormhole-query-sdk){target=\_blank}: Build, send, and decode Wormhole Queries. + - [`axios`](https://www.npmjs.com/package/axios){target=\_blank}: Make JSON-RPC and Query Proxy requests. + - [`ethers`](https://www.npmjs.com/package/ethers){target=\_blank}: Handle ABI encoding and decoding for Witnet calls. + +3. **Add environment variables**: Create a file named `.env.local` in the project root. + + ```env + # Wormhole Query Proxy + QUERY_URL=https://testnet.query.wormhole.com/v1/query + QUERIES_API_KEY=INSERT_API_KEY + + # Chain and RPC + WORMHOLE_CHAIN_ID=10003 + RPC_URL=https://arbitrum-sepolia.drpc.org + + # Witnet Price Router on Arbitrum Sepolia + CALL_TO=0x1111AbA2164AcdC6D291b08DfB374280035E1111 + + # ETH/USD feed on Witnet, six decimals + FEED_ID4=0x3d15f701 + FEED_DECIMALS=6 + FEED_HEARTBEAT_SEC=86400 + ``` + + !!! warning + Make sure to add the `.env.local` file to your `.gitignore` to exclude it from version control. Never commit API keys to your repository. + + You can use a different Witnet feed or network by updating `CALL_TO`, `FEED_ID4`, `FEED_DECIMALS`, and `WORMHOLE_CHAIN_ID`. These values allow the app to fetch a live ETH/USD price with proper scaling, timestamps, and a signed response. + + +4. **Add a configuration file**: Create `src/lib/config.ts` to access environment variables throughout the app. + + ```ts title="src/lib/config.ts" + export const QUERY_URL = process.env.QUERY_URL!; + export const QUERIES_API_KEY = process.env.QUERIES_API_KEY!; + export const RPC_URL = process.env.RPC_URL!; + + export const DEFAULTS = { + chainId: Number(process.env.WORMHOLE_CHAIN_ID || 0), + to: process.env.CALL_TO || '', + feedId4: process.env.FEED_ID4 || '', + feedDecimals: Number(process.env.FEED_DECIMALS || 0), + feedHeartbeatSec: Number(process.env.FEED_HEARTBEAT_SEC || 0), + }; + + ``` + +## Build the Server Logic + +In this section, you will implement the backend that powers the widget. You will encode the Witnet call, create and send a Wormhole Query, decode the signed response, and expose an API route for the frontend. + +### Encode Witnet Call and Build the Request + +First, encode the function call for Witnet's Price Router using the feed ID and package it into a Wormhole Query request. This query will be anchored to the latest block, ensuring the data you receive is verifiably tied to a recent snapshot of the chain state. This helper will return a serialized request that can be sent to the Wormhole Query Proxy. + +```ts title="src/lib/queries/buildRequest.ts" +import axios from 'axios'; +import { + EthCallQueryRequest, + PerChainQueryRequest, + QueryRequest, +} from '@wormhole-foundation/wormhole-query-sdk'; +import { Interface } from 'ethers'; + +// ABI interface for Witnet's Price Router +const WITNET_IFACE = new Interface([ + // Function signature for reading the latest price feed + 'function latestPrice(bytes4 id) view returns (int256 value, uint256 timestamp, bytes32 drTxHash, uint8 status)', +]); + +// Encode calldata for Witnet Router: latestPrice(bytes4) +export function encodeWitnetLatestPrice(id4: string): string { + // Validate feed ID format (must be a 4-byte hex) + if (!/^0x[0-9a-fA-F]{8}$/.test(id4)) { + throw new Error(`Invalid FEED_ID4: ${id4}`); + } + // Return ABI-encoded call data for latestPrice(bytes4) + return WITNET_IFACE.encodeFunctionData('latestPrice', [id4 as `0x${string}`]); +} + +export async function buildEthCallRequest(params: { + rpcUrl: string; + chainId: number; // Wormhole chain id + to: string; + data: string; // 0x-prefixed calldata +}) { + const { rpcUrl, chainId, to, data } = params; + + // Get the latest block number via JSON-RPC + // Short timeout prevents long hangs in the dev environment + const latestBlock: string = ( + await axios.post( + rpcUrl, + { + method: 'eth_getBlockByNumber', + params: ['latest', false], + id: 1, + jsonrpc: '2.0', + }, + { timeout: 5_000, headers: { 'Content-Type': 'application/json' } } + ) + ).data?.result?.number; + + if (!latestBlock) throw new Error('Failed to fetch latest block'); + + // Build a Wormhole Query that wraps an EthCall to the Witnet contract + const request = new QueryRequest(1, [ + new PerChainQueryRequest( + chainId, + new EthCallQueryRequest(latestBlock, [{ to, data }]) + ), + ]); + + // Serialize to bytes for sending to the Wormhole Query Proxy + return request.serialize(); // Uint8Array +} + +``` + +### Send Request to the Query Proxy + +Next, you will send the serialized query to the Wormhole Query Proxy, which forwards it to the Guardians for verification. The proxy returns a signed response containing the requested data and proof that the Guardians verified it. This step ensures that all the data your app consumes comes from a trusted and authenticated source. + +```ts title="src/lib/queries/client.ts" +import axios from 'axios'; + +export async function postQuery({ + queryUrl, + apiKey, + bytes, + timeoutMs = 25_000, +}: { + queryUrl: string; + apiKey: string; + bytes: Uint8Array; + timeoutMs?: number; +}) { + // Convert the query bytes to hex and POST to the proxy + const res = await axios.post( + queryUrl, + { bytes: Buffer.from(bytes).toString('hex') }, + { + timeout: timeoutMs, + headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' }, + validateStatus: (s) => s === 200, + } + ); + return res.data; // throws on non-200 +} + +``` + +### Decode and Verify Response + +Once you receive the signed response, you will decode it to extract the Witnet price data. +Here, you will use ethers to parse the ABI-encoded return values and scale the raw integer to a readable decimal value based on the feed's configured number of decimals. This function will output a clean result containing the latest price, timestamp, and transaction reference from the Witnet feed. + +```ts title="src/lib/queries/decode.ts" +import { + EthCallQueryResponse, + QueryResponse, +} from '@wormhole-foundation/wormhole-query-sdk'; +import { Interface, Result } from 'ethers'; + +// ABI interface for decoding Witnet's latestPrice response +const WITNET_IFACE = new Interface([ + 'function latestPrice(bytes4 id) view returns (int256 value, uint256 timestamp, bytes32 drTxHash, uint8 status)', +]); + +// Parse the first EthCall result from the proxy's response +export function parseFirstEthCallResult(proxyResponse: { bytes: string }): { + chainResp: EthCallQueryResponse; + raw: string; +} { + // Decode the top-level QueryResponse from Wormhole Guardians + const qr = QueryResponse.from(proxyResponse.bytes); + + // Extract the first chain response and its raw call result + const chainResp = qr.responses[0].response as EthCallQueryResponse; + const raw = chainResp.results[0]; + return { chainResp, raw }; +} + +// Decode Witnet's latestPrice return tuple into readable fields +export function decodeWitnetLatestPrice( + raw: string, + decimals: number +): { price: string; timestampSec: number; drTxHash: string } { + // Decode ABI-encoded result from the router call + const r: Result = WITNET_IFACE.decodeFunctionResult('latestPrice', raw); + const value = BigInt(r[0].toString()); + const timestampSec = Number(r[1].toString()); + const drTxHash = r[2] as string; + + return { + price: scaleBigintToDecimalString(value, decimals), + timestampSec, + drTxHash, + }; +} + +// Convert a bigint price into a human-readable decimal string +function scaleBigintToDecimalString(value: bigint, decimals: number): string { + const zero = BigInt(0); + const neg = value < zero ? '-' : ''; + const v = value < zero ? -value : value; + const s = v.toString().padStart(decimals + 1, '0'); + const i = s.slice(0, -decimals); + const f = s.slice(-decimals).replace(/0+$/, ''); + return neg + (f ? `${i}.${f}` : i); +} + +``` + +### Add Shared Types + +Create a `src/lib/types.ts` file to define the structure of your API responses. These types ensure consistency between the backend and the frontend, keeping the data shape predictable and type-safe. You will import these types in both the API route and the widget to keep your responses aligned across the app. + +```ts title="src/lib/types.ts" +export interface QueryApiSuccess { + ok: true; + blockNumber: string; + blockTimeMicros: string; + price: string; + decimals: number; + updatedAt: string; + stale?: boolean; +} + +export interface QueryApiError { + ok: false; + error: string; +} +export type QueryApiResponse = QueryApiSuccess | QueryApiError; + +``` + +### Add API Route for Frontend + +Finally, expose an API endpoint at `/api/queries`. This route ties everything together: it builds the query, sends it, decodes the response, and returns a structured JSON payload containing the current price, timestamp, block number, and a stale flag indicating whether the feed data is still fresh. The frontend widget will call this endpoint every few seconds to display the live, verified price data. + +```ts title="src/app/api/queries/route.ts" +import { NextResponse } from 'next/server'; +import { + buildEthCallRequest, + encodeWitnetLatestPrice, +} from '@/lib/queries/buildRequest'; +import { postQuery } from '@/lib/queries/client'; +import { QUERY_URL, QUERIES_API_KEY, RPC_URL, DEFAULTS } from '@/lib/config'; +import { + parseFirstEthCallResult, + decodeWitnetLatestPrice, +} from '@/lib/queries/decode'; +import type { QueryApiSuccess, QueryApiError } from '@/lib/types'; + +export async function GET() { + const t0 = Date.now(); + try { + // Encode the call for Witnet’s latestPrice(bytes4) + const data = encodeWitnetLatestPrice(DEFAULTS.feedId4); + + // Build a Wormhole Query request anchored to the latest block + const bytes = await buildEthCallRequest({ + rpcUrl: RPC_URL, + chainId: DEFAULTS.chainId, + to: DEFAULTS.to, + data, + }); + const t1 = Date.now(); + + // Send the query to the Wormhole Query Proxy and await the signed response + const proxyResponse = await postQuery({ + queryUrl: QUERY_URL, + apiKey: QUERIES_API_KEY, + bytes, + timeoutMs: 25_000, + }); + const t2 = Date.now(); + + // Decode the signed Guardian response and extract Witnet data + const { chainResp, raw } = parseFirstEthCallResult(proxyResponse); + const { price, timestampSec } = decodeWitnetLatestPrice( + raw, + DEFAULTS.feedDecimals + ); + + // Log the latency of each leg for debugging + console.log(`RPC ${t1 - t0}ms → Proxy ${t2 - t1}ms`); + + // Mark data as stale if older than the feed’s heartbeat interval + const heartbeat = Number(process.env.FEED_HEARTBEAT_SEC || 0); + const stale = heartbeat > 0 && Date.now() / 1000 - timestampSec > heartbeat; + + // Return a normalized JSON payload for the frontend + const body: QueryApiSuccess = { + ok: true, + blockNumber: chainResp.blockNumber.toString(), + blockTimeMicros: chainResp.blockTime.toString(), + price, + decimals: DEFAULTS.feedDecimals, + updatedAt: new Date(timestampSec * 1000).toISOString(), + stale, + }; + return NextResponse.json(body); + } catch (e: unknown) { + // Catch and return a structured error + const message = e instanceof Error ? e.message : String(e); + console.error('Error in /api/queries:', message); + const body: QueryApiError = { ok: false, error: message }; + return NextResponse.json(body, { status: 500 }); + } +} + +``` + +## Price Widget + +In this section, you will build a client component that fetches the signed price from your API, renders it with a freshness badge, and refreshes on an interval without overlapping requests. + +### Create Widget Component + +Create a client component that calls `/api/queries`, renders the current price, shows the last update time and block number, and displays a freshness badge based on the heartbeat. The component uses a ref to avoid overlapping requests and a timed interval to refresh automatically. + +```ts title="src/components/PriceWidget.tsx" +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +// Expected API success shape from /api/queries +type ApiOk = { + ok: true; + price: string; + updatedAt: number | string; + blockNumber: string; + blockTimeMicros: number; + decimals: number; + stale: boolean; +}; + +// API error shape +type ApiErr = { ok: false; error: string }; + +// Format timestamps for display +function formatTime(ts: number | string) { + let n: number; + if (typeof ts === 'string') { + const numeric = Number(ts); + if (Number.isFinite(numeric)) { + n = numeric; + } else { + const parsed = new Date(ts); + return Number.isNaN(parsed.getTime()) ? '—' : parsed.toLocaleString(); + } + } else { + n = ts; + } + if (!Number.isFinite(n)) return '—'; + // If it looks like seconds, convert to ms + const ms = n < 1_000_000_000_000 ? n * 1000 : n; + const d = new Date(ms); + return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString(); +} + +export default function PriceWidget() { + // UI state: fetched data, loading state, and any errors + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + // Keep track of polling and prevent overlapping requests + const timer = useRef(null); + const inFlight = useRef(false); + + // Fetch price data from the API route + async function fetchPrice() { + if (inFlight.current) return; // avoid concurrent requests + inFlight.current = true; + setLoading(true); + setError(null); + + try { + const res = await fetch('/api/queries', { cache: 'no-store' }); + const json: ApiOk | ApiErr = await res.json(); + if (!json.ok) throw new Error(json.error); + setData(json); + } catch (e: any) { + setError(e?.message || 'Failed to fetch price'); + } finally { + setLoading(false); + inFlight.current = false; + } + } + + // Fetch immediately and refresh every 30 seconds + useEffect(() => { + fetchPrice(); + timer.current = setInterval(fetchPrice, 30_000); + return () => { + if (timer.current) clearInterval(timer.current); + }; + }, []); + + return ( +
+
+

ETH/USD Live Price

+ {data?.stale ? ( + + Stale + + ) : ( + + Fresh + + )} +
+ +
+
+ {loading && !data ? 'Loading…' : data ? data.price : '—'} +
+ +
+ {data ? ( + <> + Updated at {formatTime(data.updatedAt)}, block {data.blockNumber} + + ) : error ? ( + {error} + ) : ( + 'Fetching latest price' + )} +
+
+ +
+ +
+
+ ); +} + +``` + +### Add the Widget to Home Page + +Render the widget on the home page with a simple heading and container so users see the price as soon as they load the app. + +```ts title="src/app/page.tsx" +import PriceWidget from '@/components/PriceWidget'; + +export default function Page() { + return ( +
+

+ Live Crypto Price Widget +

+ +
+ ); +} + +``` + +## Run the App + +Start the development server and confirm that the live widget displays data correctly: + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) to see your app running. You should see the widget display the current ETH/USD price, the last update time, the block number, and a freshness badge indicating whether the data is still within its heartbeat window. + +The price may update only intermittently. Witnet feeds refresh only when a particular time or price deviation threshold is reached to prevent unnecessary network updates. + +Your app should look like this: + +![Frontend of Queries Live Prices Widget](/docs/images/products/queries/tutorials/live-crypto-prices/live-crypto-prices-1.webp){.half} + +???- tip "Troubleshooting" + If you encounter a “Request failed with status code 403” error, it likely means your Queries API key is missing or incorrect. Check the `QUERIES_API_KEY` value in your `.env.local` file and restart the development server after updating it. + +## Resources + +If you'd like to explore the complete project or need a reference while following this tutorial, you can find the complete codebase in the Wormhole's Queries [Tutorial GitHub repository](https://github.com/wormhole-foundation/e2e-tutorial-live-crypto-prices){target=\_blank}. + +## Conclusion + +You've successfully built a live crypto price widget that fetches verified data from Wormhole Queries and Witnet. Your app encodes a feed request, sends it through the Guardian network for verification, and displays the latest signed price in a simple, responsive widget. + +The Queries flow can be extended to fetch other on-chain data or integrate multiple feeds for dashboards and analytics tools. + +Looking for more? Check out the [Wormhole Tutorial Demo repository](https://github.com/wormhole-foundation/demo-tutorials){target=\_blank} for additional examples. diff --git a/.ai/site-index.json b/.ai/site-index.json index 4297361a7..07eda86c2 100644 --- a/.ai/site-index.json +++ b/.ai/site-index.json @@ -3559,6 +3559,97 @@ "hash": "sha256:edb61bc1efa636fd806f90b03dd84374c533bf7c4f9ffe92f2230f9003c2ad1b", "token_estimator": "heuristic-v1" }, + { + "id": "products-queries-tutorials-live-crypto-prices", + "title": "Live Crypto Price Widget", + "slug": "products-queries-tutorials-live-crypto-prices", + "categories": [ + "Queries" + ], + "raw_md_url": "https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/main/.ai/pages/products-queries-tutorials-live-crypto-prices.md", + "html_url": "https://wormhole.com/docs/products/queries/tutorials/live-crypto-prices/", + "preview": ":simple-github: [Source code on GitHub](https://github.com/wormhole-foundation/e2e-tutorial-live-crypto-prices){target=\\_blank}", + "outline": [ + { + "depth": 2, + "title": "Prerequisites", + "anchor": "prerequisites" + }, + { + "depth": 2, + "title": "Project Setup", + "anchor": "project-setup" + }, + { + "depth": 2, + "title": "Build the Server Logic", + "anchor": "build-the-server-logic" + }, + { + "depth": 3, + "title": "Encode Witnet Call and Build the Request", + "anchor": "encode-witnet-call-and-build-the-request" + }, + { + "depth": 3, + "title": "Send Request to the Query Proxy", + "anchor": "send-request-to-the-query-proxy" + }, + { + "depth": 3, + "title": "Decode and Verify Response", + "anchor": "decode-and-verify-response" + }, + { + "depth": 3, + "title": "Add Shared Types", + "anchor": "add-shared-types" + }, + { + "depth": 3, + "title": "Add API Route for Frontend", + "anchor": "add-api-route-for-frontend" + }, + { + "depth": 2, + "title": "Price Widget", + "anchor": "price-widget" + }, + { + "depth": 3, + "title": "Create Widget Component", + "anchor": "create-widget-component" + }, + { + "depth": 3, + "title": "Add the Widget to Home Page", + "anchor": "add-the-widget-to-home-page" + }, + { + "depth": 2, + "title": "Run the App", + "anchor": "run-the-app" + }, + { + "depth": 2, + "title": "Resources", + "anchor": "resources" + }, + { + "depth": 2, + "title": "Conclusion", + "anchor": "conclusion" + } + ], + "stats": { + "chars": 20515, + "words": 2690, + "headings": 14, + "estimated_token_count_total": 4569 + }, + "hash": "sha256:a61ee01698bf986ef7b21237e2fc5513e69f63665edbc0edb87920a0b4e3c2fd", + "token_estimator": "heuristic-v1" + }, { "id": "products-reference-chain-ids", "title": "Chain IDs", diff --git a/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-1.ts b/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-1.ts new file mode 100644 index 000000000..ad614c279 --- /dev/null +++ b/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-1.ts @@ -0,0 +1,11 @@ +export const QUERY_URL = process.env.QUERY_URL!; +export const QUERIES_API_KEY = process.env.QUERIES_API_KEY!; +export const RPC_URL = process.env.RPC_URL!; + +export const DEFAULTS = { + chainId: Number(process.env.WORMHOLE_CHAIN_ID || 0), + to: process.env.CALL_TO || '', + feedId4: process.env.FEED_ID4 || '', + feedDecimals: Number(process.env.FEED_DECIMALS || 0), + feedHeartbeatSec: Number(process.env.FEED_HEARTBEAT_SEC || 0), +}; diff --git a/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-2.ts b/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-2.ts new file mode 100644 index 000000000..404f4902a --- /dev/null +++ b/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-2.ts @@ -0,0 +1,60 @@ +import axios from 'axios'; +import { + EthCallQueryRequest, + PerChainQueryRequest, + QueryRequest, +} from '@wormhole-foundation/wormhole-query-sdk'; +import { Interface } from 'ethers'; + +// ABI interface for Witnet's Price Router +const WITNET_IFACE = new Interface([ + // Function signature for reading the latest price feed + 'function latestPrice(bytes4 id) view returns (int256 value, uint256 timestamp, bytes32 drTxHash, uint8 status)', +]); + +// Encode calldata for Witnet Router: latestPrice(bytes4) +export function encodeWitnetLatestPrice(id4: string): string { + // Validate feed ID format (must be a 4-byte hex) + if (!/^0x[0-9a-fA-F]{8}$/.test(id4)) { + throw new Error(`Invalid FEED_ID4: ${id4}`); + } + // Return ABI-encoded call data for latestPrice(bytes4) + return WITNET_IFACE.encodeFunctionData('latestPrice', [id4 as `0x${string}`]); +} + +export async function buildEthCallRequest(params: { + rpcUrl: string; + chainId: number; // Wormhole chain id + to: string; + data: string; // 0x-prefixed calldata +}) { + const { rpcUrl, chainId, to, data } = params; + + // Get the latest block number via JSON-RPC + // Short timeout prevents long hangs in the dev environment + const latestBlock: string = ( + await axios.post( + rpcUrl, + { + method: 'eth_getBlockByNumber', + params: ['latest', false], + id: 1, + jsonrpc: '2.0', + }, + { timeout: 5_000, headers: { 'Content-Type': 'application/json' } } + ) + ).data?.result?.number; + + if (!latestBlock) throw new Error('Failed to fetch latest block'); + + // Build a Wormhole Query that wraps an EthCall to the Witnet contract + const request = new QueryRequest(1, [ + new PerChainQueryRequest( + chainId, + new EthCallQueryRequest(latestBlock, [{ to, data }]) + ), + ]); + + // Serialize to bytes for sending to the Wormhole Query Proxy + return request.serialize(); // Uint8Array +} diff --git a/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-3.ts b/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-3.ts new file mode 100644 index 000000000..fcc4d604e --- /dev/null +++ b/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-3.ts @@ -0,0 +1,25 @@ +import axios from 'axios'; + +export async function postQuery({ + queryUrl, + apiKey, + bytes, + timeoutMs = 25_000, +}: { + queryUrl: string; + apiKey: string; + bytes: Uint8Array; + timeoutMs?: number; +}) { + // Convert the query bytes to hex and POST to the proxy + const res = await axios.post( + queryUrl, + { bytes: Buffer.from(bytes).toString('hex') }, + { + timeout: timeoutMs, + headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' }, + validateStatus: (s) => s === 200, + } + ); + return res.data; // throws on non-200 +} diff --git a/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-4.ts b/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-4.ts new file mode 100644 index 000000000..63691fa0a --- /dev/null +++ b/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-4.ts @@ -0,0 +1,53 @@ +import { + EthCallQueryResponse, + QueryResponse, +} from '@wormhole-foundation/wormhole-query-sdk'; +import { Interface, Result } from 'ethers'; + +// ABI interface for decoding Witnet's latestPrice response +const WITNET_IFACE = new Interface([ + 'function latestPrice(bytes4 id) view returns (int256 value, uint256 timestamp, bytes32 drTxHash, uint8 status)', +]); + +// Parse the first EthCall result from the proxy's response +export function parseFirstEthCallResult(proxyResponse: { bytes: string }): { + chainResp: EthCallQueryResponse; + raw: string; +} { + // Decode the top-level QueryResponse from Wormhole Guardians + const qr = QueryResponse.from(proxyResponse.bytes); + + // Extract the first chain response and its raw call result + const chainResp = qr.responses[0].response as EthCallQueryResponse; + const raw = chainResp.results[0]; + return { chainResp, raw }; +} + +// Decode Witnet's latestPrice return tuple into readable fields +export function decodeWitnetLatestPrice( + raw: string, + decimals: number +): { price: string; timestampSec: number; drTxHash: string } { + // Decode ABI-encoded result from the router call + const r: Result = WITNET_IFACE.decodeFunctionResult('latestPrice', raw); + const value = BigInt(r[0].toString()); + const timestampSec = Number(r[1].toString()); + const drTxHash = r[2] as string; + + return { + price: scaleBigintToDecimalString(value, decimals), + timestampSec, + drTxHash, + }; +} + +// Convert a bigint price into a human-readable decimal string +function scaleBigintToDecimalString(value: bigint, decimals: number): string { + const zero = BigInt(0); + const neg = value < zero ? '-' : ''; + const v = value < zero ? -value : value; + const s = v.toString().padStart(decimals + 1, '0'); + const i = s.slice(0, -decimals); + const f = s.slice(-decimals).replace(/0+$/, ''); + return neg + (f ? `${i}.${f}` : i); +} diff --git a/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-5.ts b/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-5.ts new file mode 100644 index 000000000..535ddcc92 --- /dev/null +++ b/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-5.ts @@ -0,0 +1,15 @@ +export interface QueryApiSuccess { + ok: true; + blockNumber: string; + blockTimeMicros: string; + price: string; + decimals: number; + updatedAt: string; + stale?: boolean; +} + +export interface QueryApiError { + ok: false; + error: string; +} +export type QueryApiResponse = QueryApiSuccess | QueryApiError; diff --git a/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-6.ts b/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-6.ts new file mode 100644 index 000000000..c50b4f751 --- /dev/null +++ b/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-6.ts @@ -0,0 +1,70 @@ +import { NextResponse } from 'next/server'; +import { + buildEthCallRequest, + encodeWitnetLatestPrice, +} from '@/lib/queries/buildRequest'; +import { postQuery } from '@/lib/queries/client'; +import { QUERY_URL, QUERIES_API_KEY, RPC_URL, DEFAULTS } from '@/lib/config'; +import { + parseFirstEthCallResult, + decodeWitnetLatestPrice, +} from '@/lib/queries/decode'; +import type { QueryApiSuccess, QueryApiError } from '@/lib/types'; + +export async function GET() { + const t0 = Date.now(); + try { + // Encode the call for Witnet’s latestPrice(bytes4) + const data = encodeWitnetLatestPrice(DEFAULTS.feedId4); + + // Build a Wormhole Query request anchored to the latest block + const bytes = await buildEthCallRequest({ + rpcUrl: RPC_URL, + chainId: DEFAULTS.chainId, + to: DEFAULTS.to, + data, + }); + const t1 = Date.now(); + + // Send the query to the Wormhole Query Proxy and await the signed response + const proxyResponse = await postQuery({ + queryUrl: QUERY_URL, + apiKey: QUERIES_API_KEY, + bytes, + timeoutMs: 25_000, + }); + const t2 = Date.now(); + + // Decode the signed Guardian response and extract Witnet data + const { chainResp, raw } = parseFirstEthCallResult(proxyResponse); + const { price, timestampSec } = decodeWitnetLatestPrice( + raw, + DEFAULTS.feedDecimals + ); + + // Log the latency of each leg for debugging + console.log(`RPC ${t1 - t0}ms → Proxy ${t2 - t1}ms`); + + // Mark data as stale if older than the feed’s heartbeat interval + const heartbeat = Number(process.env.FEED_HEARTBEAT_SEC || 0); + const stale = heartbeat > 0 && Date.now() / 1000 - timestampSec > heartbeat; + + // Return a normalized JSON payload for the frontend + const body: QueryApiSuccess = { + ok: true, + blockNumber: chainResp.blockNumber.toString(), + blockTimeMicros: chainResp.blockTime.toString(), + price, + decimals: DEFAULTS.feedDecimals, + updatedAt: new Date(timestampSec * 1000).toISOString(), + stale, + }; + return NextResponse.json(body); + } catch (e: unknown) { + // Catch and return a structured error + const message = e instanceof Error ? e.message : String(e); + console.error('Error in /api/queries:', message); + const body: QueryApiError = { ok: false, error: message }; + return NextResponse.json(body, { status: 500 }); + } +} diff --git a/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-7.ts b/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-7.ts new file mode 100644 index 000000000..82567d415 --- /dev/null +++ b/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-7.ts @@ -0,0 +1,123 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +// Expected API success shape from /api/queries +type ApiOk = { + ok: true; + price: string; + updatedAt: number | string; + blockNumber: string; + blockTimeMicros: number; + decimals: number; + stale: boolean; +}; + +// API error shape +type ApiErr = { ok: false; error: string }; + +// Format timestamps for display +function formatTime(ts: number | string) { + let n: number; + if (typeof ts === 'string') { + const numeric = Number(ts); + if (Number.isFinite(numeric)) { + n = numeric; + } else { + const parsed = new Date(ts); + return Number.isNaN(parsed.getTime()) ? '—' : parsed.toLocaleString(); + } + } else { + n = ts; + } + if (!Number.isFinite(n)) return '—'; + // If it looks like seconds, convert to ms + const ms = n < 1_000_000_000_000 ? n * 1000 : n; + const d = new Date(ms); + return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString(); +} + +export default function PriceWidget() { + // UI state: fetched data, loading state, and any errors + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + // Keep track of polling and prevent overlapping requests + const timer = useRef(null); + const inFlight = useRef(false); + + // Fetch price data from the API route + async function fetchPrice() { + if (inFlight.current) return; // avoid concurrent requests + inFlight.current = true; + setLoading(true); + setError(null); + + try { + const res = await fetch('/api/queries', { cache: 'no-store' }); + const json: ApiOk | ApiErr = await res.json(); + if (!json.ok) throw new Error(json.error); + setData(json); + } catch (e: any) { + setError(e?.message || 'Failed to fetch price'); + } finally { + setLoading(false); + inFlight.current = false; + } + } + + // Fetch immediately and refresh every 30 seconds + useEffect(() => { + fetchPrice(); + timer.current = setInterval(fetchPrice, 30_000); + return () => { + if (timer.current) clearInterval(timer.current); + }; + }, []); + + return ( +
+
+

ETH/USD Live Price

+ {data?.stale ? ( + + Stale + + ) : ( + + Fresh + + )} +
+ +
+
+ {loading && !data ? 'Loading…' : data ? data.price : '—'} +
+ +
+ {data ? ( + <> + Updated at {formatTime(data.updatedAt)}, block {data.blockNumber} + + ) : error ? ( + {error} + ) : ( + 'Fetching latest price' + )} +
+
+ +
+ +
+
+ ); +} diff --git a/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-8.ts b/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-8.ts new file mode 100644 index 000000000..08d5b153f --- /dev/null +++ b/.snippets/code/products/queries/tutorials/live-crypto-prices/snippet-8.ts @@ -0,0 +1,12 @@ +import PriceWidget from '@/components/PriceWidget'; + +export default function Page() { + return ( +
+

+ Live Crypto Price Widget +

+ +
+ ); +} diff --git a/images/products/queries/tutorials/live-crypto-prices/live-crypto-prices-1.webp b/images/products/queries/tutorials/live-crypto-prices/live-crypto-prices-1.webp new file mode 100644 index 000000000..a81b8ad51 Binary files /dev/null and b/images/products/queries/tutorials/live-crypto-prices/live-crypto-prices-1.webp differ diff --git a/llms-full.jsonl b/llms-full.jsonl index 3af8b6d1e..48545e77e 100644 --- a/llms-full.jsonl +++ b/llms-full.jsonl @@ -471,6 +471,20 @@ {"page_id": "products-queries-reference-supported-methods", "page_title": "Queries Supported Methods", "index": 4, "depth": 3, "title": "sol_account", "anchor": "sol_account", "start_char": 3220, "end_char": 3694, "estimated_token_count": 112, "token_estimator": "heuristic-v1", "text": "### sol_account\n\nThe [`sol_account`](https://github.com/wormhole-foundation/wormhole/blob/main/whitepapers/0013_ccq.md#solana-queries){target=\\_blank} query reads on-chain data for one or more specified accounts on the Solana blockchain. This functionality is similar to using Solana's native [`getMultipleAccounts`](https://solana.com/docs/rpc/http/getmultipleaccounts){target=\\_blank} RPC method, enabling you to retrieve information for multiple accounts simultaneously"} {"page_id": "products-queries-reference-supported-methods", "page_title": "Queries Supported Methods", "index": 5, "depth": 3, "title": "sol_pda", "anchor": "sol_pda", "start_char": 3694, "end_char": 4377, "estimated_token_count": 165, "token_estimator": "heuristic-v1", "text": "### sol_pda\n\nThe [`sol_pda`](https://github.com/wormhole-foundation/wormhole/blob/main/whitepapers/0013_ccq.md#solana_queries){target=\\_blank} query reads data for one or more Solana [Program Derived Addresses](https://www.anchor-lang.com/docs/basics/pda){target=\\_blank}. It streamlines the standard process of deriving a PDA and fetching its account data.\n\nThis is particularly useful for accessing multiple PDAs owned by a specific program or for verifying Solana PDA derivations on another blockchain, such as how associated token accounts are all derived from the [Associated Token Account Program](https://www.solana-program.com/docs/associated-token-account){target=\\_blank}."} {"page_id": "products-queries-reference-supported-networks", "page_title": "Queries Supported Networks", "index": 0, "depth": 2, "title": "Mainnet", "anchor": "mainnet", "start_char": 1387, "end_char": 3416, "estimated_token_count": 358, "token_estimator": "heuristic-v1", "text": "## Mainnet\n\n| Chain | Wormhole Chain ID | eth_call | eth_call_by_timestamp | eth_call_with_finality | Expected History |\n|:-------------:|:-----------------:|:--------:|:---------------------:|:----------------------:|:----------------:|\n| Ethereum | 2 | ✅ | ✅ | ✅ | 128 blocks |\n| BSC | 4 | ✅ | ✅ | ✅ | 128 blocks |\n| Polygon | 5 | ✅ | ✅ | ✅ | 128 blocks |\n| Avalanche | 6 | ✅ | ✅ | ✅ | 32 blocks |\n| Oasis Emerald | 7 | ✅ | ✅ | ✅ | archive |\n| Fantom | 10 | ✅ | ✅ | ✅ | 16 blocks |\n| Karura | 11 | ✅ | ✅ | ✅ | archive |\n| Acala | 12 | ✅ | ✅ | ✅ | archive |\n| Kaia | 13 | ✅ | ✅ | ✅ | 128 blocks |\n| Celo | 14 | ✅ | ℹ️ | ✅ | 128 blocks |\n| Moonbeam | 16 | ✅ | ℹ️ | ✅ | 256 blocks |\n| Arbitrum One | 23 | ✅ | ✅ | ✅ | ~6742 blocks |\n| Optimism | 24 | ✅ | ✅ | ❌ | 128 blocks |\n| Base | 30 | ✅ | ✅ | ✅ | archive |\n\nℹ️`EthCallByTimestamp` arguments for `targetBlock` and `followingBlock` are currently required for requests to be successful on these chains."} +{"page_id": "products-queries-tutorials-live-crypto-prices", "page_title": "Live Crypto Price Widget", "index": 0, "depth": 2, "title": "Prerequisites", "anchor": "prerequisites", "start_char": 1116, "end_char": 1899, "estimated_token_count": 211, "token_estimator": "heuristic-v1", "text": "## Prerequisites\n\nBefore starting, make sure you have the following set up:\n\n - [Node.js and npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm){target=\\_blank} installed on your system\n - A [Wormhole Queries API key](/docs/products/queries/get-started/#request-an-api-key){target=\\_blank}\n - Access to an EVM-compatible [testnet RPC](https://chainlist.org/?testnets=true){target=\\_blank}, such as Arbitrum Sepolia\n - A [Witnet data feed identifier](https://docs.witnet.io/smart-contracts/witnet-data-feeds/addresses){target=\\_blank} (this tutorial uses the ETH/USD feed as an example)\n\n You can use a different Witnet feed or testnet if you prefer. Make sure to update the environment variables later in this tutorial with the correct values for your setup."} +{"page_id": "products-queries-tutorials-live-crypto-prices", "page_title": "Live Crypto Price Widget", "index": 1, "depth": 2, "title": "Project Setup", "anchor": "project-setup", "start_char": 1899, "end_char": 4542, "estimated_token_count": 621, "token_estimator": "heuristic-v1", "text": "## Project Setup\n\nIn this section, you will create a new Next.js project, install the required dependencies, and configure the environment variables needed to fetch data from Wormhole Queries.\n\n1. **Create a new Next.js app**: Enable TypeScript, Tailwind CSS, and the `src/` directory when prompted. You can configure the remaining options as you like. Create your app using the following command: \n\n ```bash\n npx create-next-app@latest live-crypto-prices\n cd live-crypto-prices\n ```\n\n2. **Install dependencies**: Add the required packages.\n\n ```bash\n npm install @wormhole-foundation/wormhole-query-sdk axios ethers\n ```\n\n - [`@wormhole-foundation/wormhole-query-sdk`](https://www.npmjs.com/package/@wormhole-foundation/wormhole-query-sdk){target=\\_blank}: Build, send, and decode Wormhole Queries.\n - [`axios`](https://www.npmjs.com/package/axios){target=\\_blank}: Make JSON-RPC and Query Proxy requests.\n - [`ethers`](https://www.npmjs.com/package/ethers){target=\\_blank}: Handle ABI encoding and decoding for Witnet calls.\n\n3. **Add environment variables**: Create a file named `.env.local` in the project root.\n\n ```env\n # Wormhole Query Proxy\n QUERY_URL=https://testnet.query.wormhole.com/v1/query\n QUERIES_API_KEY=INSERT_API_KEY\n\n # Chain and RPC\n WORMHOLE_CHAIN_ID=10003\n RPC_URL=https://arbitrum-sepolia.drpc.org\n\n # Witnet Price Router on Arbitrum Sepolia\n CALL_TO=0x1111AbA2164AcdC6D291b08DfB374280035E1111\n\n # ETH/USD feed on Witnet, six decimals\n FEED_ID4=0x3d15f701\n FEED_DECIMALS=6\n FEED_HEARTBEAT_SEC=86400\n ```\n\n !!! warning\n Make sure to add the `.env.local` file to your `.gitignore` to exclude it from version control. Never commit API keys to your repository.\n\n You can use a different Witnet feed or network by updating `CALL_TO`, `FEED_ID4`, `FEED_DECIMALS`, and `WORMHOLE_CHAIN_ID`. These values allow the app to fetch a live ETH/USD price with proper scaling, timestamps, and a signed response.\n \n\n4. **Add a configuration file**: Create `src/lib/config.ts` to access environment variables throughout the app.\n\n ```ts title=\"src/lib/config.ts\"\n export const QUERY_URL = process.env.QUERY_URL!;\n export const QUERIES_API_KEY = process.env.QUERIES_API_KEY!;\n export const RPC_URL = process.env.RPC_URL!;\n\n export const DEFAULTS = {\n chainId: Number(process.env.WORMHOLE_CHAIN_ID || 0),\n to: process.env.CALL_TO || '',\n feedId4: process.env.FEED_ID4 || '',\n feedDecimals: Number(process.env.FEED_DECIMALS || 0),\n feedHeartbeatSec: Number(process.env.FEED_HEARTBEAT_SEC || 0),\n };\n\n ```"} +{"page_id": "products-queries-tutorials-live-crypto-prices", "page_title": "Live Crypto Price Widget", "index": 2, "depth": 2, "title": "Build the Server Logic", "anchor": "build-the-server-logic", "start_char": 4542, "end_char": 4779, "estimated_token_count": 48, "token_estimator": "heuristic-v1", "text": "## Build the Server Logic\n\nIn this section, you will implement the backend that powers the widget. You will encode the Witnet call, create and send a Wormhole Query, decode the signed response, and expose an API route for the frontend."} +{"page_id": "products-queries-tutorials-live-crypto-prices", "page_title": "Live Crypto Price Widget", "index": 3, "depth": 3, "title": "Encode Witnet Call and Build the Request", "anchor": "encode-witnet-call-and-build-the-request", "start_char": 4779, "end_char": 7127, "estimated_token_count": 545, "token_estimator": "heuristic-v1", "text": "### Encode Witnet Call and Build the Request\n\nFirst, encode the function call for Witnet's Price Router using the feed ID and package it into a Wormhole Query request. This query will be anchored to the latest block, ensuring the data you receive is verifiably tied to a recent snapshot of the chain state. This helper will return a serialized request that can be sent to the Wormhole Query Proxy.\n\n```ts title=\"src/lib/queries/buildRequest.ts\"\nimport axios from 'axios';\nimport {\n EthCallQueryRequest,\n PerChainQueryRequest,\n QueryRequest,\n} from '@wormhole-foundation/wormhole-query-sdk';\nimport { Interface } from 'ethers';\n\n// ABI interface for Witnet's Price Router\nconst WITNET_IFACE = new Interface([\n // Function signature for reading the latest price feed\n 'function latestPrice(bytes4 id) view returns (int256 value, uint256 timestamp, bytes32 drTxHash, uint8 status)',\n]);\n\n// Encode calldata for Witnet Router: latestPrice(bytes4)\nexport function encodeWitnetLatestPrice(id4: string): string {\n // Validate feed ID format (must be a 4-byte hex)\n if (!/^0x[0-9a-fA-F]{8}$/.test(id4)) {\n throw new Error(`Invalid FEED_ID4: ${id4}`);\n }\n // Return ABI-encoded call data for latestPrice(bytes4)\n return WITNET_IFACE.encodeFunctionData('latestPrice', [id4 as `0x${string}`]);\n}\n\nexport async function buildEthCallRequest(params: {\n rpcUrl: string;\n chainId: number; // Wormhole chain id\n to: string;\n data: string; // 0x-prefixed calldata\n}) {\n const { rpcUrl, chainId, to, data } = params;\n\n // Get the latest block number via JSON-RPC\n // Short timeout prevents long hangs in the dev environment\n const latestBlock: string = (\n await axios.post(\n rpcUrl,\n {\n method: 'eth_getBlockByNumber',\n params: ['latest', false],\n id: 1,\n jsonrpc: '2.0',\n },\n { timeout: 5_000, headers: { 'Content-Type': 'application/json' } }\n )\n ).data?.result?.number;\n\n if (!latestBlock) throw new Error('Failed to fetch latest block');\n\n // Build a Wormhole Query that wraps an EthCall to the Witnet contract\n const request = new QueryRequest(1, [\n new PerChainQueryRequest(\n chainId,\n new EthCallQueryRequest(latestBlock, [{ to, data }])\n ),\n ]);\n\n // Serialize to bytes for sending to the Wormhole Query Proxy\n return request.serialize(); // Uint8Array\n}\n\n```"} +{"page_id": "products-queries-tutorials-live-crypto-prices", "page_title": "Live Crypto Price Widget", "index": 4, "depth": 3, "title": "Send Request to the Query Proxy", "anchor": "send-request-to-the-query-proxy", "start_char": 7127, "end_char": 8105, "estimated_token_count": 235, "token_estimator": "heuristic-v1", "text": "### Send Request to the Query Proxy\n\nNext, you will send the serialized query to the Wormhole Query Proxy, which forwards it to the Guardians for verification. The proxy returns a signed response containing the requested data and proof that the Guardians verified it. This step ensures that all the data your app consumes comes from a trusted and authenticated source.\n\n```ts title=\"src/lib/queries/client.ts\"\nimport axios from 'axios';\n\nexport async function postQuery({\n queryUrl,\n apiKey,\n bytes,\n timeoutMs = 25_000,\n}: {\n queryUrl: string;\n apiKey: string;\n bytes: Uint8Array;\n timeoutMs?: number;\n}) {\n // Convert the query bytes to hex and POST to the proxy\n const res = await axios.post(\n queryUrl,\n { bytes: Buffer.from(bytes).toString('hex') },\n {\n timeout: timeoutMs,\n headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },\n validateStatus: (s) => s === 200,\n }\n );\n return res.data; // throws on non-200\n}\n\n```"} +{"page_id": "products-queries-tutorials-live-crypto-prices", "page_title": "Live Crypto Price Widget", "index": 5, "depth": 3, "title": "Decode and Verify Response", "anchor": "decode-and-verify-response", "start_char": 8105, "end_char": 10439, "estimated_token_count": 546, "token_estimator": "heuristic-v1", "text": "### Decode and Verify Response\n\nOnce you receive the signed response, you will decode it to extract the Witnet price data.\nHere, you will use ethers to parse the ABI-encoded return values and scale the raw integer to a readable decimal value based on the feed's configured number of decimals. This function will output a clean result containing the latest price, timestamp, and transaction reference from the Witnet feed.\n\n```ts title=\"src/lib/queries/decode.ts\"\nimport {\n EthCallQueryResponse,\n QueryResponse,\n} from '@wormhole-foundation/wormhole-query-sdk';\nimport { Interface, Result } from 'ethers';\n\n// ABI interface for decoding Witnet's latestPrice response\nconst WITNET_IFACE = new Interface([\n 'function latestPrice(bytes4 id) view returns (int256 value, uint256 timestamp, bytes32 drTxHash, uint8 status)',\n]);\n\n// Parse the first EthCall result from the proxy's response\nexport function parseFirstEthCallResult(proxyResponse: { bytes: string }): {\n chainResp: EthCallQueryResponse;\n raw: string;\n} {\n // Decode the top-level QueryResponse from Wormhole Guardians\n const qr = QueryResponse.from(proxyResponse.bytes);\n\n // Extract the first chain response and its raw call result\n const chainResp = qr.responses[0].response as EthCallQueryResponse;\n const raw = chainResp.results[0];\n return { chainResp, raw };\n}\n\n// Decode Witnet's latestPrice return tuple into readable fields\nexport function decodeWitnetLatestPrice(\n raw: string,\n decimals: number\n): { price: string; timestampSec: number; drTxHash: string } {\n // Decode ABI-encoded result from the router call\n const r: Result = WITNET_IFACE.decodeFunctionResult('latestPrice', raw);\n const value = BigInt(r[0].toString());\n const timestampSec = Number(r[1].toString());\n const drTxHash = r[2] as string;\n\n return {\n price: scaleBigintToDecimalString(value, decimals),\n timestampSec,\n drTxHash,\n };\n}\n\n// Convert a bigint price into a human-readable decimal string\nfunction scaleBigintToDecimalString(value: bigint, decimals: number): string {\n const zero = BigInt(0);\n const neg = value < zero ? '-' : '';\n const v = value < zero ? -value : value;\n const s = v.toString().padStart(decimals + 1, '0');\n const i = s.slice(0, -decimals);\n const f = s.slice(-decimals).replace(/0+$/, '');\n return neg + (f ? `${i}.${f}` : i);\n}\n\n```"} +{"page_id": "products-queries-tutorials-live-crypto-prices", "page_title": "Live Crypto Price Widget", "index": 6, "depth": 3, "title": "Add Shared Types", "anchor": "add-shared-types", "start_char": 10439, "end_char": 11117, "estimated_token_count": 143, "token_estimator": "heuristic-v1", "text": "### Add Shared Types\n\nCreate a `src/lib/types.ts` file to define the structure of your API responses. These types ensure consistency between the backend and the frontend, keeping the data shape predictable and type-safe. You will import these types in both the API route and the widget to keep your responses aligned across the app.\n\n```ts title=\"src/lib/types.ts\"\nexport interface QueryApiSuccess {\n ok: true;\n blockNumber: string;\n blockTimeMicros: string;\n price: string;\n decimals: number;\n updatedAt: string;\n stale?: boolean;\n}\n\nexport interface QueryApiError {\n ok: false;\n error: string;\n}\nexport type QueryApiResponse = QueryApiSuccess | QueryApiError;\n\n```"} +{"page_id": "products-queries-tutorials-live-crypto-prices", "page_title": "Live Crypto Price Widget", "index": 7, "depth": 3, "title": "Add API Route for Frontend", "anchor": "add-api-route-for-frontend", "start_char": 11117, "end_char": 13978, "estimated_token_count": 623, "token_estimator": "heuristic-v1", "text": "### Add API Route for Frontend\n\nFinally, expose an API endpoint at `/api/queries`. This route ties everything together: it builds the query, sends it, decodes the response, and returns a structured JSON payload containing the current price, timestamp, block number, and a stale flag indicating whether the feed data is still fresh. The frontend widget will call this endpoint every few seconds to display the live, verified price data.\n\n```ts title=\"src/app/api/queries/route.ts\"\nimport { NextResponse } from 'next/server';\nimport {\n buildEthCallRequest,\n encodeWitnetLatestPrice,\n} from '@/lib/queries/buildRequest';\nimport { postQuery } from '@/lib/queries/client';\nimport { QUERY_URL, QUERIES_API_KEY, RPC_URL, DEFAULTS } from '@/lib/config';\nimport {\n parseFirstEthCallResult,\n decodeWitnetLatestPrice,\n} from '@/lib/queries/decode';\nimport type { QueryApiSuccess, QueryApiError } from '@/lib/types';\n\nexport async function GET() {\n const t0 = Date.now();\n try {\n // Encode the call for Witnet’s latestPrice(bytes4)\n const data = encodeWitnetLatestPrice(DEFAULTS.feedId4);\n\n // Build a Wormhole Query request anchored to the latest block\n const bytes = await buildEthCallRequest({\n rpcUrl: RPC_URL,\n chainId: DEFAULTS.chainId,\n to: DEFAULTS.to,\n data,\n });\n const t1 = Date.now();\n\n // Send the query to the Wormhole Query Proxy and await the signed response\n const proxyResponse = await postQuery({\n queryUrl: QUERY_URL,\n apiKey: QUERIES_API_KEY,\n bytes,\n timeoutMs: 25_000,\n });\n const t2 = Date.now();\n\n // Decode the signed Guardian response and extract Witnet data\n const { chainResp, raw } = parseFirstEthCallResult(proxyResponse);\n const { price, timestampSec } = decodeWitnetLatestPrice(\n raw,\n DEFAULTS.feedDecimals\n );\n\n // Log the latency of each leg for debugging\n console.log(`RPC ${t1 - t0}ms → Proxy ${t2 - t1}ms`);\n\n // Mark data as stale if older than the feed’s heartbeat interval\n const heartbeat = Number(process.env.FEED_HEARTBEAT_SEC || 0);\n const stale = heartbeat > 0 && Date.now() / 1000 - timestampSec > heartbeat;\n\n // Return a normalized JSON payload for the frontend\n const body: QueryApiSuccess = {\n ok: true,\n blockNumber: chainResp.blockNumber.toString(),\n blockTimeMicros: chainResp.blockTime.toString(),\n price,\n decimals: DEFAULTS.feedDecimals,\n updatedAt: new Date(timestampSec * 1000).toISOString(),\n stale,\n };\n return NextResponse.json(body);\n } catch (e: unknown) {\n // Catch and return a structured error\n const message = e instanceof Error ? e.message : String(e);\n console.error('Error in /api/queries:', message);\n const body: QueryApiError = { ok: false, error: message };\n return NextResponse.json(body, { status: 500 });\n }\n}\n\n```"} +{"page_id": "products-queries-tutorials-live-crypto-prices", "page_title": "Live Crypto Price Widget", "index": 8, "depth": 2, "title": "Price Widget", "anchor": "price-widget", "start_char": 13978, "end_char": 14186, "estimated_token_count": 39, "token_estimator": "heuristic-v1", "text": "## Price Widget\n\nIn this section, you will build a client component that fetches the signed price from your API, renders it with a freshness badge, and refreshes on an interval without overlapping requests."} +{"page_id": "products-queries-tutorials-live-crypto-prices", "page_title": "Live Crypto Price Widget", "index": 9, "depth": 3, "title": "Create Widget Component", "anchor": "create-widget-component", "start_char": 14186, "end_char": 18127, "estimated_token_count": 1014, "token_estimator": "heuristic-v1", "text": "### Create Widget Component\n\nCreate a client component that calls `/api/queries`, renders the current price, shows the last update time and block number, and displays a freshness badge based on the heartbeat. The component uses a ref to avoid overlapping requests and a timed interval to refresh automatically.\n\n```ts title=\"src/components/PriceWidget.tsx\"\n'use client';\n\nimport { useEffect, useRef, useState } from 'react';\n\n// Expected API success shape from /api/queries\ntype ApiOk = {\n ok: true;\n price: string;\n updatedAt: number | string;\n blockNumber: string;\n blockTimeMicros: number;\n decimals: number;\n stale: boolean;\n};\n\n// API error shape\ntype ApiErr = { ok: false; error: string };\n\n// Format timestamps for display\nfunction formatTime(ts: number | string) {\n let n: number;\n if (typeof ts === 'string') {\n const numeric = Number(ts);\n if (Number.isFinite(numeric)) {\n n = numeric;\n } else {\n const parsed = new Date(ts);\n return Number.isNaN(parsed.getTime()) ? '—' : parsed.toLocaleString();\n }\n } else {\n n = ts;\n }\n if (!Number.isFinite(n)) return '—';\n // If it looks like seconds, convert to ms\n const ms = n < 1_000_000_000_000 ? n * 1000 : n;\n const d = new Date(ms);\n return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString();\n}\n\nexport default function PriceWidget() {\n // UI state: fetched data, loading state, and any errors\n const [data, setData] = useState(null);\n const [error, setError] = useState(null);\n const [loading, setLoading] = useState(false);\n\n // Keep track of polling and prevent overlapping requests\n const timer = useRef(null);\n const inFlight = useRef(false);\n\n // Fetch price data from the API route\n async function fetchPrice() {\n if (inFlight.current) return; // avoid concurrent requests\n inFlight.current = true;\n setLoading(true);\n setError(null);\n\n try {\n const res = await fetch('/api/queries', { cache: 'no-store' });\n const json: ApiOk | ApiErr = await res.json();\n if (!json.ok) throw new Error(json.error);\n setData(json);\n } catch (e: any) {\n setError(e?.message || 'Failed to fetch price');\n } finally {\n setLoading(false);\n inFlight.current = false;\n }\n }\n\n // Fetch immediately and refresh every 30 seconds\n useEffect(() => {\n fetchPrice();\n timer.current = setInterval(fetchPrice, 30_000);\n return () => {\n if (timer.current) clearInterval(timer.current);\n };\n }, []);\n\n return (\n
\n
\n

ETH/USD Live Price

\n {data?.stale ? (\n \n Stale\n \n ) : (\n \n Fresh\n \n )}\n
\n\n
\n
\n {loading && !data ? 'Loading…' : data ? data.price : '—'}\n
\n\n
\n {data ? (\n <>\n Updated at {formatTime(data.updatedAt)}, block {data.blockNumber}\n \n ) : error ? (\n {error}\n ) : (\n 'Fetching latest price'\n )}\n
\n
\n\n
\n \n {loading ? 'Refreshing…' : 'Refresh now'}\n \n
\n
\n );\n}\n\n```"} +{"page_id": "products-queries-tutorials-live-crypto-prices", "page_title": "Live Crypto Price Widget", "index": 10, "depth": 3, "title": "Add the Widget to Home Page", "anchor": "add-the-widget-to-home-page", "start_char": 18127, "end_char": 18636, "estimated_token_count": 136, "token_estimator": "heuristic-v1", "text": "### Add the Widget to Home Page\n\nRender the widget on the home page with a simple heading and container so users see the price as soon as they load the app.\n\n```ts title=\"src/app/page.tsx\"\nimport PriceWidget from '@/components/PriceWidget';\n\nexport default function Page() {\n return (\n
\n

\n Live Crypto Price Widget\n

\n \n
\n );\n}\n\n```"} +{"page_id": "products-queries-tutorials-live-crypto-prices", "page_title": "Live Crypto Price Widget", "index": 11, "depth": 2, "title": "Run the App", "anchor": "run-the-app", "start_char": 18636, "end_char": 19645, "estimated_token_count": 224, "token_estimator": "heuristic-v1", "text": "## Run the App\n\nStart the development server and confirm that the live widget displays data correctly:\n\n```bash\nnpm run dev\n```\n\nOpen [http://localhost:3000](http://localhost:3000) to see your app running. You should see the widget display the current ETH/USD price, the last update time, the block number, and a freshness badge indicating whether the data is still within its heartbeat window.\n\nThe price may update only intermittently. Witnet feeds refresh only when a particular time or price deviation threshold is reached to prevent unnecessary network updates.\n\nYour app should look like this:\n\n![Frontend of Queries Live Prices Widget](/docs/images/products/queries/tutorials/live-crypto-prices/live-crypto-prices-1.webp){.half}\n\n???- tip \"Troubleshooting\"\n If you encounter a “Request failed with status code 403” error, it likely means your Queries API key is missing or incorrect. Check the `QUERIES_API_KEY` value in your `.env.local` file and restart the development server after updating it."} +{"page_id": "products-queries-tutorials-live-crypto-prices", "page_title": "Live Crypto Price Widget", "index": 12, "depth": 2, "title": "Resources", "anchor": "resources", "start_char": 19645, "end_char": 19936, "estimated_token_count": 69, "token_estimator": "heuristic-v1", "text": "## Resources\n\nIf you'd like to explore the complete project or need a reference while following this tutorial, you can find the complete codebase in the Wormhole's Queries [Tutorial GitHub repository](https://github.com/wormhole-foundation/e2e-tutorial-live-crypto-prices){target=\\_blank}."} +{"page_id": "products-queries-tutorials-live-crypto-prices", "page_title": "Live Crypto Price Widget", "index": 13, "depth": 2, "title": "Conclusion", "anchor": "conclusion", "start_char": 19936, "end_char": 20515, "estimated_token_count": 115, "token_estimator": "heuristic-v1", "text": "## Conclusion\n\nYou've successfully built a live crypto price widget that fetches verified data from Wormhole Queries and Witnet. Your app encodes a feed request, sends it through the Guardian network for verification, and displays the latest signed price in a simple, responsive widget.\n\nThe Queries flow can be extended to fetch other on-chain data or integrate multiple feeds for dashboards and analytics tools.\n\nLooking for more? Check out the [Wormhole Tutorial Demo repository](https://github.com/wormhole-foundation/demo-tutorials){target=\\_blank} for additional examples."} {"page_id": "products-reference-contract-addresses", "page_title": "Contract Addresses", "index": 0, "depth": 2, "title": "Core Contracts", "anchor": "core-contracts", "start_char": 22, "end_char": 8478, "estimated_token_count": 2823, "token_estimator": "heuristic-v1", "text": "## Core Contracts\n\n\n\n=== \"Mainnet\"\n\n
Chain NameContract Address
Ethereum0x98f3c9e6E3fAce36bAAd05FE09d375Ef1464288B
Solanaworm2ZoG2kUd4vFXhvjh93UUH596ayRfgQ2MgjNMTth
Algorand842125965
Aptos0x5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625
Arbitrum0xa5f208e072434bC67592E4C49C1B991BA79BCA46
Avalanche0x54a8e5f9c4CbA08F9943965859F6c34eAF03E26c
Base0xbebdb6C8ddC678FfA9f8748f85C815C556Dd8ac6
Berachain0xCa1D5a146B03f6303baF59e5AD5615ae0b9d146D
BNB Smart Chain0x98f3c9e6E3fAce36bAAd05FE09d375Ef1464288B
Celo0xa321448d90d4e5b0A732867c18eA198e75CAC48E
CreditCoin0xaBf89de706B583424328B54dD05a8fC986750Da8
Fantom0x126783A6Cb203a3E35344528B26ca3a0489a1485
Fogoworm2mrQkG1B1KTz37erMfWN8anHkSK24nzca7UD8BB
HyperEVM :material-alert:{ title='⚠️ The HyperEVM integration is experimental, as its node software is not open source. Use Wormhole messaging on HyperEVM with caution.' }0x7C0faFc4384551f063e05aee704ab943b8B53aB3
Injectiveinj17p9rzwnnfxcjp32un9ug7yhhzgtkhvl9l2q74d
Ink0xCa1D5a146B03f6303baF59e5AD5615ae0b9d146D
Kaia0x0C21603c4f3a6387e241c0091A7EA39E43E90bb7
Linea0x0C56aebD76E6D9e4a1Ec5e94F4162B4CBbf77b32
Mantle0xbebdb6C8ddC678FfA9f8748f85C815C556Dd8ac6
Mezo0xaBf89de706B583424328B54dD05a8fC986750Da8
Monad0x194B123c5E96B9b2E49763619985790Dc241CAC0
Moonbeam0xC8e2b0cD52Cf01b0Ce87d389Daa3d414d4cE29f3
NEARcontract.wormhole_crypto.near
Neutronneutron16rerygcpahqcxx5t8vjla46ym8ccn7xz7rtc6ju5ujcd36cmc7zs9zrunh
Optimism0xEe91C335eab126dF5fDB3797EA9d6aD93aeC9722
Plume0xaBf89de706B583424328B54dD05a8fC986750Da8
Polygon0x7A4B5a56256163F07b2C80A7cA55aBE66c4ec4d7
PythnetH3fxXJ86ADW2PNuDDmZJg6mzTtPxkYCpNuQUTgmJ7AjU
Scroll0xbebdb6C8ddC678FfA9f8748f85C815C556Dd8ac6
Seisei1gjrrme22cyha4ht2xapn3f08zzw6z3d4uxx6fyy9zd5dyr3yxgzqqncdqn
Seievm0xCa1D5a146B03f6303baF59e5AD5615ae0b9d146D
Sui0xaeab97f96cf9877fee2883315d459552b2b921edc16d7ceac6eab944dd88919c
Unichain0xCa1D5a146B03f6303baF59e5AD5615ae0b9d146D
World Chain0xcbcEe4e081464A15d8Ad5f58BB493954421eB506
X Layer0x194B123c5E96B9b2E49763619985790Dc241CAC0
XRPL-EVM0xaBf89de706B583424328B54dD05a8fC986750Da8
\n\n=== \"Testnet\"\n\n
Chain NameContract Address
Ethereum Holesky0xa10f2eF61dE1f19f586ab8B6F2EbA89bACE63F7a
Ethereum Sepolia0x4a8bc80Ed5a4067f1CCf107057b8270E0cC11A78
Solana3u8hJUVTA4jH1wYAyUur7FFZVQ8H635K3tSHHF4ssjQ5
Algorand86525623
Aptos0x5bc11445584a763c1fa7ed39081f1b920954da14e04b32440cba863d03e19625
Arbitrum Sepolia0x6b9C8671cdDC8dEab9c719bB87cBd3e782bA6a35
Avalanche0x7bbcE28e64B3F8b84d876Ab298393c38ad7aac4C
Base Sepolia0x79A1027a6A159502049F10906D333EC57E95F083
Berachain0xBB73cB66C26740F31d1FabDC6b7A46a038A300dd
BNB Smart Chain0x68605AD7b15c732a30b1BbC62BE8F2A509D74b4D
Celo0x88505117CA88e7dd2eC6EA1E13f0948db2D50D56
Converge0x556B259cFaCd9896B2773310080c7c3bcE90Ff01
CreditCoin0xaBf89de706B583424328B54dD05a8fC986750Da8
Fantom0x1BB3B4119b7BA9dfad76B0545fb3F531383c3bB7
FogoBhnQyKoQQgpuRTRo6D8Emz93PvXCYfVgHhnrR4T3qhw4
HyperEVM :material-alert:{ title='⚠️ The HyperEVM integration is experimental, as its node software is not open source. Use Wormhole messaging on HyperEVM with caution.' }0xBB73cB66C26740F31d1FabDC6b7A46a038A300dd
Injectiveinj1xx3aupmgv3ce537c0yce8zzd3sz567syuyedpg
Ink0xBB73cB66C26740F31d1FabDC6b7A46a038A300dd
Kaia0x1830CC6eE66c84D2F177B94D544967c774E624cA
Linea0x79A1027a6A159502049F10906D333EC57E95F083
Mantle0x376428e7f26D5867e69201b275553C45B09EE090
Mezo0x268557122Ffd64c85750d630b716471118F323c8
Moca0xaBf89de706B583424328B54dD05a8fC986750Da8
Monad0xBB73cB66C26740F31d1FabDC6b7A46a038A300dd
Moonbeam0xa5B7D85a8f27dd7907dc8FdC21FA5657D5E2F901
NEARwormhole.wormhole.testnet
Neutronneutron1enf63k37nnv9cugggpm06mg70emcnxgj9p64v2s8yx7a2yhhzk2q6xesk4
Optimism Sepolia0x31377888146f3253211EFEf5c676D41ECe7D58Fe
Osmosisosmo1hggkxr0hpw83f8vuft7ruvmmamsxmwk2hzz6nytdkzyup9krt0dq27sgyx
Plasma0xaBf89de706B583424328B54dD05a8fC986750Da8
Plume0x81705b969cDcc6FbFde91a0C6777bE0EF3A75855
Polygon Amoy0x6b9C8671cdDC8dEab9c719bB87cBd3e782bA6a35
PythnetEUrRARh92Cdc54xrDn6qzaqjA77NRrCcfbr8kPwoTL4z
Scroll0x055F47F1250012C6B20c436570a76e52c17Af2D5
Seisei1nna9mzp274djrgzhzkac2gvm3j27l402s4xzr08chq57pjsupqnqaj0d5s
Seievm0xBB73cB66C26740F31d1FabDC6b7A46a038A300dd
Sui0x31358d198147da50db32eda2562951d53973a0c0ad5ed738e9b17d88b213d790
Unichain0xBB73cB66C26740F31d1FabDC6b7A46a038A300dd
World Chain0xe5E02cD12B6FcA153b0d7fF4bF55730AE7B3C93A
X Layer0xA31aa3FDb7aF7Db93d18DDA4e19F811342EDF780
XRPL-EVM0xaBf89de706B583424328B54dD05a8fC986750Da8
\n\n=== \"Devnet\"\n\n
Chain NameContract Address
Ethereum0xC89Ce4735882C9F0f0FE26686c53074E09B0D550
SolanaBridge1p5gheXUvJ6jGWGeCsgPKgnE3YgdGKRVCMY9o
Algorand1004
Aptos0xde0036a9600559e295d5f6802ef6f3f802f510366e0c23912b0655d972166017
BNB Smart Chain0xC89Ce4735882C9F0f0FE26686c53074E09B0D550
NEARwormhole.test.near
StacksST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM
Sui0x5a5160ca3c2037f4b4051344096ef7a48ebf4400b3f385e57ea90e1628a8bde0
"} {"page_id": "products-reference-contract-addresses", "page_title": "Contract Addresses", "index": 1, "depth": 2, "title": "Wrapped Token Transfers (WTT)", "anchor": "wrapped-token-transfers-wtt", "start_char": 8478, "end_char": 15461, "estimated_token_count": 2371, "token_estimator": "heuristic-v1", "text": "## Wrapped Token Transfers (WTT)\n\n\n\n=== \"Mainnet\"\n\n
Chain NameContract Address
Ethereum0x3ee18B2214AFF97000D974cf647E7C347E8fa585
SolanawormDTUJ6AWPNvk59vGQbDvGJmqbDTdgWgAqcLBCgUb
Algorand842126029
Aptos0x576410486a2da45eee6c949c995670112ddf2fbeedab20350d506328eefc9d4f
Arbitrum0x0b2402144Bb366A632D14B83F244D2e0e21bD39c
Avalanche0x0e082F06FF657D94310cB8cE8B0D9a04541d8052
Base0x8d2de8d2f73F1F4cAB472AC9A881C9b123C79627
Berachain0x3Ff72741fd67D6AD0668d93B41a09248F4700560
BNB Smart Chain0xB6F6D86a8f9879A9c87f643768d9efc38c1Da6E7
Celo0x796Dff6D74F3E27060B71255Fe517BFb23C93eed
Fantom0x7C9Fc5741288cDFdD83CeB07f3ea7e22618D79D2
FogowormQuCVWSSmPdjVmEzAWxAXViVyTSWnLyhff5hVYGS
Injectiveinj1ghd753shjuwexxywmgs4xz7x2q732vcnxxynfn
Ink0x3Ff72741fd67D6AD0668d93B41a09248F4700560
Kaia0x5b08ac39EAED75c0439FC750d9FE7E1F9dD0193F
Linea0x167E0752de62cb76EFc0Fbb165Bd342c6e2Bb251
Mantle0x24850c6f61C438823F01B7A3BF2B89B72174Fa9d
Monad0x0B2719cdA2F10595369e6673ceA3Ee2EDFa13BA7
Moonbeam0xb1731c586ca89a23809861c6103f0b96b3f57d92
NEARcontract.portalbridge.near
Optimism0x1D68124e65faFC907325e3EDbF8c4d84499DAa8b
Polygon0x5a58505a96D1dbf8dF91cB21B54419FC36e93fdE
Scroll0x24850c6f61C438823F01B7A3BF2B89B72174Fa9d
Seisei1smzlm9t79kur392nu9egl8p8je9j92q4gzguewj56a05kyxxra0qy0nuf3
Seievm0x3Ff72741fd67D6AD0668d93B41a09248F4700560
Sui0xc57508ee0d4595e5a8728974a4a93a787d38f339757230d441e895422c07aba9
Unichain0x3Ff72741fd67D6AD0668d93B41a09248F4700560
World Chain0xc309275443519adca74c9136b02A38eF96E3a1f6
X Layer0x5537857664B0f9eFe38C9f320F75fEf23234D904
XRPL-EVM0x47F5195163270345fb4d7B9319Eda8C64C75E278
\n\n=== \"Testnet\"\n\n
Chain NameContract Address
Ethereum Holesky0x76d093BbaE4529a342080546cAFEec4AcbA59EC6
Ethereum Sepolia0xDB5492265f6038831E89f495670FF909aDe94bd9
SolanaDZnkkTmCiFWfYTfT41X3Rd1kDgozqzxWaHqsw6W4x2oe
Algorand86525641
Aptos0x576410486a2da45eee6c949c995670112ddf2fbeedab20350d506328eefc9d4f
Arbitrum Sepolia0xC7A204bDBFe983FCD8d8E61D02b475D4073fF97e
Avalanche0x61E44E506Ca5659E6c0bba9b678586fA2d729756
Base Sepolia0x86F55A04690fd7815A3D802bD587e83eA888B239
Berachain0xa10f2eF61dE1f19f586ab8B6F2EbA89bACE63F7a
BNB Smart Chain0x9dcF9D205C9De35334D646BeE44b2D2859712A09
Celo0x05ca6037eC51F8b712eD2E6Fa72219FEaE74E153
Fantom0x599CEa2204B4FaECd584Ab1F2b6aCA137a0afbE8
Fogo78HdStBqCMioGii9D8mF3zQaWDqDZBQWTUwjjpdmbJKX
HyperEVM :material-alert:{ title='⚠️ The HyperEVM integration is experimental, as its node software is not open source. Use Wormhole messaging on HyperEVM with caution.' }0x4a8bc80Ed5a4067f1CCf107057b8270E0cC11A78
Injectiveinj1q0e70vhrv063eah90mu97sazhywmeegp7myvnh
Ink0x376428e7f26D5867e69201b275553C45B09EE090
Kaia0xC7A13BE098720840dEa132D860fDfa030884b09A
Linea0xC7A204bDBFe983FCD8d8E61D02b475D4073fF97e
Mantle0x75Bfa155a9D7A3714b0861c8a8aF0C4633c45b5D
Mezo0xA31aa3FDb7aF7Db93d18DDA4e19F811342EDF780
Moca0xF97B81E513f53c7a6B57Bd0b103a6c295b3096C5
Monad0xF323dcDe4d33efe83cf455F78F9F6cc656e6B659
Moonbeam0xbc976D4b9D57E57c3cA52e1Fd136C45FF7955A96
NEARtoken.wormhole.testnet
Optimism Sepolia0x99737Ec4B815d816c49A385943baf0380e75c0Ac
Polygon Amoy0xC7A204bDBFe983FCD8d8E61D02b475D4073fF97e
Scroll0x22427d90B7dA3fA4642F7025A854c7254E4e45BF
Seisei1jv5xw094mclanxt5emammy875qelf3v62u4tl4lp5nhte3w3s9ts9w9az2
Seievm0x23908A62110e21C04F3A4e011d24F901F911744A
Sui0x6fb10cdb7aa299e9a4308752dadecb049ff55a892de92992a1edbd7912b3d6da
Unichain0xa10f2eF61dE1f19f586ab8B6F2EbA89bACE63F7a
World Chain0x430855B4D43b8AEB9D2B9869B74d58dda79C0dB2
X Layer0xdA91a06299BBF302091B053c6B9EF86Eff0f930D
XRPL-EVM0x7d8eBc211C4221eA18E511E4f0fD50c5A539f275
\n\n=== \"Devnet\"\n\n
Chain NameContract Address
Ethereum0x0290FB167208Af455bB137780163b7B7a9a10C16
SolanaB6RHG3mfcckmrYN1UhmJzyS1XX3fZKbkeUcpJe9Sy3FE
Algorand1006
Aptos0x84a5f374d29fc77e370014dce4fd6a55b58ad608de8074b0be5571701724da31
BNB Smart Chain0x0290FB167208Af455bB137780163b7B7a9a10C16
NEARtoken.test.near
Sui0xa6a3da85bbe05da5bfd953708d56f1a3a023e7fb58e5a824a3d4de3791e8f690
"} {"page_id": "products-reference-contract-addresses", "page_title": "Contract Addresses", "index": 2, "depth": 2, "title": "Wormhole Relayer", "anchor": "wormhole-relayer", "start_char": 15461, "end_char": 19517, "estimated_token_count": 1384, "token_estimator": "heuristic-v1", "text": "## Wormhole Relayer \n\n\n\n=== \"Mainnet\"\n\n
Chain NameContract Address
Ethereum0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
Arbitrum0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
Avalanche0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
Base0x706f82e9bb5b0813501714ab5974216704980e31
Berachain0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
BNB Smart Chain0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
Celo0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
Fantom0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
Ink0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
Kaia0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
Mantle0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
Mezo0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
Moonbeam0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
Optimism0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
Plume0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
Polygon0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
Scroll0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
Seievm0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
Unichain0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
World Chain0x1520cc9e779c56dab5866bebfb885c86840c33d3
X Layer0x27428DD2d3DD32A4D7f7C497eAaa23130d894911
\n\n=== \"Testnet\"\n\n
Chain NameContract Address
Ethereum Sepolia0x7B1bD7a6b4E61c2a123AC6BC2cbfC614437D0470
Arbitrum Sepolia0x7B1bD7a6b4E61c2a123AC6BC2cbfC614437D0470
Avalanche0xA3cF45939bD6260bcFe3D66bc73d60f19e49a8BB
Base Sepolia0x93BAD53DDfB6132b0aC8E37f6029163E63372cEE
Berachain0x362fca37E45fe1096b42021b543f462D49a5C8df
BNB Smart Chain0x80aC94316391752A193C1c47E27D382b507c93F3
Celo0x306B68267Deb7c5DfCDa3619E22E9Ca39C374f84
Fantom0x7B1bD7a6b4E61c2a123AC6BC2cbfC614437D0470
Ink0x362fca37E45fe1096b42021b543f462D49a5C8df
Mezo0x362fca37E45fe1096b42021b543f462D49a5C8df
Monad0x362fca37E45fe1096b42021b543f462D49a5C8df
Moonbeam0x0591C25ebd0580E0d4F27A82Fc2e24E7489CB5e0
Optimism Sepolia0x93BAD53DDfB6132b0aC8E37f6029163E63372cEE
Polygon Amoy0x362fca37E45fe1096b42021b543f462D49a5C8df
Seievm0x362fca37E45fe1096b42021b543f462D49a5C8df
Unichain0x362fca37E45fe1096b42021b543f462D49a5C8df
XRPL-EVM0x362fca37E45fe1096b42021b543f462D49a5C8df
\n\n=== \"Devnet\"\n\n
Chain NameContract Address
Ethereum0xcC680D088586c09c3E0E099a676FA4b6e42467b4
BNB Smart Chain0xcC680D088586c09c3E0E099a676FA4b6e42467b4
"} diff --git a/llms-full.txt b/llms-full.txt index acd3afdcf..d5a20cdb2 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -59,6 +59,7 @@ Doc-Page: https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/re Doc-Page: https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/refs/heads/main/products/queries/overview.md Doc-Page: https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/refs/heads/main/products/queries/reference/supported-methods.md Doc-Page: https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/refs/heads/main/products/queries/reference/supported-networks.md +Doc-Page: https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/refs/heads/main/products/queries/tutorials/live-crypto-prices.md Doc-Page: https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/refs/heads/main/products/reference/chain-ids.md Doc-Page: https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/refs/heads/main/products/reference/consistency-levels.md Doc-Page: https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/refs/heads/main/products/reference/contract-addresses.md @@ -16991,6 +16992,526 @@ For example, many chains use a fork of [Geth](https://github.com/ethereum/go-eth ℹ️`EthCallByTimestamp` arguments for `targetBlock` and `followingBlock` are currently required for requests to be successful on these chains. --- END CONTENT --- +Doc-Content: https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/refs/heads/main/products/queries/tutorials/live-crypto-prices.md +--- BEGIN CONTENT --- +--- +title: Live Crypto Price Widget +description: Learn how to fetch real-time crypto prices using Wormhole Queries and display them in a live widget powered by secure and verified Witnet data feeds. +categories: Queries +--- + +# Live Crypto Price Widget + +:simple-github: [Source code on GitHub](https://github.com/wormhole-foundation/e2e-tutorial-live-crypto-prices){target=\_blank} + +In this tutorial, you'll build a widget that displays live crypto prices using [Wormhole Queries](/docs/products/queries/overview/){target=\_blank} and [Witnet](https://witnet.io/){target=\_blank} data feeds. You'll learn how to request signed price data from the network, verify the response, and show it in a responsive frontend built with [Next.js](https://nextjs.org/){target=\_blank} and [TypeScript](https://www.typescriptlang.org/){target=\_blank}. + +Wormhole Queries make it possible to fetch verified off-chain data directly on-chain or in web applications without needing your own oracle infrastructure. Each response is cryptographically signed by the [Wormhole Guardians](/docs/protocol/infrastructure/guardians/){target=\_blank}, ensuring authenticity and preventing tampering. By combining Queries with Witnet's decentralized price feeds, you can access real-time, trustworthy market data through a single API call, without managing relayers or custom backends. + +## Prerequisites + +Before starting, make sure you have the following set up: + + - [Node.js and npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm){target=\_blank} installed on your system + - A [Wormhole Queries API key](/docs/products/queries/get-started/#request-an-api-key){target=\_blank} + - Access to an EVM-compatible [testnet RPC](https://chainlist.org/?testnets=true){target=\_blank}, such as Arbitrum Sepolia + - A [Witnet data feed identifier](https://docs.witnet.io/smart-contracts/witnet-data-feeds/addresses){target=\_blank} (this tutorial uses the ETH/USD feed as an example) + +!!! note + You can use a different Witnet feed or testnet if you prefer. Make sure to update the environment variables later in this tutorial with the correct values for your setup. + +## Project Setup + +In this section, you will create a new Next.js project, install the required dependencies, and configure the environment variables needed to fetch data from Wormhole Queries. + +1. **Create a new Next.js app**: Enable TypeScript, Tailwind CSS, and the `src/` directory when prompted. Other options are up to you. + + ```bash + npx create-next-app@latest live-crypto-prices + cd live-crypto-prices + ``` + +2. **Install dependencies**: Add the required packages. + + ```bash + npm install @wormhole-foundation/wormhole-query-sdk axios ethers + ``` + + - [`@wormhole-foundation/wormhole-query-sdk`](https://www.npmjs.com/package/@wormhole-foundation/wormhole-query-sdk){target=\_blank}: Build, send, and decode Wormhole Queries. + - [`axios`](https://www.npmjs.com/package/axios){target=\_blank}: Make JSON-RPC and Query Proxy requests. + - [`ethers`](https://www.npmjs.com/package/ethers){target=\_blank}: Handle ABI encoding and decoding for Witnet calls. + +3. **Add environment variables**: Create a file named `.env.local` in the project root. + + ```env + # Wormhole Query Proxy + QUERY_URL=https://testnet.query.wormhole.com/v1/query + QUERIES_API_KEY=INSERT_API_KEY + + # Chain and RPC + WORMHOLE_CHAIN_ID=10003 + RPC_URL=https://arbitrum-sepolia.drpc.org + + # Witnet Price Router on Arbitrum Sepolia + CALL_TO=0x1111AbA2164AcdC6D291b08DfB374280035E1111 + + # ETH/USD feed on Witnet, six decimals + FEED_ID4=0x3d15f701 + FEED_DECIMALS=6 + FEED_HEARTBEAT_SEC=86400 + ``` + + !!! warning + Make sure to add the `.env.local` file to your `.gitignore` to exclude it from version control. Never commit API keys to your repository. + + You can choose a different Witnet feed or network if you prefer. Just update `CALL_TO`, `FEED_ID4`, `FEED_DECIMALS`, and `WORMHOLE_CHAIN_ID`. + + They allow the app to fetch a live ETH/USD price with proper scaling, timestamps, and a signed response. + +4. **Add a configuration file**: Create `src/lib/config.ts` to access environment variables throughout the app. + + ```ts title="src/lib/config.ts" + -export const QUERY_URL = process.env.QUERY_URL!; +export const QUERIES_API_KEY = process.env.QUERIES_API_KEY!; +export const RPC_URL = process.env.RPC_URL!; + +export const DEFAULTS = { + chainId: Number(process.env.WORMHOLE_CHAIN_ID || 0), + to: process.env.CALL_TO || '', + feedId4: process.env.FEED_ID4 || '', + feedDecimals: Number(process.env.FEED_DECIMALS || 0), + feedHeartbeatSec: Number(process.env.FEED_HEARTBEAT_SEC || 0), +}; + ``` + +## Build the Server Logic + +In this section, you will implement the backend that powers the widget. You will encode the Witnet call, create and send a Wormhole Query, decode the signed response, and expose an API route for the frontend. + +1. **Encode the Witnet call and build the request**: Encode the function call for Witnet's Price Router using the feed ID and package it into a Wormhole Query request. This query will be anchored to the latest block so that the data you receive is verifiably tied to a recent snapshot of the chain state. This helper will return a serialized request that can be sent to the Wormhole Query Proxy. + + ```ts title="src/lib/queries/buildRequest.ts" + -import axios from 'axios'; +import { + EthCallQueryRequest, + PerChainQueryRequest, + QueryRequest, +} from '@wormhole-foundation/wormhole-query-sdk'; +import { Interface } from 'ethers'; + +// ABI interface for Witnet's Price Router +const WITNET_IFACE = new Interface([ + // Function signature for reading the latest price feed + 'function latestPrice(bytes4 id) view returns (int256 value, uint256 timestamp, bytes32 drTxHash, uint8 status)', +]); + +// Encode calldata for Witnet Router: latestPrice(bytes4) +export function encodeWitnetLatestPrice(id4: string): string { + // Validate feed ID format (must be a 4-byte hex) + if (!/^0x[0-9a-fA-F]{8}$/.test(id4)) { + throw new Error(`Invalid FEED_ID4: ${id4}`); + } + // Return ABI-encoded call data for latestPrice(bytes4) + return WITNET_IFACE.encodeFunctionData('latestPrice', [id4 as `0x${string}`]); +} + +export async function buildEthCallRequest(params: { + rpcUrl: string; + chainId: number; // Wormhole chain id + to: string; + data: string; // 0x-prefixed calldata +}) { + const { rpcUrl, chainId, to, data } = params; + + // Get the latest block number via JSON-RPC + // Short timeout prevents long hangs in the dev environment + const latestBlock: string = ( + await axios.post( + rpcUrl, + { + method: 'eth_getBlockByNumber', + params: ['latest', false], + id: 1, + jsonrpc: '2.0', + }, + { timeout: 5_000, headers: { 'Content-Type': 'application/json' } } + ) + ).data?.result?.number; + + if (!latestBlock) throw new Error('Failed to fetch latest block'); + + // Build a Wormhole Query that wraps an EthCall to the Witnet contract + const request = new QueryRequest(1, [ + new PerChainQueryRequest( + chainId, + new EthCallQueryRequest(latestBlock, [{ to, data }]) + ), + ]); + + // Serialize to bytes for sending to the Wormhole Query Proxy + return request.serialize(); // Uint8Array +} + ``` + +2. **Send the serialized request to the Query Proxy**: Next, you will send the serialized query to the Wormhole Query Proxy, which forwards it to the Guardians for verification. The proxy returns a signed response containing the requested data and proof that the Guardians verified it. This step ensures that all the data your app consumes comes from a trusted and authenticated source. + + ```ts title="src/lib/queries/client.ts" + -import axios from 'axios'; + +export async function postQuery({ + queryUrl, + apiKey, + bytes, + timeoutMs = 25_000, +}: { + queryUrl: string; + apiKey: string; + bytes: Uint8Array; + timeoutMs?: number; +}) { + // Convert the query bytes to hex and POST to the proxy + const res = await axios.post( + queryUrl, + { bytes: Buffer.from(bytes).toString('hex') }, + { + timeout: timeoutMs, + headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' }, + validateStatus: (s) => s === 200, + } + ); + return res.data; // throws on non-200 +} + ``` + +3. **Decode and verify the response**: Once you receive the signed response, you will decode it to extract the Witnet price data. +Here, you will use ethers to parse the ABI-encoded return values and scale the raw integer to a readable decimal value based on the feed's configured number of decimals. This function will output a clean result containing the latest price, timestamp, and transaction reference from the Witnet feed. + + ```ts title="src/lib/queries/decode.ts" + -import { + EthCallQueryResponse, + QueryResponse, +} from '@wormhole-foundation/wormhole-query-sdk'; +import { Interface, Result } from 'ethers'; + +// ABI interface for decoding Witnet's latestPrice response +const WITNET_IFACE = new Interface([ + 'function latestPrice(bytes4 id) view returns (int256 value, uint256 timestamp, bytes32 drTxHash, uint8 status)', +]); + +// Parse the first EthCall result from the proxy's response +export function parseFirstEthCallResult(proxyResponse: { bytes: string }): { + chainResp: EthCallQueryResponse; + raw: string; +} { + // Decode the top-level QueryResponse from Wormhole Guardians + const qr = QueryResponse.from(proxyResponse.bytes); + + // Extract the first chain response and its raw call result + const chainResp = qr.responses[0].response as EthCallQueryResponse; + const raw = chainResp.results[0]; + return { chainResp, raw }; +} + +// Decode Witnet's latestPrice return tuple into readable fields +export function decodeWitnetLatestPrice( + raw: string, + decimals: number +): { price: string; timestampSec: number; drTxHash: string } { + // Decode ABI-encoded result from the router call + const r: Result = WITNET_IFACE.decodeFunctionResult('latestPrice', raw); + const value = BigInt(r[0].toString()); + const timestampSec = Number(r[1].toString()); + const drTxHash = r[2] as string; + + return { + price: scaleBigintToDecimalString(value, decimals), + timestampSec, + drTxHash, + }; +} + +// Convert a bigint price into a human-readable decimal string +function scaleBigintToDecimalString(value: bigint, decimals: number): string { + const zero = BigInt(0); + const neg = value < zero ? '-' : ''; + const v = value < zero ? -value : value; + const s = v.toString().padStart(decimals + 1, '0'); + const i = s.slice(0, -decimals); + const f = s.slice(-decimals).replace(/0+$/, ''); + return neg + (f ? `${i}.${f}` : i); +} + ``` + +4. **Add Shared Types**: Create a `src/lib/types.ts` file to define the structure of your API responses. These types ensure consistency between the backend and frontend, keeping the data shape predictable and type safe. You will import these types in both the API route and the widget to keep your responses aligned across the app. + + ```ts title="src/lib/types.ts" + -export interface QueryApiSuccess { + ok: true; + blockNumber: string; + blockTimeMicros: string; + price: string; + decimals: number; + updatedAt: string; + stale?: boolean; +} + +export interface QueryApiError { + ok: false; + error: string; +} +export type QueryApiResponse = QueryApiSuccess | QueryApiError; + ``` + +5. **Add an API route for the frontend**: Finally, expose an API endpoint at `/api/queries`. This route ties everything together: it builds the query, sends it, decodes the response, and returns a structured JSON payload with the current price, timestamp, block number, and a stale flag that indicates whether the feed data is still fresh. The frontend widget will call this endpoint every few seconds to display the live, verified price data. + + ```ts title="src/app/api/queries/route.ts" + -import { NextResponse } from 'next/server'; +import { + buildEthCallRequest, + encodeWitnetLatestPrice, +} from '@/lib/queries/buildRequest'; +import { postQuery } from '@/lib/queries/client'; +import { QUERY_URL, QUERIES_API_KEY, RPC_URL, DEFAULTS } from '@/lib/config'; +import { + parseFirstEthCallResult, + decodeWitnetLatestPrice, +} from '@/lib/queries/decode'; +import type { QueryApiSuccess, QueryApiError } from '@/lib/types'; + +export async function GET() { + const t0 = Date.now(); + try { + // Encode the call for Witnet’s latestPrice(bytes4) + const data = encodeWitnetLatestPrice(DEFAULTS.feedId4); + + // Build a Wormhole Query request anchored to the latest block + const bytes = await buildEthCallRequest({ + rpcUrl: RPC_URL, + chainId: DEFAULTS.chainId, + to: DEFAULTS.to, + data, + }); + const t1 = Date.now(); + + // Send the query to the Wormhole Query Proxy and await the signed response + const proxyResponse = await postQuery({ + queryUrl: QUERY_URL, + apiKey: QUERIES_API_KEY, + bytes, + timeoutMs: 25_000, + }); + const t2 = Date.now(); + + // Decode the signed Guardian response and extract Witnet data + const { chainResp, raw } = parseFirstEthCallResult(proxyResponse); + const { price, timestampSec } = decodeWitnetLatestPrice( + raw, + DEFAULTS.feedDecimals + ); + + // Log the latency of each leg for debugging + console.log(`RPC ${t1 - t0}ms → Proxy ${t2 - t1}ms`); + + // Mark data as stale if older than the feed’s heartbeat interval + const heartbeat = Number(process.env.FEED_HEARTBEAT_SEC || 0); + const stale = heartbeat > 0 && Date.now() / 1000 - timestampSec > heartbeat; + + // Return a normalized JSON payload for the frontend + const body: QueryApiSuccess = { + ok: true, + blockNumber: chainResp.blockNumber.toString(), + blockTimeMicros: chainResp.blockTime.toString(), + price, + decimals: DEFAULTS.feedDecimals, + updatedAt: new Date(timestampSec * 1000).toISOString(), + stale, + }; + return NextResponse.json(body); + } catch (e: unknown) { + // Catch and return a structured error + const message = e instanceof Error ? e.message : String(e); + console.error('Error in /api/queries:', message); + const body: QueryApiError = { ok: false, error: message }; + return NextResponse.json(body, { status: 500 }); + } +} + ``` + +## Price Widget + +In this section, you will build a client component that fetches the signed price from your API, renders it with a freshness badge, and refreshes on an interval without overlapping requests. + +1. **Create the widget component**: Create a client component that calls `/api/queries`, renders the current price, shows the last update time and block number, and displays a freshness badge based on the heartbeat. It uses a ref to avoid overlapping requests and a timed interval to refresh automatically. + + ```ts title="src/components/PriceWidget.tsx" + -'use client'; + +import { useEffect, useRef, useState } from 'react'; + +// Expected API success shape from /api/queries +type ApiOk = { + ok: true; + price: string; + updatedAt: number | string; + blockNumber: string; + blockTimeMicros: number; + decimals: number; + stale: boolean; +}; + +// API error shape +type ApiErr = { ok: false; error: string }; + +// Format timestamps for display +function formatTime(ts: number | string) { + let n: number; + if (typeof ts === 'string') { + const numeric = Number(ts); + if (Number.isFinite(numeric)) { + n = numeric; + } else { + const parsed = new Date(ts); + return Number.isNaN(parsed.getTime()) ? '—' : parsed.toLocaleString(); + } + } else { + n = ts; + } + if (!Number.isFinite(n)) return '—'; + // If it looks like seconds, convert to ms + const ms = n < 1_000_000_000_000 ? n * 1000 : n; + const d = new Date(ms); + return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString(); +} + +export default function PriceWidget() { + // UI state: fetched data, loading state, and any errors + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + // Keep track of polling and prevent overlapping requests + const timer = useRef(null); + const inFlight = useRef(false); + + // Fetch price data from the API route + async function fetchPrice() { + if (inFlight.current) return; // avoid concurrent requests + inFlight.current = true; + setLoading(true); + setError(null); + + try { + const res = await fetch('/api/queries', { cache: 'no-store' }); + const json: ApiOk | ApiErr = await res.json(); + if (!json.ok) throw new Error(json.error); + setData(json); + } catch (e: any) { + setError(e?.message || 'Failed to fetch price'); + } finally { + setLoading(false); + inFlight.current = false; + } + } + + // Fetch immediately and refresh every 30 seconds + useEffect(() => { + fetchPrice(); + timer.current = setInterval(fetchPrice, 30_000); + return () => { + if (timer.current) clearInterval(timer.current); + }; + }, []); + + return ( +
+
+

ETH/USD Live Price

+ {data?.stale ? ( + + Stale + + ) : ( + + Fresh + + )} +
+
+
+ {loading && !data ? 'Loading…' : data ? data.price : '—'} +
+
+ {data ? ( + <> + Updated at {formatTime(data.updatedAt)}, block {data.blockNumber} + + ) : error ? ( + {error} + ) : ( + 'Fetching latest price' + )} +
+
+
+ +
+
+ ); +} +
+ ``` + +2. **Add the widget to the home page**: Render the widget on the home page with a simple heading and container so users see the price as soon as they load the app. + + ```ts title="src/app/page.tsx" + -import PriceWidget from '@/components/PriceWidget'; + +export default function Page() { + return ( +
+

+ Live Crypto Price Widget +

+ +
+ ); +} + ``` + +## Run the App + +Start the development server and confirm that the live widget displays data correctly: + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) to see your app running. You should see the widget displaying the current ETH/USD price, along with the last update time, the block number, and a freshness badge showing whether the data is still within its heartbeat window. + +The price may not update every few seconds, because Witnet feeds refresh only when a particular time or price deviation threshold is reached. This ensures data remains reliable and prevents unnecessary network updates. + +Your app should look like this: + +![Frontend of Queries Live Prices Widget](/docs/images/products/queries/tutorials/live-crypto-prices/live-crypto-prices-1.webp){.half} + +???- tip "Troubleshooting" + If you encounter a “Request failed with status code 403” error, it likely means your Queries API key is missing or incorrect. Check the `QUERIES_API_KEY` value in your `.env.local` file and restart the development server after updating it. + +## Resources + +If you'd like to explore the complete project or need a reference while following this tutorial, you can find the complete codebase in the Wormhole's Queries [Tutorial GitHub repository](https://github.com/wormhole-foundation/e2e-tutorial-live-crypto-prices){target=\_blank}. + +## Conclusion + +You've successfully built a live crypto price widget that fetches verified data from Wormhole Queries and Witnet. Your app encodes a feed request, sends it through the Guardian network for verification, and displays the latest signed price in a simple, responsive widget. + +This same flow can be extended to fetch other types of on-chain data or integrate multiple feeds for dashboards and analytics tools. + +Looking for more? Check out the [Wormhole Tutorial Demo repository](https://github.com/wormhole-foundation/demo-tutorials){target=\_blank} for additional examples. +--- END CONTENT --- + Doc-Content: https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/refs/heads/main/products/reference/chain-ids.md --- BEGIN CONTENT --- --- diff --git a/llms.txt b/llms.txt index fc70bead7..59c8a8794 100644 --- a/llms.txt +++ b/llms.txt @@ -6,7 +6,7 @@ This directory lists URLs for raw Markdown pages that complement the rendered pages on the documentation site. Use these Markdown files to retain semantic context when prompting models while avoiding passing HTML elements. ## Metadata -- Documentation pages: 125 +- Documentation pages: 126 - Categories: 19 ## Docs @@ -124,6 +124,7 @@ Docs: Queries - [Queries Overview](https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/main/.ai/pages/products-queries-overview.md): Learn how Wormhole Queries enable smart contracts to fetch real-time, Guardian-verified data across multiple blockchains. - [Queries Supported Methods](https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/main/.ai/pages/products-queries-reference-supported-methods.md): Retrieve multichain data via historical timestamp queries, finality confirmation queries, and Solana lookups. - [Queries Supported Networks](https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/main/.ai/pages/products-queries-reference-supported-networks.md): Reference table of chains supported by Wormhole Queries, including method support, finality, and expected historical data availability. +- [Live Crypto Price Widget](https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/main/.ai/pages/products-queries-tutorials-live-crypto-prices.md): Learn how to fetch real-time crypto prices using Wormhole Queries and display them in a live widget powered by secure and verified Witnet data feeds. Docs: Transfer - [Get Started with CCTP](https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/main/.ai/pages/products-cctp-bridge-get-started.md): Transfer USDC across chains using Wormhole's CCTP integration with the TypeScript SDK, including setup, attestation, and redemption steps. diff --git a/products/queries/.nav.yml b/products/queries/.nav.yml index c03aa7cbf..c2f8182b4 100644 --- a/products/queries/.nav.yml +++ b/products/queries/.nav.yml @@ -3,5 +3,6 @@ nav: - 'Overview': overview.md - 'Get Started': get-started.md - guides +- tutorials - 'FAQs': faqs.md - reference diff --git a/products/queries/tutorials/.nav.yml b/products/queries/tutorials/.nav.yml new file mode 100644 index 000000000..1eebaa6f9 --- /dev/null +++ b/products/queries/tutorials/.nav.yml @@ -0,0 +1,3 @@ +title: Tutorials +nav: + - 'Live Crypto Prices': live-crypto-prices.md diff --git a/products/queries/tutorials/live-crypto-prices.md b/products/queries/tutorials/live-crypto-prices.md new file mode 100644 index 000000000..11963fcf5 --- /dev/null +++ b/products/queries/tutorials/live-crypto-prices.md @@ -0,0 +1,173 @@ +--- +title: Live Crypto Price Widget +description: Learn how to fetch real-time crypto prices using Wormhole Queries and display them in a live widget powered by secure and verified Witnet data feeds. +categories: Queries +--- + +# Live Crypto Price Widget + +:simple-github: [Source code on GitHub](https://github.com/wormhole-foundation/e2e-tutorial-live-crypto-prices){target=\_blank} + +In this tutorial, you'll build a widget that displays live crypto prices using [Wormhole Queries](/docs/products/queries/overview/){target=\_blank} and [Witnet](https://witnet.io/){target=\_blank} data feeds. You'll learn how to request signed price data from the network, verify the response, and show it in a responsive frontend built with [Next.js](https://nextjs.org/){target=\_blank} and [TypeScript](https://www.typescriptlang.org/){target=\_blank}. + +Queries enable fetching verified off-chain data directly on-chain or in web applications without requiring your own oracle infrastructure. Each response is cryptographically signed by the [Wormhole Guardians](/docs/protocol/infrastructure/guardians/){target=\_blank}, ensuring authenticity and preventing tampering. By combining Queries with Witnet's decentralized price feeds, you can access real-time, trustworthy market data through a single API call, without managing relayers or custom backends. + +## Prerequisites + +Before starting, make sure you have the following set up: + + - [Node.js and npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm){target=\_blank} installed on your system + - A [Wormhole Queries API key](/docs/products/queries/get-started/#request-an-api-key){target=\_blank} + - Access to an EVM-compatible [testnet RPC](https://chainlist.org/?testnets=true){target=\_blank}, such as Arbitrum Sepolia + - A [Witnet data feed identifier](https://docs.witnet.io/smart-contracts/witnet-data-feeds/addresses){target=\_blank} (this tutorial uses the ETH/USD feed as an example) + + You can use a different Witnet feed or testnet if you prefer. Make sure to update the environment variables later in this tutorial with the correct values for your setup. + +## Project Setup + +In this section, you will create a new Next.js project, install the required dependencies, and configure the environment variables needed to fetch data from Wormhole Queries. + +1. **Create a new Next.js app**: Enable TypeScript, Tailwind CSS, and the `src/` directory when prompted. You can configure the remaining options as you like. Create your app using the following command: + + ```bash + npx create-next-app@latest live-crypto-prices + cd live-crypto-prices + ``` + +2. **Install dependencies**: Add the required packages. + + ```bash + npm install @wormhole-foundation/wormhole-query-sdk axios ethers + ``` + + - [`@wormhole-foundation/wormhole-query-sdk`](https://www.npmjs.com/package/@wormhole-foundation/wormhole-query-sdk){target=\_blank}: Build, send, and decode Wormhole Queries. + - [`axios`](https://www.npmjs.com/package/axios){target=\_blank}: Make JSON-RPC and Query Proxy requests. + - [`ethers`](https://www.npmjs.com/package/ethers){target=\_blank}: Handle ABI encoding and decoding for Witnet calls. + +3. **Add environment variables**: Create a file named `.env.local` in the project root. + + ```env + # Wormhole Query Proxy + QUERY_URL=https://testnet.query.wormhole.com/v1/query + QUERIES_API_KEY=INSERT_API_KEY + + # Chain and RPC + WORMHOLE_CHAIN_ID=10003 + RPC_URL=https://arbitrum-sepolia.drpc.org + + # Witnet Price Router on Arbitrum Sepolia + CALL_TO=0x1111AbA2164AcdC6D291b08DfB374280035E1111 + + # ETH/USD feed on Witnet, six decimals + FEED_ID4=0x3d15f701 + FEED_DECIMALS=6 + FEED_HEARTBEAT_SEC=86400 + ``` + + !!! warning + Make sure to add the `.env.local` file to your `.gitignore` to exclude it from version control. Never commit API keys to your repository. + + You can use a different Witnet feed or network by updating `CALL_TO`, `FEED_ID4`, `FEED_DECIMALS`, and `WORMHOLE_CHAIN_ID`. These values allow the app to fetch a live ETH/USD price with proper scaling, timestamps, and a signed response. + + +4. **Add a configuration file**: Create `src/lib/config.ts` to access environment variables throughout the app. + + ```ts title="src/lib/config.ts" + ---8<-- "code/products/queries/tutorials/live-crypto-prices/snippet-1.ts" + ``` + +## Build the Server Logic + +In this section, you will implement the backend that powers the widget. You will encode the Witnet call, create and send a Wormhole Query, decode the signed response, and expose an API route for the frontend. + +### Encode Witnet Call and Build the Request + +First, encode the function call for Witnet's Price Router using the feed ID and package it into a Wormhole Query request. This query will be anchored to the latest block, ensuring the data you receive is verifiably tied to a recent snapshot of the chain state. This helper will return a serialized request that can be sent to the Wormhole Query Proxy. + +```ts title="src/lib/queries/buildRequest.ts" +---8<-- "code/products/queries/tutorials/live-crypto-prices/snippet-2.ts" +``` + +### Send Request to the Query Proxy + +Next, you will send the serialized query to the Wormhole Query Proxy, which forwards it to the Guardians for verification. The proxy returns a signed response containing the requested data and proof that the Guardians verified it. This step ensures that all the data your app consumes comes from a trusted and authenticated source. + +```ts title="src/lib/queries/client.ts" +---8<-- "code/products/queries/tutorials/live-crypto-prices/snippet-3.ts" +``` + +### Decode and Verify Response + +Once you receive the signed response, you will decode it to extract the Witnet price data. +Here, you will use ethers to parse the ABI-encoded return values and scale the raw integer to a readable decimal value based on the feed's configured number of decimals. This function will output a clean result containing the latest price, timestamp, and transaction reference from the Witnet feed. + +```ts title="src/lib/queries/decode.ts" +---8<-- "code/products/queries/tutorials/live-crypto-prices/snippet-4.ts" +``` + +### Add Shared Types + +Create a `src/lib/types.ts` file to define the structure of your API responses. These types ensure consistency between the backend and the frontend, keeping the data shape predictable and type-safe. You will import these types in both the API route and the widget to keep your responses aligned across the app. + +```ts title="src/lib/types.ts" +---8<-- "code/products/queries/tutorials/live-crypto-prices/snippet-5.ts" +``` + +### Add API Route for Frontend + +Finally, expose an API endpoint at `/api/queries`. This route ties everything together: it builds the query, sends it, decodes the response, and returns a structured JSON payload containing the current price, timestamp, block number, and a stale flag indicating whether the feed data is still fresh. The frontend widget will call this endpoint every few seconds to display the live, verified price data. + +```ts title="src/app/api/queries/route.ts" +---8<-- "code/products/queries/tutorials/live-crypto-prices/snippet-6.ts" +``` + +## Price Widget + +In this section, you will build a client component that fetches the signed price from your API, renders it with a freshness badge, and refreshes on an interval without overlapping requests. + +### Create Widget Component + +Create a client component that calls `/api/queries`, renders the current price, shows the last update time and block number, and displays a freshness badge based on the heartbeat. The component uses a ref to avoid overlapping requests and a timed interval to refresh automatically. + +```ts title="src/components/PriceWidget.tsx" +---8<-- "code/products/queries/tutorials/live-crypto-prices/snippet-7.ts" +``` + +### Add the Widget to Home Page + +Render the widget on the home page with a simple heading and container so users see the price as soon as they load the app. + +```ts title="src/app/page.tsx" +---8<-- "code/products/queries/tutorials/live-crypto-prices/snippet-8.ts" +``` + +## Run the App + +Start the development server and confirm that the live widget displays data correctly: + +```bash +npm run dev +``` + +Open [http://localhost:3000](http://localhost:3000) to see your app running. You should see the widget display the current ETH/USD price, the last update time, the block number, and a freshness badge indicating whether the data is still within its heartbeat window. + +The price may update only intermittently. Witnet feeds refresh only when a particular time or price deviation threshold is reached to prevent unnecessary network updates. + +Your app should look like this: + +![Frontend of Queries Live Prices Widget](/docs/images/products/queries/tutorials/live-crypto-prices/live-crypto-prices-1.webp){.half} + +???- tip "Troubleshooting" + If you encounter a “Request failed with status code 403” error, it likely means your Queries API key is missing or incorrect. Check the `QUERIES_API_KEY` value in your `.env.local` file and restart the development server after updating it. + +## Resources + +If you'd like to explore the complete project or need a reference while following this tutorial, you can find the complete codebase in the Wormhole's Queries [Tutorial GitHub repository](https://github.com/wormhole-foundation/e2e-tutorial-live-crypto-prices){target=\_blank}. + +## Conclusion + +You've successfully built a live crypto price widget that fetches verified data from Wormhole Queries and Witnet. Your app encodes a feed request, sends it through the Guardian network for verification, and displays the latest signed price in a simple, responsive widget. + +The Queries flow can be extended to fetch other on-chain data or integrate multiple feeds for dashboards and analytics tools. + +Looking for more? Check out the [Wormhole Tutorial Demo repository](https://github.com/wormhole-foundation/demo-tutorials){target=\_blank} for additional examples. \ No newline at end of file