Skip to content
42 changes: 42 additions & 0 deletions src/oracle/fetch.ts
Original file line number Diff line number Diff line change
@@ -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<Result<void, string>> {
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);
}
215 changes: 215 additions & 0 deletions src/oracle/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, { consecutiveFailures: number; cooloffUntil: number }>;

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<Result<any[], string>> {
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);
}
8 changes: 7 additions & 1 deletion src/order/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}

/**
Expand Down
31 changes: 21 additions & 10 deletions src/order/quote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down Expand Up @@ -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}`,
Expand Down
2 changes: 2 additions & 0 deletions src/state/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { consecutiveFailures: number; cooloffUntil: number }> = new Map();

constructor(config: SharedStateConfig) {
this.appOptions = config.appOptions;
Expand Down
3 changes: 3 additions & 0 deletions src/subgraph/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function getQueryPaginated(skip: number, filters?: SgFilter): string {
owner
orderHash
orderBytes
meta
active
nonce
orderbook {
Expand Down Expand Up @@ -108,6 +109,7 @@ export const getTxsQuery = (startTimestamp: number, skip: number, endTimestamp?:
owner
orderHash
orderBytes
meta
active
nonce
orderbook {
Expand Down Expand Up @@ -142,6 +144,7 @@ export const getTxsQuery = (startTimestamp: number, skip: number, endTimestamp?:
owner
orderHash
orderBytes
meta
active
nonce
orderbook {
Expand Down
Loading