Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
f2d09ce
add new tutorial to navbar
martin0995 Oct 20, 2025
6a3f7f3
queries tutorial intro
martin0995 Oct 20, 2025
06051f1
add prerequisites section
martin0995 Oct 20, 2025
6f89813
add project setup
martin0995 Oct 20, 2025
a9bf98e
add environment config
martin0995 Oct 20, 2025
f43b4fa
add helper files
martin0995 Oct 20, 2025
2b40539
add backend logic
martin0995 Oct 20, 2025
9530040
add last part of tutorial
martin0995 Oct 20, 2025
4fb8b51
update project setup section
martin0995 Oct 20, 2025
93acd92
add section intro
martin0995 Oct 20, 2025
9db744a
update backend steps
martin0995 Oct 20, 2025
b4da221
add all code in snippets files
martin0995 Oct 20, 2025
ba3c077
update last section
martin0995 Oct 20, 2025
eb224fc
add resources and conclusion
martin0995 Oct 20, 2025
30ed1a5
add troubleshooting note
martin0995 Oct 20, 2025
12dc407
update frontend
martin0995 Oct 20, 2025
cd3e20e
add widget screenshot
martin0995 Oct 20, 2025
547407f
update snippets with comments
martin0995 Oct 20, 2025
ce911e1
llm check
martin0995 Oct 20, 2025
ea516d0
style check
martin0995 Oct 20, 2025
d4a4668
llm check
martin0995 Oct 20, 2025
7a7572b
Apply suggestions from code review
martin0995 Oct 20, 2025
ba1afdd
llm check
martin0995 Oct 20, 2025
a34494c
Merge branch 'dev' into martinh/queries-e2e-tutorial
martin0995 Oct 24, 2025
f7cc7b3
Merge branch 'dev' into martinh/queries-e2e-tutorial
ilariae Oct 24, 2025
6129179
Merge branch 'dev' into martinh/queries-e2e-tutorial
martin0995 Oct 28, 2025
0fb9e47
merge new llms
martin0995 Oct 30, 2025
848eaf3
merge with new llms
martin0995 Oct 30, 2025
6d27085
merge to dev
martin0995 Oct 30, 2025
529708c
merge to dev
martin0995 Oct 30, 2025
dd59ac9
Update products/queries/tutorials/live-crypto-prices.md
martin0995 Oct 31, 2025
32c1767
llm check
martin0995 Oct 31, 2025
e4a94d2
Apply suggestions from code review
martin0995 Nov 5, 2025
7b211b2
update style
martin0995 Nov 5, 2025
35ad887
llm check
martin0995 Nov 5, 2025
a8ca19d
merge to dev
martin0995 Nov 5, 2025
cc5b777
llm check
martin0995 Nov 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
546 changes: 546 additions & 0 deletions .ai/categories/queries.md

Large diffs are not rendered by default.

543 changes: 543 additions & 0 deletions .ai/pages/products-queries-tutorials-live-crypto-prices.md

Large diffs are not rendered by default.

91 changes: 91 additions & 0 deletions .ai/site-index.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
};
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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 });
}
}
Loading
Loading