Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/core/process/order.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export async function processOrder(

const quoteOrderTime = performance.now();
try {
await this.orderManager.quoteOrder(orderDetails);
await this.orderManager.quoteOrder(orderDetails, dataFetcherBlockNumber);
if (orderDetails.takeOrder.quote?.maxOutput === 0n) {
// remove from pair maps if quote fails, to keep the pair map list free
// of orders with 0 maxoutput this will make counterparty lookups faster
Expand Down
197 changes: 123 additions & 74 deletions src/oracle/fetch.test.ts

Large diffs are not rendered by default.

39 changes: 23 additions & 16 deletions src/oracle/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,16 @@ export async function fetchSignedContext(
url: string,
request: OracleOrderRequest,
healthMap: OracleHealthMap,
isMaxOwnerProfile?: boolean,
): Promise<Result<SignedContextV2, OracleError>> {
if (!OracleConstants.isKnown(url)) {
return Result.err(
new OracleError(`Oracle ${url} is unknown, skipping`, OracleErrorType.Cooloff),
);
}

if (isInCooloff(healthMap, url)) {
const owner = request.order.owner.toLowerCase();
if (isInCooloff(healthMap, url, owner)) {
return Result.err(
new OracleError(`Oracle ${url} is in cooloff, skipping`, OracleErrorType.Cooloff),
);
Expand Down Expand Up @@ -63,10 +65,10 @@ export async function fetchSignedContext(

// Validate shape of response
if (SignedContextV2.isValidList(response.data)) {
recordOracleSuccess(healthMap, url);
recordOracleSuccess(healthMap, url, owner);
return Result.ok(response.data[0]);
} else {
recordOracleFailure(healthMap, url);
recordOracleFailure(healthMap, url, owner, isMaxOwnerProfile);
return Result.err(
new OracleError(
"Oracle response is not a valid SignedContextV2 list",
Expand All @@ -76,7 +78,7 @@ export async function fetchSignedContext(
);
}
} catch (err) {
recordOracleFailure(healthMap, url);
recordOracleFailure(healthMap, url, owner, isMaxOwnerProfile);

// default error if not AxiosError type
let error = new OracleError(
Expand Down Expand Up @@ -140,8 +142,8 @@ export function extractOracleUrl(metaHex: string): string | undefined {
}

/** Checks if the given oracle URL is in cooloff period or not */
export function isInCooloff(healthMap: OracleHealthMap, url: string): boolean {
const state = healthMap.get(url);
export function isInCooloff(healthMap: OracleHealthMap, url: string, owner: string): boolean {
const state = healthMap.get(OracleHealthMap.key(url, owner));
if (!state || state.cooloffUntil === 0) return false;
if (Date.now() >= state.cooloffUntil) {
state.cooloffUntil = 0;
Expand All @@ -151,20 +153,25 @@ export function isInCooloff(healthMap: OracleHealthMap, url: string): boolean {
}

/** Records the sucess in orcale health map */
export function recordOracleSuccess(healthMap: OracleHealthMap, url: string) {
healthMap.set(url, { consecutiveFailures: 0, cooloffUntil: 0 });
export function recordOracleSuccess(healthMap: OracleHealthMap, url: string, owner: string) {
const state = OracleHealthMap.getOrCreate(healthMap, url, owner);
state.consecutiveFailures = 0;
state.cooloffUntil = 0;
}

/** Records the failure in orcale health map */
export function recordOracleFailure(healthMap: OracleHealthMap, url: string) {
const state = healthMap.get(url) ?? { consecutiveFailures: 0, cooloffUntil: 0 };
export function recordOracleFailure(
healthMap: OracleHealthMap,
url: string,
owner: string,
isMaxOwnerProfile?: boolean,
) {
const cooloffDuration = isMaxOwnerProfile
? OracleConstants.COOLOFF_MAX_PROFILE_OWNER
: OracleConstants.COOLOFF_DURATION_MS;
const state = OracleHealthMap.getOrCreate(healthMap, url, owner);
state.consecutiveFailures++;
if (state.consecutiveFailures >= OracleConstants.COOLOFF_THRESHOLD) {
state.cooloffUntil = Date.now() + OracleConstants.COOLOFF_DURATION_MS;
// console.warn(
// `Oracle ${url} entered cooloff for ${COOLOFF_DURATION_MS / 1000}s ` +
// `after ${state.consecutiveFailures} consecutive failures`,
// );
state.cooloffUntil = Date.now() + cooloffDuration;
}
healthMap.set(url, state);
}
115 changes: 115 additions & 0 deletions src/oracle/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,23 @@ describe("fetchOracleContext", () => {
let mockState: SharedState;
let mockOrderDetails: Pair;

const testOwner = "0x1234567890123456789012345678901234567890";

beforeEach(() => {
vi.clearAllMocks();
mockState = {
oracleHealth: new Map(),
appOptions: {},
} as SharedState;

mockOrderDetails = {
oracleUrl: "https://example.com",
takeOrder: {
id: "0xOrderHash",
struct: {
order: {
type: Order.Type.V4,
owner: testOwner,
},
inputIOIndex: 0,
outputIOIndex: 0,
Expand Down Expand Up @@ -70,6 +76,7 @@ describe("fetchOracleContext", () => {
counterparty: "0x0000000000000000000000000000000000000000",
},
mockState.oracleHealth,
false,
);
});

Expand Down Expand Up @@ -97,7 +104,115 @@ describe("fetchOracleContext", () => {
counterparty: "0x0000000000000000000000000000000000000000",
},
mockState.oracleHealth,
false,
);
expect(mockOrderDetails.takeOrder.struct.signedContext).toEqual([validSignedContext]);
});

it("returns cached result for unchanged block number without refetching", async () => {
const validSignedContext = {
signer: "0x000000000000000000000000abcdef1234567890",
context: ["0x01"],
signature: "0xsignature",
};
(fetchSignedContext as Mock).mockResolvedValue(Result.ok(validSignedContext));

// first call fetches and caches
const result1 = await fetchOracleContext.call(mockState, mockOrderDetails, 100n);
assert(result1.isOk());
expect(fetchSignedContext as Mock).toHaveBeenCalledTimes(1);

// second call with same block number hits the cache
mockOrderDetails.takeOrder.struct.signedContext = [];
const result2 = await fetchOracleContext.call(mockState, mockOrderDetails, 100n);
assert(result2.isOk());
expect(fetchSignedContext as Mock).toHaveBeenCalledTimes(1);
expect(mockOrderDetails.takeOrder.struct.signedContext).toEqual([validSignedContext]);
});

it("caches independently per IO indexes of the same order", async () => {
const validSignedContext = {
signer: "0x000000000000000000000000abcdef1234567890",
context: ["0x01"],
signature: "0xsignature",
};
(fetchSignedContext as Mock).mockResolvedValue(Result.ok(validSignedContext));

// first call for pair with IO indexes 0/0 fetches and caches
await fetchOracleContext.call(mockState, mockOrderDetails, 100n);
expect(fetchSignedContext as Mock).toHaveBeenCalledTimes(1);

// same order hash with different IO indexes at same block fetches again
mockOrderDetails.takeOrder.struct.inputIOIndex = 1;
mockOrderDetails.takeOrder.struct.outputIOIndex = 2;
await fetchOracleContext.call(mockState, mockOrderDetails, 100n);
expect(fetchSignedContext as Mock).toHaveBeenCalledTimes(2);

// both combinations now hit their own cache at the same block
await fetchOracleContext.call(mockState, mockOrderDetails, 100n);
mockOrderDetails.takeOrder.struct.inputIOIndex = 0;
mockOrderDetails.takeOrder.struct.outputIOIndex = 0;
await fetchOracleContext.call(mockState, mockOrderDetails, 100n);
expect(fetchSignedContext as Mock).toHaveBeenCalledTimes(2);
});

it("fetches again when block number changes", async () => {
const validSignedContext = {
signer: "0x000000000000000000000000abcdef1234567890",
context: ["0x01"],
signature: "0xsignature",
};
(fetchSignedContext as Mock).mockResolvedValue(Result.ok(validSignedContext));

await fetchOracleContext.call(mockState, mockOrderDetails, 100n);
await fetchOracleContext.call(mockState, mockOrderDetails, 101n);

expect(fetchSignedContext as Mock).toHaveBeenCalledTimes(2);
});

it("returns cached error for unchanged block number without refetching", async () => {
const error = new OracleError("some error", OracleErrorType.FetchError);
(fetchSignedContext as Mock).mockResolvedValue(Result.err(error));

const result1 = await fetchOracleContext.call(mockState, mockOrderDetails, 100n);
assert(result1.isErr());

const result2 = await fetchOracleContext.call(mockState, mockOrderDetails, 100n);
assert(result2.isErr());
expect(result2.error).toEqual(error);
expect(fetchSignedContext as Mock).toHaveBeenCalledTimes(1);
});

it("does not cache when block number is not provided", async () => {
const validSignedContext = {
signer: "0x000000000000000000000000abcdef1234567890",
context: ["0x01"],
signature: "0xsignature",
};
(fetchSignedContext as Mock).mockResolvedValue(Result.ok(validSignedContext));

await fetchOracleContext.call(mockState, mockOrderDetails);
await fetchOracleContext.call(mockState, mockOrderDetails);

expect(fetchSignedContext as Mock).toHaveBeenCalledTimes(2);
expect(mockState.oracleHealth.size).toBe(0);
});

it("passes max owner profile flag to fetchSignedContext", async () => {
(mockState as any).appOptions = {
ownerProfile: { [testOwner]: Number.MAX_SAFE_INTEGER },
};
(fetchSignedContext as Mock).mockResolvedValueOnce(
Result.err(new OracleError("some error", OracleErrorType.FetchError)),
);

await fetchOracleContext.call(mockState, mockOrderDetails);

expect(fetchSignedContext as Mock).toHaveBeenLastCalledWith(
mockOrderDetails.oracleUrl,
expect.any(Object),
mockState.oracleHealth,
true,
);
});
});
42 changes: 41 additions & 1 deletion src/oracle/index.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,26 @@
import { Result } from "../common";
import { OracleError } from "./error";
import { SharedState } from "../state";
import { AppOptions } from "../config";
import { OracleHealthMap } from "./types";
import { Order, Pair } from "../order/types";
import { fetchSignedContext } from "./fetch";

/**
* If the order has an oracle URL, fetch signed context and inject it
* into the takeOrder struct. Called with SharedState as `this` to access
* the oracle health map.
* the oracle health map and results cache.
*
* The fetch result is cached per order hash along the given block number,
* so repeated calls for the same order at an unchanged block number reuse
* the cached result instead of hitting the oracle again.
*
* @returns Result that callers decide how to handle failures.
*/
export async function fetchOracleContext(
this: SharedState,
orderDetails: Pair,
blockNumber?: bigint,
): Promise<Result<void, OracleError>> {
const oracleUrl = orderDetails.oracleUrl;
if (!oracleUrl) return Result.ok(undefined);
Expand All @@ -22,6 +29,32 @@ export async function fetchOracleContext(
const order = orderDetails.takeOrder.struct.order;
if (order.type !== Order.Type.V4) return Result.ok(undefined);

// reuse the cached result without hitting the oracle again if the block
// number has not changed since the previous fetch for this order pair,
// keyed by order hash and IO indexes since the same order can be fetched
// for different input/output IO combinations
const cacheKey = [
orderDetails.takeOrder.id.toLowerCase(),
orderDetails.takeOrder.struct.inputIOIndex,
orderDetails.takeOrder.struct.outputIOIndex,
].join("-");
if (typeof blockNumber === "bigint") {
const cached = this.oracleHealth
.get(OracleHealthMap.key(oracleUrl, order.owner))
?.cache?.get(cacheKey);
if (cached && cached.blockNumber === blockNumber) {
if (cached.result.isErr()) {
return Result.err(cached.result.error);
}
orderDetails.takeOrder.struct.signedContext = [cached.result.value];
return Result.ok(undefined);
}
}

const isMaxOwnerProfile = AppOptions.isMaxOwnerProfile(
orderDetails.takeOrder.struct.order.owner,
this.appOptions.ownerProfile,
);
const result = await fetchSignedContext(
oracleUrl,
{
Expand All @@ -31,8 +64,15 @@ export async function fetchOracleContext(
counterparty: "0x0000000000000000000000000000000000000000",
},
this.oracleHealth,
isMaxOwnerProfile,
);

// cache the result for this order pair at the given block number
if (typeof blockNumber === "bigint") {
const state = OracleHealthMap.getOrCreate(this.oracleHealth, oracleUrl, order.owner);
(state.cache ??= new Map()).set(cacheKey, { blockNumber, result });
Comment thread
rouzwelt marked this conversation as resolved.
}

if (result.isErr()) {
return Result.err(result.error);
}
Expand Down
51 changes: 48 additions & 3 deletions src/oracle/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { Order } from "../order";
import type { Order } from "../order";
import type { Result } from "../common";
import type { OracleError } from "./error";
import type { SignedContextV2 } from "../order/types/v4";

/** Provides constants and functionalities for interacting with oracles */
export namespace OracleConstants {
Expand All @@ -10,7 +13,9 @@ export namespace OracleConstants {
/** Per-request timeout */
export const ORACLE_TIMEOUT_MS = 5_000 as const;
/** How long to skip a failing oracle (ms) */
export const COOLOFF_DURATION_MS = 15 * 1_000;
export const COOLOFF_DURATION_MS = 3 * 60 * 1_000;
/** How long to skip a failing oracle (ms) for an owner with max profile */
export const COOLOFF_MAX_PROFILE_OWNER = 0;

/** List of known oracle URLs */
export const KnownUrls = [
Expand All @@ -23,7 +28,47 @@ export namespace OracleConstants {
}
}

export type OracleHealthMap = Map<string, { consecutiveFailures: number; cooloffUntil: number }>;
/** Represents the health state of an oracle for an owner */
export type OracleHealthState = {
/** Number of consecutive failed fetches */
consecutiveFailures: number;
/** Timestamp (ms) until which the oracle is in cooloff, 0 means no cooloff */
cooloffUntil: number;
/**
* Caches the result of the last oracle fetch per order pair, keyed as
* `orderHash-inputIOIndex-outputIOIndex`, along the block number it was
* fetched at, so that repeated fetches for the same order pair at the
* same block number get the cached result instead of hitting the oracle
*/
cache?: Map<string, { blockNumber: bigint; result: Result<SignedContextV2, OracleError> }>;
};

/** Keeps oracles health state per oracle url and owner */
export type OracleHealthMap = Map<string, OracleHealthState>;
export namespace OracleHealthMap {
/** Builds the health map key for the given oracle url and owner */
export function key(url: string, owner: string): string {
return `${url}-${owner.toLowerCase()}`;
}

/**
* Gets the health state for the given oracle url and owner,
* creates and stores a fresh state if none exists yet
*/
export function getOrCreate(
healthMap: OracleHealthMap,
url: string,
owner: string,
): OracleHealthState {
const k = key(url, owner);
let state = healthMap.get(k);
if (!state) {
state = { consecutiveFailures: 0, cooloffUntil: 0 };
healthMap.set(k, state);
}
return state;
}
}

/**
* Oracle request entry — mirrors the spec's (OrderV4, uint256, uint256, address) tuple.
Expand Down
Loading
Loading