diff --git a/src/oracle/fetch.ts b/src/oracle/fetch.ts new file mode 100644 index 00000000..ed4dc57f --- /dev/null +++ b/src/oracle/fetch.ts @@ -0,0 +1,42 @@ +import { Pair } from "../order/types"; +import { SharedState } from "../state"; +import { Result } from "../common"; +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. + * + * Returns Result — callers decide how to handle failures. + */ +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); + if (!oracleUrl) 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", + }, + ], + this.oracleHealth, + ); + + 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 new file mode 100644 index 00000000..1150ab65 --- /dev/null +++ b/src/oracle/index.ts @@ -0,0 +1,215 @@ +import { encodeAbiParameters, hexToBytes } from "viem"; +import { Result } from "../common"; +import { Order } from "../order/types"; + +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. + * + * @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; +} + +/** + * 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: Order.V3 | 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 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. + */ +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 +// --------------------------------------------------------------------------- + +/** + * 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. + * + * 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[], + healthMap: OracleHealthMap, +): 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]); + 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); + } + + if (!Array.isArray(json)) { + 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`); + } + } + + recordOracleSuccess(healthMap, url); + return Result.ok(json); +} diff --git a/src/order/index.ts b/src/order/index.ts index ac765c0e..0e961a3a 100644 --- a/src/order/index.ts +++ b/src/order/index.ts @@ -458,7 +458,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, + this.state, + blockNumber, + this.quoteGas, + ); } /** diff --git a/src/order/quote.ts b/src/order/quote.ts index 0504f33f..c803edea 100644 --- a/src/order/quote.ts +++ b/src/order/quote.ts @@ -4,40 +4,47 @@ import { AppOptions } from "../config"; import { ABI, normalizeFloat } from "../common"; import { BundledOrders, Pair, TakeOrder } from "./types"; import { decodeFunctionResult, encodeFunctionData, PublicClient } from "viem"; +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, ) { if (Pair.isV3(orderDetails)) { - return quoteSingleOrderV3(orderDetails, viemClient, blockNumber, gas); + return quoteSingleOrderV3(orderDetails, viemClient, state, blockNumber, gas); } else { - return quoteSingleOrderV4(orderDetails, viemClient, blockNumber, gas); + 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, ) { + if (state) { + const oracleResult = await fetchOracleContext.call(state, orderDetails); + if (oracleResult.isErr()) { + console.warn("Failed to fetch oracle context:", oracleResult.error); + } + } + const { data } = await viemClient .call({ to: orderDetails.orderbook as `0x${string}`, @@ -71,17 +78,21 @@ 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, ) { + if (state) { + const oracleResult = await fetchOracleContext.call(state, orderDetails); + if (oracleResult.isErr()) { + console.warn("Failed to fetch oracle context:", oracleResult.error); + } + } + const { data } = await viemClient .call({ to: orderDetails.orderbook as `0x${string}`, 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; 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 {