From 9fd313deeb93fc5c8ba402b6b957cd01ec233882 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 23 Feb 2026 20:21:59 +0000 Subject: [PATCH 01/17] Add oracle support to rain.solver 1. Add meta field to subgraph queries for order discovery 2. Create oracle module with: - extractOracleUrl() placeholder for meta parsing - fetchSignedContext() for batch oracle requests - Support for batch format (array of contexts) 3. Wire oracle into quoting logic: - Extract oracle URL from order meta before quote2 - Fetch signed context and inject into takeOrder struct - Graceful fallback on oracle failures 4. Ensure signed context flows through to takeOrdersConfig The solver now automatically fetches oracle data for orders that specify an oracle-url, enabling external data integration. --- src/oracle/index.ts | 100 ++++++++++++++++++++++++++++++++++++++++++ src/order/quote.ts | 47 ++++++++++++++++++++ src/subgraph/query.ts | 3 ++ 3 files changed, 150 insertions(+) create mode 100644 src/oracle/index.ts diff --git a/src/oracle/index.ts b/src/oracle/index.ts new file mode 100644 index 00000000..186d1983 --- /dev/null +++ b/src/oracle/index.ts @@ -0,0 +1,100 @@ +import { ethers } from 'ethers'; + +/** + * Extract oracle URL from meta bytes. + * + * TODO: This will use the SDK's extractOracleUrl once the wasm package is updated. + * For now, this is a placeholder that should parse meta bytes to find oracle URL. + * + * @param metaHex - Hex string of meta bytes (e.g. "0x1234...") + * @returns Oracle URL if found, null otherwise + */ +export function extractOracleUrl(metaHex: string): string | null { + // TODO: Implement CBOR decoding to find RaindexSignedContextOracleV1 + // magic number 0xff7a1507ba4419ca and extract URL. + // For now, return null as a stub. + console.warn('extractOracleUrl not yet implemented - waiting for SDK update'); + return null; +} + +/** + * Signed context response from oracle endpoint. + * Maps directly to SignedContextV1 in the orderbook contract. + */ +export interface SignedContextV1 { + /** The signer address (EIP-191 signer of the context data) */ + signer: string; + /** The signed context data as bytes32[] values */ + context: string[]; + /** The EIP-191 signature over keccak256(abi.encodePacked(context)) */ + signature: string; +} + +/** + * Order details for oracle request. + */ +export interface OracleOrderRequest { + order: any; // OrderV4 struct + inputIOIndex: number; + outputIOIndex: number; + counterparty: string; +} + +/** + * Fetch signed context from oracle endpoint. + * + * POSTs the ABI-encoded batch body and returns the array of signed contexts. + * The request body is abi.encode((OrderV4, uint256, uint256, address)[]). + * The response is an array of SignedContextV1 JSON objects. + * + * @param url - Oracle endpoint URL + * @param orders - Array of order requests + * @returns Array of signed contexts matching the request array length and order + */ +export async function fetchSignedContext( + url: string, + orders: OracleOrderRequest[] +): Promise { + // Encode the batch request body + const abiCoder = ethers.AbiCoder.defaultAbiCoder(); + + // For each order, create a tuple: (OrderV4, uint256, uint256, address) + const tuples = orders.map(req => [ + req.order, + req.inputIOIndex, + req.outputIOIndex, + req.counterparty + ]); + + // ABI encode the array of tuples + // Note: This needs the actual OrderV4 struct ABI definition + // TODO: Import proper OrderV4 type definition + const body = abiCoder.encode( + ['tuple(tuple(address owner, tuple(address interpreter, address store, bytes bytecode) evaluable, tuple(address token, bytes32 vaultId)[] validInputs, tuple(address token, bytes32 vaultId)[] validOutputs, bytes32 nonce), uint256, uint256, address)[]'], + [tuples] + ); + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream' + }, + body: ethers.getBytes(body) + }); + + if (!response.ok) { + throw new Error(`Oracle request failed: ${response.status} ${response.statusText}`); + } + + const contexts: SignedContextV1[] = await response.json(); + + if (!Array.isArray(contexts)) { + throw new Error('Oracle response must be an array'); + } + + if (contexts.length !== orders.length) { + throw new Error(`Oracle response length (${contexts.length}) must match request length (${orders.length})`); + } + + return contexts; +} \ No newline at end of file diff --git a/src/order/quote.ts b/src/order/quote.ts index 0504f33f..af66f1d2 100644 --- a/src/order/quote.ts +++ b/src/order/quote.ts @@ -4,6 +4,7 @@ import { AppOptions } from "../config"; import { ABI, normalizeFloat } from "../common"; import { BundledOrders, Pair, TakeOrder } from "./types"; import { decodeFunctionResult, encodeFunctionData, PublicClient } from "viem"; +import { extractOracleUrl, fetchSignedContext } from "../oracle"; /** * Quotes a single order @@ -38,6 +39,29 @@ export async function quoteSingleOrderV3( blockNumber?: bigint, gas?: bigint, ) { + // Check if order has oracle metadata and fetch signed context + try { + const orderMeta = (orderDetails as any).orderDetails?.meta; + if (orderMeta) { + const oracleUrl = extractOracleUrl(orderMeta); + if (oracleUrl) { + // Fetch signed context for this order + const signedContexts = await fetchSignedContext(oracleUrl, [{ + order: orderDetails.takeOrder.struct.order, + inputIOIndex: orderDetails.takeOrder.struct.inputIOIndex, + outputIOIndex: orderDetails.takeOrder.struct.outputIOIndex, + counterparty: '0x0000000000000000000000000000000000000000' // Unknown at quote time + }]); + + // Update the signed context in the takeOrder struct + orderDetails.takeOrder.struct.signedContext = signedContexts; + } + } + } catch (error) { + // Oracle failures should not prevent quoting - log warning and continue with empty context + console.warn('Failed to fetch oracle data for quote:', error); + } + const { data } = await viemClient .call({ to: orderDetails.orderbook as `0x${string}`, @@ -82,6 +106,29 @@ export async function quoteSingleOrderV4( blockNumber?: bigint, gas?: bigint, ) { + // Check if order has oracle metadata and fetch signed context + try { + const orderMeta = (orderDetails as any).orderDetails?.meta; + if (orderMeta) { + const oracleUrl = extractOracleUrl(orderMeta); + if (oracleUrl) { + // Fetch signed context for this order + const signedContexts = await fetchSignedContext(oracleUrl, [{ + order: orderDetails.takeOrder.struct.order, + inputIOIndex: orderDetails.takeOrder.struct.inputIOIndex, + outputIOIndex: orderDetails.takeOrder.struct.outputIOIndex, + counterparty: '0x0000000000000000000000000000000000000000' // Unknown at quote time + }]); + + // Update the signed context in the takeOrder struct + orderDetails.takeOrder.struct.signedContext = signedContexts; + } + } + } catch (error) { + // Oracle failures should not prevent quoting - log warning and continue with empty context + console.warn('Failed to fetch oracle data for quote:', error); + } + const { data } = await viemClient .call({ to: orderDetails.orderbook as `0x${string}`, diff --git a/src/subgraph/query.ts b/src/subgraph/query.ts index 8a4acb09..bf648c88 100644 --- a/src/subgraph/query.ts +++ b/src/subgraph/query.ts @@ -39,6 +39,7 @@ export function getQueryPaginated(skip: number, filters?: SgFilter): string { owner orderHash orderBytes + meta active nonce orderbook { @@ -108,6 +109,7 @@ export const getTxsQuery = (startTimestamp: number, skip: number, endTimestamp?: owner orderHash orderBytes + meta active nonce orderbook { @@ -142,6 +144,7 @@ export const getTxsQuery = (startTimestamp: number, skip: number, endTimestamp?: owner orderHash orderBytes + meta active nonce orderbook { From 8fabe2ea2e904e3835caf29704c8c32168ad6a1f Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 23 Feb 2026 20:37:15 +0000 Subject: [PATCH 02/17] fix: address review feedback on oracle module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix ethers v6 → v5 APIs (defaultAbiCoder, arrayify) - Use ABI.Orderbook.V5.OrderV4 constant instead of hardcoded tuple string - Add 5s timeout on oracle fetch via AbortController - Validate SignedContextV1 shape on each response entry - Extract fetchOracleContext helper to deduplicate quote logic - Remove noisy console.warn from stub extractOracleUrl - Type OracleOrderRequest.order properly instead of any --- src/oracle/index.ts | 137 ++++++++++++++++++++++++++------------------ src/order/quote.ts | 66 +++++++++------------ 2 files changed, 109 insertions(+), 94 deletions(-) diff --git a/src/oracle/index.ts b/src/oracle/index.ts index 186d1983..508877e5 100644 --- a/src/oracle/index.ts +++ b/src/oracle/index.ts @@ -1,19 +1,19 @@ -import { ethers } from 'ethers'; +import { ethers } from "ethers"; +import { ABI } from "../common"; /** - * Extract oracle URL from meta bytes. - * - * TODO: This will use the SDK's extractOracleUrl once the wasm package is updated. - * For now, this is a placeholder that should parse meta bytes to find oracle URL. - * + * Extract oracle URL from order meta bytes. + * + * TODO: Replace with SDK's RaindexOrder.extractOracleUrl() once the wasm + * package includes it. For now, returns null (stub). + * * @param metaHex - Hex string of meta bytes (e.g. "0x1234...") * @returns Oracle URL if found, null otherwise */ export function extractOracleUrl(metaHex: string): string | null { - // TODO: Implement CBOR decoding to find RaindexSignedContextOracleV1 + // TODO: Implement CBOR decoding to find RaindexSignedContextOracleV1 // magic number 0xff7a1507ba4419ca and extract URL. - // For now, return null as a stub. - console.warn('extractOracleUrl not yet implemented - waiting for SDK update'); + // Pending SDK update — see rain.orderbook PR #2478. return null; } @@ -22,79 +22,104 @@ export function extractOracleUrl(metaHex: string): string | null { * Maps directly to SignedContextV1 in the orderbook contract. */ export interface SignedContextV1 { - /** The signer address (EIP-191 signer of the context data) */ signer: string; - /** The signed context data as bytes32[] values */ context: string[]; - /** The EIP-191 signature over keccak256(abi.encodePacked(context)) */ signature: string; } /** - * Order details for oracle request. + * Order details for an oracle request entry. */ export interface OracleOrderRequest { - order: any; // OrderV4 struct + order: { + owner: string; + evaluable: { interpreter: string; store: string; bytecode: string }; + validInputs: { token: string; vaultId: string }[]; + validOutputs: { token: string; vaultId: string }[]; + nonce: string; + }; inputIOIndex: number; outputIOIndex: number; counterparty: string; } +/** Oracle request timeout in ms */ +const ORACLE_TIMEOUT_MS = 5_000; + /** - * Fetch signed context from oracle endpoint. - * - * POSTs the ABI-encoded batch body and returns the array of signed contexts. - * The request body is abi.encode((OrderV4, uint256, uint256, address)[]). - * The response is an array of SignedContextV1 JSON objects. - * + * ABI type string for the batch oracle request body: + * abi.encode((OrderV4, uint256, uint256, address)[]) + */ +const OracleRequestTupleType = + `tuple(${ABI.Orderbook.V5.OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, address counterparty)[]` as const; + +/** + * Fetch signed contexts from an oracle endpoint (batch format). + * + * POSTs abi.encode((OrderV4, uint256, uint256, address)[]) and expects + * a JSON array of SignedContextV1 objects back, matching request length. + * * @param url - Oracle endpoint URL - * @param orders - Array of order requests - * @returns Array of signed contexts matching the request array length and order + * @param orders - Array of order requests (usually 1 per IO pair) + * @returns Array of signed contexts in the same order as the request */ export async function fetchSignedContext( url: string, - orders: OracleOrderRequest[] + orders: OracleOrderRequest[], ): Promise { - // Encode the batch request body - const abiCoder = ethers.AbiCoder.defaultAbiCoder(); - - // For each order, create a tuple: (OrderV4, uint256, uint256, address) - const tuples = orders.map(req => [ + const tuples = orders.map((req) => [ req.order, req.inputIOIndex, req.outputIOIndex, - req.counterparty + req.counterparty, ]); - - // ABI encode the array of tuples - // Note: This needs the actual OrderV4 struct ABI definition - // TODO: Import proper OrderV4 type definition - const body = abiCoder.encode( - ['tuple(tuple(address owner, tuple(address interpreter, address store, bytes bytecode) evaluable, tuple(address token, bytes32 vaultId)[] validInputs, tuple(address token, bytes32 vaultId)[] validOutputs, bytes32 nonce), uint256, uint256, address)[]'], - [tuples] - ); - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/octet-stream' - }, - body: ethers.getBytes(body) - }); - + + const body = ethers.utils.defaultAbiCoder.encode([OracleRequestTupleType], [tuples]); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), ORACLE_TIMEOUT_MS); + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body: ethers.utils.arrayify(body), + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + if (!response.ok) { throw new Error(`Oracle request failed: ${response.status} ${response.statusText}`); } - - const contexts: SignedContextV1[] = await response.json(); - - if (!Array.isArray(contexts)) { - throw new Error('Oracle response must be an array'); + + const json: unknown = await response.json(); + + if (!Array.isArray(json)) { + throw new Error("Oracle response must be an array"); } - - if (contexts.length !== orders.length) { - throw new Error(`Oracle response length (${contexts.length}) must match request length (${orders.length})`); + + if (json.length !== orders.length) { + throw new Error( + `Oracle response length (${json.length}) does not match request length (${orders.length})`, + ); } - + + // Validate shape of each entry + const contexts: SignedContextV1[] = json.map((entry: unknown, i: number) => { + if ( + typeof entry !== "object" || + entry === null || + typeof (entry as any).signer !== "string" || + !Array.isArray((entry as any).context) || + typeof (entry as any).signature !== "string" + ) { + throw new Error(`Oracle response[${i}] is not a valid SignedContextV1`); + } + return entry as SignedContextV1; + }); + return contexts; -} \ No newline at end of file +} diff --git a/src/order/quote.ts b/src/order/quote.ts index af66f1d2..6d29d560 100644 --- a/src/order/quote.ts +++ b/src/order/quote.ts @@ -6,6 +6,30 @@ import { BundledOrders, Pair, TakeOrder } from "./types"; import { decodeFunctionResult, encodeFunctionData, PublicClient } from "viem"; import { extractOracleUrl, fetchSignedContext } from "../oracle"; +/** + * If the order has oracle metadata, fetch signed context and inject it + * into the takeOrder struct. Failures are swallowed so quoting proceeds + * with empty signed context. + */ +async function fetchOracleContext(orderDetails: Pair): Promise { + const orderMeta = (orderDetails as any).meta; + if (!orderMeta) return; + + const oracleUrl = extractOracleUrl(orderMeta); + if (!oracleUrl) return; + + const signedContexts = await fetchSignedContext(oracleUrl, [ + { + order: orderDetails.takeOrder.struct.order, + inputIOIndex: orderDetails.takeOrder.struct.inputIOIndex, + outputIOIndex: orderDetails.takeOrder.struct.outputIOIndex, + counterparty: "0x0000000000000000000000000000000000000000", + }, + ]); + + orderDetails.takeOrder.struct.signedContext = signedContexts; +} + /** * Quotes a single order * @param orderDetails - Order details to quote @@ -39,27 +63,10 @@ export async function quoteSingleOrderV3( blockNumber?: bigint, gas?: bigint, ) { - // Check if order has oracle metadata and fetch signed context try { - const orderMeta = (orderDetails as any).orderDetails?.meta; - if (orderMeta) { - const oracleUrl = extractOracleUrl(orderMeta); - if (oracleUrl) { - // Fetch signed context for this order - const signedContexts = await fetchSignedContext(oracleUrl, [{ - order: orderDetails.takeOrder.struct.order, - inputIOIndex: orderDetails.takeOrder.struct.inputIOIndex, - outputIOIndex: orderDetails.takeOrder.struct.outputIOIndex, - counterparty: '0x0000000000000000000000000000000000000000' // Unknown at quote time - }]); - - // Update the signed context in the takeOrder struct - orderDetails.takeOrder.struct.signedContext = signedContexts; - } - } + await fetchOracleContext(orderDetails); } catch (error) { - // Oracle failures should not prevent quoting - log warning and continue with empty context - console.warn('Failed to fetch oracle data for quote:', error); + console.warn("Failed to fetch oracle context:", error); } const { data } = await viemClient @@ -106,27 +113,10 @@ export async function quoteSingleOrderV4( blockNumber?: bigint, gas?: bigint, ) { - // Check if order has oracle metadata and fetch signed context try { - const orderMeta = (orderDetails as any).orderDetails?.meta; - if (orderMeta) { - const oracleUrl = extractOracleUrl(orderMeta); - if (oracleUrl) { - // Fetch signed context for this order - const signedContexts = await fetchSignedContext(oracleUrl, [{ - order: orderDetails.takeOrder.struct.order, - inputIOIndex: orderDetails.takeOrder.struct.inputIOIndex, - outputIOIndex: orderDetails.takeOrder.struct.outputIOIndex, - counterparty: '0x0000000000000000000000000000000000000000' // Unknown at quote time - }]); - - // Update the signed context in the takeOrder struct - orderDetails.takeOrder.struct.signedContext = signedContexts; - } - } + await fetchOracleContext(orderDetails); } catch (error) { - // Oracle failures should not prevent quoting - log warning and continue with empty context - console.warn('Failed to fetch oracle data for quote:', error); + console.warn("Failed to fetch oracle context:", error); } const { data } = await viemClient From 5513fce050888fda5ba06ed8681b07e212c6bff3 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 23 Feb 2026 20:59:12 +0000 Subject: [PATCH 03/17] fix: use viem instead of ethers for ABI encoding Replace ethers.utils.defaultAbiCoder/arrayify with viem's encodeAbiParameters/hexToBytes. Use proper viem ABI parameter definitions instead of string-based encoding. --- src/oracle/index.ts | 70 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 55 insertions(+), 15 deletions(-) diff --git a/src/oracle/index.ts b/src/oracle/index.ts index 508877e5..38fa3dbf 100644 --- a/src/oracle/index.ts +++ b/src/oracle/index.ts @@ -1,11 +1,11 @@ -import { ethers } from "ethers"; +import { encodeAbiParameters, hexToBytes } from "viem"; import { ABI } from "../common"; /** * Extract oracle URL from order meta bytes. * * TODO: Replace with SDK's RaindexOrder.extractOracleUrl() once the wasm - * package includes it. For now, returns null (stub). + * package includes it. Pending rain.orderbook PR #2478. * * @param metaHex - Hex string of meta bytes (e.g. "0x1234...") * @returns Oracle URL if found, null otherwise @@ -13,7 +13,6 @@ import { ABI } from "../common"; export function extractOracleUrl(metaHex: string): string | null { // TODO: Implement CBOR decoding to find RaindexSignedContextOracleV1 // magic number 0xff7a1507ba4419ca and extract URL. - // Pending SDK update — see rain.orderbook PR #2478. return null; } @@ -47,11 +46,52 @@ export interface OracleOrderRequest { const ORACLE_TIMEOUT_MS = 5_000; /** - * ABI type string for the batch oracle request body: - * abi.encode((OrderV4, uint256, uint256, address)[]) + * ABI parameter definition for the batch oracle request body. + * Encodes as: abi.encode((OrderV4, uint256, uint256, address)[]) */ -const OracleRequestTupleType = - `tuple(${ABI.Orderbook.V5.OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, address counterparty)[]` as const; +const oracleBatchAbiParams = [ + { + type: "tuple[]", + components: [ + { + name: "order", + type: "tuple", + components: [ + { name: "owner", type: "address" }, + { + name: "evaluable", + type: "tuple", + components: [ + { name: "interpreter", type: "address" }, + { name: "store", type: "address" }, + { name: "bytecode", type: "bytes" }, + ], + }, + { + name: "validInputs", + type: "tuple[]", + components: [ + { name: "token", type: "address" }, + { name: "vaultId", type: "bytes32" }, + ], + }, + { + name: "validOutputs", + type: "tuple[]", + components: [ + { name: "token", type: "address" }, + { name: "vaultId", type: "bytes32" }, + ], + }, + { name: "nonce", type: "bytes32" }, + ], + }, + { name: "inputIOIndex", type: "uint256" }, + { name: "outputIOIndex", type: "uint256" }, + { name: "counterparty", type: "address" }, + ], + }, +] as const; /** * Fetch signed contexts from an oracle endpoint (batch format). @@ -67,14 +107,14 @@ export async function fetchSignedContext( url: string, orders: OracleOrderRequest[], ): Promise { - const tuples = orders.map((req) => [ - req.order, - req.inputIOIndex, - req.outputIOIndex, - req.counterparty, - ]); + const tuples = orders.map((req) => ({ + order: req.order, + inputIOIndex: BigInt(req.inputIOIndex), + outputIOIndex: BigInt(req.outputIOIndex), + counterparty: req.counterparty as `0x${string}`, + })); - const body = ethers.utils.defaultAbiCoder.encode([OracleRequestTupleType], [tuples]); + const encoded = encodeAbiParameters(oracleBatchAbiParams, [tuples]); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), ORACLE_TIMEOUT_MS); @@ -84,7 +124,7 @@ export async function fetchSignedContext( response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/octet-stream" }, - body: ethers.utils.arrayify(body), + body: hexToBytes(encoded), signal: controller.signal, }); } finally { From e0cf6c56e97f2e875e635e5db5018436bdb3747a Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 23 Feb 2026 21:01:52 +0000 Subject: [PATCH 04/17] feat: add retry with backoff and per-URL cooloff for oracle fetching - Up to 2 retries with exponential backoff (500ms, 1s) - After 3 consecutive failures, oracle URL enters 5min cooloff - During cooloff, requests to that URL are skipped immediately - Cooloff resets on first successful response - Invalid responses (bad shape, wrong length) also count as failures - All configurable via module constants --- src/oracle/index.ts | 164 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 143 insertions(+), 21 deletions(-) diff --git a/src/oracle/index.ts b/src/oracle/index.ts index 38fa3dbf..3cc4db43 100644 --- a/src/oracle/index.ts +++ b/src/oracle/index.ts @@ -1,5 +1,4 @@ import { encodeAbiParameters, hexToBytes } from "viem"; -import { ABI } from "../common"; /** * Extract oracle URL from order meta bytes. @@ -42,8 +41,70 @@ export interface OracleOrderRequest { counterparty: string; } -/** Oracle request timeout in ms */ +// --------------------------------------------------------------------------- +// Retry & cooloff configuration +// --------------------------------------------------------------------------- + +/** Per-request timeout */ const ORACLE_TIMEOUT_MS = 5_000; +/** Max retries per request (total attempts = MAX_RETRIES + 1) */ +const MAX_RETRIES = 2; +/** Base delay between retries (doubled each attempt) */ +const RETRY_BASE_DELAY_MS = 500; +/** How long to skip a failing oracle after repeated failures */ +const COOLOFF_DURATION_MS = 5 * 60 * 1_000; // 5 minutes +/** Number of consecutive failures before entering cooloff */ +const COOLOFF_THRESHOLD = 3; + +/** Tracks per-URL failure counts and cooloff deadlines */ +interface OracleHealthState { + consecutiveFailures: number; + cooloffUntil: number; // unix ms, 0 = not cooling off +} + +const oracleHealth: Map = new Map(); + +function getHealth(url: string): OracleHealthState { + let state = oracleHealth.get(url); + if (!state) { + state = { consecutiveFailures: 0, cooloffUntil: 0 }; + oracleHealth.set(url, state); + } + return state; +} + +function recordSuccess(url: string) { + const state = getHealth(url); + state.consecutiveFailures = 0; + state.cooloffUntil = 0; +} + +function recordFailure(url: string) { + const state = getHealth(url); + state.consecutiveFailures++; + if (state.consecutiveFailures >= COOLOFF_THRESHOLD) { + state.cooloffUntil = Date.now() + COOLOFF_DURATION_MS; + console.warn( + `Oracle ${url} entered cooloff for ${COOLOFF_DURATION_MS / 1000}s after ${state.consecutiveFailures} consecutive failures`, + ); + } +} + +function isInCooloff(url: string): boolean { + const state = getHealth(url); + if (state.cooloffUntil === 0) return false; + if (Date.now() >= state.cooloffUntil) { + // Cooloff expired — reset but keep failure count so next failure + // re-enters cooloff immediately + state.cooloffUntil = 0; + return false; + } + return true; +} + +// --------------------------------------------------------------------------- +// ABI encoding +// --------------------------------------------------------------------------- /** * ABI parameter definition for the batch oracle request body. @@ -93,12 +154,75 @@ const oracleBatchAbiParams = [ }, ] as const; +// --------------------------------------------------------------------------- +// Core fetch with retry +// --------------------------------------------------------------------------- + +/** Sleep helper */ +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Single attempt to fetch signed contexts from an oracle endpoint. + */ +async function fetchOnce(url: string, body: Uint8Array): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), ORACLE_TIMEOUT_MS); + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body, + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } + + if (!response.ok) { + throw new Error(`Oracle request failed: ${response.status} ${response.statusText}`); + } + + return response.json(); +} + +/** + * Fetch with exponential backoff retry. + */ +async function fetchWithRetry(url: string, body: Uint8Array): Promise { + let lastError: Error | undefined; + + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + return await fetchOnce(url, body); + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + if (attempt < MAX_RETRIES) { + const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt); + await sleep(delay); + } + } + } + + throw lastError; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + /** * Fetch signed contexts from an oracle endpoint (batch format). * * POSTs abi.encode((OrderV4, uint256, uint256, address)[]) and expects * a JSON array of SignedContextV1 objects back, matching request length. * + * Includes: + * - Exponential backoff retry (up to MAX_RETRIES) + * - Per-URL cooloff: after COOLOFF_THRESHOLD consecutive failures, the URL + * is skipped for COOLOFF_DURATION_MS before being retried + * * @param url - Oracle endpoint URL * @param orders - Array of order requests (usually 1 per IO pair) * @returns Array of signed contexts in the same order as the request @@ -107,6 +231,11 @@ export async function fetchSignedContext( url: string, orders: OracleOrderRequest[], ): Promise { + // Skip if oracle is in cooloff + if (isInCooloff(url)) { + throw new Error(`Oracle ${url} is in cooloff, skipping`); + } + const tuples = orders.map((req) => ({ order: req.order, inputIOIndex: BigInt(req.inputIOIndex), @@ -115,39 +244,29 @@ export async function fetchSignedContext( })); const encoded = encodeAbiParameters(oracleBatchAbiParams, [tuples]); + const body = hexToBytes(encoded); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), ORACLE_TIMEOUT_MS); - - let response: Response; + let json: unknown; try { - response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/octet-stream" }, - body: hexToBytes(encoded), - signal: controller.signal, - }); - } finally { - clearTimeout(timeout); - } - - if (!response.ok) { - throw new Error(`Oracle request failed: ${response.status} ${response.statusText}`); + json = await fetchWithRetry(url, body); + } catch (err) { + recordFailure(url); + throw err; } - const json: unknown = await response.json(); - + // Validate response if (!Array.isArray(json)) { + recordFailure(url); throw new Error("Oracle response must be an array"); } if (json.length !== orders.length) { + recordFailure(url); throw new Error( `Oracle response length (${json.length}) does not match request length (${orders.length})`, ); } - // Validate shape of each entry const contexts: SignedContextV1[] = json.map((entry: unknown, i: number) => { if ( typeof entry !== "object" || @@ -161,5 +280,8 @@ export async function fetchSignedContext( return entry as SignedContextV1; }); + // Success — clear failure state + recordSuccess(url); + return contexts; } From 08a544ff941f9786cc2f74e30942330b94683c49 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 23 Feb 2026 21:03:24 +0000 Subject: [PATCH 05/17] refactor: remove retry delays, use fail-fast with cooloff only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No retries, no delays in the loop. Single attempt with 5s timeout — if it fails, record the failure and move on. After 3 consecutive failures the URL enters a 5min cooloff where it's skipped immediately (no network call at all). This way one bad oracle can't block the processing of other orders. --- src/oracle/index.ts | 97 +++++++++++++-------------------------------- 1 file changed, 28 insertions(+), 69 deletions(-) diff --git a/src/oracle/index.ts b/src/oracle/index.ts index 3cc4db43..38b1fec6 100644 --- a/src/oracle/index.ts +++ b/src/oracle/index.ts @@ -42,16 +42,12 @@ export interface OracleOrderRequest { } // --------------------------------------------------------------------------- -// Retry & cooloff configuration +// Cooloff configuration // --------------------------------------------------------------------------- /** Per-request timeout */ const ORACLE_TIMEOUT_MS = 5_000; -/** Max retries per request (total attempts = MAX_RETRIES + 1) */ -const MAX_RETRIES = 2; -/** Base delay between retries (doubled each attempt) */ -const RETRY_BASE_DELAY_MS = 500; -/** How long to skip a failing oracle after repeated failures */ +/** How long to skip a failing oracle after consecutive failures */ const COOLOFF_DURATION_MS = 5 * 60 * 1_000; // 5 minutes /** Number of consecutive failures before entering cooloff */ const COOLOFF_THRESHOLD = 3; @@ -85,7 +81,8 @@ function recordFailure(url: string) { if (state.consecutiveFailures >= COOLOFF_THRESHOLD) { state.cooloffUntil = Date.now() + COOLOFF_DURATION_MS; console.warn( - `Oracle ${url} entered cooloff for ${COOLOFF_DURATION_MS / 1000}s after ${state.consecutiveFailures} consecutive failures`, + `Oracle ${url} entered cooloff for ${COOLOFF_DURATION_MS / 1000}s ` + + `after ${state.consecutiveFailures} consecutive failures`, ); } } @@ -154,60 +151,6 @@ const oracleBatchAbiParams = [ }, ] as const; -// --------------------------------------------------------------------------- -// Core fetch with retry -// --------------------------------------------------------------------------- - -/** Sleep helper */ -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -/** - * Single attempt to fetch signed contexts from an oracle endpoint. - */ -async function fetchOnce(url: string, body: Uint8Array): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), ORACLE_TIMEOUT_MS); - - let response: Response; - try { - response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/octet-stream" }, - body, - signal: controller.signal, - }); - } finally { - clearTimeout(timeout); - } - - if (!response.ok) { - throw new Error(`Oracle request failed: ${response.status} ${response.statusText}`); - } - - return response.json(); -} - -/** - * Fetch with exponential backoff retry. - */ -async function fetchWithRetry(url: string, body: Uint8Array): Promise { - let lastError: Error | undefined; - - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - try { - return await fetchOnce(url, body); - } catch (err) { - lastError = err instanceof Error ? err : new Error(String(err)); - if (attempt < MAX_RETRIES) { - const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt); - await sleep(delay); - } - } - } - - throw lastError; -} - // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- @@ -218,10 +161,10 @@ async function fetchWithRetry(url: string, body: Uint8Array): Promise { * POSTs abi.encode((OrderV4, uint256, uint256, address)[]) and expects * a JSON array of SignedContextV1 objects back, matching request length. * - * Includes: - * - Exponential backoff retry (up to MAX_RETRIES) - * - Per-URL cooloff: after COOLOFF_THRESHOLD consecutive failures, the URL - * is skipped for COOLOFF_DURATION_MS before being retried + * Single attempt with a hard timeout — no retries, no in-loop delays. + * Failed oracles accumulate toward a cooloff threshold. Once in cooloff, + * the URL is skipped immediately for COOLOFF_DURATION_MS so one bad + * oracle server can't hold up unrelated orders. * * @param url - Oracle endpoint URL * @param orders - Array of order requests (usually 1 per IO pair) @@ -231,7 +174,7 @@ export async function fetchSignedContext( url: string, orders: OracleOrderRequest[], ): Promise { - // Skip if oracle is in cooloff + // Skip immediately if oracle is in cooloff if (isInCooloff(url)) { throw new Error(`Oracle ${url} is in cooloff, skipping`); } @@ -246,12 +189,29 @@ export async function fetchSignedContext( const encoded = encodeAbiParameters(oracleBatchAbiParams, [tuples]); const body = hexToBytes(encoded); + // Single attempt — fail fast, no retries + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), ORACLE_TIMEOUT_MS); + let json: unknown; try { - json = await fetchWithRetry(url, body); + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(`Oracle request failed: ${response.status} ${response.statusText}`); + } + + json = await response.json(); } catch (err) { recordFailure(url); throw err; + } finally { + clearTimeout(timeout); } // Validate response @@ -275,13 +235,12 @@ export async function fetchSignedContext( !Array.isArray((entry as any).context) || typeof (entry as any).signature !== "string" ) { + recordFailure(url); throw new Error(`Oracle response[${i}] is not a valid SignedContextV1`); } return entry as SignedContextV1; }); - // Success — clear failure state recordSuccess(url); - return contexts; } From 4162cbb30918365f089a73f9e3555958ad4c6c3a Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 23 Feb 2026 21:06:13 +0000 Subject: [PATCH 06/17] refactor: move oracle health state to OracleManager class on OrderManager Extract oracle cooloff tracking from module-level singleton into an OracleManager class. Instance lives on OrderManager, threaded through to quote functions. This makes it properly scoped to the solver instance lifecycle and testable. - OracleManager class in src/oracle/manager.ts - fetchSignedContext takes OracleManager as parameter - OrderManager creates and owns the OracleManager instance - OracleManager is optional in quote functions for backward compat --- src/oracle/index.ts | 85 +++++++----------------------------------- src/oracle/manager.ts | 86 +++++++++++++++++++++++++++++++++++++++++++ src/order/index.ts | 12 +++++- src/order/quote.ts | 38 ++++++++++++------- 4 files changed, 134 insertions(+), 87 deletions(-) create mode 100644 src/oracle/manager.ts diff --git a/src/oracle/index.ts b/src/oracle/index.ts index 38b1fec6..07a75065 100644 --- a/src/oracle/index.ts +++ b/src/oracle/index.ts @@ -1,4 +1,7 @@ import { encodeAbiParameters, hexToBytes } from "viem"; +import { OracleManager } from "./manager"; + +export { OracleManager } from "./manager"; /** * Extract oracle URL from order meta bytes. @@ -41,67 +44,8 @@ export interface OracleOrderRequest { counterparty: string; } -// --------------------------------------------------------------------------- -// Cooloff configuration -// --------------------------------------------------------------------------- - /** Per-request timeout */ const ORACLE_TIMEOUT_MS = 5_000; -/** How long to skip a failing oracle after consecutive failures */ -const COOLOFF_DURATION_MS = 5 * 60 * 1_000; // 5 minutes -/** Number of consecutive failures before entering cooloff */ -const COOLOFF_THRESHOLD = 3; - -/** Tracks per-URL failure counts and cooloff deadlines */ -interface OracleHealthState { - consecutiveFailures: number; - cooloffUntil: number; // unix ms, 0 = not cooling off -} - -const oracleHealth: Map = new Map(); - -function getHealth(url: string): OracleHealthState { - let state = oracleHealth.get(url); - if (!state) { - state = { consecutiveFailures: 0, cooloffUntil: 0 }; - oracleHealth.set(url, state); - } - return state; -} - -function recordSuccess(url: string) { - const state = getHealth(url); - state.consecutiveFailures = 0; - state.cooloffUntil = 0; -} - -function recordFailure(url: string) { - const state = getHealth(url); - state.consecutiveFailures++; - if (state.consecutiveFailures >= COOLOFF_THRESHOLD) { - state.cooloffUntil = Date.now() + COOLOFF_DURATION_MS; - console.warn( - `Oracle ${url} entered cooloff for ${COOLOFF_DURATION_MS / 1000}s ` + - `after ${state.consecutiveFailures} consecutive failures`, - ); - } -} - -function isInCooloff(url: string): boolean { - const state = getHealth(url); - if (state.cooloffUntil === 0) return false; - if (Date.now() >= state.cooloffUntil) { - // Cooloff expired — reset but keep failure count so next failure - // re-enters cooloff immediately - state.cooloffUntil = 0; - return false; - } - return true; -} - -// --------------------------------------------------------------------------- -// ABI encoding -// --------------------------------------------------------------------------- /** * ABI parameter definition for the batch oracle request body. @@ -151,10 +95,6 @@ const oracleBatchAbiParams = [ }, ] as const; -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - /** * Fetch signed contexts from an oracle endpoint (batch format). * @@ -162,20 +102,21 @@ const oracleBatchAbiParams = [ * a JSON array of SignedContextV1 objects back, matching request length. * * Single attempt with a hard timeout — no retries, no in-loop delays. - * Failed oracles accumulate toward a cooloff threshold. Once in cooloff, - * the URL is skipped immediately for COOLOFF_DURATION_MS so one bad - * oracle server can't hold up unrelated orders. + * Uses the provided OracleManager to track failures and skip oracles + * in cooloff. * * @param url - Oracle endpoint URL * @param orders - Array of order requests (usually 1 per IO pair) + * @param oracleManager - Health tracker for cooloff management * @returns Array of signed contexts in the same order as the request */ export async function fetchSignedContext( url: string, orders: OracleOrderRequest[], + oracleManager: OracleManager, ): Promise { // Skip immediately if oracle is in cooloff - if (isInCooloff(url)) { + if (oracleManager.isInCooloff(url)) { throw new Error(`Oracle ${url} is in cooloff, skipping`); } @@ -208,7 +149,7 @@ export async function fetchSignedContext( json = await response.json(); } catch (err) { - recordFailure(url); + oracleManager.recordFailure(url); throw err; } finally { clearTimeout(timeout); @@ -216,12 +157,12 @@ export async function fetchSignedContext( // Validate response if (!Array.isArray(json)) { - recordFailure(url); + oracleManager.recordFailure(url); throw new Error("Oracle response must be an array"); } if (json.length !== orders.length) { - recordFailure(url); + oracleManager.recordFailure(url); throw new Error( `Oracle response length (${json.length}) does not match request length (${orders.length})`, ); @@ -235,12 +176,12 @@ export async function fetchSignedContext( !Array.isArray((entry as any).context) || typeof (entry as any).signature !== "string" ) { - recordFailure(url); + oracleManager.recordFailure(url); throw new Error(`Oracle response[${i}] is not a valid SignedContextV1`); } return entry as SignedContextV1; }); - recordSuccess(url); + oracleManager.recordSuccess(url); return contexts; } diff --git a/src/oracle/manager.ts b/src/oracle/manager.ts new file mode 100644 index 00000000..34fa5199 --- /dev/null +++ b/src/oracle/manager.ts @@ -0,0 +1,86 @@ +/** Tracks per-URL failure counts and cooloff deadlines */ +interface OracleHealthState { + consecutiveFailures: number; + cooloffUntil: number; // unix ms, 0 = not cooling off +} + +/** + * Manages oracle endpoint health tracking and cooloff. + * + * Tracks consecutive failures per oracle URL and places failing + * oracles into a cooloff period so they are skipped without any + * network calls, preventing slow/dead oracles from blocking + * order processing. + */ +export class OracleManager { + /** How long to skip a failing oracle (ms) */ + readonly cooloffDurationMs: number; + /** Number of consecutive failures before entering cooloff */ + readonly cooloffThreshold: number; + + private health: Map = new Map(); + + constructor( + cooloffDurationMs: number = 5 * 60 * 1_000, + cooloffThreshold: number = 3, + ) { + this.cooloffDurationMs = cooloffDurationMs; + this.cooloffThreshold = cooloffThreshold; + } + + private getHealth(url: string): OracleHealthState { + let state = this.health.get(url); + if (!state) { + state = { consecutiveFailures: 0, cooloffUntil: 0 }; + this.health.set(url, state); + } + return state; + } + + /** Record a successful oracle response — clears failure state */ + recordSuccess(url: string) { + const state = this.getHealth(url); + state.consecutiveFailures = 0; + state.cooloffUntil = 0; + } + + /** Record a failed oracle request — may trigger cooloff */ + recordFailure(url: string) { + const state = this.getHealth(url); + state.consecutiveFailures++; + if (state.consecutiveFailures >= this.cooloffThreshold) { + state.cooloffUntil = Date.now() + this.cooloffDurationMs; + console.warn( + `Oracle ${url} entered cooloff for ${this.cooloffDurationMs / 1000}s ` + + `after ${state.consecutiveFailures} consecutive failures`, + ); + } + } + + /** Check if an oracle URL is currently in cooloff */ + isInCooloff(url: string): boolean { + const state = this.getHealth(url); + if (state.cooloffUntil === 0) return false; + if (Date.now() >= state.cooloffUntil) { + // Cooloff expired — reset but keep failure count so next + // failure re-enters cooloff immediately + state.cooloffUntil = 0; + return false; + } + return true; + } + + /** Get current health info for an oracle (for logging/diagnostics) */ + getStatus(url: string): { consecutiveFailures: number; inCooloff: boolean } { + const state = this.getHealth(url); + return { + consecutiveFailures: state.consecutiveFailures, + inCooloff: this.isInCooloff(url), + }; + } + + /** Reset all health tracking state */ + reset() { + this.health.clear(); + } +} diff --git a/src/order/index.ts b/src/order/index.ts index ac765c0e..a72dff88 100644 --- a/src/order/index.ts +++ b/src/order/index.ts @@ -10,6 +10,7 @@ import { downscaleProtection } from "./protection"; import { normalizeFloat, Result, TokenDetails } from "../common"; import { OrderManagerError, OrderManagerErrorType } from "./error"; import { addToPairMap, removeFromPairMap, getSortedPairList } from "./pair"; +import { OracleManager } from "../oracle"; import { Pair, Order, @@ -42,6 +43,8 @@ export class OrderManager { readonly state: SharedState; /** Subgraph manager instance */ readonly subgraphManager: SubgraphManager; + /** Oracle health tracker */ + readonly oracleManager: OracleManager; /** Orderbooks owners profile map */ ownersMap: OrderbooksOwnersProfileMap; @@ -84,6 +87,7 @@ export class OrderManager { this.quoteGas = state.orderManagerConfig.quoteGas; this.ownerLimits = state.orderManagerConfig.ownerLimits; this.subgraphManager = subgraphManager ?? new SubgraphManager(state.subgraphConfig); + this.oracleManager = new OracleManager(); } /** @@ -458,7 +462,13 @@ export class OrderManager { * @param blockNumber - Optional block number for the quote */ async quoteOrder(orderDetails: Pair, blockNumber?: bigint) { - return await quoteSingleOrder(orderDetails, this.state.client, blockNumber, this.quoteGas); + return await quoteSingleOrder( + orderDetails, + this.state.client, + blockNumber, + this.quoteGas, + this.oracleManager, + ); } /** diff --git a/src/order/quote.ts b/src/order/quote.ts index 6d29d560..2fbfd7d2 100644 --- a/src/order/quote.ts +++ b/src/order/quote.ts @@ -4,28 +4,35 @@ import { AppOptions } from "../config"; import { ABI, normalizeFloat } from "../common"; import { BundledOrders, Pair, TakeOrder } from "./types"; import { decodeFunctionResult, encodeFunctionData, PublicClient } from "viem"; -import { extractOracleUrl, fetchSignedContext } from "../oracle"; +import { extractOracleUrl, fetchSignedContext, OracleManager } from "../oracle"; /** * If the order has oracle metadata, fetch signed context and inject it * into the takeOrder struct. Failures are swallowed so quoting proceeds * with empty signed context. */ -async function fetchOracleContext(orderDetails: Pair): Promise { +async function fetchOracleContext( + orderDetails: Pair, + oracleManager: OracleManager, +): Promise { const orderMeta = (orderDetails as any).meta; if (!orderMeta) return; const oracleUrl = extractOracleUrl(orderMeta); if (!oracleUrl) return; - const signedContexts = await fetchSignedContext(oracleUrl, [ - { - order: orderDetails.takeOrder.struct.order, - inputIOIndex: orderDetails.takeOrder.struct.inputIOIndex, - outputIOIndex: orderDetails.takeOrder.struct.outputIOIndex, - counterparty: "0x0000000000000000000000000000000000000000", - }, - ]); + const signedContexts = await fetchSignedContext( + oracleUrl, + [ + { + order: orderDetails.takeOrder.struct.order, + inputIOIndex: orderDetails.takeOrder.struct.inputIOIndex, + outputIOIndex: orderDetails.takeOrder.struct.outputIOIndex, + counterparty: "0x0000000000000000000000000000000000000000", + }, + ], + oracleManager, + ); orderDetails.takeOrder.struct.signedContext = signedContexts; } @@ -42,11 +49,12 @@ export async function quoteSingleOrder( viemClient: PublicClient, blockNumber?: bigint, gas?: bigint, + oracleManager?: OracleManager, ) { if (Pair.isV3(orderDetails)) { - return quoteSingleOrderV3(orderDetails, viemClient, blockNumber, gas); + return quoteSingleOrderV3(orderDetails, viemClient, blockNumber, gas, oracleManager); } else { - return quoteSingleOrderV4(orderDetails, viemClient, blockNumber, gas); + return quoteSingleOrderV4(orderDetails, viemClient, blockNumber, gas, oracleManager); } } @@ -62,9 +70,10 @@ export async function quoteSingleOrderV3( viemClient: PublicClient, blockNumber?: bigint, gas?: bigint, + oracleManager?: OracleManager, ) { try { - await fetchOracleContext(orderDetails); + if (oracleManager) await fetchOracleContext(orderDetails, oracleManager); } catch (error) { console.warn("Failed to fetch oracle context:", error); } @@ -112,9 +121,10 @@ export async function quoteSingleOrderV4( viemClient: PublicClient, blockNumber?: bigint, gas?: bigint, + oracleManager?: OracleManager, ) { try { - await fetchOracleContext(orderDetails); + if (oracleManager) await fetchOracleContext(orderDetails, oracleManager); } catch (error) { console.warn("Failed to fetch oracle context:", error); } From 77d52ba5f9b97fee140b5736aac34199fdec3a94 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 23 Feb 2026 21:13:51 +0000 Subject: [PATCH 07/17] refactor: drop OracleManager class, use SharedState + standalone fns Follow codebase conventions: - Oracle health map lives on SharedState.oracleHealth - fetchOracleContext is a standalone fn with this: SharedState, called via .call(state) like processOrder/findBestTrade - Health helpers (isInCooloff, recordOracleSuccess/Failure) are plain exported functions operating on the health map - No new classes, no module-level singletons - quoteSingleOrder receives SharedState to thread through --- src/oracle/fetch.ts | 36 ++++++++++++++++++ src/oracle/index.ts | 73 +++++++++++++++++++++++++----------- src/oracle/manager.ts | 86 ------------------------------------------- src/order/index.ts | 6 +-- src/order/quote.ts | 72 ++++++++++-------------------------- src/state/index.ts | 2 + 6 files changed, 110 insertions(+), 165 deletions(-) create mode 100644 src/oracle/fetch.ts delete mode 100644 src/oracle/manager.ts diff --git a/src/oracle/fetch.ts b/src/oracle/fetch.ts new file mode 100644 index 00000000..236e3154 --- /dev/null +++ b/src/oracle/fetch.ts @@ -0,0 +1,36 @@ +import { Pair } from "../order/types"; +import { SharedState } from "../state"; +import { extractOracleUrl, fetchSignedContext } from "."; + +/** + * If the order has oracle metadata, fetch signed context and inject it + * into the takeOrder struct. Called with SharedState as `this` to access + * the oracle health map. + * + * Failures are swallowed so quoting proceeds with empty signed context. + */ +export async function fetchOracleContext( + this: SharedState, + orderDetails: Pair, +): Promise { + const orderMeta = (orderDetails as any).meta; + if (!orderMeta) return; + + const oracleUrl = extractOracleUrl(orderMeta); + if (!oracleUrl) return; + + const signedContexts = await fetchSignedContext( + oracleUrl, + [ + { + order: orderDetails.takeOrder.struct.order, + inputIOIndex: orderDetails.takeOrder.struct.inputIOIndex, + outputIOIndex: orderDetails.takeOrder.struct.outputIOIndex, + counterparty: "0x0000000000000000000000000000000000000000", + }, + ], + this.oracleHealth, + ); + + orderDetails.takeOrder.struct.signedContext = signedContexts; +} diff --git a/src/oracle/index.ts b/src/oracle/index.ts index 07a75065..21dd1418 100644 --- a/src/oracle/index.ts +++ b/src/oracle/index.ts @@ -1,7 +1,6 @@ import { encodeAbiParameters, hexToBytes } from "viem"; -import { OracleManager } from "./manager"; -export { OracleManager } from "./manager"; +export { fetchOracleContext } from "./fetch"; /** * Extract oracle URL from order meta bytes. @@ -44,14 +43,55 @@ export interface OracleOrderRequest { counterparty: string; } +// --------------------------------------------------------------------------- +// Oracle health / cooloff helpers +// --------------------------------------------------------------------------- + /** Per-request timeout */ -const ORACLE_TIMEOUT_MS = 5_000; +export const ORACLE_TIMEOUT_MS = 5_000; +/** How long to skip a failing oracle (ms) */ +export const COOLOFF_DURATION_MS = 5 * 60 * 1_000; +/** Consecutive failures before entering cooloff */ +export const COOLOFF_THRESHOLD = 3; + +export type OracleHealthMap = Map; + +export function isInCooloff(healthMap: OracleHealthMap, url: string): boolean { + const state = healthMap.get(url); + if (!state || state.cooloffUntil === 0) return false; + if (Date.now() >= state.cooloffUntil) { + state.cooloffUntil = 0; + return false; + } + return true; +} + +export function recordOracleSuccess(healthMap: OracleHealthMap, url: string) { + healthMap.set(url, { consecutiveFailures: 0, cooloffUntil: 0 }); +} + +export function recordOracleFailure(healthMap: OracleHealthMap, url: string) { + const state = healthMap.get(url) ?? { consecutiveFailures: 0, cooloffUntil: 0 }; + state.consecutiveFailures++; + if (state.consecutiveFailures >= COOLOFF_THRESHOLD) { + state.cooloffUntil = Date.now() + COOLOFF_DURATION_MS; + console.warn( + `Oracle ${url} entered cooloff for ${COOLOFF_DURATION_MS / 1000}s ` + + `after ${state.consecutiveFailures} consecutive failures`, + ); + } + healthMap.set(url, state); +} + +// --------------------------------------------------------------------------- +// ABI encoding +// --------------------------------------------------------------------------- /** * ABI parameter definition for the batch oracle request body. * Encodes as: abi.encode((OrderV4, uint256, uint256, address)[]) */ -const oracleBatchAbiParams = [ +export const oracleBatchAbiParams = [ { type: "tuple[]", components: [ @@ -102,21 +142,14 @@ const oracleBatchAbiParams = [ * a JSON array of SignedContextV1 objects back, matching request length. * * Single attempt with a hard timeout — no retries, no in-loop delays. - * Uses the provided OracleManager to track failures and skip oracles - * in cooloff. - * - * @param url - Oracle endpoint URL - * @param orders - Array of order requests (usually 1 per IO pair) - * @param oracleManager - Health tracker for cooloff management - * @returns Array of signed contexts in the same order as the request + * Uses the provided health map for cooloff tracking. */ export async function fetchSignedContext( url: string, orders: OracleOrderRequest[], - oracleManager: OracleManager, + healthMap: OracleHealthMap, ): Promise { - // Skip immediately if oracle is in cooloff - if (oracleManager.isInCooloff(url)) { + if (isInCooloff(healthMap, url)) { throw new Error(`Oracle ${url} is in cooloff, skipping`); } @@ -130,7 +163,6 @@ export async function fetchSignedContext( const encoded = encodeAbiParameters(oracleBatchAbiParams, [tuples]); const body = hexToBytes(encoded); - // Single attempt — fail fast, no retries const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), ORACLE_TIMEOUT_MS); @@ -149,20 +181,19 @@ export async function fetchSignedContext( json = await response.json(); } catch (err) { - oracleManager.recordFailure(url); + recordOracleFailure(healthMap, url); throw err; } finally { clearTimeout(timeout); } - // Validate response if (!Array.isArray(json)) { - oracleManager.recordFailure(url); + recordOracleFailure(healthMap, url); throw new Error("Oracle response must be an array"); } if (json.length !== orders.length) { - oracleManager.recordFailure(url); + recordOracleFailure(healthMap, url); throw new Error( `Oracle response length (${json.length}) does not match request length (${orders.length})`, ); @@ -176,12 +207,12 @@ export async function fetchSignedContext( !Array.isArray((entry as any).context) || typeof (entry as any).signature !== "string" ) { - oracleManager.recordFailure(url); + recordOracleFailure(healthMap, url); throw new Error(`Oracle response[${i}] is not a valid SignedContextV1`); } return entry as SignedContextV1; }); - oracleManager.recordSuccess(url); + recordOracleSuccess(healthMap, url); return contexts; } diff --git a/src/oracle/manager.ts b/src/oracle/manager.ts deleted file mode 100644 index 34fa5199..00000000 --- a/src/oracle/manager.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** Tracks per-URL failure counts and cooloff deadlines */ -interface OracleHealthState { - consecutiveFailures: number; - cooloffUntil: number; // unix ms, 0 = not cooling off -} - -/** - * Manages oracle endpoint health tracking and cooloff. - * - * Tracks consecutive failures per oracle URL and places failing - * oracles into a cooloff period so they are skipped without any - * network calls, preventing slow/dead oracles from blocking - * order processing. - */ -export class OracleManager { - /** How long to skip a failing oracle (ms) */ - readonly cooloffDurationMs: number; - /** Number of consecutive failures before entering cooloff */ - readonly cooloffThreshold: number; - - private health: Map = new Map(); - - constructor( - cooloffDurationMs: number = 5 * 60 * 1_000, - cooloffThreshold: number = 3, - ) { - this.cooloffDurationMs = cooloffDurationMs; - this.cooloffThreshold = cooloffThreshold; - } - - private getHealth(url: string): OracleHealthState { - let state = this.health.get(url); - if (!state) { - state = { consecutiveFailures: 0, cooloffUntil: 0 }; - this.health.set(url, state); - } - return state; - } - - /** Record a successful oracle response — clears failure state */ - recordSuccess(url: string) { - const state = this.getHealth(url); - state.consecutiveFailures = 0; - state.cooloffUntil = 0; - } - - /** Record a failed oracle request — may trigger cooloff */ - recordFailure(url: string) { - const state = this.getHealth(url); - state.consecutiveFailures++; - if (state.consecutiveFailures >= this.cooloffThreshold) { - state.cooloffUntil = Date.now() + this.cooloffDurationMs; - console.warn( - `Oracle ${url} entered cooloff for ${this.cooloffDurationMs / 1000}s ` + - `after ${state.consecutiveFailures} consecutive failures`, - ); - } - } - - /** Check if an oracle URL is currently in cooloff */ - isInCooloff(url: string): boolean { - const state = this.getHealth(url); - if (state.cooloffUntil === 0) return false; - if (Date.now() >= state.cooloffUntil) { - // Cooloff expired — reset but keep failure count so next - // failure re-enters cooloff immediately - state.cooloffUntil = 0; - return false; - } - return true; - } - - /** Get current health info for an oracle (for logging/diagnostics) */ - getStatus(url: string): { consecutiveFailures: number; inCooloff: boolean } { - const state = this.getHealth(url); - return { - consecutiveFailures: state.consecutiveFailures, - inCooloff: this.isInCooloff(url), - }; - } - - /** Reset all health tracking state */ - reset() { - this.health.clear(); - } -} diff --git a/src/order/index.ts b/src/order/index.ts index a72dff88..0e961a3a 100644 --- a/src/order/index.ts +++ b/src/order/index.ts @@ -10,7 +10,6 @@ import { downscaleProtection } from "./protection"; import { normalizeFloat, Result, TokenDetails } from "../common"; import { OrderManagerError, OrderManagerErrorType } from "./error"; import { addToPairMap, removeFromPairMap, getSortedPairList } from "./pair"; -import { OracleManager } from "../oracle"; import { Pair, Order, @@ -43,8 +42,6 @@ export class OrderManager { readonly state: SharedState; /** Subgraph manager instance */ readonly subgraphManager: SubgraphManager; - /** Oracle health tracker */ - readonly oracleManager: OracleManager; /** Orderbooks owners profile map */ ownersMap: OrderbooksOwnersProfileMap; @@ -87,7 +84,6 @@ export class OrderManager { this.quoteGas = state.orderManagerConfig.quoteGas; this.ownerLimits = state.orderManagerConfig.ownerLimits; this.subgraphManager = subgraphManager ?? new SubgraphManager(state.subgraphConfig); - this.oracleManager = new OracleManager(); } /** @@ -465,9 +461,9 @@ export class OrderManager { return await quoteSingleOrder( orderDetails, this.state.client, + this.state, blockNumber, this.quoteGas, - this.oracleManager, ); } diff --git a/src/order/quote.ts b/src/order/quote.ts index 2fbfd7d2..62eadee5 100644 --- a/src/order/quote.ts +++ b/src/order/quote.ts @@ -4,78 +4,46 @@ import { AppOptions } from "../config"; import { ABI, normalizeFloat } from "../common"; import { BundledOrders, Pair, TakeOrder } from "./types"; import { decodeFunctionResult, encodeFunctionData, PublicClient } from "viem"; -import { extractOracleUrl, fetchSignedContext, OracleManager } from "../oracle"; - -/** - * If the order has oracle metadata, fetch signed context and inject it - * into the takeOrder struct. Failures are swallowed so quoting proceeds - * with empty signed context. - */ -async function fetchOracleContext( - orderDetails: Pair, - oracleManager: OracleManager, -): Promise { - const orderMeta = (orderDetails as any).meta; - if (!orderMeta) return; - - const oracleUrl = extractOracleUrl(orderMeta); - if (!oracleUrl) return; - - const signedContexts = await fetchSignedContext( - oracleUrl, - [ - { - order: orderDetails.takeOrder.struct.order, - inputIOIndex: orderDetails.takeOrder.struct.inputIOIndex, - outputIOIndex: orderDetails.takeOrder.struct.outputIOIndex, - counterparty: "0x0000000000000000000000000000000000000000", - }, - ], - oracleManager, - ); - - orderDetails.takeOrder.struct.signedContext = signedContexts; -} +import { fetchOracleContext } from "../oracle"; /** * Quotes a single order * @param orderDetails - Order details to quote * @param viemClient - Viem client + * @param state - SharedState for oracle health tracking * @param blockNumber - Optional block number * @param gas - Optional read gas */ export async function quoteSingleOrder( orderDetails: Pair, viemClient: PublicClient, + state?: SharedState, blockNumber?: bigint, gas?: bigint, - oracleManager?: OracleManager, ) { if (Pair.isV3(orderDetails)) { - return quoteSingleOrderV3(orderDetails, viemClient, blockNumber, gas, oracleManager); + return quoteSingleOrderV3(orderDetails, viemClient, state, blockNumber, gas); } else { - return quoteSingleOrderV4(orderDetails, viemClient, blockNumber, gas, oracleManager); + return quoteSingleOrderV4(orderDetails, viemClient, state, blockNumber, gas); } } /** * Quotes a single order v3 - * @param orderDetails - Order details to quote - * @param viemClient - Viem client - * @param blockNumber - Optional block number - * @param gas - Optional read gas */ export async function quoteSingleOrderV3( orderDetails: Pair, viemClient: PublicClient, + state?: SharedState, blockNumber?: bigint, gas?: bigint, - oracleManager?: OracleManager, ) { - try { - if (oracleManager) await fetchOracleContext(orderDetails, oracleManager); - } catch (error) { - console.warn("Failed to fetch oracle context:", error); + if (state) { + try { + await fetchOracleContext.call(state, orderDetails); + } catch (error) { + console.warn("Failed to fetch oracle context:", error); + } } const { data } = await viemClient @@ -111,22 +79,20 @@ export async function quoteSingleOrderV3( /** * Quotes a single order v4 - * @param orderDetails - Order details to quote - * @param viemClient - Viem client - * @param blockNumber - Optional block number - * @param gas - Optional read gas */ export async function quoteSingleOrderV4( orderDetails: Pair, viemClient: PublicClient, + state?: SharedState, blockNumber?: bigint, gas?: bigint, - oracleManager?: OracleManager, ) { - try { - if (oracleManager) await fetchOracleContext(orderDetails, oracleManager); - } catch (error) { - console.warn("Failed to fetch oracle context:", error); + if (state) { + try { + await fetchOracleContext.call(state, orderDetails); + } catch (error) { + console.warn("Failed to fetch oracle context:", error); + } } const { data } = await viemClient diff --git a/src/state/index.ts b/src/state/index.ts index 3b792553..087336ae 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -224,6 +224,8 @@ export class SharedState { writeRpc?: RpcState; /** List of latest successful transactions gas costs */ gasCosts: bigint[] = []; + /** Oracle endpoint health tracking for cooloff */ + oracleHealth: Map = new Map(); constructor(config: SharedStateConfig) { this.appOptions = config.appOptions; From 82ae2c9dabe3ce3923856ea177072f11badc20d4 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 23 Feb 2026 21:16:12 +0000 Subject: [PATCH 08/17] refactor: use Result type instead of throwing - fetchSignedContext returns Result - fetchOracleContext returns Result - Callers check .isErr() instead of try/catch - Follows codebase convention for error handling --- src/oracle/fetch.ts | 18 ++++++++++++------ src/oracle/index.ts | 25 ++++++++++++++----------- src/order/quote.ts | 14 ++++++-------- 3 files changed, 32 insertions(+), 25 deletions(-) diff --git a/src/oracle/fetch.ts b/src/oracle/fetch.ts index 236e3154..ed4dc57f 100644 --- a/src/oracle/fetch.ts +++ b/src/oracle/fetch.ts @@ -1,5 +1,6 @@ import { Pair } from "../order/types"; import { SharedState } from "../state"; +import { Result } from "../common"; import { extractOracleUrl, fetchSignedContext } from "."; /** @@ -7,19 +8,19 @@ import { extractOracleUrl, fetchSignedContext } from "."; * into the takeOrder struct. Called with SharedState as `this` to access * the oracle health map. * - * Failures are swallowed so quoting proceeds with empty signed context. + * Returns Result — callers decide how to handle failures. */ export async function fetchOracleContext( this: SharedState, orderDetails: Pair, -): Promise { +): Promise> { const orderMeta = (orderDetails as any).meta; - if (!orderMeta) return; + if (!orderMeta) return Result.ok(undefined); const oracleUrl = extractOracleUrl(orderMeta); - if (!oracleUrl) return; + if (!oracleUrl) return Result.ok(undefined); - const signedContexts = await fetchSignedContext( + const result = await fetchSignedContext( oracleUrl, [ { @@ -32,5 +33,10 @@ export async function fetchOracleContext( this.oracleHealth, ); - orderDetails.takeOrder.struct.signedContext = signedContexts; + if (result.isErr()) { + return Result.err(result.error); + } + + orderDetails.takeOrder.struct.signedContext = result.value; + return Result.ok(undefined); } diff --git a/src/oracle/index.ts b/src/oracle/index.ts index 21dd1418..f36896f1 100644 --- a/src/oracle/index.ts +++ b/src/oracle/index.ts @@ -1,4 +1,5 @@ import { encodeAbiParameters, hexToBytes } from "viem"; +import { Result } from "../common"; export { fetchOracleContext } from "./fetch"; @@ -148,9 +149,9 @@ export async function fetchSignedContext( url: string, orders: OracleOrderRequest[], healthMap: OracleHealthMap, -): Promise { +): Promise> { if (isInCooloff(healthMap, url)) { - throw new Error(`Oracle ${url} is in cooloff, skipping`); + return Result.err(`Oracle ${url} is in cooloff, skipping`); } const tuples = orders.map((req) => ({ @@ -176,30 +177,33 @@ export async function fetchSignedContext( }); if (!response.ok) { - throw new Error(`Oracle request failed: ${response.status} ${response.statusText}`); + recordOracleFailure(healthMap, url); + return Result.err(`Oracle request failed: ${response.status} ${response.statusText}`); } json = await response.json(); } catch (err) { recordOracleFailure(healthMap, url); - throw err; + return Result.err(`Oracle fetch error: ${err instanceof Error ? err.message : String(err)}`); } finally { clearTimeout(timeout); } if (!Array.isArray(json)) { recordOracleFailure(healthMap, url); - throw new Error("Oracle response must be an array"); + return Result.err("Oracle response must be an array"); } if (json.length !== orders.length) { recordOracleFailure(healthMap, url); - throw new Error( + return Result.err( `Oracle response length (${json.length}) does not match request length (${orders.length})`, ); } - const contexts: SignedContextV1[] = json.map((entry: unknown, i: number) => { + // Validate shape of each entry + for (let i = 0; i < json.length; i++) { + const entry = json[i]; if ( typeof entry !== "object" || entry === null || @@ -208,11 +212,10 @@ export async function fetchSignedContext( typeof (entry as any).signature !== "string" ) { recordOracleFailure(healthMap, url); - throw new Error(`Oracle response[${i}] is not a valid SignedContextV1`); + return Result.err(`Oracle response[${i}] is not a valid SignedContextV1`); } - return entry as SignedContextV1; - }); + } recordOracleSuccess(healthMap, url); - return contexts; + return Result.ok(json as SignedContextV1[]); } diff --git a/src/order/quote.ts b/src/order/quote.ts index 62eadee5..c803edea 100644 --- a/src/order/quote.ts +++ b/src/order/quote.ts @@ -39,10 +39,9 @@ export async function quoteSingleOrderV3( gas?: bigint, ) { if (state) { - try { - await fetchOracleContext.call(state, orderDetails); - } catch (error) { - console.warn("Failed to fetch oracle context:", error); + const oracleResult = await fetchOracleContext.call(state, orderDetails); + if (oracleResult.isErr()) { + console.warn("Failed to fetch oracle context:", oracleResult.error); } } @@ -88,10 +87,9 @@ export async function quoteSingleOrderV4( gas?: bigint, ) { if (state) { - try { - await fetchOracleContext.call(state, orderDetails); - } catch (error) { - console.warn("Failed to fetch oracle context:", error); + const oracleResult = await fetchOracleContext.call(state, orderDetails); + if (oracleResult.isErr()) { + console.warn("Failed to fetch oracle context:", oracleResult.error); } } From 1c39eea1a9c48bc8b05dc55e8cd8b2afc751e293 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 23 Feb 2026 21:20:05 +0000 Subject: [PATCH 09/17] refactor: use existing order types, drop redundant SignedContextV1 interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OracleOrderRequest.order uses Order.V3 | Order.V4 from order/types - OracleOrderRequest.counterparty typed as 0x - Drop custom SignedContextV1 interface — signed context is already typed as any[] on TakeOrderV3/V4, and the response validation ensures the right shape at runtime - fetchSignedContext returns Result matching the existing signedContext field type --- src/oracle/index.ts | 50 ++++++++++++++++++++------------------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/src/oracle/index.ts b/src/oracle/index.ts index f36896f1..1150ab65 100644 --- a/src/oracle/index.ts +++ b/src/oracle/index.ts @@ -1,5 +1,6 @@ import { encodeAbiParameters, hexToBytes } from "viem"; import { Result } from "../common"; +import { Order } from "../order/types"; export { fetchOracleContext } from "./fetch"; @@ -19,33 +20,18 @@ export function extractOracleUrl(metaHex: string): string | null { } /** - * Signed context response from oracle endpoint. - * Maps directly to SignedContextV1 in the orderbook contract. - */ -export interface SignedContextV1 { - signer: string; - context: string[]; - signature: string; -} - -/** - * Order details for an oracle request entry. + * Oracle request entry — mirrors the spec's (OrderV4, uint256, uint256, address) tuple. + * Uses the existing Order.V3 | Order.V4 types from the order module. */ export interface OracleOrderRequest { - order: { - owner: string; - evaluable: { interpreter: string; store: string; bytecode: string }; - validInputs: { token: string; vaultId: string }[]; - validOutputs: { token: string; vaultId: string }[]; - nonce: string; - }; + order: Order.V3 | Order.V4; inputIOIndex: number; outputIOIndex: number; - counterparty: string; + counterparty: `0x${string}`; } // --------------------------------------------------------------------------- -// Oracle health / cooloff helpers +// Oracle health / cooloff // --------------------------------------------------------------------------- /** Per-request timeout */ @@ -91,8 +77,10 @@ export function recordOracleFailure(healthMap: OracleHealthMap, url: string) { /** * ABI parameter definition for the batch oracle request body. * Encodes as: abi.encode((OrderV4, uint256, uint256, address)[]) + * + * Uses the same struct shape as ABI.Orderbook.V5.OrderV4 / IOV2 / EvaluableV4. */ -export const oracleBatchAbiParams = [ +const oracleBatchAbiParams = [ { type: "tuple[]", components: [ @@ -136,6 +124,10 @@ export const oracleBatchAbiParams = [ }, ] as const; +// --------------------------------------------------------------------------- +// Fetch +// --------------------------------------------------------------------------- + /** * Fetch signed contexts from an oracle endpoint (batch format). * @@ -149,7 +141,7 @@ export async function fetchSignedContext( url: string, orders: OracleOrderRequest[], healthMap: OracleHealthMap, -): Promise> { +): Promise> { if (isInCooloff(healthMap, url)) { return Result.err(`Oracle ${url} is in cooloff, skipping`); } @@ -158,7 +150,7 @@ export async function fetchSignedContext( order: req.order, inputIOIndex: BigInt(req.inputIOIndex), outputIOIndex: BigInt(req.outputIOIndex), - counterparty: req.counterparty as `0x${string}`, + counterparty: req.counterparty, })); const encoded = encodeAbiParameters(oracleBatchAbiParams, [tuples]); @@ -184,7 +176,9 @@ export async function fetchSignedContext( json = await response.json(); } catch (err) { recordOracleFailure(healthMap, url); - return Result.err(`Oracle fetch error: ${err instanceof Error ? err.message : String(err)}`); + return Result.err( + `Oracle fetch error: ${err instanceof Error ? err.message : String(err)}`, + ); } finally { clearTimeout(timeout); } @@ -207,9 +201,9 @@ export async function fetchSignedContext( if ( typeof entry !== "object" || entry === null || - typeof (entry as any).signer !== "string" || - !Array.isArray((entry as any).context) || - typeof (entry as any).signature !== "string" + typeof entry.signer !== "string" || + !Array.isArray(entry.context) || + typeof entry.signature !== "string" ) { recordOracleFailure(healthMap, url); return Result.err(`Oracle response[${i}] is not a valid SignedContextV1`); @@ -217,5 +211,5 @@ export async function fetchSignedContext( } recordOracleSuccess(healthMap, url); - return Result.ok(json as SignedContextV1[]); + return Result.ok(json); } From b6be597bf272cac9c1e9017d20454a47ae20230b Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Thu, 26 Mar 2026 14:43:07 +0000 Subject: [PATCH 10/17] fix: make oracle signed context actually work - extractOracleUrl: implement CBOR meta parsing (was a stub returning null) - Add oracleUrl field to PairBase, thread through V3/V4 fromArgs - Add meta field to SgOrder type (already in subgraph query) - fetchOracleContext: use pair.oracleUrl instead of dead (pair as any).meta - Switch from batch to single request encoding to match oracle server spec - Restrict oracle requests to V4 orders only - Strip internal type discriminant before ABI encoding --- src/oracle/fetch.ts | 31 ++++---- src/oracle/index.ts | 165 ++++++++++++++++++++------------------- src/order/types/index.ts | 2 + src/order/types/v3.ts | 2 + src/order/types/v4.ts | 2 + src/subgraph/types.ts | 1 + 6 files changed, 108 insertions(+), 95 deletions(-) diff --git a/src/oracle/fetch.ts b/src/oracle/fetch.ts index ed4dc57f..55fbdddd 100644 --- a/src/oracle/fetch.ts +++ b/src/oracle/fetch.ts @@ -1,10 +1,10 @@ -import { Pair } from "../order/types"; +import { Order, Pair } from "../order/types"; import { SharedState } from "../state"; import { Result } from "../common"; -import { extractOracleUrl, fetchSignedContext } from "."; +import { fetchSignedContext } from "."; /** - * If the order has oracle metadata, fetch signed context and inject it + * If the order has an oracle URL, fetch signed context and inject it * into the takeOrder struct. Called with SharedState as `this` to access * the oracle health map. * @@ -14,22 +14,21 @@ export async function fetchOracleContext( this: SharedState, orderDetails: Pair, ): Promise> { - const orderMeta = (orderDetails as any).meta; - if (!orderMeta) return Result.ok(undefined); - - const oracleUrl = extractOracleUrl(orderMeta); + const oracleUrl = orderDetails.oracleUrl; if (!oracleUrl) return Result.ok(undefined); + // Oracle signed context only supported for V4 orders + const order = orderDetails.takeOrder.struct.order; + if (order.type !== Order.Type.V4) return Result.ok(undefined); + const result = await fetchSignedContext( oracleUrl, - [ - { - order: orderDetails.takeOrder.struct.order, - inputIOIndex: orderDetails.takeOrder.struct.inputIOIndex, - outputIOIndex: orderDetails.takeOrder.struct.outputIOIndex, - counterparty: "0x0000000000000000000000000000000000000000", - }, - ], + { + order: order as Order.V4, + inputIOIndex: orderDetails.takeOrder.struct.inputIOIndex, + outputIOIndex: orderDetails.takeOrder.struct.outputIOIndex, + counterparty: "0x0000000000000000000000000000000000000000", + }, this.oracleHealth, ); @@ -37,6 +36,6 @@ export async function fetchOracleContext( return Result.err(result.error); } - orderDetails.takeOrder.struct.signedContext = result.value; + orderDetails.takeOrder.struct.signedContext = [result.value]; return Result.ok(undefined); } diff --git a/src/oracle/index.ts b/src/oracle/index.ts index 1150ab65..c8b2b291 100644 --- a/src/oracle/index.ts +++ b/src/oracle/index.ts @@ -7,24 +7,51 @@ export { fetchOracleContext } from "./fetch"; /** * Extract oracle URL from order meta bytes. * - * TODO: Replace with SDK's RaindexOrder.extractOracleUrl() once the wasm - * package includes it. Pending rain.orderbook PR #2478. + * Searches for the RaindexSignedContextOracleV1 CBOR item identified by + * magic number 0xff7a1507ba4419ca and extracts the URL payload. * * @param metaHex - Hex string of meta bytes (e.g. "0x1234...") * @returns Oracle URL if found, null otherwise */ export function extractOracleUrl(metaHex: string): string | null { - // TODO: Implement CBOR decoding to find RaindexSignedContextOracleV1 - // magic number 0xff7a1507ba4419ca and extract URL. - return null; + if (!metaHex) return null; + const hex = metaHex.startsWith("0x") ? metaHex.slice(2) : metaHex; + + // RaindexSignedContextOracleV1 magic number + const magicHex = "ff7a1507ba4419ca"; + const magicIdx = hex.indexOf(magicHex); + if (magicIdx === -1) return null; + + // The URL is encoded as a CBOR byte string before the magic in the same + // CBOR map: a2 00 58 01 1b + // Find "https://" or "http://" in hex before the magic + const httpsHex = Buffer.from("https://").toString("hex"); + const httpHex = Buffer.from("http://").toString("hex"); + + const searchRegion = hex.substring(0, magicIdx); + let urlStartIdx = searchRegion.lastIndexOf(httpsHex); + if (urlStartIdx === -1) urlStartIdx = searchRegion.lastIndexOf(httpHex); + if (urlStartIdx === -1) return null; + + // URL ends before the "01 1b" marker (CBOR key 1, uint64 prefix) that precedes the magic + const endMarker = "011b"; + const endIdx = searchRegion.lastIndexOf(endMarker); + if (endIdx === -1 || endIdx < urlStartIdx) return null; + + const urlHex = hex.substring(urlStartIdx, endIdx); + try { + return Buffer.from(urlHex, "hex").toString("utf8"); + } catch { + return null; + } } /** * Oracle request entry — mirrors the spec's (OrderV4, uint256, uint256, address) tuple. - * Uses the existing Order.V3 | Order.V4 types from the order module. + * Only V4 orders support oracle signed context. */ export interface OracleOrderRequest { - order: Order.V3 | Order.V4; + order: Order.V4; inputIOIndex: number; outputIOIndex: number; counterparty: `0x${string}`; @@ -75,53 +102,48 @@ export function recordOracleFailure(healthMap: OracleHealthMap, url: string) { // --------------------------------------------------------------------------- /** - * ABI parameter definition for the batch oracle request body. - * Encodes as: abi.encode((OrderV4, uint256, uint256, address)[]) + * ABI parameter definition for a single oracle request body. + * Encodes as: abi.encode(OrderV4, uint256, uint256, address) * * Uses the same struct shape as ABI.Orderbook.V5.OrderV4 / IOV2 / EvaluableV4. */ -const oracleBatchAbiParams = [ +const oracleSingleAbiParams = [ { - type: "tuple[]", + name: "order", + type: "tuple", components: [ + { name: "owner", type: "address" }, { - name: "order", + name: "evaluable", type: "tuple", components: [ - { name: "owner", type: "address" }, - { - name: "evaluable", - type: "tuple", - components: [ - { name: "interpreter", type: "address" }, - { name: "store", type: "address" }, - { name: "bytecode", type: "bytes" }, - ], - }, - { - name: "validInputs", - type: "tuple[]", - components: [ - { name: "token", type: "address" }, - { name: "vaultId", type: "bytes32" }, - ], - }, - { - name: "validOutputs", - type: "tuple[]", - components: [ - { name: "token", type: "address" }, - { name: "vaultId", type: "bytes32" }, - ], - }, - { name: "nonce", type: "bytes32" }, + { name: "interpreter", type: "address" }, + { name: "store", type: "address" }, + { name: "bytecode", type: "bytes" }, + ], + }, + { + name: "validInputs", + type: "tuple[]", + components: [ + { name: "token", type: "address" }, + { name: "vaultId", type: "bytes32" }, + ], + }, + { + name: "validOutputs", + type: "tuple[]", + components: [ + { name: "token", type: "address" }, + { name: "vaultId", type: "bytes32" }, ], }, - { name: "inputIOIndex", type: "uint256" }, - { name: "outputIOIndex", type: "uint256" }, - { name: "counterparty", type: "address" }, + { name: "nonce", type: "bytes32" }, ], }, + { name: "inputIOIndex", type: "uint256" }, + { name: "outputIOIndex", type: "uint256" }, + { name: "counterparty", type: "address" }, ] as const; // --------------------------------------------------------------------------- @@ -129,31 +151,31 @@ const oracleBatchAbiParams = [ // --------------------------------------------------------------------------- /** - * Fetch signed contexts from an oracle endpoint (batch format). + * Fetch signed context from an oracle endpoint (single request format). * - * POSTs abi.encode((OrderV4, uint256, uint256, address)[]) and expects - * a JSON array of SignedContextV1 objects back, matching request length. + * POSTs abi.encode(OrderV4, uint256, uint256, address) and expects + * a JSON SignedContextV1 object back. * * Single attempt with a hard timeout — no retries, no in-loop delays. * Uses the provided health map for cooloff tracking. */ export async function fetchSignedContext( url: string, - orders: OracleOrderRequest[], + request: OracleOrderRequest, healthMap: OracleHealthMap, -): Promise> { +): Promise> { if (isInCooloff(healthMap, url)) { return Result.err(`Oracle ${url} is in cooloff, skipping`); } - const tuples = orders.map((req) => ({ - order: req.order, - inputIOIndex: BigInt(req.inputIOIndex), - outputIOIndex: BigInt(req.outputIOIndex), - counterparty: req.counterparty, - })); - - const encoded = encodeAbiParameters(oracleBatchAbiParams, [tuples]); + // Strip the internal `type` discriminant before ABI encoding + const { type: _type, ...orderStruct } = request.order; + const encoded = encodeAbiParameters(oracleSingleAbiParams, [ + orderStruct, + BigInt(request.inputIOIndex), + BigInt(request.outputIOIndex), + request.counterparty, + ]); const body = hexToBytes(encoded); const controller = new AbortController(); @@ -183,31 +205,16 @@ export async function fetchSignedContext( clearTimeout(timeout); } - if (!Array.isArray(json)) { + // Validate shape of response + if ( + typeof json !== "object" || + json === null || + typeof (json as any).signer !== "string" || + !Array.isArray((json as any).context) || + typeof (json as any).signature !== "string" + ) { recordOracleFailure(healthMap, url); - return Result.err("Oracle response must be an array"); - } - - if (json.length !== orders.length) { - recordOracleFailure(healthMap, url); - return Result.err( - `Oracle response length (${json.length}) does not match request length (${orders.length})`, - ); - } - - // Validate shape of each entry - for (let i = 0; i < json.length; i++) { - const entry = json[i]; - if ( - typeof entry !== "object" || - entry === null || - typeof entry.signer !== "string" || - !Array.isArray(entry.context) || - typeof entry.signature !== "string" - ) { - recordOracleFailure(healthMap, url); - return Result.err(`Oracle response[${i}] is not a valid SignedContextV1`); - } + return Result.err("Oracle response is not a valid SignedContextV1"); } recordOracleSuccess(healthMap, url); diff --git a/src/order/types/index.ts b/src/order/types/index.ts index 6fad1e39..445ec743 100644 --- a/src/order/types/index.ts +++ b/src/order/types/index.ts @@ -144,6 +144,8 @@ export type PairBase = { sellTokenDecimals: number; sellTokenSymbol: string; sellTokenVaultBalance: bigint; + /** Oracle URL extracted from order meta, if present */ + oracleUrl?: string | null; }; export type Pair = PairV3 | PairV4; export namespace Pair { diff --git a/src/order/types/v3.ts b/src/order/types/v3.ts index bd310ddd..c9eccc5d 100644 --- a/src/order/types/v3.ts +++ b/src/order/types/v3.ts @@ -3,6 +3,7 @@ import { SgOrder } from "../../subgraph"; import { ABI, Result } from "../../common"; import { Order, PairBase, TakeOrderDetailsBase } from "."; import { decodeAbiParameters, DecodeAbiParametersErrorType } from "viem"; +import { extractOracleUrl } from "../../oracle"; // these types are used in orderbook v4 @@ -121,6 +122,7 @@ export namespace PairV3 { sellTokenSymbol: outputSymbol, sellTokenDecimals: outputDecimals, sellTokenVaultBalance: BigInt(outputBalance), + oracleUrl: orderDetails.meta ? extractOracleUrl(orderDetails.meta) : null, takeOrder: { id: orderHash, struct: { diff --git a/src/order/types/v4.ts b/src/order/types/v4.ts index d56f16bd..de96880c 100644 --- a/src/order/types/v4.ts +++ b/src/order/types/v4.ts @@ -3,6 +3,7 @@ import { SgOrder, SubgraphVersions } from "../../subgraph"; import { WasmEncodedError } from "@rainlanguage/float"; import { Order, PairBase, TakeOrderDetailsBase } from "."; import { ABI, normalizeFloat, Result } from "../../common"; +import { extractOracleUrl } from "../../oracle"; import { decodeAbiParameters, DecodeAbiParametersErrorType } from "viem"; // these types are used in orderbook v5 @@ -140,6 +141,7 @@ export namespace PairV4 { sellTokenSymbol: outputSymbol, sellTokenDecimals: outputDecimals, sellTokenVaultBalance: outputBalanceRes.value, + oracleUrl: orderDetails.meta ? extractOracleUrl(orderDetails.meta) : null, takeOrder: { id: orderHash, struct: { diff --git a/src/subgraph/types.ts b/src/subgraph/types.ts index 481a8875..d882404e 100644 --- a/src/subgraph/types.ts +++ b/src/subgraph/types.ts @@ -5,6 +5,7 @@ export type SgOrder = { owner: string; orderHash: string; orderBytes: string; + meta?: string; active: boolean; nonce: string; orderbook: { From d983c496ddd83c1e2b93a46af8f499b1c6206f48 Mon Sep 17 00:00:00 2001 From: rouzwelt Date: Fri, 3 Apr 2026 15:08:29 +0000 Subject: [PATCH 11/17] update --- src/common/abis/orderbook.ts | 24 +- src/oracle/error.ts | 35 ++ src/oracle/fetch.test.ts | 830 +++++++++++++++++++++++++++++++++++ src/oracle/fetch.ts | 204 +++++++-- src/oracle/index.test.ts | 103 +++++ src/oracle/index.ts | 248 ++--------- src/oracle/types.ts | 79 ++++ src/order/index.test.ts | 1 + src/order/index.ts | 8 +- src/order/quote.test.ts | 53 ++- src/order/quote.ts | 37 +- src/order/types/index.ts | 2 +- src/order/types/v3.ts | 4 +- src/order/types/v4.ts | 12 +- src/state/index.ts | 3 +- 15 files changed, 1327 insertions(+), 316 deletions(-) create mode 100644 src/oracle/error.ts create mode 100644 src/oracle/fetch.test.ts create mode 100644 src/oracle/index.test.ts create mode 100644 src/oracle/types.ts diff --git a/src/common/abis/orderbook.ts b/src/common/abis/orderbook.ts index c3368b50..fd10d6d5 100644 --- a/src/common/abis/orderbook.ts +++ b/src/common/abis/orderbook.ts @@ -52,16 +52,16 @@ export namespace _v5 { export const Float = "bytes32" as const; export const IOV2 = `(address token, bytes32 vaultId)` as const; export const EvaluableV4 = `(address interpreter, address store, bytes bytecode)` as const; - export const SignedContextV1 = "(address signer, bytes32[] context, bytes signature)" as const; - export const TaskV2 = `(${EvaluableV4} evaluable, ${SignedContextV1}[] signedContext)` as const; + export const SignedContextV2 = "(address signer, bytes32[] context, bytes signature)" as const; + export const TaskV2 = `(${EvaluableV4} evaluable, ${SignedContextV2}[] signedContext)` as const; export const ClearStateChangeV2 = `(${Float} aliceOutput, ${Float} bobOutput, ${Float} aliceInput, ${Float} bobInput)` as const; export const OrderV4 = `(address owner, ${EvaluableV4} evaluable, ${IOV2}[] validInputs, ${IOV2}[] validOutputs, bytes32 nonce)` as const; export const TakeOrderConfigV4 = - `(${OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, ${SignedContextV1}[] signedContext)` as const; + `(${OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, ${SignedContextV2}[] signedContext)` as const; export const QuoteV2 = - `(${OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, ${SignedContextV1}[] signedContext)` as const; + `(${OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, ${SignedContextV2}[] signedContext)` as const; export const TakeOrdersConfigV4 = `(${Float} minimumInput, ${Float} maximumInput, ${Float} maximumIORatio, ${TakeOrderConfigV4}[] orders, bytes data)` as const; export const OrderConfigV4 = @@ -90,7 +90,7 @@ export namespace _v5 { `function addOrder3(${OrderConfigV4} calldata config, ${TaskV2}[] calldata tasks) external returns (bool stateChanged)` as const, `function quote2(${QuoteV2} calldata quoteConfig) external view returns (bool exists, ${Float} outputMax, ${Float} ioRatio)` as const, `function takeOrders3(${TakeOrdersConfigV4} calldata config) external returns (${Float} totalTakerInput, ${Float} totalTakerOutput)` as const, - `function clear3(${OrderV4} memory alice, ${OrderV4} memory bob, ${ClearConfigV2} calldata clearConfig, ${SignedContextV1}[] memory aliceSignedContext, ${SignedContextV1}[] memory bobSignedContext) external` as const, + `function clear3(${OrderV4} memory alice, ${OrderV4} memory bob, ${ClearConfigV2} calldata clearConfig, ${SignedContextV2}[] memory aliceSignedContext, ${SignedContextV2}[] memory bobSignedContext) external` as const, "function multicall(bytes[] calldata data) external returns (bytes[] memory results)", ] as const; export const Arb = [ @@ -105,16 +105,16 @@ export namespace _v6 { export const Float = "bytes32" as const; export const IOV2 = `(address token, bytes32 vaultId)` as const; export const EvaluableV4 = `(address interpreter, address store, bytes bytecode)` as const; - export const SignedContextV1 = "(address signer, bytes32[] context, bytes signature)" as const; - export const TaskV2 = `(${EvaluableV4} evaluable, ${SignedContextV1}[] signedContext)` as const; + export const SignedContextV2 = "(address signer, bytes32[] context, bytes signature)" as const; + export const TaskV2 = `(${EvaluableV4} evaluable, ${SignedContextV2}[] signedContext)` as const; export const ClearStateChangeV2 = `(${Float} aliceOutput, ${Float} bobOutput, ${Float} aliceInput, ${Float} bobInput)` as const; export const OrderV4 = `(address owner, ${EvaluableV4} evaluable, ${IOV2}[] validInputs, ${IOV2}[] validOutputs, bytes32 nonce)` as const; export const TakeOrderConfigV4 = - `(${OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, ${SignedContextV1}[] signedContext)` as const; + `(${OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, ${SignedContextV2}[] signedContext)` as const; export const QuoteV2 = - `(${OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, ${SignedContextV1}[] signedContext)` as const; + `(${OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, ${SignedContextV2}[] signedContext)` as const; export const TakeOrdersConfigV5 = `(${Float} minimumIO, ${Float} maximumIO, ${Float} maximumIORatio, bool IOIsInput, ${TakeOrderConfigV4}[] orders, bytes data)` as const; export const OrderConfigV4 = @@ -143,7 +143,7 @@ export namespace _v6 { `function addOrder4(${OrderConfigV4} calldata config, ${TaskV2}[] calldata tasks) external returns (bool stateChanged)` as const, `function quote2(${QuoteV2} calldata quoteConfig) external view returns (bool exists, ${Float} outputMax, ${Float} ioRatio)` as const, `function takeOrders4(${TakeOrdersConfigV5} calldata config) external returns (${Float} totalTakerInput, ${Float} totalTakerOutput)` as const, - `function clear3(${OrderV4} memory alice, ${OrderV4} memory bob, ${ClearConfigV2} calldata clearConfig, ${SignedContextV1}[] memory aliceSignedContext, ${SignedContextV1}[] memory bobSignedContext) external` as const, + `function clear3(${OrderV4} memory alice, ${OrderV4} memory bob, ${ClearConfigV2} calldata clearConfig, ${SignedContextV2}[] memory aliceSignedContext, ${SignedContextV2}[] memory bobSignedContext) external` as const, "function multicall(bytes[] calldata data) external returns (bytes[] memory results)", ] as const; export const Arb = [ @@ -218,7 +218,7 @@ export namespace OrderbookAbi { export const Float = _v5.Float; export const IO = _v5.IOV2; export const Evaluable = _v5.EvaluableV4; - export const SignedContext = _v5.SignedContextV1; + export const SignedContext = _v5.SignedContextV2; export const Task = _v5.TaskV2; export const ClearStateChange = _v5.ClearStateChangeV2; export const Order = _v5.OrderV4; @@ -264,7 +264,7 @@ export namespace OrderbookAbi { export const Float = _v6.Float; export const IO = _v6.IOV2; export const Evaluable = _v6.EvaluableV4; - export const SignedContext = _v6.SignedContextV1; + export const SignedContext = _v6.SignedContextV2; export const Task = _v6.TaskV2; export const ClearStateChange = _v6.ClearStateChangeV2; export const Order = _v6.OrderV4; diff --git a/src/oracle/error.ts b/src/oracle/error.ts new file mode 100644 index 00000000..e8b4cf47 --- /dev/null +++ b/src/oracle/error.ts @@ -0,0 +1,35 @@ +import { RainSolverBaseError } from "../error"; + +/** Enumerates the possible error types that can occur within the Oracle functionalities */ +export enum OracleErrorType { + UnknownUrl, + Cooloff, + RequestFailed, + FetchError, + InvalidResponseType, +} + +/** + * Represents an error type for the Oracle functionalities. + * This error class extends the `RainSolverBaseError` error class, with the `type` + * property indicates the specific category of the error, as defined by the + * `OracleErrorType` enum. The optional `cause` property can be used to + * attach the original error or any relevant context that led to this error. + * + * @example + * ```typescript + * // without cause + * throw new OracleError("msg", OracleErrorType); + * + * // with cause + * throw new OracleError("msg", OracleErrorType, originalError); + * ``` + */ +export class OracleError extends RainSolverBaseError { + type: OracleErrorType; + constructor(message: string, type: OracleErrorType, cause?: any) { + super(message, cause); + this.type = type; + this.name = "OracleError"; + } +} diff --git a/src/oracle/fetch.test.ts b/src/oracle/fetch.test.ts new file mode 100644 index 00000000..04d1bbc3 --- /dev/null +++ b/src/oracle/fetch.test.ts @@ -0,0 +1,830 @@ +import { OracleErrorType } from "./error"; +import { describe, it, expect, vi, beforeEach, afterEach, assert } from "vitest"; +import { OracleConstants, OracleHealthMap, OracleOrderRequest } from "./types"; +import { + isInCooloff, + extractOracleUrl, + fetchSignedContext, + recordOracleSuccess, + recordOracleFailure, + isValidSignedContextV2, +} from "./fetch"; +import { Order } from "../order"; + +// Mock fetch globally +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +describe("fetchSignedContext", () => { + let healthMap: OracleHealthMap; + const testUrl = "https://oracle.example.com"; + + const mockOrderRequest: OracleOrderRequest = { + order: { + type: Order.Type.V4, + owner: "0x1234567890123456789012345678901234567890", + evaluable: { + interpreter: "0x1234567890123456789012345678901234567890", + store: "0x1234567890123456789012345678901234567890", + bytecode: "0x00", + }, + validInputs: [ + { + token: "0x1234567890123456789012345678901234567890", + vaultId: "0x0000000000000000000000000000000000000000000000000000000000000001", + }, + ], + validOutputs: [ + { + token: "0x1234567890123456789012345678901234567890", + vaultId: "0x0000000000000000000000000000000000000000000000000000000000000001", + }, + ], + nonce: "0x0000000000000000000000000000000000000000000000000000000000000123", + }, + inputIOIndex: 0, + outputIOIndex: 0, + counterparty: "0x1234567890123456789012345678901234567890", + }; + + const validSignedContext = { + signer: "0x000000000000000000000000abcdef1234567890", + context: [ + "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000002", + ], + signature: "0xsignature", + }; + + beforeEach(() => { + healthMap = new Map(); + mockFetch.mockReset(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-03T12:00:00Z")); + + // Mock OracleConstants.isKnown to return true for test URL + vi.spyOn(OracleConstants, "isKnown").mockReturnValue(true); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("returns error when URL is unknown", async () => { + vi.spyOn(OracleConstants, "isKnown").mockReturnValue(false); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.Cooloff); + expect(result.error.message).toContain("unknown"); + }); + + it("returns error when URL is in cooloff", async () => { + healthMap.set(testUrl, { + consecutiveFailures: 5, + cooloffUntil: Date.now() + 60000, + }); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.Cooloff); + expect(result.error.message).toContain("cooloff"); + }); + + it("returns valid SignedContextV2 on successful response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => validSignedContext, + }); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isOk()); + expect(result.value).toEqual(validSignedContext); + }); + + it("records success in health map on valid response", async () => { + healthMap.set(testUrl, { consecutiveFailures: 3, cooloffUntil: 0 }); + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => validSignedContext, + }); + + await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(0); + expect(state?.cooloffUntil).toBe(0); + }); + + it("returns error on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.RequestFailed); + expect(result.error.message).toContain("500"); + }); + + it("records failure in health map on non-ok response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(1); + }); + + it("returns error on fetch exception", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.FetchError); + expect(result.error.message).toContain("Network error"); + }); + + it("records failure in health map on fetch exception", async () => { + mockFetch.mockRejectedValueOnce(new Error("Network error")); + + await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(1); + }); + + it("returns error on invalid response shape", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ invalid: "response" }), + }); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.InvalidResponseType); + }); + + it("records failure in health map on invalid response shape", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ invalid: "response" }), + }); + + await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(1); + }); + + it("sends correct request headers and method", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => validSignedContext, + }); + + await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + expect(mockFetch).toHaveBeenCalledWith( + testUrl, + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + }), + ); + }); + + it("sends body as Uint8Array", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => validSignedContext, + }); + + await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + const callArgs = mockFetch.mock.calls[0][1]; + expect(callArgs.body).toBeInstanceOf(Uint8Array); + }); + + it("handles non-Error exceptions gracefully", async () => { + mockFetch.mockRejectedValueOnce("string error"); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.FetchError); + expect(result.error.message).toContain("string error"); + }); + + it("handles response with missing signer", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + context: ["0x01"], + signature: "0xsig", + }), + }); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.InvalidResponseType); + }); + + it("handles response with missing context", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + signer: "0x1234", + signature: "0xsig", + }), + }); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.InvalidResponseType); + }); + + it("handles response with missing signature", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + signer: "0x1234", + context: ["0x01"], + }), + }); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.InvalidResponseType); + }); + + it("handles 404 response", async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: "Not Found", + }); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.RequestFailed); + expect(result.error.message).toContain("404"); + }); + + it("handles json parsing error", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => { + throw new Error("Invalid JSON"); + }, + }); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.FetchError); + }); + + it("uses abort controller for timeout", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => validSignedContext, + }); + + await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + const callArgs = mockFetch.mock.calls[0][1]; + expect(callArgs.signal).toBeInstanceOf(AbortSignal); + }); + + it("processes expired cooloff correctly", async () => { + // Set expired cooloff + healthMap.set(testUrl, { + consecutiveFailures: 5, + cooloffUntil: Date.now() - 1000, + }); + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => validSignedContext, + }); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isOk()); + }); +}); + +describe("extractOracleUrl", () => { + const endMarker = "011b"; + + const buildMetaHex = (url: string): string => { + const urlHex = Buffer.from(url).toString("hex"); + // Simulates CBOR structure: + return "a200" + urlHex + endMarker + OracleConstants.RaindexSignedContextOracleV1; + }; + + it("returns undefined for empty string", () => { + expect(extractOracleUrl("")).toBeUndefined(); + }); + + it("returns undefined for null/undefined input", () => { + expect(extractOracleUrl(null as any)).toBeUndefined(); + expect(extractOracleUrl(undefined as any)).toBeUndefined(); + }); + + it("returns undefined when magic number is not present", () => { + const urlHex = Buffer.from("https://oracle.example.com").toString("hex"); + expect(extractOracleUrl(urlHex)).toBeUndefined(); + }); + + it("extracts https URL from valid meta hex", () => { + const url = "https://oracle.example.com"; + const metaHex = buildMetaHex(url); + + expect(extractOracleUrl(metaHex)).toBe(url); + }); + + it("extracts http URL from valid meta hex", () => { + const url = "http://oracle.example.com"; + const metaHex = buildMetaHex(url); + + expect(extractOracleUrl(metaHex)).toBe(url); + }); + + it("handles 0x prefix", () => { + const url = "https://oracle.example.com"; + const metaHex = "0x" + buildMetaHex(url); + + expect(extractOracleUrl(metaHex)).toBe(url); + }); + + it("returns undefined when URL protocol is not found", () => { + const noProtocolUrl = "oracle.example.com"; + const urlHex = Buffer.from(noProtocolUrl).toString("hex"); + const metaHex = "a200" + urlHex + endMarker + OracleConstants.RaindexSignedContextOracleV1; + + expect(extractOracleUrl(metaHex)).toBeUndefined(); + }); + + it("returns undefined when end marker is missing", () => { + const url = "https://oracle.example.com"; + const urlHex = Buffer.from(url).toString("hex"); + const metaHex = "a200" + urlHex + OracleConstants.RaindexSignedContextOracleV1; + + expect(extractOracleUrl(metaHex)).toBeUndefined(); + }); + + it("returns undefined when end marker is before URL", () => { + const url = "https://oracle.example.com"; + const urlHex = Buffer.from(url).toString("hex"); + // Place end marker before URL + const metaHex = endMarker + "a200" + urlHex + OracleConstants.RaindexSignedContextOracleV1; + + expect(extractOracleUrl(metaHex)).toBeUndefined(); + }); + + it("extracts URL with path and query parameters", () => { + const url = "https://oracle.example.com/api/v1?key=value"; + const metaHex = buildMetaHex(url); + + expect(extractOracleUrl(metaHex)).toBe(url); + }); + + it("extracts URL with port number", () => { + const url = "https://oracle.example.com:8080/endpoint"; + const metaHex = buildMetaHex(url); + + expect(extractOracleUrl(metaHex)).toBe(url); + }); + + it("handles additional data after magic number", () => { + const url = "https://oracle.example.com"; + const metaHex = buildMetaHex(url) + "deadbeef"; + + expect(extractOracleUrl(metaHex)).toBe(url); + }); + + it("handles additional data before URL", () => { + const url = "https://oracle.example.com"; + const urlHex = Buffer.from(url).toString("hex"); + const metaHex = + "deadbeefa200" + urlHex + endMarker + OracleConstants.RaindexSignedContextOracleV1; + + expect(extractOracleUrl(metaHex)).toBe(url); + }); + + it("uses last occurrence of https when multiple present", () => { + const url1 = "https://first.example.com"; + const url2 = "https://second.example.com"; + const url1Hex = Buffer.from(url1).toString("hex"); + const url2Hex = Buffer.from(url2).toString("hex"); + // Structure with two URLs, should pick the last one before magic + const metaHex = + url1Hex + "a200" + url2Hex + endMarker + OracleConstants.RaindexSignedContextOracleV1; + + expect(extractOracleUrl(metaHex)).toBe(url2); + }); + + it("returns undefined for malformed hex that cannot be decoded", () => { + // Invalid hex characters after URL marker but before end marker + const httpsHex = Buffer.from("https://").toString("hex"); + // Incomplete/invalid URL hex + const metaHex = + "a200" + httpsHex + "zzzz" + endMarker + OracleConstants.RaindexSignedContextOracleV1; + + // This should still find https:// and try to decode, but the result + // will include invalid characters - Buffer.from handles this gracefully + const result = extractOracleUrl(metaHex); + expect(result).toBeDefined(); + expect(result?.startsWith("https://")).toBe(true); + }); + + it("handles real-world CBOR structure", () => { + // Simulating a more realistic CBOR map structure + const url = "https://api.raindex.io/oracle"; + const urlHex = Buffer.from(url).toString("hex"); + const length = (urlHex.length / 2).toString(16).padStart(2, "0"); + // a2 = map with 2 items, 00 = key 0, 58 = byte string with 1-byte length + const metaHex = + "a20058" + length + urlHex + endMarker + OracleConstants.RaindexSignedContextOracleV1; + + expect(extractOracleUrl(metaHex)).toBe(url); + }); +}); + +describe("isInCooloff", () => { + let healthMap: OracleHealthMap; + const testUrl = "https://oracle.example.com"; + + beforeEach(() => { + healthMap = new Map(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-03T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns false for unknown URL", () => { + expect(isInCooloff(healthMap, testUrl)).toBe(false); + }); + + it("returns false when cooloffUntil is 0", () => { + healthMap.set(testUrl, { consecutiveFailures: 5, cooloffUntil: 0 }); + + expect(isInCooloff(healthMap, testUrl)).toBe(false); + }); + + it("returns true when in active cooloff period", () => { + const futureTime = Date.now() + 60000; // 1 minute in the future + healthMap.set(testUrl, { consecutiveFailures: 5, cooloffUntil: futureTime }); + + expect(isInCooloff(healthMap, testUrl)).toBe(true); + }); + + it("returns false and resets cooloff when cooloff period has expired", () => { + const pastTime = Date.now() - 1000; // 1 second in the past + healthMap.set(testUrl, { consecutiveFailures: 5, cooloffUntil: pastTime }); + + expect(isInCooloff(healthMap, testUrl)).toBe(false); + expect(healthMap.get(testUrl)?.cooloffUntil).toBe(0); + }); + + it("returns false and resets cooloff when cooloff period equals current time", () => { + const currentTime = Date.now(); + healthMap.set(testUrl, { consecutiveFailures: 5, cooloffUntil: currentTime }); + + expect(isInCooloff(healthMap, testUrl)).toBe(false); + expect(healthMap.get(testUrl)?.cooloffUntil).toBe(0); + }); + + it("preserves consecutiveFailures when resetting expired cooloff", () => { + const pastTime = Date.now() - 1000; + healthMap.set(testUrl, { consecutiveFailures: 10, cooloffUntil: pastTime }); + + isInCooloff(healthMap, testUrl); + + expect(healthMap.get(testUrl)?.consecutiveFailures).toBe(10); + }); + + it("handles multiple URLs independently", () => { + const url1 = "https://oracle1.example.com"; + const url2 = "https://oracle2.example.com"; + + healthMap.set(url1, { consecutiveFailures: 3, cooloffUntil: Date.now() + 60000 }); + healthMap.set(url2, { consecutiveFailures: 3, cooloffUntil: 0 }); + + expect(isInCooloff(healthMap, url1)).toBe(true); + expect(isInCooloff(healthMap, url2)).toBe(false); + }); + + it("does not modify state when in active cooloff", () => { + const futureTime = Date.now() + 60000; + healthMap.set(testUrl, { consecutiveFailures: 5, cooloffUntil: futureTime }); + + isInCooloff(healthMap, testUrl); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(5); + expect(state?.cooloffUntil).toBe(futureTime); + }); + + it("returns false for state with undefined values treated as fresh", () => { + healthMap.set(testUrl, { consecutiveFailures: 0, cooloffUntil: 0 }); + + expect(isInCooloff(healthMap, testUrl)).toBe(false); + }); +}); + +describe("recordOracleSuccess", () => { + let healthMap: OracleHealthMap; + const testUrl = "https://oracle.example.com"; + + beforeEach(() => { + healthMap = new Map(); + }); + + it("creates new state entry for unknown URL", () => { + recordOracleSuccess(healthMap, testUrl); + + const state = healthMap.get(testUrl); + expect(state).toBeDefined(); + expect(state?.consecutiveFailures).toBe(0); + expect(state?.cooloffUntil).toBe(0); + }); + + it("resets consecutive failures to zero", () => { + healthMap.set(testUrl, { consecutiveFailures: 5, cooloffUntil: 0 }); + + recordOracleSuccess(healthMap, testUrl); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(0); + }); + + it("clears cooloff period", () => { + healthMap.set(testUrl, { + consecutiveFailures: 10, + cooloffUntil: Date.now() + 60000, + }); + + recordOracleSuccess(healthMap, testUrl); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(0); + expect(state?.cooloffUntil).toBe(0); + }); + + it("handles multiple URLs independently", () => { + const url1 = "https://oracle1.example.com"; + const url2 = "https://oracle2.example.com"; + + healthMap.set(url1, { consecutiveFailures: 3, cooloffUntil: 1000 }); + healthMap.set(url2, { consecutiveFailures: 5, cooloffUntil: 2000 }); + + recordOracleSuccess(healthMap, url1); + + expect(healthMap.get(url1)?.consecutiveFailures).toBe(0); + expect(healthMap.get(url1)?.cooloffUntil).toBe(0); + expect(healthMap.get(url2)?.consecutiveFailures).toBe(5); + expect(healthMap.get(url2)?.cooloffUntil).toBe(2000); + }); + + it("overwrites existing state completely", () => { + healthMap.set(testUrl, { + consecutiveFailures: 100, + cooloffUntil: 999999, + }); + + recordOracleSuccess(healthMap, testUrl); + + const state = healthMap.get(testUrl); + expect(state).toEqual({ consecutiveFailures: 0, cooloffUntil: 0 }); + }); + + it("can be called multiple times without side effects", () => { + recordOracleSuccess(healthMap, testUrl); + recordOracleSuccess(healthMap, testUrl); + recordOracleSuccess(healthMap, testUrl); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(0); + expect(state?.cooloffUntil).toBe(0); + }); +}); + +describe("recordOracleFailure", () => { + let healthMap: OracleHealthMap; + const testUrl = "https://oracle.example.com"; + + beforeEach(() => { + healthMap = new Map(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-03T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("creates new state entry for unknown URL", () => { + recordOracleFailure(healthMap, testUrl); + + const state = healthMap.get(testUrl); + expect(state).toBeDefined(); + expect(state?.consecutiveFailures).toBe(1); + expect(state?.cooloffUntil).toBe(0); + }); + + it("increments consecutive failures for existing URL", () => { + healthMap.set(testUrl, { consecutiveFailures: 2, cooloffUntil: 0 }); + + recordOracleFailure(healthMap, testUrl); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(3); + }); + + it("enters cooloff when reaching threshold", () => { + healthMap.set(testUrl, { + consecutiveFailures: OracleConstants.COOLOFF_THRESHOLD - 1, + cooloffUntil: 0, + }); + + recordOracleFailure(healthMap, testUrl); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(OracleConstants.COOLOFF_THRESHOLD); + expect(state?.cooloffUntil).toBe(Date.now() + OracleConstants.COOLOFF_DURATION_MS); + }); + + it("updates cooloff time when exceeding threshold", () => { + healthMap.set(testUrl, { + consecutiveFailures: OracleConstants.COOLOFF_THRESHOLD, + cooloffUntil: 0, + }); + + recordOracleFailure(healthMap, testUrl); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(OracleConstants.COOLOFF_THRESHOLD + 1); + expect(state?.cooloffUntil).toBe(Date.now() + OracleConstants.COOLOFF_DURATION_MS); + }); + + it("does not set cooloff before reaching threshold", () => { + recordOracleFailure(healthMap, testUrl); + recordOracleFailure(healthMap, testUrl); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(2); + expect(state?.cooloffUntil).toBe(0); + }); + + it("handles multiple URLs independently", () => { + const url1 = "https://oracle1.example.com"; + const url2 = "https://oracle2.example.com"; + + recordOracleFailure(healthMap, url1); + recordOracleFailure(healthMap, url1); + recordOracleFailure(healthMap, url2); + + expect(healthMap.get(url1)?.consecutiveFailures).toBe(2); + expect(healthMap.get(url2)?.consecutiveFailures).toBe(1); + }); + + it("preserves existing cooloff time when below threshold after reset", () => { + const existingCooloff = Date.now() + 5000; + healthMap.set(testUrl, { + consecutiveFailures: 1, + cooloffUntil: existingCooloff, + }); + + recordOracleFailure(healthMap, testUrl); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(2); + expect(state?.cooloffUntil).toBe(existingCooloff); + }); +}); + +describe("isValidSignedContextV2", () => { + it("returns true for valid SignedContextV2 object", () => { + const valid = { + signer: "0x1234567890abcdef", + context: ["0x01", "0x02"], + signature: "0xabcdef", + }; + expect(isValidSignedContextV2(valid)).toBe(true); + }); + + it("returns true for valid object with empty context array", () => { + const valid = { + signer: "0x1234", + context: [], + signature: "0xsig", + }; + expect(isValidSignedContextV2(valid)).toBe(true); + }); + + it("returns false for null", () => { + expect(isValidSignedContextV2(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isValidSignedContextV2(undefined)).toBe(false); + }); + + it("returns false for non-object types", () => { + expect(isValidSignedContextV2("string")).toBe(false); + expect(isValidSignedContextV2(123)).toBe(false); + expect(isValidSignedContextV2(true)).toBe(false); + }); + + it("returns false when signer is missing", () => { + const invalid = { + context: ["0x01"], + signature: "0xsig", + }; + expect(isValidSignedContextV2(invalid)).toBe(false); + }); + + it("returns false when signer is not a string", () => { + const invalid = { + signer: 12345, + context: ["0x01"], + signature: "0xsig", + }; + expect(isValidSignedContextV2(invalid)).toBe(false); + }); + + it("returns false when context is missing", () => { + const invalid = { + signer: "0x1234", + signature: "0xsig", + }; + expect(isValidSignedContextV2(invalid)).toBe(false); + }); + + it("returns false when context is not an array", () => { + const invalid = { + signer: "0x1234", + context: "not-an-array", + signature: "0xsig", + }; + expect(isValidSignedContextV2(invalid)).toBe(false); + }); + + it("returns false when signature is missing", () => { + const invalid = { + signer: "0x1234", + context: ["0x01"], + }; + expect(isValidSignedContextV2(invalid)).toBe(false); + }); + + it("returns false when signature is not a string", () => { + const invalid = { + signer: "0x1234", + context: ["0x01"], + signature: 12345, + }; + expect(isValidSignedContextV2(invalid)).toBe(false); + }); + + it("returns true when extra properties are present", () => { + const valid = { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + extraField: "extra", + }; + expect(isValidSignedContextV2(valid)).toBe(true); + }); + + it("returns false for empty object", () => { + expect(isValidSignedContextV2({})).toBe(false); + }); + + it("returns false for array", () => { + expect(isValidSignedContextV2([])).toBe(false); + }); +}); diff --git a/src/oracle/fetch.ts b/src/oracle/fetch.ts index 55fbdddd..60893066 100644 --- a/src/oracle/fetch.ts +++ b/src/oracle/fetch.ts @@ -1,41 +1,183 @@ -import { Order, Pair } from "../order/types"; -import { SharedState } from "../state"; import { Result } from "../common"; -import { fetchSignedContext } from "."; +import { SignedContextV2 } from "../order/types/v4"; +import { OracleError, OracleErrorType } from "./error"; +import { encodeAbiParameters, hexToBytes } from "viem"; +import { + OracleConstants, + OracleHealthMap, + OracleOrderRequest, + OracleSingleAbiParams, +} from "./types"; /** - * If the order has an oracle URL, fetch signed context and inject it - * into the takeOrder struct. Called with SharedState as `this` to access - * the oracle health map. + * Fetch signed context from an oracle endpoint (single request format). * - * Returns Result — callers decide how to handle failures. + * POSTs abi.encode(OrderV4, uint256, uint256, address) and expects + * a JSON SignedContextV1 object back. + * + * Single attempt with a hard timeout — no retries, no in-loop delays. + * Uses the provided health map for cooloff tracking. + * + * @param url - Oracle URL + * @param request - Order to request orcale for + * @param healthMap - Oracle endpoint health tracking for cooloff */ -export async function fetchOracleContext( - this: SharedState, - orderDetails: Pair, -): Promise> { - const oracleUrl = orderDetails.oracleUrl; - if (!oracleUrl) return Result.ok(undefined); - - // Oracle signed context only supported for V4 orders - const order = orderDetails.takeOrder.struct.order; - if (order.type !== Order.Type.V4) return Result.ok(undefined); - - const result = await fetchSignedContext( - oracleUrl, - { - order: order as Order.V4, - inputIOIndex: orderDetails.takeOrder.struct.inputIOIndex, - outputIOIndex: orderDetails.takeOrder.struct.outputIOIndex, - counterparty: "0x0000000000000000000000000000000000000000", - }, - this.oracleHealth, +export async function fetchSignedContext( + url: string, + request: OracleOrderRequest, + healthMap: OracleHealthMap, +): Promise> { + if (!OracleConstants.isKnown(url)) { + return Result.err( + new OracleError(`Oracle ${url} is unknown, skipping`, OracleErrorType.Cooloff), + ); + } + + if (isInCooloff(healthMap, url)) { + return Result.err( + new OracleError(`Oracle ${url} is in cooloff, skipping`, OracleErrorType.Cooloff), + ); + } + + // Strip the internal `type` discriminant before ABI encoding + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { type: _type, ...orderStruct } = request.order; + const encoded = encodeAbiParameters(OracleSingleAbiParams, [ + orderStruct, + BigInt(request.inputIOIndex), + BigInt(request.outputIOIndex), + request.counterparty, + ]); + const body = hexToBytes(encoded); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), OracleConstants.ORACLE_TIMEOUT_MS); + + let json: unknown; + try { + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/octet-stream" }, + body, + signal: controller.signal, + }); + + if (!response.ok) { + recordOracleFailure(healthMap, url); + return Result.err( + new OracleError( + `Oracle request failed: ${response.status} ${response.statusText}`, + OracleErrorType.RequestFailed, + ), + ); + } + + json = await response.json(); + } catch (err) { + recordOracleFailure(healthMap, url); + return Result.err( + new OracleError( + `Oracle fetch error: ${err instanceof Error ? err.message : String(err)}`, + OracleErrorType.FetchError, + ), + ); + } finally { + clearTimeout(timeout); + } + + // Validate shape of response + if (isValidSignedContextV2(json)) { + recordOracleSuccess(healthMap, url); + return Result.ok(json); + } + + recordOracleFailure(healthMap, url); + return Result.err( + new OracleError( + "Oracle response is not a valid SignedContextV2", + OracleErrorType.InvalidResponseType, + ), ); +} + +/** + * Extract oracle URL from order meta bytes. + * + * Searches for the RaindexSignedContextOracleV1 CBOR item identified by + * magic number 0xff7a1507ba4419ca and extracts the URL payload. + * + * @param metaHex - Hex string of meta bytes (e.g. "0x1234...") + * @returns Oracle URL if found, null otherwise + */ +export function extractOracleUrl(metaHex: string): string | undefined { + if (!metaHex) return undefined; + metaHex = metaHex.toLowerCase(); + const hex = metaHex.startsWith("0x") ? metaHex.slice(2) : metaHex; + + const magicIdx = hex.indexOf(OracleConstants.RaindexSignedContextOracleV1); + if (magicIdx === -1) return undefined; + + // The URL is encoded as a CBOR byte string before the magic in the same + // CBOR map: a2 00 58 01 1b + // Find "https://" or "http://" in hex before the magic + const httpsHex = Buffer.from("https://").toString("hex"); + const httpHex = Buffer.from("http://").toString("hex"); - if (result.isErr()) { - return Result.err(result.error); + const searchRegion = hex.substring(0, magicIdx); + let urlStartIdx = searchRegion.lastIndexOf(httpsHex); + if (urlStartIdx === -1) urlStartIdx = searchRegion.lastIndexOf(httpHex); + if (urlStartIdx === -1) return undefined; + + // URL ends before the "01 1b" marker (CBOR key 1, uint64 prefix) that precedes the magic + const endMarker = "011b"; + const endIdx = searchRegion.lastIndexOf(endMarker); + if (endIdx === -1 || endIdx < urlStartIdx) return undefined; + + const urlHex = hex.substring(urlStartIdx, endIdx); + try { + return Buffer.from(urlHex, "hex").toString("utf8"); + } catch { + return undefined; } +} - orderDetails.takeOrder.struct.signedContext = [result.value]; - return Result.ok(undefined); +/** Checks if the given oracle URL is in cooloff period or not */ +export function isInCooloff(healthMap: OracleHealthMap, url: string): boolean { + const state = healthMap.get(url); + if (!state || state.cooloffUntil === 0) return false; + if (Date.now() >= state.cooloffUntil) { + state.cooloffUntil = 0; + return false; + } + return true; +} + +/** Records the sucess in orcale health map */ +export function recordOracleSuccess(healthMap: OracleHealthMap, url: string) { + healthMap.set(url, { consecutiveFailures: 0, cooloffUntil: 0 }); +} + +/** Records the failure in orcale health map */ +export function recordOracleFailure(healthMap: OracleHealthMap, url: string) { + const state = healthMap.get(url) ?? { consecutiveFailures: 0, cooloffUntil: 0 }; + state.consecutiveFailures++; + if (state.consecutiveFailures >= OracleConstants.COOLOFF_THRESHOLD) { + state.cooloffUntil = Date.now() + OracleConstants.COOLOFF_DURATION_MS; + // console.warn( + // `Oracle ${url} entered cooloff for ${COOLOFF_DURATION_MS / 1000}s ` + + // `after ${state.consecutiveFailures} consecutive failures`, + // ); + } + healthMap.set(url, state); +} + +/** Validates if the given value is of SignedContextV2 type */ +export function isValidSignedContextV2(value: any): value is SignedContextV2 { + return !( + typeof value !== "object" || + value === null || + typeof (value as any).signer !== "string" || + !Array.isArray((value as any).context) || + typeof (value as any).signature !== "string" + ); } diff --git a/src/oracle/index.test.ts b/src/oracle/index.test.ts new file mode 100644 index 00000000..5e48ad22 --- /dev/null +++ b/src/oracle/index.test.ts @@ -0,0 +1,103 @@ +import { Result } from "../common"; +import { SharedState } from "../state"; +import { fetchOracleContext } from "./index"; +import { Order, Pair } from "../order/types"; +import { fetchSignedContext } from "./fetch"; +import { OracleError, OracleErrorType } from "./error"; +import { assert, describe, it, expect, vi, beforeEach, Mock } from "vitest"; + +// Mock the fetchSignedContext function +vi.mock("./fetch", () => ({ + fetchSignedContext: vi.fn(), +})); + +describe("fetchOracleContext", () => { + let mockState: SharedState; + let mockOrderDetails: Pair; + + beforeEach(() => { + mockState = { + oracleHealth: new Map(), + } as SharedState; + + mockOrderDetails = { + oracleUrl: "https://example.com", + takeOrder: { + struct: { + order: { + type: Order.Type.V4, + }, + inputIOIndex: 0, + outputIOIndex: 0, + signedContext: [], + }, + }, + } as any; + }); + + it("returns ok when no oracle URL is present", async () => { + mockOrderDetails.oracleUrl = undefined; + const result = await fetchOracleContext.call(mockState, mockOrderDetails); + + assert(result.isOk()); + expect(result.value).toBeUndefined(); + expect(fetchSignedContext as Mock).not.toHaveBeenCalled(); + }); + + it("returns ok when Order V3", async () => { + mockOrderDetails.takeOrder.struct.order.type = Order.Type.V3; + const result = await fetchOracleContext.call(mockState, mockOrderDetails); + + assert(result.isOk()); + expect(result.value).toBeUndefined(); + expect(fetchSignedContext as Mock).not.toHaveBeenCalled(); + }); + + it("returns currectly call fetchSignedContext when Order V4 when it returns error", async () => { + const error = new OracleError("some error", OracleErrorType.FetchError); + (fetchSignedContext as Mock).mockResolvedValueOnce(Result.err(error)); + const result = await fetchOracleContext.call(mockState, mockOrderDetails); + + assert(result.isErr()); + expect(result.error).toEqual(error); + expect(fetchSignedContext as Mock).toHaveBeenNthCalledWith( + 1, + mockOrderDetails.oracleUrl, + { + order: mockOrderDetails.takeOrder.struct.order, + inputIOIndex: mockOrderDetails.takeOrder.struct.inputIOIndex, + outputIOIndex: mockOrderDetails.takeOrder.struct.outputIOIndex, + counterparty: "0x0000000000000000000000000000000000000000", + }, + mockState.oracleHealth, + ); + }); + + it("returns currectly call fetchSignedContext when Order V4 when it returns ok", async () => { + const validSignedContext = { + signer: "0x000000000000000000000000abcdef1234567890", + context: [ + "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000002", + ], + signature: "0xsignature", + }; + (fetchSignedContext as Mock).mockResolvedValueOnce(Result.ok(validSignedContext)); + const result = await fetchOracleContext.call(mockState, mockOrderDetails); + + assert(result.isOk()); + expect(result.value).toBeUndefined(); + expect(fetchSignedContext as Mock).toHaveBeenNthCalledWith( + 1, + mockOrderDetails.oracleUrl, + { + order: mockOrderDetails.takeOrder.struct.order, + inputIOIndex: mockOrderDetails.takeOrder.struct.inputIOIndex, + outputIOIndex: mockOrderDetails.takeOrder.struct.outputIOIndex, + counterparty: "0x0000000000000000000000000000000000000000", + }, + mockState.oracleHealth, + ); + expect(mockOrderDetails.takeOrder.struct.signedContext).toEqual([validSignedContext]); + }); +}); diff --git a/src/oracle/index.ts b/src/oracle/index.ts index c8b2b291..169270da 100644 --- a/src/oracle/index.ts +++ b/src/oracle/index.ts @@ -1,222 +1,42 @@ -import { encodeAbiParameters, hexToBytes } from "viem"; import { Result } from "../common"; -import { Order } from "../order/types"; - -export { fetchOracleContext } from "./fetch"; +import { OracleError } from "./error"; +import { SharedState } from "../state"; +import { Order, Pair } from "../order/types"; +import { fetchSignedContext } from "./fetch"; /** - * Extract oracle URL from order meta bytes. - * - * Searches for the RaindexSignedContextOracleV1 CBOR item identified by - * magic number 0xff7a1507ba4419ca and extracts the URL payload. + * If the order has an oracle URL, fetch signed context and inject it + * into the takeOrder struct. Called with SharedState as `this` to access + * the oracle health map. * - * @param metaHex - Hex string of meta bytes (e.g. "0x1234...") - * @returns Oracle URL if found, null otherwise - */ -export function extractOracleUrl(metaHex: string): string | null { - if (!metaHex) return null; - const hex = metaHex.startsWith("0x") ? metaHex.slice(2) : metaHex; - - // RaindexSignedContextOracleV1 magic number - const magicHex = "ff7a1507ba4419ca"; - const magicIdx = hex.indexOf(magicHex); - if (magicIdx === -1) return null; - - // The URL is encoded as a CBOR byte string before the magic in the same - // CBOR map: a2 00 58 01 1b - // Find "https://" or "http://" in hex before the magic - const httpsHex = Buffer.from("https://").toString("hex"); - const httpHex = Buffer.from("http://").toString("hex"); - - const searchRegion = hex.substring(0, magicIdx); - let urlStartIdx = searchRegion.lastIndexOf(httpsHex); - if (urlStartIdx === -1) urlStartIdx = searchRegion.lastIndexOf(httpHex); - if (urlStartIdx === -1) return null; - - // URL ends before the "01 1b" marker (CBOR key 1, uint64 prefix) that precedes the magic - const endMarker = "011b"; - const endIdx = searchRegion.lastIndexOf(endMarker); - if (endIdx === -1 || endIdx < urlStartIdx) return null; - - const urlHex = hex.substring(urlStartIdx, endIdx); - try { - return Buffer.from(urlHex, "hex").toString("utf8"); - } catch { - return null; - } -} - -/** - * Oracle request entry — mirrors the spec's (OrderV4, uint256, uint256, address) tuple. - * Only V4 orders support oracle signed context. + * @returns Result that callers decide how to handle failures. */ -export interface OracleOrderRequest { - order: Order.V4; - inputIOIndex: number; - outputIOIndex: number; - counterparty: `0x${string}`; -} - -// --------------------------------------------------------------------------- -// Oracle health / cooloff -// --------------------------------------------------------------------------- - -/** Per-request timeout */ -export const ORACLE_TIMEOUT_MS = 5_000; -/** How long to skip a failing oracle (ms) */ -export const COOLOFF_DURATION_MS = 5 * 60 * 1_000; -/** Consecutive failures before entering cooloff */ -export const COOLOFF_THRESHOLD = 3; - -export type OracleHealthMap = Map; - -export function isInCooloff(healthMap: OracleHealthMap, url: string): boolean { - const state = healthMap.get(url); - if (!state || state.cooloffUntil === 0) return false; - if (Date.now() >= state.cooloffUntil) { - state.cooloffUntil = 0; - return false; - } - return true; -} - -export function recordOracleSuccess(healthMap: OracleHealthMap, url: string) { - healthMap.set(url, { consecutiveFailures: 0, cooloffUntil: 0 }); -} - -export function recordOracleFailure(healthMap: OracleHealthMap, url: string) { - const state = healthMap.get(url) ?? { consecutiveFailures: 0, cooloffUntil: 0 }; - state.consecutiveFailures++; - if (state.consecutiveFailures >= COOLOFF_THRESHOLD) { - state.cooloffUntil = Date.now() + COOLOFF_DURATION_MS; - console.warn( - `Oracle ${url} entered cooloff for ${COOLOFF_DURATION_MS / 1000}s ` + - `after ${state.consecutiveFailures} consecutive failures`, - ); - } - healthMap.set(url, state); -} - -// --------------------------------------------------------------------------- -// ABI encoding -// --------------------------------------------------------------------------- - -/** - * ABI parameter definition for a single oracle request body. - * Encodes as: abi.encode(OrderV4, uint256, uint256, address) - * - * Uses the same struct shape as ABI.Orderbook.V5.OrderV4 / IOV2 / EvaluableV4. - */ -const oracleSingleAbiParams = [ - { - name: "order", - type: "tuple", - components: [ - { name: "owner", type: "address" }, - { - name: "evaluable", - type: "tuple", - components: [ - { name: "interpreter", type: "address" }, - { name: "store", type: "address" }, - { name: "bytecode", type: "bytes" }, - ], - }, - { - name: "validInputs", - type: "tuple[]", - components: [ - { name: "token", type: "address" }, - { name: "vaultId", type: "bytes32" }, - ], - }, - { - name: "validOutputs", - type: "tuple[]", - components: [ - { name: "token", type: "address" }, - { name: "vaultId", type: "bytes32" }, - ], - }, - { name: "nonce", type: "bytes32" }, - ], - }, - { name: "inputIOIndex", type: "uint256" }, - { name: "outputIOIndex", type: "uint256" }, - { name: "counterparty", type: "address" }, -] as const; - -// --------------------------------------------------------------------------- -// Fetch -// --------------------------------------------------------------------------- - -/** - * Fetch signed context from an oracle endpoint (single request format). - * - * POSTs abi.encode(OrderV4, uint256, uint256, address) and expects - * a JSON SignedContextV1 object back. - * - * Single attempt with a hard timeout — no retries, no in-loop delays. - * Uses the provided health map for cooloff tracking. - */ -export async function fetchSignedContext( - url: string, - request: OracleOrderRequest, - healthMap: OracleHealthMap, -): Promise> { - if (isInCooloff(healthMap, url)) { - return Result.err(`Oracle ${url} is in cooloff, skipping`); - } - - // Strip the internal `type` discriminant before ABI encoding - const { type: _type, ...orderStruct } = request.order; - const encoded = encodeAbiParameters(oracleSingleAbiParams, [ - orderStruct, - BigInt(request.inputIOIndex), - BigInt(request.outputIOIndex), - request.counterparty, - ]); - const body = hexToBytes(encoded); - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), ORACLE_TIMEOUT_MS); - - let json: unknown; - try { - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/octet-stream" }, - body, - signal: controller.signal, - }); - - if (!response.ok) { - recordOracleFailure(healthMap, url); - return Result.err(`Oracle request failed: ${response.status} ${response.statusText}`); - } - - json = await response.json(); - } catch (err) { - recordOracleFailure(healthMap, url); - return Result.err( - `Oracle fetch error: ${err instanceof Error ? err.message : String(err)}`, - ); - } finally { - clearTimeout(timeout); - } - - // Validate shape of response - if ( - typeof json !== "object" || - json === null || - typeof (json as any).signer !== "string" || - !Array.isArray((json as any).context) || - typeof (json as any).signature !== "string" - ) { - recordOracleFailure(healthMap, url); - return Result.err("Oracle response is not a valid SignedContextV1"); +export async function fetchOracleContext( + this: SharedState, + orderDetails: Pair, +): Promise> { + const oracleUrl = orderDetails.oracleUrl; + if (!oracleUrl) return Result.ok(undefined); + + // Oracle signed context only supported for V4 orders + const order = orderDetails.takeOrder.struct.order; + if (order.type !== Order.Type.V4) return Result.ok(undefined); + + const result = await fetchSignedContext( + oracleUrl, + { + order: order as Order.V4, + inputIOIndex: orderDetails.takeOrder.struct.inputIOIndex, + outputIOIndex: orderDetails.takeOrder.struct.outputIOIndex, + counterparty: "0x0000000000000000000000000000000000000000", + }, + this.oracleHealth, + ); + + if (result.isErr()) { + return Result.err(result.error); } - recordOracleSuccess(healthMap, url); - return Result.ok(json); + orderDetails.takeOrder.struct.signedContext = [result.value]; + return Result.ok(undefined); } diff --git a/src/oracle/types.ts b/src/oracle/types.ts new file mode 100644 index 00000000..1c7b9428 --- /dev/null +++ b/src/oracle/types.ts @@ -0,0 +1,79 @@ +import { Order } from "../order"; + +/** Provides constants and functionalities for interacting with oracles */ +export namespace OracleConstants { + /** RaindexSignedContextOracleV1 magic number */ + export const RaindexSignedContextOracleV1 = "ff7a1507ba4419ca" as const; + + /** Consecutive failures before entering cooloff */ + export const COOLOFF_THRESHOLD = 3 as const; + /** Per-request timeout */ + export const ORACLE_TIMEOUT_MS = 5_000 as const; + /** How long to skip a failing oracle (ms) */ + export const COOLOFF_DURATION_MS = 5 * 60 * 1_000; + + /** List of known oracle URLs */ + export const KnownUrls = ["https://st0x-oracle-server.fly.dev/context"] as const; + + export function isKnown(url: string): boolean { + return KnownUrls.some(url as any); + } +} + +export type OracleHealthMap = Map; + +/** + * Oracle request entry — mirrors the spec's (OrderV4, uint256, uint256, address) tuple. + * Only V4 orders support oracle signed context. + */ +export interface OracleOrderRequest { + order: Order.V4; + inputIOIndex: number; + outputIOIndex: number; + counterparty: `0x${string}`; +} + +/** + * ABI parameter definition for a single oracle request body. + * Encodes as: abi.encode(OrderV4, uint256, uint256, address) + * + * Uses the same struct shape as ABI.Orderbook.V5.OrderV4 / IOV2 / EvaluableV4. + */ +export const OracleSingleAbiParams = [ + { + name: "order", + type: "tuple", + components: [ + { name: "owner", type: "address" }, + { + name: "evaluable", + type: "tuple", + components: [ + { name: "interpreter", type: "address" }, + { name: "store", type: "address" }, + { name: "bytecode", type: "bytes" }, + ], + }, + { + name: "validInputs", + type: "tuple[]", + components: [ + { name: "token", type: "address" }, + { name: "vaultId", type: "bytes32" }, + ], + }, + { + name: "validOutputs", + type: "tuple[]", + components: [ + { name: "token", type: "address" }, + { name: "vaultId", type: "bytes32" }, + ], + }, + { name: "nonce", type: "bytes32" }, + ], + }, + { name: "inputIOIndex", type: "uint256" }, + { name: "outputIOIndex", type: "uint256" }, + { name: "counterparty", type: "address" }, +] as const; diff --git a/src/order/index.test.ts b/src/order/index.test.ts index ba53530e..9a8d11a4 100644 --- a/src/order/index.test.ts +++ b/src/order/index.test.ts @@ -1404,6 +1404,7 @@ describe("Test OrderManager", () => { sellTokenDecimals: 6, buyTokenVaultBalance: 1000n, sellTokenVaultBalance: 2000n, + oracleUrl: undefined, takeOrder: { id: "0xHash", struct: { diff --git a/src/order/index.ts b/src/order/index.ts index 0e961a3a..aeb9051c 100644 --- a/src/order/index.ts +++ b/src/order/index.ts @@ -458,13 +458,7 @@ export class OrderManager { * @param blockNumber - Optional block number for the quote */ async quoteOrder(orderDetails: Pair, blockNumber?: bigint) { - return await quoteSingleOrder( - orderDetails, - this.state.client, - this.state, - blockNumber, - this.quoteGas, - ); + return await quoteSingleOrder(orderDetails, this.state, blockNumber, this.quoteGas); } /** diff --git a/src/order/quote.test.ts b/src/order/quote.test.ts index 1ef678ea..29992a7c 100644 --- a/src/order/quote.test.ts +++ b/src/order/quote.test.ts @@ -1,4 +1,5 @@ import { ChainId } from "sushi"; +import { SharedState } from "../state"; import { BundledOrders, Order, Pair } from "./types"; import { decodeFunctionResult, PublicClient } from "viem"; import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; @@ -18,9 +19,11 @@ vi.mock("./types", async (importOriginal) => ({ })); describe("Test quoteSingleOrder", () => { - const client = { - call: vi.fn().mockResolvedValue({ data: "0x" }), - } as any as PublicClient; + const state = { + client: { + call: vi.fn().mockResolvedValue({ data: "0x" }), + } as any as PublicClient, + } as any as SharedState; beforeEach(() => { vi.clearAllMocks(); @@ -35,13 +38,13 @@ describe("Test quoteSingleOrder", () => { }, }, } as any; - await quoteSingleOrder(orderDetails, client); + await quoteSingleOrder(orderDetails, state); expect(orderDetails.takeOrder.quote).toEqual({ maxOutput: 100n, ratio: 2n, }); - expect(client.call).toHaveBeenCalled(); + expect(state.client.call).toHaveBeenCalled(); }); it("should set quote on the takeOrder when data is returned", async () => { @@ -58,21 +61,23 @@ describe("Test quoteSingleOrder", () => { }, }, } as any; - await quoteSingleOrder(orderDetails, client); + await quoteSingleOrder(orderDetails, state); expect(orderDetails.takeOrder.quote).toEqual({ maxOutput: 100n, ratio: 2n, }); - expect(client.call).toHaveBeenCalled(); + expect(state.client.call).toHaveBeenCalled(); }); }); describe("Test quoteSingleOrderV3", () => { let orderDetails: Pair; - const client = { - call: vi.fn().mockResolvedValueOnce({ data: "0x" }), - } as any as PublicClient; + const state = { + client: { + call: vi.fn().mockResolvedValue({ data: "0x" }), + } as any as PublicClient, + } as any as SharedState; beforeEach(() => { vi.clearAllMocks(); @@ -87,18 +92,18 @@ describe("Test quoteSingleOrderV3", () => { }); it("should set quote on the takeOrder when data is returned", async () => { - await quoteSingleOrderV3(orderDetails, client); + await quoteSingleOrderV3(orderDetails, state); expect(orderDetails.takeOrder.quote).toEqual({ maxOutput: 100n, ratio: 2n, }); - expect(client.call).toHaveBeenCalled(); + expect(state.client.call).toHaveBeenCalled(); }); it("should reject if no data is returned", async () => { - (client.call as Mock).mockResolvedValueOnce({ data: undefined }); - await expect(quoteSingleOrderV3(orderDetails, client)).rejects.toMatch( + (state.client.call as Mock).mockResolvedValueOnce({ data: undefined }); + await expect(quoteSingleOrderV3(orderDetails, state)).rejects.toMatch( /Failed to quote order/, ); }); @@ -106,9 +111,11 @@ describe("Test quoteSingleOrderV3", () => { describe("Test quoteSingleOrderV4", () => { let orderDetails: Pair; - const client = { - call: vi.fn().mockResolvedValue({ data: "0x" }), - } as any as PublicClient; + const state = { + client: { + call: vi.fn().mockResolvedValue({ data: "0x" }), + } as any as PublicClient, + } as any as SharedState; beforeEach(() => { vi.clearAllMocks(); @@ -128,18 +135,18 @@ describe("Test quoteSingleOrderV4", () => { "0xffffffee00000000000000000000000000000000000000000000000000000064", "0xffffffee00000000000000000000000000000000000000000000000000000002", ]); - await quoteSingleOrderV4(orderDetails, client); + await quoteSingleOrderV4(orderDetails, state); expect(orderDetails.takeOrder.quote).toEqual({ maxOutput: 100n, ratio: 2n, }); - expect(client.call).toHaveBeenCalled(); + expect(state.client.call).toHaveBeenCalled(); }); it("should reject if no data is returned", async () => { - (client.call as Mock).mockResolvedValueOnce({ data: undefined }); - await expect(quoteSingleOrderV4(orderDetails, client)).rejects.toMatch( + (state.client.call as Mock).mockResolvedValueOnce({ data: undefined }); + await expect(quoteSingleOrderV4(orderDetails, state)).rejects.toMatch( /Failed to quote order/, ); }); @@ -150,7 +157,7 @@ describe("Test quoteSingleOrderV4", () => { "0xinvalid", "0x0000000000000000000000000000000000000000000000000000000000000001", ]); - await expect(quoteSingleOrderV4(orderDetails, client)).rejects.toContain( + await expect(quoteSingleOrderV4(orderDetails, state)).rejects.toContain( "Invalid hex string", ); }); @@ -161,7 +168,7 @@ describe("Test quoteSingleOrderV4", () => { "0x0000000000000000000000000000000000000000000000000000000000000001", "0xinvalid", ]); - await expect(quoteSingleOrderV4(orderDetails, client)).rejects.toContain( + await expect(quoteSingleOrderV4(orderDetails, state)).rejects.toContain( "Invalid hex string", ); }); diff --git a/src/order/quote.ts b/src/order/quote.ts index c803edea..eff4d63f 100644 --- a/src/order/quote.ts +++ b/src/order/quote.ts @@ -1,10 +1,10 @@ import { ChainId } from "sushi"; import { SharedState } from "../state"; import { AppOptions } from "../config"; +import { fetchOracleContext } from "../oracle"; import { ABI, normalizeFloat } from "../common"; import { BundledOrders, Pair, TakeOrder } from "./types"; -import { decodeFunctionResult, encodeFunctionData, PublicClient } from "viem"; -import { fetchOracleContext } from "../oracle"; +import { decodeFunctionResult, encodeFunctionData } from "viem"; /** * Quotes a single order @@ -16,15 +16,14 @@ import { fetchOracleContext } from "../oracle"; */ export async function quoteSingleOrder( orderDetails: Pair, - viemClient: PublicClient, - state?: SharedState, + state: SharedState, blockNumber?: bigint, gas?: bigint, ) { if (Pair.isV3(orderDetails)) { - return quoteSingleOrderV3(orderDetails, viemClient, state, blockNumber, gas); + return quoteSingleOrderV3(orderDetails, state, blockNumber, gas); } else { - return quoteSingleOrderV4(orderDetails, viemClient, state, blockNumber, gas); + return quoteSingleOrderV4(orderDetails, state, blockNumber, gas); } } @@ -33,19 +32,16 @@ export async function quoteSingleOrder( */ export async function quoteSingleOrderV3( orderDetails: Pair, - viemClient: PublicClient, - state?: SharedState, + state: SharedState, blockNumber?: bigint, gas?: bigint, ) { - if (state) { - const oracleResult = await fetchOracleContext.call(state, orderDetails); - if (oracleResult.isErr()) { - console.warn("Failed to fetch oracle context:", oracleResult.error); - } + const oracleResult = await fetchOracleContext.call(state, orderDetails); + if (oracleResult.isErr()) { + throw oracleResult.error; } - const { data } = await viemClient + const { data } = await state.client .call({ to: orderDetails.orderbook as `0x${string}`, data: encodeFunctionData({ @@ -81,19 +77,16 @@ export async function quoteSingleOrderV3( */ export async function quoteSingleOrderV4( orderDetails: Pair, - viemClient: PublicClient, - state?: SharedState, + state: SharedState, blockNumber?: bigint, gas?: bigint, ) { - if (state) { - const oracleResult = await fetchOracleContext.call(state, orderDetails); - if (oracleResult.isErr()) { - console.warn("Failed to fetch oracle context:", oracleResult.error); - } + const oracleResult = await fetchOracleContext.call(state, orderDetails); + if (oracleResult.isErr()) { + throw oracleResult.error; } - const { data } = await viemClient + const { data } = await state.client .call({ to: orderDetails.orderbook as `0x${string}`, data: encodeFunctionData({ diff --git a/src/order/types/index.ts b/src/order/types/index.ts index 445ec743..04693910 100644 --- a/src/order/types/index.ts +++ b/src/order/types/index.ts @@ -145,7 +145,7 @@ export type PairBase = { sellTokenSymbol: string; sellTokenVaultBalance: bigint; /** Oracle URL extracted from order meta, if present */ - oracleUrl?: string | null; + oracleUrl: string | undefined; }; export type Pair = PairV3 | PairV4; export namespace Pair { diff --git a/src/order/types/v3.ts b/src/order/types/v3.ts index c9eccc5d..d97944dc 100644 --- a/src/order/types/v3.ts +++ b/src/order/types/v3.ts @@ -3,7 +3,7 @@ import { SgOrder } from "../../subgraph"; import { ABI, Result } from "../../common"; import { Order, PairBase, TakeOrderDetailsBase } from "."; import { decodeAbiParameters, DecodeAbiParametersErrorType } from "viem"; -import { extractOracleUrl } from "../../oracle"; +import { extractOracleUrl } from "../../oracle/fetch"; // these types are used in orderbook v4 @@ -122,7 +122,7 @@ export namespace PairV3 { sellTokenSymbol: outputSymbol, sellTokenDecimals: outputDecimals, sellTokenVaultBalance: BigInt(outputBalance), - oracleUrl: orderDetails.meta ? extractOracleUrl(orderDetails.meta) : null, + oracleUrl: orderDetails.meta ? extractOracleUrl(orderDetails.meta) : undefined, takeOrder: { id: orderHash, struct: { diff --git a/src/order/types/v4.ts b/src/order/types/v4.ts index de96880c..a30329c7 100644 --- a/src/order/types/v4.ts +++ b/src/order/types/v4.ts @@ -3,7 +3,7 @@ import { SgOrder, SubgraphVersions } from "../../subgraph"; import { WasmEncodedError } from "@rainlanguage/float"; import { Order, PairBase, TakeOrderDetailsBase } from "."; import { ABI, normalizeFloat, Result } from "../../common"; -import { extractOracleUrl } from "../../oracle"; +import { extractOracleUrl } from "../../oracle/fetch"; import { decodeAbiParameters, DecodeAbiParametersErrorType } from "viem"; // these types are used in orderbook v5 @@ -57,11 +57,17 @@ export namespace V4 { } } +export type SignedContextV2 = { + signer: `0x${string}`; + context: `0x${string}`[]; + signature: `0x${string}`; +}; + export type TakeOrderV4 = { order: V4; inputIOIndex: number; outputIOIndex: number; - signedContext: any[]; + signedContext: SignedContextV2[]; }; export type TakeOrderDetailsV4 = TakeOrderDetailsBase & { @@ -141,7 +147,7 @@ export namespace PairV4 { sellTokenSymbol: outputSymbol, sellTokenDecimals: outputDecimals, sellTokenVaultBalance: outputBalanceRes.value, - oracleUrl: orderDetails.meta ? extractOracleUrl(orderDetails.meta) : null, + oracleUrl: orderDetails.meta ? extractOracleUrl(orderDetails.meta) : undefined, takeOrder: { id: orderHash, struct: { diff --git a/src/state/index.ts b/src/state/index.ts index 087336ae..1517dc4e 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -9,6 +9,7 @@ import { SushiRouter } from "../router/sushi"; import { AddressProvider } from "@balancer/sdk"; import { WalletConfig } from "../wallet/config"; import { Result, TokenDetails } from "../common"; +import { OracleHealthMap } from "../oracle/types"; import { RainSolverRouter } from "../router/router"; import { SubgraphConfig } from "../subgraph/config"; import { RainSolverBaseError } from "../error/types"; @@ -225,7 +226,7 @@ export class SharedState { /** List of latest successful transactions gas costs */ gasCosts: bigint[] = []; /** Oracle endpoint health tracking for cooloff */ - oracleHealth: Map = new Map(); + oracleHealth: OracleHealthMap = new Map(); constructor(config: SharedStateConfig) { this.appOptions = config.appOptions; From 497717cce786facbb4b124ea46aeb5ce47028958 Mon Sep 17 00:00:00 2001 From: rouzwelt Date: Fri, 3 Apr 2026 17:25:57 +0000 Subject: [PATCH 12/17] update --- lib/sushiswap | 2 +- src/oracle/fetch.test.ts | 108 +------------------------------------ src/oracle/fetch.ts | 15 +----- src/oracle/index.test.ts | 4 +- src/oracle/types.ts | 2 +- src/order/types/v4.test.ts | 105 +++++++++++++++++++++++++++++++++++- src/order/types/v4.ts | 13 +++++ 7 files changed, 125 insertions(+), 124 deletions(-) diff --git a/lib/sushiswap b/lib/sushiswap index 1b2b675e..fe4be0e3 160000 --- a/lib/sushiswap +++ b/lib/sushiswap @@ -1 +1 @@ -Subproject commit 1b2b675effe0187889d3de66da2048e711a117e2 +Subproject commit fe4be0e3d02e297e55671dbe545c464a5017a0ba diff --git a/src/oracle/fetch.test.ts b/src/oracle/fetch.test.ts index 04d1bbc3..6141476d 100644 --- a/src/oracle/fetch.test.ts +++ b/src/oracle/fetch.test.ts @@ -1,15 +1,14 @@ +import { Order } from "../order"; import { OracleErrorType } from "./error"; -import { describe, it, expect, vi, beforeEach, afterEach, assert } from "vitest"; import { OracleConstants, OracleHealthMap, OracleOrderRequest } from "./types"; +import { describe, it, expect, vi, beforeEach, afterEach, assert } from "vitest"; import { isInCooloff, extractOracleUrl, fetchSignedContext, recordOracleSuccess, recordOracleFailure, - isValidSignedContextV2, } from "./fetch"; -import { Order } from "../order"; // Mock fetch globally const mockFetch = vi.fn(); @@ -725,106 +724,3 @@ describe("recordOracleFailure", () => { expect(state?.cooloffUntil).toBe(existingCooloff); }); }); - -describe("isValidSignedContextV2", () => { - it("returns true for valid SignedContextV2 object", () => { - const valid = { - signer: "0x1234567890abcdef", - context: ["0x01", "0x02"], - signature: "0xabcdef", - }; - expect(isValidSignedContextV2(valid)).toBe(true); - }); - - it("returns true for valid object with empty context array", () => { - const valid = { - signer: "0x1234", - context: [], - signature: "0xsig", - }; - expect(isValidSignedContextV2(valid)).toBe(true); - }); - - it("returns false for null", () => { - expect(isValidSignedContextV2(null)).toBe(false); - }); - - it("returns false for undefined", () => { - expect(isValidSignedContextV2(undefined)).toBe(false); - }); - - it("returns false for non-object types", () => { - expect(isValidSignedContextV2("string")).toBe(false); - expect(isValidSignedContextV2(123)).toBe(false); - expect(isValidSignedContextV2(true)).toBe(false); - }); - - it("returns false when signer is missing", () => { - const invalid = { - context: ["0x01"], - signature: "0xsig", - }; - expect(isValidSignedContextV2(invalid)).toBe(false); - }); - - it("returns false when signer is not a string", () => { - const invalid = { - signer: 12345, - context: ["0x01"], - signature: "0xsig", - }; - expect(isValidSignedContextV2(invalid)).toBe(false); - }); - - it("returns false when context is missing", () => { - const invalid = { - signer: "0x1234", - signature: "0xsig", - }; - expect(isValidSignedContextV2(invalid)).toBe(false); - }); - - it("returns false when context is not an array", () => { - const invalid = { - signer: "0x1234", - context: "not-an-array", - signature: "0xsig", - }; - expect(isValidSignedContextV2(invalid)).toBe(false); - }); - - it("returns false when signature is missing", () => { - const invalid = { - signer: "0x1234", - context: ["0x01"], - }; - expect(isValidSignedContextV2(invalid)).toBe(false); - }); - - it("returns false when signature is not a string", () => { - const invalid = { - signer: "0x1234", - context: ["0x01"], - signature: 12345, - }; - expect(isValidSignedContextV2(invalid)).toBe(false); - }); - - it("returns true when extra properties are present", () => { - const valid = { - signer: "0x1234", - context: ["0x01"], - signature: "0xsig", - extraField: "extra", - }; - expect(isValidSignedContextV2(valid)).toBe(true); - }); - - it("returns false for empty object", () => { - expect(isValidSignedContextV2({})).toBe(false); - }); - - it("returns false for array", () => { - expect(isValidSignedContextV2([])).toBe(false); - }); -}); diff --git a/src/oracle/fetch.ts b/src/oracle/fetch.ts index 60893066..db12f38c 100644 --- a/src/oracle/fetch.ts +++ b/src/oracle/fetch.ts @@ -13,7 +13,7 @@ import { * Fetch signed context from an oracle endpoint (single request format). * * POSTs abi.encode(OrderV4, uint256, uint256, address) and expects - * a JSON SignedContextV1 object back. + * a JSON SignedContextV2 object back. * * Single attempt with a hard timeout — no retries, no in-loop delays. * Uses the provided health map for cooloff tracking. @@ -86,7 +86,7 @@ export async function fetchSignedContext( } // Validate shape of response - if (isValidSignedContextV2(json)) { + if (SignedContextV2.isValid(json)) { recordOracleSuccess(healthMap, url); return Result.ok(json); } @@ -170,14 +170,3 @@ export function recordOracleFailure(healthMap: OracleHealthMap, url: string) { } healthMap.set(url, state); } - -/** Validates if the given value is of SignedContextV2 type */ -export function isValidSignedContextV2(value: any): value is SignedContextV2 { - return !( - typeof value !== "object" || - value === null || - typeof (value as any).signer !== "string" || - !Array.isArray((value as any).context) || - typeof (value as any).signature !== "string" - ); -} diff --git a/src/oracle/index.test.ts b/src/oracle/index.test.ts index 5e48ad22..c6cbfda8 100644 --- a/src/oracle/index.test.ts +++ b/src/oracle/index.test.ts @@ -53,7 +53,7 @@ describe("fetchOracleContext", () => { expect(fetchSignedContext as Mock).not.toHaveBeenCalled(); }); - it("returns currectly call fetchSignedContext when Order V4 when it returns error", async () => { + it("returns correctly call fetchSignedContext when Order V4 when it returns error", async () => { const error = new OracleError("some error", OracleErrorType.FetchError); (fetchSignedContext as Mock).mockResolvedValueOnce(Result.err(error)); const result = await fetchOracleContext.call(mockState, mockOrderDetails); @@ -73,7 +73,7 @@ describe("fetchOracleContext", () => { ); }); - it("returns currectly call fetchSignedContext when Order V4 when it returns ok", async () => { + it("returns correctly call fetchSignedContext when Order V4 when it returns ok", async () => { const validSignedContext = { signer: "0x000000000000000000000000abcdef1234567890", context: [ diff --git a/src/oracle/types.ts b/src/oracle/types.ts index 1c7b9428..8119e1f0 100644 --- a/src/oracle/types.ts +++ b/src/oracle/types.ts @@ -16,7 +16,7 @@ export namespace OracleConstants { export const KnownUrls = ["https://st0x-oracle-server.fly.dev/context"] as const; export function isKnown(url: string): boolean { - return KnownUrls.some(url as any); + return KnownUrls.includes(url as any); } } diff --git a/src/order/types/v4.test.ts b/src/order/types/v4.test.ts index b71d00bd..7506370c 100644 --- a/src/order/types/v4.test.ts +++ b/src/order/types/v4.test.ts @@ -1,6 +1,6 @@ import assert from "assert"; import { Order, OrderbookVersions } from "./index"; -import { V4, PairV4 } from "./v4"; +import { V4, PairV4, SignedContextV2 } from "./v4"; import { SgOrder } from "../../subgraph"; import { decodeAbiParameters } from "viem"; import { normalizeFloat, Result } from "../../common"; @@ -187,3 +187,106 @@ describe("PairV4.fromArgs", () => { expect(result.error.readableMsg).toContain("failed to normalize"); }); }); + +describe("SignedContextV2.isValid", () => { + it("returns true for valid SignedContextV2 object", () => { + const valid = { + signer: "0x1234567890abcdef", + context: ["0x01", "0x02"], + signature: "0xabcdef", + }; + expect(SignedContextV2.isValid(valid)).toBe(true); + }); + + it("returns true for valid object with empty context array", () => { + const valid = { + signer: "0x1234", + context: [], + signature: "0xsig", + }; + expect(SignedContextV2.isValid(valid)).toBe(true); + }); + + it("returns false for null", () => { + expect(SignedContextV2.isValid(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(SignedContextV2.isValid(undefined)).toBe(false); + }); + + it("returns false for non-object types", () => { + expect(SignedContextV2.isValid("string")).toBe(false); + expect(SignedContextV2.isValid(123)).toBe(false); + expect(SignedContextV2.isValid(true)).toBe(false); + }); + + it("returns false when signer is missing", () => { + const invalid = { + context: ["0x01"], + signature: "0xsig", + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); + + it("returns false when signer is not a string", () => { + const invalid = { + signer: 12345, + context: ["0x01"], + signature: "0xsig", + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); + + it("returns false when context is missing", () => { + const invalid = { + signer: "0x1234", + signature: "0xsig", + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); + + it("returns false when context is not an array", () => { + const invalid = { + signer: "0x1234", + context: "not-an-array", + signature: "0xsig", + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); + + it("returns false when signature is missing", () => { + const invalid = { + signer: "0x1234", + context: ["0x01"], + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); + + it("returns false when signature is not a string", () => { + const invalid = { + signer: "0x1234", + context: ["0x01"], + signature: 12345, + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); + + it("returns true when extra properties are present", () => { + const valid = { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + extraField: "extra", + }; + expect(SignedContextV2.isValid(valid)).toBe(true); + }); + + it("returns false for empty object", () => { + expect(SignedContextV2.isValid({})).toBe(false); + }); + + it("returns false for array", () => { + expect(SignedContextV2.isValid([])).toBe(false); + }); +}); diff --git a/src/order/types/v4.ts b/src/order/types/v4.ts index a30329c7..f8223c05 100644 --- a/src/order/types/v4.ts +++ b/src/order/types/v4.ts @@ -62,6 +62,19 @@ export type SignedContextV2 = { context: `0x${string}`[]; signature: `0x${string}`; }; +export namespace SignedContextV2 { + /** Validates if the given value is of SignedContextV2 type */ + export function isValid(value: any): value is SignedContextV2 { + return !( + typeof value !== "object" || + value === null || + typeof (value as any).signer !== "string" || + !Array.isArray((value as any).context) || + (value as any).context.some((v: any) => typeof v !== "string") || + typeof (value as any).signature !== "string" + ); + } +} export type TakeOrderV4 = { order: V4; From 40bcbe586924f245a91b3babf4f578ba31b109dd Mon Sep 17 00:00:00 2001 From: rouzwelt Date: Fri, 3 Apr 2026 22:35:19 +0000 Subject: [PATCH 13/17] Update order.ts --- src/core/process/order.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/core/process/order.ts b/src/core/process/order.ts index 766fe964..693ae983 100644 --- a/src/core/process/order.ts +++ b/src/core/process/order.ts @@ -64,6 +64,9 @@ export async function processOrder( spanAttributes["details.pair"] = tokenPair; spanAttributes["details.orderbook"] = orderDetails.orderbook; spanAttributes["details.owner"] = orderDetails.takeOrder.struct.order.owner.toLowerCase(); + if (orderDetails.oracleUrl) { + spanAttributes["details.oracle"] = orderDetails.oracleUrl; + } const quoteOrderTime = performance.now(); try { From aad8a8446cc5cafe472e361e4e2af0089c75cfee Mon Sep 17 00:00:00 2001 From: rouzwelt Date: Fri, 10 Apr 2026 13:53:01 +0000 Subject: [PATCH 14/17] update --- src/oracle/fetch.ts | 6 ++---- src/oracle/types.ts | 5 ++++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/oracle/fetch.ts b/src/oracle/fetch.ts index db12f38c..54e75f96 100644 --- a/src/oracle/fetch.ts +++ b/src/oracle/fetch.ts @@ -39,11 +39,8 @@ export async function fetchSignedContext( ); } - // Strip the internal `type` discriminant before ABI encoding - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { type: _type, ...orderStruct } = request.order; const encoded = encodeAbiParameters(OracleSingleAbiParams, [ - orderStruct, + request.order, BigInt(request.inputIOIndex), BigInt(request.outputIOIndex), request.counterparty, @@ -79,6 +76,7 @@ export async function fetchSignedContext( new OracleError( `Oracle fetch error: ${err instanceof Error ? err.message : String(err)}`, OracleErrorType.FetchError, + err, ), ); } finally { diff --git a/src/oracle/types.ts b/src/oracle/types.ts index 8119e1f0..2a96f956 100644 --- a/src/oracle/types.ts +++ b/src/oracle/types.ts @@ -13,7 +13,10 @@ export namespace OracleConstants { export const COOLOFF_DURATION_MS = 5 * 60 * 1_000; /** List of known oracle URLs */ - export const KnownUrls = ["https://st0x-oracle-server.fly.dev/context"] as const; + export const KnownUrls = [ + "https://st0x-oracle-server.fly.dev/context", + "https://st0x-oracle-server.fly.dev/context/v1", + ] as const; export function isKnown(url: string): boolean { return KnownUrls.includes(url as any); From 9ff0cc3f2b98bb88fe67f11d937a627f77681de1 Mon Sep 17 00:00:00 2001 From: rouzwelt Date: Fri, 10 Apr 2026 17:11:12 +0000 Subject: [PATCH 15/17] update --- src/oracle/fetch.test.ts | 337 +++++++++++++++++++++---------- src/oracle/fetch.ts | 74 ++++--- src/oracle/types.ts | 68 +++---- src/order/types/v4.test.ts | 393 +++++++++++++++++++++++++++++-------- src/order/types/v4.ts | 18 +- 5 files changed, 634 insertions(+), 256 deletions(-) diff --git a/src/oracle/fetch.test.ts b/src/oracle/fetch.test.ts index 6141476d..ba866f91 100644 --- a/src/oracle/fetch.test.ts +++ b/src/oracle/fetch.test.ts @@ -1,7 +1,8 @@ import { Order } from "../order"; +import axios, { AxiosError } from "axios"; import { OracleErrorType } from "./error"; import { OracleConstants, OracleHealthMap, OracleOrderRequest } from "./types"; -import { describe, it, expect, vi, beforeEach, afterEach, assert } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach, assert, Mock } from "vitest"; import { isInCooloff, extractOracleUrl, @@ -10,9 +11,18 @@ import { recordOracleFailure, } from "./fetch"; -// Mock fetch globally -const mockFetch = vi.fn(); -vi.stubGlobal("fetch", mockFetch); +// Mock axios +vi.mock("axios", async () => { + const actual = await vi.importActual("axios"); + return { + default: { + ...actual.default, + post: vi.fn(), + isAxiosError: vi.fn(), + }, + AxiosError: actual.AxiosError, + }; +}); describe("fetchSignedContext", () => { let healthMap: OracleHealthMap; @@ -57,19 +67,12 @@ describe("fetchSignedContext", () => { beforeEach(() => { healthMap = new Map(); - mockFetch.mockReset(); - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-04-03T12:00:00Z")); + vi.clearAllMocks(); // Mock OracleConstants.isKnown to return true for test URL vi.spyOn(OracleConstants, "isKnown").mockReturnValue(true); }); - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - it("returns error when URL is unknown", async () => { vi.spyOn(OracleConstants, "isKnown").mockReturnValue(false); @@ -94,9 +97,12 @@ describe("fetchSignedContext", () => { }); it("returns valid SignedContextV2 on successful response", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => validSignedContext, + (axios.post as Mock).mockResolvedValueOnce({ + data: [validSignedContext], + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, }); const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); @@ -107,9 +113,12 @@ describe("fetchSignedContext", () => { it("records success in health map on valid response", async () => { healthMap.set(testUrl, { consecutiveFailures: 3, cooloffUntil: 0 }); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => validSignedContext, + (axios.post as Mock).mockResolvedValueOnce({ + data: [validSignedContext], + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, }); await fetchSignedContext(testUrl, mockOrderRequest, healthMap); @@ -119,26 +128,49 @@ describe("fetchSignedContext", () => { expect(state?.cooloffUntil).toBe(0); }); - it("returns error on non-ok response", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: "Internal Server Error", - }); + it("returns error on response error (500)", async () => { + const axiosError = new AxiosError( + "Request failed with status code 500", + "ERR_BAD_RESPONSE", + {} as any, + {}, + { + status: 500, + statusText: "Internal Server Error", + data: {}, + headers: {}, + config: {} as any, + }, + ); + + (axios.post as Mock).mockRejectedValueOnce(axiosError); + (axios.isAxiosError as any as Mock).mockReturnValue(true); const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); assert(result.isErr()); expect(result.error.type).toBe(OracleErrorType.RequestFailed); expect(result.error.message).toContain("500"); - }); + expect(result.error.message).toContain("Internal Server Error"); + }); + + it("records failure in health map on response error", async () => { + const axiosError = new AxiosError( + "Request failed with status code 500", + "ERR_BAD_RESPONSE", + {} as any, + {}, + { + status: 500, + statusText: "Internal Server Error", + data: {}, + headers: {}, + config: {} as any, + }, + ); - it("records failure in health map on non-ok response", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 500, - statusText: "Internal Server Error", - }); + (axios.post as Mock).mockRejectedValueOnce(axiosError); + (axios.isAxiosError as any as Mock).mockReturnValue(true); await fetchSignedContext(testUrl, mockOrderRequest, healthMap); @@ -146,18 +178,26 @@ describe("fetchSignedContext", () => { expect(state?.consecutiveFailures).toBe(1); }); - it("returns error on fetch exception", async () => { - mockFetch.mockRejectedValueOnce(new Error("Network error")); + it("returns error on network error", async () => { + const axiosError = new AxiosError("Network Error", "ERR_NETWORK", {} as any, {}, undefined); + axiosError.request = {}; + + (axios.post as Mock).mockRejectedValueOnce(axiosError); + (axios.isAxiosError as any as Mock).mockReturnValue(true); const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); assert(result.isErr()); - expect(result.error.type).toBe(OracleErrorType.FetchError); - expect(result.error.message).toContain("Network error"); + expect(result.error.type).toBe(OracleErrorType.RequestFailed); + expect(result.error.message).toContain("Network Error"); }); - it("records failure in health map on fetch exception", async () => { - mockFetch.mockRejectedValueOnce(new Error("Network error")); + it("records failure in health map on network error", async () => { + const axiosError = new AxiosError("Network Error", "ERR_NETWORK", {} as any, {}, undefined); + axiosError.request = {}; + + (axios.post as Mock).mockRejectedValueOnce(axiosError); + (axios.isAxiosError as any as Mock).mockReturnValue(true); await fetchSignedContext(testUrl, mockOrderRequest, healthMap); @@ -166,9 +206,12 @@ describe("fetchSignedContext", () => { }); it("returns error on invalid response shape", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ invalid: "response" }), + (axios.post as Mock).mockResolvedValueOnce({ + data: { invalid: "response" }, + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, }); const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); @@ -178,9 +221,12 @@ describe("fetchSignedContext", () => { }); it("records failure in health map on invalid response shape", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ invalid: "response" }), + (axios.post as Mock).mockResolvedValueOnce({ + data: { invalid: "response" }, + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, }); await fetchSignedContext(testUrl, mockOrderRequest, healthMap); @@ -189,52 +235,67 @@ describe("fetchSignedContext", () => { expect(state?.consecutiveFailures).toBe(1); }); - it("sends correct request headers and method", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => validSignedContext, + it("sends correct request headers", async () => { + (axios.post as Mock).mockResolvedValueOnce({ + data: [validSignedContext], + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, }); await fetchSignedContext(testUrl, mockOrderRequest, healthMap); - expect(mockFetch).toHaveBeenCalledWith( + expect(axios.post).toHaveBeenCalledWith( testUrl, + expect.any(Uint8Array), expect.objectContaining({ - method: "POST", headers: { "Content-Type": "application/octet-stream" }, + timeout: OracleConstants.ORACLE_TIMEOUT_MS, + responseType: "json", }), ); }); it("sends body as Uint8Array", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => validSignedContext, + (axios.post as Mock).mockResolvedValueOnce({ + data: [validSignedContext], + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, }); await fetchSignedContext(testUrl, mockOrderRequest, healthMap); - const callArgs = mockFetch.mock.calls[0][1]; - expect(callArgs.body).toBeInstanceOf(Uint8Array); + const callArgs = (axios.post as Mock).mock.calls[0]; + expect(callArgs[1]).toBeInstanceOf(Uint8Array); }); - it("handles non-Error exceptions gracefully", async () => { - mockFetch.mockRejectedValueOnce("string error"); + it("handles non-AxiosError exceptions gracefully", async () => { + const genericError = new Error("Generic error"); + (axios.post as Mock).mockRejectedValueOnce(genericError); + (axios.isAxiosError as any as Mock).mockReturnValue(false); const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); assert(result.isErr()); expect(result.error.type).toBe(OracleErrorType.FetchError); - expect(result.error.message).toContain("string error"); + expect(result.error.message).toContain("Generic error"); }); it("handles response with missing signer", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - context: ["0x01"], - signature: "0xsig", - }), + (axios.post as Mock).mockResolvedValueOnce({ + data: [ + { + context: ["0x01"], + signature: "0xsig", + }, + ], + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, }); const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); @@ -244,12 +305,17 @@ describe("fetchSignedContext", () => { }); it("handles response with missing context", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - signer: "0x1234", - signature: "0xsig", - }), + (axios.post as Mock).mockResolvedValueOnce({ + data: [ + { + signer: "0x1234", + signature: "0xsig", + }, + ], + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, }); const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); @@ -259,12 +325,17 @@ describe("fetchSignedContext", () => { }); it("handles response with missing signature", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => ({ - signer: "0x1234", - context: ["0x01"], - }), + (axios.post as Mock).mockResolvedValueOnce({ + data: [ + { + signer: "0x1234", + context: ["0x01"], + }, + ], + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, }); const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); @@ -274,11 +345,22 @@ describe("fetchSignedContext", () => { }); it("handles 404 response", async () => { - mockFetch.mockResolvedValueOnce({ - ok: false, - status: 404, - statusText: "Not Found", - }); + const axiosError = new AxiosError( + "Request failed with status code 404", + "ERR_BAD_REQUEST", + {} as any, + {}, + { + status: 404, + statusText: "Not Found", + data: {}, + headers: {}, + config: {} as any, + }, + ); + + (axios.post as Mock).mockRejectedValueOnce(axiosError); + (axios.isAxiosError as any as Mock).mockReturnValue(true); const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); @@ -287,30 +369,29 @@ describe("fetchSignedContext", () => { expect(result.error.message).toContain("404"); }); - it("handles json parsing error", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => { - throw new Error("Invalid JSON"); + it("handles 400 Bad Request response", async () => { + const axiosError = new AxiosError( + "Request failed with status code 400", + "ERR_BAD_REQUEST", + {} as any, + {}, + { + status: 400, + statusText: "Bad Request", + data: { error: "Invalid request body" }, + headers: {}, + config: {} as any, }, - }); + ); + + (axios.post as Mock).mockRejectedValueOnce(axiosError); + (axios.isAxiosError as any as Mock).mockReturnValue(true); const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); assert(result.isErr()); - expect(result.error.type).toBe(OracleErrorType.FetchError); - }); - - it("uses abort controller for timeout", async () => { - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => validSignedContext, - }); - - await fetchSignedContext(testUrl, mockOrderRequest, healthMap); - - const callArgs = mockFetch.mock.calls[0][1]; - expect(callArgs.signal).toBeInstanceOf(AbortSignal); + expect(result.error.type).toBe(OracleErrorType.RequestFailed); + expect(result.error.message).toContain("400"); }); it("processes expired cooloff correctly", async () => { @@ -320,15 +401,67 @@ describe("fetchSignedContext", () => { cooloffUntil: Date.now() - 1000, }); - mockFetch.mockResolvedValueOnce({ - ok: true, - json: async () => validSignedContext, + (axios.post as Mock).mockResolvedValueOnce({ + data: [validSignedContext], + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, }); const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); assert(result.isOk()); }); + + it("handles timeout error", async () => { + const axiosError = new AxiosError( + "timeout of 5000ms exceeded", + "ECONNABORTED", + {} as any, + {}, + undefined, + ); + axiosError.request = {}; + + (axios.post as Mock).mockRejectedValueOnce(axiosError); + (axios.isAxiosError as any as Mock).mockReturnValue(true); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.RequestFailed); + }); + + it("handles cancelled request", async () => { + const axiosError = new AxiosError( + "Request cancelled", + "ERR_CANCELED", + {} as any, + {}, + undefined, + ); + axiosError.request = {}; + + (axios.post as Mock).mockRejectedValueOnce(axiosError); + (axios.isAxiosError as any as Mock).mockReturnValue(true); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.RequestFailed); + }); + + it("handles non-Error string exceptions", async () => { + (axios.post as Mock).mockRejectedValueOnce("string error"); + (axios.isAxiosError as any as Mock).mockReturnValue(false); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.FetchError); + expect(result.error.message).toContain("string error"); + }); }); describe("extractOracleUrl", () => { diff --git a/src/oracle/fetch.ts b/src/oracle/fetch.ts index 54e75f96..a3c1c548 100644 --- a/src/oracle/fetch.ts +++ b/src/oracle/fetch.ts @@ -1,4 +1,5 @@ import { Result } from "../common"; +import axios, { AxiosError } from "axios"; import { SignedContextV2 } from "../order/types/v4"; import { OracleError, OracleErrorType } from "./error"; import { encodeAbiParameters, hexToBytes } from "viem"; @@ -40,53 +41,64 @@ export async function fetchSignedContext( } const encoded = encodeAbiParameters(OracleSingleAbiParams, [ - request.order, - BigInt(request.inputIOIndex), - BigInt(request.outputIOIndex), - request.counterparty, + [ + { + order: request.order, + inputIOIndex: BigInt(request.inputIOIndex), + outputIOIndex: BigInt(request.outputIOIndex), + counterparty: request.counterparty, + }, + ], ]); const body = hexToBytes(encoded); - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), OracleConstants.ORACLE_TIMEOUT_MS); - let json: unknown; try { - const response = await fetch(url, { - method: "POST", - headers: { "Content-Type": "application/octet-stream" }, - body, - signal: controller.signal, + const response = await axios.post(url, body, { + headers: { + "Content-Type": "application/octet-stream", + }, + timeout: OracleConstants.ORACLE_TIMEOUT_MS, + responseType: "json", }); - if (!response.ok) { - recordOracleFailure(healthMap, url); + json = response.data; + } catch (error) { + recordOracleFailure(healthMap, url); + if (axios.isAxiosError(error)) { + const axiosError = error as AxiosError; + if (axiosError.response) { + return Result.err( + new OracleError( + `Oracle request failed with: ${axiosError.response.status} ${axiosError.response.statusText}`, + OracleErrorType.RequestFailed, + axiosError, + ), + ); + } else { + return Result.err( + new OracleError( + `Oracle request failed with msg: ${axiosError.message}`, + OracleErrorType.RequestFailed, + axiosError, + ), + ); + } + } else { return Result.err( new OracleError( - `Oracle request failed: ${response.status} ${response.statusText}`, - OracleErrorType.RequestFailed, + `Oracle fetch error: ${error instanceof Error ? error.message : String(error)}`, + OracleErrorType.FetchError, + error, ), ); } - - json = await response.json(); - } catch (err) { - recordOracleFailure(healthMap, url); - return Result.err( - new OracleError( - `Oracle fetch error: ${err instanceof Error ? err.message : String(err)}`, - OracleErrorType.FetchError, - err, - ), - ); - } finally { - clearTimeout(timeout); } // Validate shape of response - if (SignedContextV2.isValid(json)) { + if (SignedContextV2.isValidList(json)) { recordOracleSuccess(healthMap, url); - return Result.ok(json); + return Result.ok(json[0]); } recordOracleFailure(healthMap, url); diff --git a/src/oracle/types.ts b/src/oracle/types.ts index 2a96f956..de1346ad 100644 --- a/src/oracle/types.ts +++ b/src/oracle/types.ts @@ -13,13 +13,10 @@ export namespace OracleConstants { export const COOLOFF_DURATION_MS = 5 * 60 * 1_000; /** List of known oracle URLs */ - export const KnownUrls = [ - "https://st0x-oracle-server.fly.dev/context", - "https://st0x-oracle-server.fly.dev/context/v1", - ] as const; + export const KnownUrls = ["https://st0x-oracle-server.fly.dev/context"] as const; export function isKnown(url: string): boolean { - return KnownUrls.includes(url as any); + return KnownUrls.some((v) => v.startsWith(url)); } } @@ -38,45 +35,50 @@ export interface OracleOrderRequest { /** * ABI parameter definition for a single oracle request body. - * Encodes as: abi.encode(OrderV4, uint256, uint256, address) + * Encodes as: abi.encode((OrderV4, uint256, uint256, address)[]) * * Uses the same struct shape as ABI.Orderbook.V5.OrderV4 / IOV2 / EvaluableV4. */ export const OracleSingleAbiParams = [ { - name: "order", - type: "tuple", + type: "tuple[]", components: [ - { name: "owner", type: "address" }, { - name: "evaluable", + name: "order", type: "tuple", components: [ - { name: "interpreter", type: "address" }, - { name: "store", type: "address" }, - { name: "bytecode", type: "bytes" }, + { name: "owner", type: "address" }, + { + name: "evaluable", + type: "tuple", + components: [ + { name: "interpreter", type: "address" }, + { name: "store", type: "address" }, + { name: "bytecode", type: "bytes" }, + ], + }, + { + name: "validInputs", + type: "tuple[]", + components: [ + { name: "token", type: "address" }, + { name: "vaultId", type: "bytes32" }, + ], + }, + { + name: "validOutputs", + type: "tuple[]", + components: [ + { name: "token", type: "address" }, + { name: "vaultId", type: "bytes32" }, + ], + }, + { name: "nonce", type: "bytes32" }, ], }, - { - name: "validInputs", - type: "tuple[]", - components: [ - { name: "token", type: "address" }, - { name: "vaultId", type: "bytes32" }, - ], - }, - { - name: "validOutputs", - type: "tuple[]", - components: [ - { name: "token", type: "address" }, - { name: "vaultId", type: "bytes32" }, - ], - }, - { name: "nonce", type: "bytes32" }, + { name: "inputIOIndex", type: "uint256" }, + { name: "outputIOIndex", type: "uint256" }, + { name: "counterparty", type: "address" }, ], }, - { name: "inputIOIndex", type: "uint256" }, - { name: "outputIOIndex", type: "uint256" }, - { name: "counterparty", type: "address" }, ] as const; diff --git a/src/order/types/v4.test.ts b/src/order/types/v4.test.ts index 7506370c..33b14af2 100644 --- a/src/order/types/v4.test.ts +++ b/src/order/types/v4.test.ts @@ -188,105 +188,326 @@ describe("PairV4.fromArgs", () => { }); }); -describe("SignedContextV2.isValid", () => { - it("returns true for valid SignedContextV2 object", () => { - const valid = { - signer: "0x1234567890abcdef", - context: ["0x01", "0x02"], - signature: "0xabcdef", - }; - expect(SignedContextV2.isValid(valid)).toBe(true); - }); +describe("SignedContextV2", () => { + describe("test isValid() function", () => { + it("returns true for valid SignedContextV2 object", () => { + const valid = { + signer: "0x1234567890abcdef", + context: ["0x01", "0x02"], + signature: "0xabcdef", + }; + expect(SignedContextV2.isValid(valid)).toBe(true); + }); - it("returns true for valid object with empty context array", () => { - const valid = { - signer: "0x1234", - context: [], - signature: "0xsig", - }; - expect(SignedContextV2.isValid(valid)).toBe(true); - }); + it("returns true for valid object with empty context array", () => { + const valid = { + signer: "0x1234", + context: [], + signature: "0xsig", + }; + expect(SignedContextV2.isValid(valid)).toBe(true); + }); - it("returns false for null", () => { - expect(SignedContextV2.isValid(null)).toBe(false); - }); + it("returns false for null", () => { + expect(SignedContextV2.isValid(null)).toBe(false); + }); - it("returns false for undefined", () => { - expect(SignedContextV2.isValid(undefined)).toBe(false); - }); + it("returns false for undefined", () => { + expect(SignedContextV2.isValid(undefined)).toBe(false); + }); - it("returns false for non-object types", () => { - expect(SignedContextV2.isValid("string")).toBe(false); - expect(SignedContextV2.isValid(123)).toBe(false); - expect(SignedContextV2.isValid(true)).toBe(false); - }); + it("returns false for non-object types", () => { + expect(SignedContextV2.isValid("string")).toBe(false); + expect(SignedContextV2.isValid(123)).toBe(false); + expect(SignedContextV2.isValid(true)).toBe(false); + }); - it("returns false when signer is missing", () => { - const invalid = { - context: ["0x01"], - signature: "0xsig", - }; - expect(SignedContextV2.isValid(invalid)).toBe(false); - }); + it("returns false when signer is missing", () => { + const invalid = { + context: ["0x01"], + signature: "0xsig", + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); - it("returns false when signer is not a string", () => { - const invalid = { - signer: 12345, - context: ["0x01"], - signature: "0xsig", - }; - expect(SignedContextV2.isValid(invalid)).toBe(false); - }); + it("returns false when signer is not a string", () => { + const invalid = { + signer: 12345, + context: ["0x01"], + signature: "0xsig", + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); - it("returns false when context is missing", () => { - const invalid = { - signer: "0x1234", - signature: "0xsig", - }; - expect(SignedContextV2.isValid(invalid)).toBe(false); - }); + it("returns false when context is missing", () => { + const invalid = { + signer: "0x1234", + signature: "0xsig", + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); - it("returns false when context is not an array", () => { - const invalid = { - signer: "0x1234", - context: "not-an-array", - signature: "0xsig", - }; - expect(SignedContextV2.isValid(invalid)).toBe(false); - }); + it("returns false when context is not an array", () => { + const invalid = { + signer: "0x1234", + context: "not-an-array", + signature: "0xsig", + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); - it("returns false when signature is missing", () => { - const invalid = { - signer: "0x1234", - context: ["0x01"], - }; - expect(SignedContextV2.isValid(invalid)).toBe(false); - }); + it("returns false when signature is missing", () => { + const invalid = { + signer: "0x1234", + context: ["0x01"], + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); - it("returns false when signature is not a string", () => { - const invalid = { - signer: "0x1234", - context: ["0x01"], - signature: 12345, - }; - expect(SignedContextV2.isValid(invalid)).toBe(false); - }); + it("returns false when signature is not a string", () => { + const invalid = { + signer: "0x1234", + context: ["0x01"], + signature: 12345, + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); - it("returns true when extra properties are present", () => { - const valid = { - signer: "0x1234", - context: ["0x01"], - signature: "0xsig", - extraField: "extra", - }; - expect(SignedContextV2.isValid(valid)).toBe(true); - }); + it("returns true when extra properties are present", () => { + const valid = { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + extraField: "extra", + }; + expect(SignedContextV2.isValid(valid)).toBe(true); + }); - it("returns false for empty object", () => { - expect(SignedContextV2.isValid({})).toBe(false); + it("returns false for empty object", () => { + expect(SignedContextV2.isValid({})).toBe(false); + }); + + it("returns false for array", () => { + expect(SignedContextV2.isValid([])).toBe(false); + }); }); - it("returns false for array", () => { - expect(SignedContextV2.isValid([])).toBe(false); + describe("test isValidList() function", () => { + it("returns true for valid array of SignedContextV2 objects", () => { + const validList = [ + { + signer: "0x1234567890abcdef", + context: ["0x01", "0x02"], + signature: "0xabcdef", + }, + { + signer: "0xabcdef1234567890", + context: ["0x03"], + signature: "0x123456", + }, + ]; + expect(SignedContextV2.isValidList(validList)).toBe(true); + }); + + it("returns true for single item array", () => { + const validList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + ]; + expect(SignedContextV2.isValidList(validList)).toBe(true); + }); + + it("returns true for empty array", () => { + expect(SignedContextV2.isValidList([])).toBe(true); + }); + + it("returns true for array with empty context arrays", () => { + const validList = [ + { + signer: "0x1234", + context: [], + signature: "0xsig", + }, + { + signer: "0x5678", + context: [], + signature: "0xsig2", + }, + ]; + expect(SignedContextV2.isValidList(validList)).toBe(true); + }); + + it("returns false for null", () => { + expect(SignedContextV2.isValidList(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(SignedContextV2.isValidList(undefined)).toBe(false); + }); + + it("returns false for non-array types", () => { + expect(SignedContextV2.isValidList("string")).toBe(false); + expect(SignedContextV2.isValidList(123)).toBe(false); + expect(SignedContextV2.isValidList(true)).toBe(false); + }); + + it("returns false for object instead of array", () => { + const obj = { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }; + expect(SignedContextV2.isValidList(obj)).toBe(false); + }); + + it("returns false when array contains invalid item (missing signer)", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + { + context: ["0x02"], + signature: "0xsig2", + }, + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); + + it("returns false when array contains invalid item (missing context)", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + { + signer: "0x5678", + signature: "0xsig2", + }, + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); + + it("returns false when array contains invalid item (missing signature)", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + { + signer: "0x5678", + context: ["0x02"], + }, + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); + + it("returns false when array contains non-object item", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + "not an object", + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); + + it("returns false when array contains null item", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + null, + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); + + it("returns false when array contains item with invalid signer type", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + { + signer: 12345, + context: ["0x02"], + signature: "0xsig2", + }, + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); + + it("returns false when array contains item with invalid context type", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + { + signer: "0x5678", + context: "not-an-array", + signature: "0xsig2", + }, + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); + + it("returns false when array contains item with invalid signature type", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + { + signer: "0x5678", + context: ["0x02"], + signature: 12345, + }, + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); + + it("returns true when all items have extra properties", () => { + const validList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + extra: "field1", + }, + { + signer: "0x5678", + context: ["0x02"], + signature: "0xsig2", + another: "field2", + }, + ]; + expect(SignedContextV2.isValidList(validList)).toBe(true); + }); + + it("returns false when first item is valid but second is invalid", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + {}, + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); }); }); diff --git a/src/order/types/v4.ts b/src/order/types/v4.ts index f8223c05..b5efaedd 100644 --- a/src/order/types/v4.ts +++ b/src/order/types/v4.ts @@ -63,15 +63,25 @@ export type SignedContextV2 = { signature: `0x${string}`; }; export namespace SignedContextV2 { + /** Validates if the given value is a list of SignedContextV2 type */ + export function isValidList(value: any): value is SignedContextV2[] { + return !( + typeof value !== "object" || + value === null || + !Array.isArray(value) || + value.some((v) => !isValid(v)) + ); + } + /** Validates if the given value is of SignedContextV2 type */ export function isValid(value: any): value is SignedContextV2 { return !( typeof value !== "object" || value === null || - typeof (value as any).signer !== "string" || - !Array.isArray((value as any).context) || - (value as any).context.some((v: any) => typeof v !== "string") || - typeof (value as any).signature !== "string" + typeof value.signer !== "string" || + !Array.isArray(value.context) || + value.context.some((v: any) => typeof v !== "string") || + typeof value.signature !== "string" ); } } From 15cec04b95385f382ab350aeb22e897e1328906b Mon Sep 17 00:00:00 2001 From: rouzwelt Date: Fri, 10 Apr 2026 17:28:16 +0000 Subject: [PATCH 16/17] fix --- src/oracle/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/oracle/types.ts b/src/oracle/types.ts index de1346ad..1fc0cd8c 100644 --- a/src/oracle/types.ts +++ b/src/oracle/types.ts @@ -16,7 +16,7 @@ export namespace OracleConstants { export const KnownUrls = ["https://st0x-oracle-server.fly.dev/context"] as const; export function isKnown(url: string): boolean { - return KnownUrls.some((v) => v.startsWith(url)); + return KnownUrls.some((v) => url.startsWith(v)); } } From 9d197bc3018132001c1c54e12a8069cf2f467a11 Mon Sep 17 00:00:00 2001 From: rouzwelt Date: Sat, 11 Apr 2026 00:28:56 +0000 Subject: [PATCH 17/17] upd --- src/oracle/fetch.test.ts | 6 ++-- src/oracle/fetch.ts | 68 +++++++++++++++++----------------------- 2 files changed, 31 insertions(+), 43 deletions(-) diff --git a/src/oracle/fetch.test.ts b/src/oracle/fetch.test.ts index ba866f91..65752b25 100644 --- a/src/oracle/fetch.test.ts +++ b/src/oracle/fetch.test.ts @@ -188,7 +188,7 @@ describe("fetchSignedContext", () => { const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); assert(result.isErr()); - expect(result.error.type).toBe(OracleErrorType.RequestFailed); + expect(result.error.type).toBe(OracleErrorType.FetchError); expect(result.error.message).toContain("Network Error"); }); @@ -430,7 +430,7 @@ describe("fetchSignedContext", () => { const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); assert(result.isErr()); - expect(result.error.type).toBe(OracleErrorType.RequestFailed); + expect(result.error.type).toBe(OracleErrorType.FetchError); }); it("handles cancelled request", async () => { @@ -449,7 +449,7 @@ describe("fetchSignedContext", () => { const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); assert(result.isErr()); - expect(result.error.type).toBe(OracleErrorType.RequestFailed); + expect(result.error.type).toBe(OracleErrorType.FetchError); }); it("handles non-Error string exceptions", async () => { diff --git a/src/oracle/fetch.ts b/src/oracle/fetch.ts index a3c1c548..2c3dca39 100644 --- a/src/oracle/fetch.ts +++ b/src/oracle/fetch.ts @@ -1,5 +1,5 @@ +import axios from "axios"; import { Result } from "../common"; -import axios, { AxiosError } from "axios"; import { SignedContextV2 } from "../order/types/v4"; import { OracleError, OracleErrorType } from "./error"; import { encodeAbiParameters, hexToBytes } from "viem"; @@ -52,7 +52,6 @@ export async function fetchSignedContext( ]); const body = hexToBytes(encoded); - let json: unknown; try { const response = await axios.post(url, body, { headers: { @@ -62,52 +61,41 @@ export async function fetchSignedContext( responseType: "json", }); - json = response.data; - } catch (error) { - recordOracleFailure(healthMap, url); - if (axios.isAxiosError(error)) { - const axiosError = error as AxiosError; - if (axiosError.response) { - return Result.err( - new OracleError( - `Oracle request failed with: ${axiosError.response.status} ${axiosError.response.statusText}`, - OracleErrorType.RequestFailed, - axiosError, - ), - ); - } else { - return Result.err( - new OracleError( - `Oracle request failed with msg: ${axiosError.message}`, - OracleErrorType.RequestFailed, - axiosError, - ), - ); - } + // Validate shape of response + if (SignedContextV2.isValidList(response.data)) { + recordOracleSuccess(healthMap, url); + return Result.ok(response.data[0]); } else { + recordOracleFailure(healthMap, url); return Result.err( new OracleError( - `Oracle fetch error: ${error instanceof Error ? error.message : String(error)}`, - OracleErrorType.FetchError, - error, + "Oracle response is not a valid SignedContextV2 list", + OracleErrorType.InvalidResponseType, + response.data, ), ); } - } + } catch (err) { + recordOracleFailure(healthMap, url); - // Validate shape of response - if (SignedContextV2.isValidList(json)) { - recordOracleSuccess(healthMap, url); - return Result.ok(json[0]); - } + // default error if not AxiosError type + let error = new OracleError( + `Oracle fetch error: ${err instanceof Error ? err.message : String(err)}`, + OracleErrorType.FetchError, + err, + ); - recordOracleFailure(healthMap, url); - return Result.err( - new OracleError( - "Oracle response is not a valid SignedContextV2", - OracleErrorType.InvalidResponseType, - ), - ); + if (axios.isAxiosError(err)) { + if (err.response) { + error = new OracleError( + `Oracle request failed with: ${err.response.status} ${err.response.statusText}`, + OracleErrorType.RequestFailed, + err, + ); + } + } + return Result.err(error); + } } /**