Skip to content

Commit cb6ce21

Browse files
martin0995Copilotilariaedawnkelly09
authored
Martinh/queries e2e tutorial (#662)
* add new tutorial to navbar * queries tutorial intro * add prerequisites section * add project setup * add environment config * add helper files * add backend logic * add last part of tutorial * update project setup section * add section intro * update backend steps * add all code in snippets files * update last section * add resources and conclusion * add troubleshooting note * update frontend * add widget screenshot * update snippets with comments * llm check * style check * llm check * Apply suggestions from code review Co-authored-by: Copilot <[email protected]> * llm check * merge with new llms * merge to dev * Update products/queries/tutorials/live-crypto-prices.md Co-authored-by: Ilaria <[email protected]> * llm check * Apply suggestions from code review Co-authored-by: Dawn Kelly <[email protected]> * update style * llm check * llm check --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Ilaria <[email protected]> Co-authored-by: Dawn Kelly <[email protected]>
1 parent aaca6b1 commit cb6ce21

File tree

18 files changed

+2263
-1
lines changed

18 files changed

+2263
-1
lines changed

.ai/categories/queries.md

Lines changed: 546 additions & 0 deletions
Large diffs are not rendered by default.

.ai/pages/products-queries-tutorials-live-crypto-prices.md

Lines changed: 543 additions & 0 deletions
Large diffs are not rendered by default.

.ai/site-index.json

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3559,6 +3559,97 @@
35593559
"hash": "sha256:edb61bc1efa636fd806f90b03dd84374c533bf7c4f9ffe92f2230f9003c2ad1b",
35603560
"token_estimator": "heuristic-v1"
35613561
},
3562+
{
3563+
"id": "products-queries-tutorials-live-crypto-prices",
3564+
"title": "Live Crypto Price Widget",
3565+
"slug": "products-queries-tutorials-live-crypto-prices",
3566+
"categories": [
3567+
"Queries"
3568+
],
3569+
"raw_md_url": "https://raw.githubusercontent.com/wormhole-foundation/wormhole-docs/main/.ai/pages/products-queries-tutorials-live-crypto-prices.md",
3570+
"html_url": "https://wormhole.com/docs/products/queries/tutorials/live-crypto-prices/",
3571+
"preview": ":simple-github: [Source code on GitHub](https://github.com/wormhole-foundation/e2e-tutorial-live-crypto-prices){target=\\_blank}",
3572+
"outline": [
3573+
{
3574+
"depth": 2,
3575+
"title": "Prerequisites",
3576+
"anchor": "prerequisites"
3577+
},
3578+
{
3579+
"depth": 2,
3580+
"title": "Project Setup",
3581+
"anchor": "project-setup"
3582+
},
3583+
{
3584+
"depth": 2,
3585+
"title": "Build the Server Logic",
3586+
"anchor": "build-the-server-logic"
3587+
},
3588+
{
3589+
"depth": 3,
3590+
"title": "Encode Witnet Call and Build the Request",
3591+
"anchor": "encode-witnet-call-and-build-the-request"
3592+
},
3593+
{
3594+
"depth": 3,
3595+
"title": "Send Request to the Query Proxy",
3596+
"anchor": "send-request-to-the-query-proxy"
3597+
},
3598+
{
3599+
"depth": 3,
3600+
"title": "Decode and Verify Response",
3601+
"anchor": "decode-and-verify-response"
3602+
},
3603+
{
3604+
"depth": 3,
3605+
"title": "Add Shared Types",
3606+
"anchor": "add-shared-types"
3607+
},
3608+
{
3609+
"depth": 3,
3610+
"title": "Add API Route for Frontend",
3611+
"anchor": "add-api-route-for-frontend"
3612+
},
3613+
{
3614+
"depth": 2,
3615+
"title": "Price Widget",
3616+
"anchor": "price-widget"
3617+
},
3618+
{
3619+
"depth": 3,
3620+
"title": "Create Widget Component",
3621+
"anchor": "create-widget-component"
3622+
},
3623+
{
3624+
"depth": 3,
3625+
"title": "Add the Widget to Home Page",
3626+
"anchor": "add-the-widget-to-home-page"
3627+
},
3628+
{
3629+
"depth": 2,
3630+
"title": "Run the App",
3631+
"anchor": "run-the-app"
3632+
},
3633+
{
3634+
"depth": 2,
3635+
"title": "Resources",
3636+
"anchor": "resources"
3637+
},
3638+
{
3639+
"depth": 2,
3640+
"title": "Conclusion",
3641+
"anchor": "conclusion"
3642+
}
3643+
],
3644+
"stats": {
3645+
"chars": 20515,
3646+
"words": 2690,
3647+
"headings": 14,
3648+
"estimated_token_count_total": 4569
3649+
},
3650+
"hash": "sha256:a61ee01698bf986ef7b21237e2fc5513e69f63665edbc0edb87920a0b4e3c2fd",
3651+
"token_estimator": "heuristic-v1"
3652+
},
35623653
{
35633654
"id": "products-reference-chain-ids",
35643655
"title": "Chain IDs",
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export const QUERY_URL = process.env.QUERY_URL!;
2+
export const QUERIES_API_KEY = process.env.QUERIES_API_KEY!;
3+
export const RPC_URL = process.env.RPC_URL!;
4+
5+
export const DEFAULTS = {
6+
chainId: Number(process.env.WORMHOLE_CHAIN_ID || 0),
7+
to: process.env.CALL_TO || '',
8+
feedId4: process.env.FEED_ID4 || '',
9+
feedDecimals: Number(process.env.FEED_DECIMALS || 0),
10+
feedHeartbeatSec: Number(process.env.FEED_HEARTBEAT_SEC || 0),
11+
};
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import axios from 'axios';
2+
import {
3+
EthCallQueryRequest,
4+
PerChainQueryRequest,
5+
QueryRequest,
6+
} from '@wormhole-foundation/wormhole-query-sdk';
7+
import { Interface } from 'ethers';
8+
9+
// ABI interface for Witnet's Price Router
10+
const WITNET_IFACE = new Interface([
11+
// Function signature for reading the latest price feed
12+
'function latestPrice(bytes4 id) view returns (int256 value, uint256 timestamp, bytes32 drTxHash, uint8 status)',
13+
]);
14+
15+
// Encode calldata for Witnet Router: latestPrice(bytes4)
16+
export function encodeWitnetLatestPrice(id4: string): string {
17+
// Validate feed ID format (must be a 4-byte hex)
18+
if (!/^0x[0-9a-fA-F]{8}$/.test(id4)) {
19+
throw new Error(`Invalid FEED_ID4: ${id4}`);
20+
}
21+
// Return ABI-encoded call data for latestPrice(bytes4)
22+
return WITNET_IFACE.encodeFunctionData('latestPrice', [id4 as `0x${string}`]);
23+
}
24+
25+
export async function buildEthCallRequest(params: {
26+
rpcUrl: string;
27+
chainId: number; // Wormhole chain id
28+
to: string;
29+
data: string; // 0x-prefixed calldata
30+
}) {
31+
const { rpcUrl, chainId, to, data } = params;
32+
33+
// Get the latest block number via JSON-RPC
34+
// Short timeout prevents long hangs in the dev environment
35+
const latestBlock: string = (
36+
await axios.post(
37+
rpcUrl,
38+
{
39+
method: 'eth_getBlockByNumber',
40+
params: ['latest', false],
41+
id: 1,
42+
jsonrpc: '2.0',
43+
},
44+
{ timeout: 5_000, headers: { 'Content-Type': 'application/json' } }
45+
)
46+
).data?.result?.number;
47+
48+
if (!latestBlock) throw new Error('Failed to fetch latest block');
49+
50+
// Build a Wormhole Query that wraps an EthCall to the Witnet contract
51+
const request = new QueryRequest(1, [
52+
new PerChainQueryRequest(
53+
chainId,
54+
new EthCallQueryRequest(latestBlock, [{ to, data }])
55+
),
56+
]);
57+
58+
// Serialize to bytes for sending to the Wormhole Query Proxy
59+
return request.serialize(); // Uint8Array
60+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import axios from 'axios';
2+
3+
export async function postQuery({
4+
queryUrl,
5+
apiKey,
6+
bytes,
7+
timeoutMs = 25_000,
8+
}: {
9+
queryUrl: string;
10+
apiKey: string;
11+
bytes: Uint8Array;
12+
timeoutMs?: number;
13+
}) {
14+
// Convert the query bytes to hex and POST to the proxy
15+
const res = await axios.post(
16+
queryUrl,
17+
{ bytes: Buffer.from(bytes).toString('hex') },
18+
{
19+
timeout: timeoutMs,
20+
headers: { 'X-API-Key': apiKey, 'Content-Type': 'application/json' },
21+
validateStatus: (s) => s === 200,
22+
}
23+
);
24+
return res.data; // throws on non-200
25+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import {
2+
EthCallQueryResponse,
3+
QueryResponse,
4+
} from '@wormhole-foundation/wormhole-query-sdk';
5+
import { Interface, Result } from 'ethers';
6+
7+
// ABI interface for decoding Witnet's latestPrice response
8+
const WITNET_IFACE = new Interface([
9+
'function latestPrice(bytes4 id) view returns (int256 value, uint256 timestamp, bytes32 drTxHash, uint8 status)',
10+
]);
11+
12+
// Parse the first EthCall result from the proxy's response
13+
export function parseFirstEthCallResult(proxyResponse: { bytes: string }): {
14+
chainResp: EthCallQueryResponse;
15+
raw: string;
16+
} {
17+
// Decode the top-level QueryResponse from Wormhole Guardians
18+
const qr = QueryResponse.from(proxyResponse.bytes);
19+
20+
// Extract the first chain response and its raw call result
21+
const chainResp = qr.responses[0].response as EthCallQueryResponse;
22+
const raw = chainResp.results[0];
23+
return { chainResp, raw };
24+
}
25+
26+
// Decode Witnet's latestPrice return tuple into readable fields
27+
export function decodeWitnetLatestPrice(
28+
raw: string,
29+
decimals: number
30+
): { price: string; timestampSec: number; drTxHash: string } {
31+
// Decode ABI-encoded result from the router call
32+
const r: Result = WITNET_IFACE.decodeFunctionResult('latestPrice', raw);
33+
const value = BigInt(r[0].toString());
34+
const timestampSec = Number(r[1].toString());
35+
const drTxHash = r[2] as string;
36+
37+
return {
38+
price: scaleBigintToDecimalString(value, decimals),
39+
timestampSec,
40+
drTxHash,
41+
};
42+
}
43+
44+
// Convert a bigint price into a human-readable decimal string
45+
function scaleBigintToDecimalString(value: bigint, decimals: number): string {
46+
const zero = BigInt(0);
47+
const neg = value < zero ? '-' : '';
48+
const v = value < zero ? -value : value;
49+
const s = v.toString().padStart(decimals + 1, '0');
50+
const i = s.slice(0, -decimals);
51+
const f = s.slice(-decimals).replace(/0+$/, '');
52+
return neg + (f ? `${i}.${f}` : i);
53+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export interface QueryApiSuccess {
2+
ok: true;
3+
blockNumber: string;
4+
blockTimeMicros: string;
5+
price: string;
6+
decimals: number;
7+
updatedAt: string;
8+
stale?: boolean;
9+
}
10+
11+
export interface QueryApiError {
12+
ok: false;
13+
error: string;
14+
}
15+
export type QueryApiResponse = QueryApiSuccess | QueryApiError;
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { NextResponse } from 'next/server';
2+
import {
3+
buildEthCallRequest,
4+
encodeWitnetLatestPrice,
5+
} from '@/lib/queries/buildRequest';
6+
import { postQuery } from '@/lib/queries/client';
7+
import { QUERY_URL, QUERIES_API_KEY, RPC_URL, DEFAULTS } from '@/lib/config';
8+
import {
9+
parseFirstEthCallResult,
10+
decodeWitnetLatestPrice,
11+
} from '@/lib/queries/decode';
12+
import type { QueryApiSuccess, QueryApiError } from '@/lib/types';
13+
14+
export async function GET() {
15+
const t0 = Date.now();
16+
try {
17+
// Encode the call for Witnet’s latestPrice(bytes4)
18+
const data = encodeWitnetLatestPrice(DEFAULTS.feedId4);
19+
20+
// Build a Wormhole Query request anchored to the latest block
21+
const bytes = await buildEthCallRequest({
22+
rpcUrl: RPC_URL,
23+
chainId: DEFAULTS.chainId,
24+
to: DEFAULTS.to,
25+
data,
26+
});
27+
const t1 = Date.now();
28+
29+
// Send the query to the Wormhole Query Proxy and await the signed response
30+
const proxyResponse = await postQuery({
31+
queryUrl: QUERY_URL,
32+
apiKey: QUERIES_API_KEY,
33+
bytes,
34+
timeoutMs: 25_000,
35+
});
36+
const t2 = Date.now();
37+
38+
// Decode the signed Guardian response and extract Witnet data
39+
const { chainResp, raw } = parseFirstEthCallResult(proxyResponse);
40+
const { price, timestampSec } = decodeWitnetLatestPrice(
41+
raw,
42+
DEFAULTS.feedDecimals
43+
);
44+
45+
// Log the latency of each leg for debugging
46+
console.log(`RPC ${t1 - t0}ms → Proxy ${t2 - t1}ms`);
47+
48+
// Mark data as stale if older than the feed’s heartbeat interval
49+
const heartbeat = Number(process.env.FEED_HEARTBEAT_SEC || 0);
50+
const stale = heartbeat > 0 && Date.now() / 1000 - timestampSec > heartbeat;
51+
52+
// Return a normalized JSON payload for the frontend
53+
const body: QueryApiSuccess = {
54+
ok: true,
55+
blockNumber: chainResp.blockNumber.toString(),
56+
blockTimeMicros: chainResp.blockTime.toString(),
57+
price,
58+
decimals: DEFAULTS.feedDecimals,
59+
updatedAt: new Date(timestampSec * 1000).toISOString(),
60+
stale,
61+
};
62+
return NextResponse.json(body);
63+
} catch (e: unknown) {
64+
// Catch and return a structured error
65+
const message = e instanceof Error ? e.message : String(e);
66+
console.error('Error in /api/queries:', message);
67+
const body: QueryApiError = { ok: false, error: message };
68+
return NextResponse.json(body, { status: 500 });
69+
}
70+
}

0 commit comments

Comments
 (0)