From 4bcc05a00295534f5be4297cbb1d0fdef6f4ecb3 Mon Sep 17 00:00:00 2001 From: macjayz Date: Sun, 1 Nov 2026 09:34:59 +0100 Subject: [PATCH] examples: add Airnode demo catching a provider serving wrong data A local stand-in provider answers block requests with a block from 40 blocks earlier, always with HTTP 200. A real, paid AirnodeHub call answers the same pinned question with a signed response, and detector D2 raises provider_disagreement naming the verified signer. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- examples/airnode-demo/README.md | 59 +++++++ examples/airnode-demo/demo.ts | 144 +++++++++++++++ examples/airnode-demo/package.json | 17 ++ package-lock.json | 272 ++++++++++++++++++++++++++++- 5 files changed, 493 insertions(+), 2 deletions(-) create mode 100644 examples/airnode-demo/README.md create mode 100644 examples/airnode-demo/demo.ts create mode 100644 examples/airnode-demo/package.json diff --git a/README.md b/README.md index 4cb3eb7..4ca4edc 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,8 @@ verify: { ``` A signature proves who answered, not that the answer is right, so it changes no detector weight. -Setup, x402 payment and the trust model: [docs/airnode.md](docs/airnode.md). +Setup, x402 payment and the trust model: [docs/airnode.md](docs/airnode.md). To see it catch a +provider serving wrong data, run [examples/airnode-demo](examples/airnode-demo). ## Run it diff --git a/examples/airnode-demo/README.md b/examples/airnode-demo/README.md new file mode 100644 index 0000000..188b444 --- /dev/null +++ b/examples/airnode-demo/README.md @@ -0,0 +1,59 @@ +# Airnode demo: catching wrong data with signed proof + +## The problem + +Ada runs a DeFi app on Base. One evening users report deposits that are not showing, while +others see the right balance. Her logs show nothing: every RPC call returned HTTP 200, and her +monitoring is green. + +One of her provider's nodes is serving stale data. It answers successfully, just with the wrong +block, and nothing that watches status codes and latency can see that. Without a second opinion, +she cannot tell whether the fault is her code, the chain or the provider, and the provider can +always say it is on her side. + +## What this demo does + +1. Starts **your-provider**, a local stand-in for that node. It proxies Base mainnet, but answers + every block request with the block from 40 blocks earlier, always with HTTP 200. +2. Makes a real, paid call through [AirnodeHub](https://airnodehub.api3.org)'s dRPC listing. Its + answer is signed by the Airnode, and Heightproof checks that the signature is valid and bound + to the exact request it sent. +3. Asks both the same question at the same pinned height and runs detector D2 on the result. + +``` +Asking both providers for Base block 53366354... + + your-provider HTTP 200 269ms result 144aef43c1f5fbd971… + airnode-drpc HTTP 200 2233ms result 54bd736f3a74339318… + signed by 0x511AcE8648D2f64260d50D036F8f8ce622d92137 + signature valid: true, bound to this request: true + +šŸ”“ D2 provider_disagreement (weight 40) + eth_getBlockByNumber at block 53366354 returned a different result than airnode-drpc; at equal + height this is a provider bug or an unpropagated reorg. Airnode-signed evidence from + 0x511AcE8648D2f64260d50D036F8f8ce622d92137 can be verified independently +``` + +The broken provider is simulated; the AirnodeHub call, its payment and its signature are real. +Ada now knows the provider is at fault, and has evidence the provider cannot dispute. + +## Run it + +You need a Base Sepolia key holding aUSD, AirnodeHub's test token. Each call costs $0.01. + +1. Get a little Base Sepolia ETH for gas from the + [Coinbase faucet](https://portal.cdp.coinbase.com/products/faucet). +2. Mint aUSD at [airnodehub.api3.org/faucet](https://airnodehub.api3.org/faucet), or call + `mintFromFaucet(uint256)` on `0xbeA34973e5f67F18F656A2C6070C64326C791808` yourself. +3. From the repository root: + +```bash +npm install +AIRNODE_PAYER_KEY=0x... npm run demo -w examples/airnode-demo +``` + +Use a throwaway key: it only ever needs testnet funds. `BASE_RPC_URL` overrides the upstream the +fake provider proxies, which defaults to `https://mainnet.base.org`. + +How signed verification works, and what a signature does and does not prove: +[docs/airnode.md](../../docs/airnode.md). diff --git a/examples/airnode-demo/demo.ts b/examples/airnode-demo/demo.ts new file mode 100644 index 0000000..71c5e2f --- /dev/null +++ b/examples/airnode-demo/demo.ts @@ -0,0 +1,144 @@ +/** + * A provider serves wrong data with HTTP 200, and Heightproof catches it with signed proof. + * + * "your-provider" is a local stand-in for a misbehaving RPC: it proxies Base mainnet but + * answers block requests with a cached block from 40 blocks earlier. The other side is a real, + * paid call through AirnodeHub's dRPC listing, whose answer is signed by its Airnode. Both are + * asked the same question at the same pinned height, and detector D2 judges the result. + * + * Needs a wallet holding aUSD on Base Sepolia (see README). Usage: + * AIRNODE_PAYER_KEY=0x... npm run demo -w examples/airnode-demo + */ +import { createServer } from "node:http"; +import { AddressInfo } from "node:net"; +import { randomUUID } from "node:crypto"; +import { privateKeyToAccount } from "viem/accounts"; +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { registerExactEvmScheme } from "@x402/evm/exact/client"; +import { RpcVerifier, airnodeDrpcEndpoint } from "@heightproof/web3"; +import { detectDisagreement, type HeightproofEvent } from "@heightproof/shared"; + +const UPSTREAM = process.env.BASE_RPC_URL ?? "https://mainnet.base.org"; +const STALE_BY = 40; +const AUSD = "0xbeA34973e5f67F18F656A2C6070C64326C791808"; + +const payerKey = process.env.AIRNODE_PAYER_KEY as `0x${string}` | undefined; +if (!payerKey) { + console.error("Set AIRNODE_PAYER_KEY to a Base Sepolia key holding aUSD. See README.md."); + process.exit(1); +} + +// The misbehaving provider. Everything is a genuine mainnet answer, just not for the block asked. +const stale = createServer((request, response) => { + let raw = ""; + request.on("data", (chunk) => (raw += chunk)); + request.on("end", async () => { + const call = JSON.parse(raw) as { method: string; params: unknown[] }; + if (call.method === "eth_getBlockByNumber" && typeof call.params[0] === "string") { + call.params[0] = `0x${(Number.parseInt(call.params[0], 16) - STALE_BY).toString(16)}`; + } + const upstream = await fetch(UPSTREAM, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, ...call }) + }); + response.writeHead(200, { "content-type": "application/json" }); + response.end(await upstream.text()); + }); +}); +await new Promise((resolve) => stale.listen(0, "127.0.0.1", resolve)); +const staleUrl = `http://127.0.0.1:${(stale.address() as AddressInfo).port}`; + +// Pays AirnodeHub's $0.01 per call in aUSD, capped at 1 aUSD per payment. +const payments = new x402Client().setSpendControls({ + allowedAssets: [{ network: "eip155:84532", asset: AUSD, maxAmountPerPayment: "1000000" }] +}); +registerExactEvmScheme(payments, { signer: privateKeyToAccount(payerKey) }); + +const verifier = new RpcVerifier({ + sampleRate: 1, + timeoutMs: 30_000, + endpoints: [ + { rpcUrl: staleUrl, provider: "your-provider" }, + airnodeDrpcEndpoint({ network: "base", fetchImpl: wrapFetchWithPayment(fetch, payments) }) + ] +}); + +const method = "eth_getBlockByNumber"; +const blockNumber = await verifier.resolvePinHeight(undefined, UPSTREAM); +if (!blockNumber) throw new Error(`could not read the chain head from ${UPSTREAM}`); + +console.log(`\nAsking both providers for Base block ${blockNumber}...\n`); +const run = await verifier.verify(method, ["latest", false], blockNumber); +stale.close(); +if (!run) throw new Error("verification was not run"); + +const [primary, peer] = run.observations; +for (const observation of run.observations) { + const answer = observation.resultHash + ? `${observation.resultHash.slice(0, 18)}…` + : observation.rpcErrorMessage; + console.log( + ` ${observation.provider?.padEnd(14)} HTTP 200 ${observation.latencyMs}ms result ${answer}` + ); + const attestation = observation.attestation; + if (attestation) { + console.log(` ${"".padEnd(14)} signed by ${attestation.signer}`); + console.log( + ` ${"".padEnd(14)} signature valid: ${attestation.signatureValid}, bound to this request: ${attestation.requestBound}` + ); + } +} + +if (!primary?.resultHash || !peer?.resultHash) { + console.log("\nComparison incomplete: one side gave no answer."); + process.exit(1); +} + +// The event Heightproof would have recorded for your provider's answer. +const event: HeightproofEvent = { + id: randomUUID(), + traceId: randomUUID().replaceAll("-", ""), + projectId: "demo", + serviceName: "airnode-demo", + environment: "demo", + timestamp: new Date().toISOString(), + kind: "evm_rpc", + request: { + method: "POST", + path: "/rpc", + route: "/rpc", + headers: {}, + query: {}, + auth: { present: true, scheme: "RPC", failed: false } + }, + response: { statusCode: 200, latencyMs: primary.latencyMs }, + evmRpc: { + method, + chainId: "8453", + provider: primary.provider, + blockNumber: run.blockNumber, + resultHash: primary.resultHash, + resultShape: primary.resultShape, + endpointHash: primary.endpointHash + } +}; + +const attested = peer.attestation?.signatureValid && peer.attestation.requestBound; +const signals = detectDisagreement(event, { + peerResults: [ + { + provider: peer.provider, + endpointHash: peer.endpointHash, + resultHash: peer.resultHash, + attestedBy: attested ? peer.attestation?.signer : undefined + } + ] +}); + +if (signals.length === 0) { + console.log(`\nNo incident: both providers agree at block ${run.blockNumber}.`); +} else { + for (const signal of signals) + console.log(`\nšŸ”“ D2 ${signal.name} (weight ${signal.weight})\n ${signal.reason}`); +} diff --git a/examples/airnode-demo/package.json b/examples/airnode-demo/package.json new file mode 100644 index 0000000..8e109e1 --- /dev/null +++ b/examples/airnode-demo/package.json @@ -0,0 +1,17 @@ +{ + "name": "@heightproof/example-airnode-demo", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "demo": "tsx demo.ts" + }, + "dependencies": { + "@heightproof/shared": "0.1.1", + "@heightproof/web3": "0.1.1", + "@x402/evm": "2.29.0", + "@x402/fetch": "2.29.0", + "tsx": "^4.19.0", + "viem": "2.57.14" + } +} diff --git a/package-lock.json b/package-lock.json index 94b2499..059e0a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,6 +81,18 @@ "tsx": "^4.19.0" } }, + "examples/airnode-demo": { + "name": "@heightproof/example-airnode-demo", + "version": "0.1.0", + "dependencies": { + "@heightproof/shared": "0.1.1", + "@heightproof/web3": "0.1.1", + "@x402/evm": "2.29.0", + "@x402/fetch": "2.29.0", + "tsx": "^4.19.0", + "viem": "2.57.14" + } + }, "examples/express": { "name": "heightproof-express-example", "version": "0.1.0", @@ -99,6 +111,12 @@ "tsx": "^4.19.0" } }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -1185,6 +1203,10 @@ "resolved": "apps/dashboard", "link": true }, + "node_modules/@heightproof/example-airnode-demo": { + "resolved": "examples/airnode-demo", + "link": true + }, "node_modules/@heightproof/example-web3-rpc": { "resolved": "examples/web3-rpc", "link": true @@ -1318,6 +1340,18 @@ "node": "^22.20 || ^24.12 || >=25" } }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/curves": { "version": "1.9.7", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", @@ -1793,6 +1827,42 @@ "win32" ] }, + "node_modules/@scure/base": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.2.6.tgz", + "integrity": "sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.7.0.tgz", + "integrity": "sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.9.0", + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.6.0.tgz", + "integrity": "sha512-+lF0BbLiJNwVlev4eKelw1WWLaiKXw7sSl8T6FvBlWkdX+94aGJ4o8XjUdlyhTCjd8c+B3KT3JfS8P0bLRNU6A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.8.0", + "@scure/base": "~1.2.5" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2405,6 +2475,74 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@x402/core": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/@x402/core/-/core-2.29.0.tgz", + "integrity": "sha512-fkS0JqVwHgczteDlwItLRipOlIzm28U9HWLvYnYpnNbfmepoBT/4ux7LjrR0xGD51JHoC8/r+Qo7uCfoAQYeuw==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.6.6" + } + }, + "node_modules/@x402/core/node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@x402/evm": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/@x402/evm/-/evm-2.29.0.tgz", + "integrity": "sha512-Gk4bJ6WoRR3jeT2+vERSPMSkSr1nRL5kjtVh14uoynRm8y+fGFH8xmvlGzuwKT0liA6OLjdr9xjZtN8i0WYk9g==", + "license": "Apache-2.0", + "dependencies": { + "@x402/core": "~2.29.0", + "viem": "^2.48.11", + "zod": "^4.6.6" + } + }, + "node_modules/@x402/evm/node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@x402/fetch": { + "version": "2.29.0", + "resolved": "https://registry.npmjs.org/@x402/fetch/-/fetch-2.29.0.tgz", + "integrity": "sha512-ym/+6Cg7uUqScrE/pCV0sfmKecX46KTk5B/VKyJo579Xn1Z6dq2KiLd04rTvvO2xldLKKluU/3SqpDIgkp6T9Q==", + "license": "Apache-2.0", + "dependencies": { + "@x402/core": "~2.29.0" + } + }, + "node_modules/abitype": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", + "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/wevm" + }, + "peerDependencies": { + "typescript": ">=5.0.4", + "zod": "^3.22.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -3394,6 +3532,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -4071,6 +4215,21 @@ "dev": true, "license": "ISC" }, + "node_modules/isows": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", + "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4524,6 +4683,51 @@ "node": ">= 0.8.0" } }, + "node_modules/ox": { + "version": "0.14.59", + "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.59.tgz", + "integrity": "sha512-Y+DPEQScTEGKteQATjtytE1JRQ5cg1R2+DvYQ/lbhUCm/xS9n0Jnkkf2AqQyIntefQxs8xY6dZ+kyNZ7zf2FzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "^1.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "1.9.1", + "@noble/hashes": "^1.8.0", + "@scure/bip32": "^1.7.0", + "@scure/bip39": "^1.6.0", + "abitype": "^1.2.3", + "eventemitter3": "5.0.1" + }, + "peerDependencies": { + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ox/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5710,7 +5914,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -5786,6 +5990,72 @@ "node": ">= 0.8" } }, + "node_modules/viem": { + "version": "2.57.14", + "resolved": "https://registry.npmjs.org/viem/-/viem-2.57.14.tgz", + "integrity": "sha512-ctaSyx3I24va98H2PYz8C30FKXXo8Th/H6Wrn7za7N+RuPQHiL09zFedX6GZDCtz73YFLrf7YeVOgU5CUVByng==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/wevm" + } + ], + "license": "MIT", + "dependencies": { + "@noble/curves": "1.9.1", + "@noble/hashes": "1.8.0", + "@scure/bip32": "1.7.0", + "@scure/bip39": "1.6.0", + "abitype": "1.2.3", + "isows": "1.0.7", + "ox": "0.14.59", + "ws": "8.21.0" + }, + "peerDependencies": { + "typescript": ">=5.0.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/viem/node_modules/@noble/curves": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.1.tgz", + "integrity": "sha512-k11yZxZg+t+gWvBbIswW0yoJlu8cHOC7dhunwOzoWH/mXGBiYyR4YY6hAEK/3EUs4UpB8la1RfdRpeGsFHkWsA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/viem/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/vite": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz",