diff --git a/README.md b/README.md index db41831..1191ef3 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,12 @@ npm run build # tsc -b && vite build && node scripts/build-extension.mjs All four run in CI (`.github/workflows/ci.yml`) on every push to `main` and on every pull request. The linting step automatically enforces that no network-call APIs (like `fetch`, `XMLHttpRequest`, `WebSocket`, `EventSource`, `navigator.sendBeacon`) or Horizon server connections are initiated outside the `src/adapter/` directory. +## Timeout Behavior for getScore + +`getScore` now includes a built‑in timeout to prevent the signing flow from hanging indefinitely. The default timeout is **5 seconds** and can be overridden per call via the optional `options` parameter. If the operation exceeds the timeout, the function resolves with a fallback score of `-1`, which is treated as an unknown score and results in a safe warning tier. + +You can configure the default timeout by modifying `src/adapter/config.ts` (`DEFAULT_GET_SCORE_TIMEOUT_MS`). Tests use a shorter timeout to verify the fallback behaviour. + ## Roadmap - [x] Popup renders one score across the four tiers. _(stub)_ diff --git a/src/adapter/config.ts b/src/adapter/config.ts new file mode 100644 index 0000000..d1ca633 --- /dev/null +++ b/src/adapter/config.ts @@ -0,0 +1 @@ +export const DEFAULT_GET_SCORE_TIMEOUT_MS = 5000; diff --git a/src/adapter/oracleAdapter.test.ts b/src/adapter/oracleAdapter.test.ts index 263256c..52a2a33 100644 --- a/src/adapter/oracleAdapter.test.ts +++ b/src/adapter/oracleAdapter.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { getScore } from './oracleAdapter' describe('getScore', () => { @@ -13,4 +13,14 @@ describe('getScore', () => { const b = await getScore('SAME') expect(a).toBe(b) }) -}) + + it('returns fallback -1 on timeout', async () => { + vi.useFakeTimers(); + const promise = getScore('TIMEOUTDEST', { timeoutMs: 50 }); + // advance timers past the internal delay (150ms) and timeout (50ms) + vi.advanceTimersByTime(200); + const result = await promise; + expect(result).toBe(-1); + vi.useRealTimers(); + }); +}); diff --git a/src/adapter/oracleAdapter.ts b/src/adapter/oracleAdapter.ts index ba601f3..0ef7307 100644 --- a/src/adapter/oracleAdapter.ts +++ b/src/adapter/oracleAdapter.ts @@ -1,11 +1,47 @@ +import { DEFAULT_GET_SCORE_TIMEOUT_MS } from './config'; + /** - * Stand-in for the grydlock-oracle-adapter package's getScore(destination). - * The popup only depends on this function's signature — swap the body for - * the real import once the adapter package is available. + * Retrieves a risk score for a destination with a configurable timeout. + * If the operation exceeds the timeout, it resolves with a fallback score of -1. + * + * @param destination The destination address to score. + * @param options Optional configuration: timeoutMs overrides the default timeout, + * signal allows external cancellation. */ -export async function getScore(destination: string): Promise { - await new Promise((resolve) => setTimeout(resolve, 150)) - return stubScoreFor(destination) +export async function getScore( + destination: string, + options?: { timeoutMs?: number; signal?: AbortSignal } +): Promise { + const timeoutMs = options?.timeoutMs ?? DEFAULT_GET_SCORE_TIMEOUT_MS; + const controller = new AbortController(); + const signal = options?.signal ?? controller.signal; + + // Create a timeout promise that aborts after the specified duration. + const timeoutPromise = new Promise((_, reject) => { + const id = setTimeout(() => { + // Abort any ongoing work and resolve with fallback. + controller.abort(); + reject(new Error('Timeout')); + }, timeoutMs); + // Ensure timeout cleared if operation finishes first. + signal.addEventListener('abort', () => clearTimeout(id)); + }); + + // The actual score computation (stub) wrapped in a promise. + const scorePromise = (async () => { + // Simulate async work (the existing stub delay). + await new Promise((resolve) => setTimeout(resolve, 150)); + return stubScoreFor(destination); + })(); + + try { + const result = await Promise.race([scorePromise, timeoutPromise]); + return result as number; + } catch (e) { + // On timeout, return fallback score. + console.warn('getScore timeout for destination', destination); + return -1; // fallback indicating unknown score + } } function stubScoreFor(destination: string): number { diff --git a/src/intercept/resolveOutcome.ts b/src/intercept/resolveOutcome.ts index 07e1a7d..111f599 100644 --- a/src/intercept/resolveOutcome.ts +++ b/src/intercept/resolveOutcome.ts @@ -2,7 +2,7 @@ import type { Decision, Outcome } from './protocol' export interface ResolveOutcomeDeps { extractDestination: (xdr: string) => { destination: string; asset?: string } | null - getScore: (destination: string) => Promise + getScore: (destination: string, options?: { timeoutMs?: number; signal?: AbortSignal }) => Promise requestDecision: (info: { destination: string; asset?: string; score: number }) => Promise }