Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions site/public/_headers
Original file line number Diff line number Diff line change
@@ -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=()
33 changes: 30 additions & 3 deletions site/src/lib/catalog.test.ts
Original file line number Diff line number Diff line change
@@ -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.',
Expand All @@ -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', () => {
Expand All @@ -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,
});
});
});
30 changes: 25 additions & 5 deletions site/src/lib/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Candidate, 'listing' | 'operation'>,
): 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);
Expand Down
7 changes: 5 additions & 2 deletions site/src/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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',
);
Expand Down
6 changes: 3 additions & 3 deletions site/src/lib/policy.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SUPPORTED_LISTINGS } from './catalog';
import { isPinnedPriceCandidate } from './catalog';
import type {
Candidate,
CandidateDecision,
Expand Down Expand Up @@ -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,
});
Expand Down
2 changes: 1 addition & 1 deletion use-cases/trust-aware-agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
Loading