From 9fd313deeb93fc5c8ba402b6b957cd01ec233882 Mon Sep 17 00:00:00 2001 From: Josh Hardy Date: Mon, 23 Feb 2026 20:21:59 +0000 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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); }