From 592beb4b61def7a703e09c5c2999c0161136bb7a Mon Sep 17 00:00:00 2001 From: serhhan Date: Thu, 3 Sep 2026 14:17:58 -0300 Subject: [PATCH 1/2] fix: pin trusted price source identities --- site/src/lib/catalog.test.ts | 33 ++++++++++++++++++++++++--- site/src/lib/catalog.ts | 30 ++++++++++++++++++++---- site/src/lib/client.ts | 7 ++++-- site/src/lib/policy.ts | 6 ++--- use-cases/trust-aware-agent/README.md | 2 +- 5 files changed, 64 insertions(+), 14 deletions(-) diff --git a/site/src/lib/catalog.test.ts b/site/src/lib/catalog.test.ts index cef994f..e6ab8b3 100644 --- a/site/src/lib/catalog.test.ts +++ b/site/src/lib/catalog.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from 'vitest'; -import { PRICE_CATALOG, supplementPriceCandidates } from './catalog'; +import { + isPinnedPriceCandidate, + PRICE_CATALOG, + supplementPriceCandidates, +} from './catalog'; describe('price candidate discovery', () => { - it('keeps resolver data and adds missing known sources for trust ranking', () => { + it('pins known resolver identities and adds missing sources for trust ranking', () => { const resolvedCoinGecko = { ...PRICE_CATALOG[1], why: 'Returned by the live resolver.', @@ -15,7 +19,10 @@ describe('price candidate discovery', () => { 'nodary', 'tickerlayer', ]); - expect(candidates[0]).toBe(resolvedCoinGecko); + expect(candidates[0]).toEqual({ + ...PRICE_CATALOG[1], + origin: 'resolver', + }); }); it('marks supplemented sources as demo additions rather than discoveries', () => { @@ -28,4 +35,24 @@ describe('price candidate discovery', () => { 'demo-catalog', ]); }); + + it('does not let the resolver redefine a known source identity', () => { + const spoofedNodary = { + ...PRICE_CATALOG[0], + airnode: 'https://example.invalid/', + address: '0x1111111111111111111111111111111111111111' as const, + attestation: 'third-party' as const, + origin: 'resolver' as const, + }; + + const [candidate] = supplementPriceCandidates([spoofedNodary]); + + expect(isPinnedPriceCandidate(spoofedNodary)).toBe(false); + expect(isPinnedPriceCandidate(candidate)).toBe(true); + expect(candidate).toMatchObject({ + airnode: PRICE_CATALOG[0].airnode, + address: PRICE_CATALOG[0].address, + attestation: PRICE_CATALOG[0].attestation, + }); + }); }); diff --git a/site/src/lib/catalog.ts b/site/src/lib/catalog.ts index 2cb8c8e..57afc46 100644 --- a/site/src/lib/catalog.ts +++ b/site/src/lib/catalog.ts @@ -58,22 +58,42 @@ export const PRICE_CATALOG: Candidate[] = [ }, ]; -export const SUPPORTED_LISTINGS = new Set( - PRICE_CATALOG.map((candidate) => candidate.listing), +function candidateKey( + candidate: Pick, +): string { + return `${candidate.listing}:${candidate.operation}`; +} + +const PRICE_CATALOG_BY_KEY = new Map( + PRICE_CATALOG.map((candidate) => [candidateKey(candidate), candidate]), ); export function supplementPriceCandidates(candidates: Candidate[]): Candidate[] { + const pinnedCandidates = candidates.map((candidate) => { + const pinned = PRICE_CATALOG_BY_KEY.get(candidateKey(candidate)); + return pinned ? { ...pinned, origin: 'resolver' as const } : candidate; + }); const discovered = new Set( - candidates.map((candidate) => `${candidate.listing}:${candidate.operation}`), + pinnedCandidates.map(candidateKey), ); return [ - ...candidates, + ...pinnedCandidates, ...PRICE_CATALOG.filter( - (candidate) => !discovered.has(`${candidate.listing}:${candidate.operation}`), + (candidate) => !discovered.has(candidateKey(candidate)), ), ]; } +export function isPinnedPriceCandidate(candidate: Candidate): boolean { + const pinned = PRICE_CATALOG_BY_KEY.get(candidateKey(candidate)); + return Boolean( + pinned && + candidate.airnode === pinned.airnode && + candidate.address.toLowerCase() === pinned.address.toLowerCase() && + candidate.attestation === pinned.attestation, + ); +} + export function supportsCatalogFallback(intent: string): boolean { const normalized = intent.toLowerCase(); const asksForEth = /\b(eth|ethereum)\b/.test(normalized); diff --git a/site/src/lib/client.ts b/site/src/lib/client.ts index 1f6da2e..979c8f2 100644 --- a/site/src/lib/client.ts +++ b/site/src/lib/client.ts @@ -126,7 +126,8 @@ async function discover( ...candidate, origin: 'resolver', })); - const candidates = supportsCatalogFallback(intent) + const hasPinnedPriceCatalog = supportsCatalogFallback(intent); + const candidates = hasPinnedPriceCatalog ? supplementPriceCandidates(resolved) : resolved; const addedCount = candidates.length - resolved.length; @@ -138,7 +139,9 @@ async function discover( ? `${resolved.length} discovered, ${addedCount} added by the demo` : `${resolved.length} candidates discovered`, addedCount - ? 'The resolver returned the discovered sources. The demo added its own known ETH/USD sources so the trust policy can rank them, and marks them separately.' + ? 'Resolver matches use identities pinned by the demo. Missing known ETH/USD sources were added so the trust policy can rank them.' + : hasPinnedPriceCatalog + ? 'The resolver matched known ETH/USD sources; endpoint, signer, and provenance identities remain pinned by the demo.' : 'Parameters, provenance, signer, and payment requirements came from the resolver.', 'success', ); diff --git a/site/src/lib/policy.ts b/site/src/lib/policy.ts index 34ae289..bcbe8e7 100644 --- a/site/src/lib/policy.ts +++ b/site/src/lib/policy.ts @@ -1,4 +1,4 @@ -import { SUPPORTED_LISTINGS } from './catalog'; +import { isPinnedPriceCandidate } from './catalog'; import type { Candidate, CandidateDecision, @@ -43,12 +43,12 @@ export function evaluateCandidates( const rejected: CandidateDecision[] = []; for (const candidate of unique.values()) { - if (!SUPPORTED_LISTINGS.has(candidate.listing)) { + if (!isPinnedPriceCandidate(candidate)) { rejected.push({ listing: candidate.listing, provenance: candidate.attestation, status: 'rejected', - reason: 'This MVP has no price adapter for the listing.', + reason: 'This MVP has no pinned identity for the listing and operation.', rank: null, candidate, }); diff --git a/use-cases/trust-aware-agent/README.md b/use-cases/trust-aware-agent/README.md index d3e8e5e..6f6659a 100644 --- a/use-cases/trust-aware-agent/README.md +++ b/use-cases/trust-aware-agent/README.md @@ -34,7 +34,7 @@ Open the local URL printed by Vite and select **Trust-Aware Agent**. No API key 4. It calls the Airnode directly and verifies the request hash, response bytes, EIP-191 signature, signer, freshness, and normalized price. 5. Only a verified value is released with a portable receipt. -The resolver may return a retryable `503` during early access. After two bounded attempts, the demo uses its clearly labelled price catalog, still checks the selected Airnode's live document, and still verifies the live response. +The resolver may return a retryable `503` during early access. After two bounded attempts, the demo uses its clearly labelled price catalog, still checks the selected Airnode's live document, and still verifies the live response. For known price operations, that catalog pins the endpoint, signer, and provenance so a resolver response cannot redefine a trusted source identity. The agent-ready prompt is [`site/public/prompts/trust-aware-agent.md`](../../site/public/prompts/trust-aware-agent.md). The framework-neutral tool contract is [`site/examples/agent-tool.json`](../../site/examples/agent-tool.json). From 8111ab946d77360b0db5bdc5804dfdc10bf2fd06 Mon Sep 17 00:00:00 2001 From: serhhan Date: Thu, 3 Sep 2026 14:18:14 -0300 Subject: [PATCH 2/2] chore: add safe Cloudflare response headers --- site/public/_headers | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 site/public/_headers diff --git a/site/public/_headers b/site/public/_headers new file mode 100644 index 0000000..597c55b --- /dev/null +++ b/site/public/_headers @@ -0,0 +1,6 @@ +/* + Content-Security-Policy: base-uri 'self'; frame-ancestors 'none'; object-src 'none' + X-Content-Type-Options: nosniff + X-Frame-Options: DENY + Referrer-Policy: strict-origin-when-cross-origin + Permissions-Policy: camera=(), geolocation=(), microphone=(), payment=(), usb=()