diff --git a/src/core/process/order.ts b/src/core/process/order.ts index 693ae983..e811a934 100644 --- a/src/core/process/order.ts +++ b/src/core/process/order.ts @@ -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 diff --git a/src/oracle/fetch.test.ts b/src/oracle/fetch.test.ts index 65752b25..b18fa6d2 100644 --- a/src/oracle/fetch.test.ts +++ b/src/oracle/fetch.test.ts @@ -27,6 +27,8 @@ vi.mock("axios", async () => { describe("fetchSignedContext", () => { let healthMap: OracleHealthMap; const testUrl = "https://oracle.example.com"; + const testOwner = "0x1234567890123456789012345678901234567890"; + const testKey = `${testUrl}-${testOwner}`; const mockOrderRequest: OracleOrderRequest = { order: { @@ -84,7 +86,7 @@ describe("fetchSignedContext", () => { }); it("returns error when URL is in cooloff", async () => { - healthMap.set(testUrl, { + healthMap.set(testKey, { consecutiveFailures: 5, cooloffUntil: Date.now() + 60000, }); @@ -112,7 +114,7 @@ describe("fetchSignedContext", () => { }); it("records success in health map on valid response", async () => { - healthMap.set(testUrl, { consecutiveFailures: 3, cooloffUntil: 0 }); + healthMap.set(testKey, { consecutiveFailures: 3, cooloffUntil: 0 }); (axios.post as Mock).mockResolvedValueOnce({ data: [validSignedContext], status: 200, @@ -123,7 +125,7 @@ describe("fetchSignedContext", () => { await fetchSignedContext(testUrl, mockOrderRequest, healthMap); - const state = healthMap.get(testUrl); + const state = healthMap.get(testKey); expect(state?.consecutiveFailures).toBe(0); expect(state?.cooloffUntil).toBe(0); }); @@ -174,7 +176,7 @@ describe("fetchSignedContext", () => { await fetchSignedContext(testUrl, mockOrderRequest, healthMap); - const state = healthMap.get(testUrl); + const state = healthMap.get(testKey); expect(state?.consecutiveFailures).toBe(1); }); @@ -201,7 +203,7 @@ describe("fetchSignedContext", () => { await fetchSignedContext(testUrl, mockOrderRequest, healthMap); - const state = healthMap.get(testUrl); + const state = healthMap.get(testKey); expect(state?.consecutiveFailures).toBe(1); }); @@ -231,7 +233,7 @@ describe("fetchSignedContext", () => { await fetchSignedContext(testUrl, mockOrderRequest, healthMap); - const state = healthMap.get(testUrl); + const state = healthMap.get(testKey); expect(state?.consecutiveFailures).toBe(1); }); @@ -396,7 +398,7 @@ describe("fetchSignedContext", () => { it("processes expired cooloff correctly", async () => { // Set expired cooloff - healthMap.set(testUrl, { + healthMap.set(testKey, { consecutiveFailures: 5, cooloffUntil: Date.now() - 1000, }); @@ -605,6 +607,8 @@ describe("extractOracleUrl", () => { describe("isInCooloff", () => { let healthMap: OracleHealthMap; const testUrl = "https://oracle.example.com"; + const testOwner = "0x1234567890123456789012345678901234567890"; + const testKey = `${testUrl}-${testOwner}`; beforeEach(() => { healthMap = new Map(); @@ -617,111 +621,116 @@ describe("isInCooloff", () => { }); it("returns false for unknown URL", () => { - expect(isInCooloff(healthMap, testUrl)).toBe(false); + expect(isInCooloff(healthMap, testUrl, testOwner)).toBe(false); }); it("returns false when cooloffUntil is 0", () => { - healthMap.set(testUrl, { consecutiveFailures: 5, cooloffUntil: 0 }); + healthMap.set(testKey, { consecutiveFailures: 5, cooloffUntil: 0 }); - expect(isInCooloff(healthMap, testUrl)).toBe(false); + expect(isInCooloff(healthMap, testUrl, testOwner)).toBe(false); }); it("returns true when in active cooloff period", () => { const futureTime = Date.now() + 60000; // 1 minute in the future - healthMap.set(testUrl, { consecutiveFailures: 5, cooloffUntil: futureTime }); + healthMap.set(testKey, { consecutiveFailures: 5, cooloffUntil: futureTime }); - expect(isInCooloff(healthMap, testUrl)).toBe(true); + expect(isInCooloff(healthMap, testUrl, testOwner)).toBe(true); }); it("returns false and resets cooloff when cooloff period has expired", () => { const pastTime = Date.now() - 1000; // 1 second in the past - healthMap.set(testUrl, { consecutiveFailures: 5, cooloffUntil: pastTime }); + healthMap.set(testKey, { consecutiveFailures: 5, cooloffUntil: pastTime }); - expect(isInCooloff(healthMap, testUrl)).toBe(false); - expect(healthMap.get(testUrl)?.cooloffUntil).toBe(0); + expect(isInCooloff(healthMap, testUrl, testOwner)).toBe(false); + expect(healthMap.get(testKey)?.cooloffUntil).toBe(0); }); it("returns false and resets cooloff when cooloff period equals current time", () => { const currentTime = Date.now(); - healthMap.set(testUrl, { consecutiveFailures: 5, cooloffUntil: currentTime }); + healthMap.set(testKey, { consecutiveFailures: 5, cooloffUntil: currentTime }); - expect(isInCooloff(healthMap, testUrl)).toBe(false); - expect(healthMap.get(testUrl)?.cooloffUntil).toBe(0); + expect(isInCooloff(healthMap, testUrl, testOwner)).toBe(false); + expect(healthMap.get(testKey)?.cooloffUntil).toBe(0); }); it("preserves consecutiveFailures when resetting expired cooloff", () => { const pastTime = Date.now() - 1000; - healthMap.set(testUrl, { consecutiveFailures: 10, cooloffUntil: pastTime }); + healthMap.set(testKey, { consecutiveFailures: 10, cooloffUntil: pastTime }); - isInCooloff(healthMap, testUrl); + isInCooloff(healthMap, testUrl, testOwner); - expect(healthMap.get(testUrl)?.consecutiveFailures).toBe(10); + expect(healthMap.get(testKey)?.consecutiveFailures).toBe(10); }); it("handles multiple URLs independently", () => { const url1 = "https://oracle1.example.com"; const url2 = "https://oracle2.example.com"; - healthMap.set(url1, { consecutiveFailures: 3, cooloffUntil: Date.now() + 60000 }); - healthMap.set(url2, { consecutiveFailures: 3, cooloffUntil: 0 }); + healthMap.set(`${url1}-${testOwner}`, { + consecutiveFailures: 3, + cooloffUntil: Date.now() + 60000, + }); + healthMap.set(`${url2}-${testOwner}`, { consecutiveFailures: 3, cooloffUntil: 0 }); - expect(isInCooloff(healthMap, url1)).toBe(true); - expect(isInCooloff(healthMap, url2)).toBe(false); + expect(isInCooloff(healthMap, url1, testOwner)).toBe(true); + expect(isInCooloff(healthMap, url2, testOwner)).toBe(false); }); it("does not modify state when in active cooloff", () => { const futureTime = Date.now() + 60000; - healthMap.set(testUrl, { consecutiveFailures: 5, cooloffUntil: futureTime }); + healthMap.set(testKey, { consecutiveFailures: 5, cooloffUntil: futureTime }); - isInCooloff(healthMap, testUrl); + isInCooloff(healthMap, testUrl, testOwner); - const state = healthMap.get(testUrl); + const state = healthMap.get(testKey); expect(state?.consecutiveFailures).toBe(5); expect(state?.cooloffUntil).toBe(futureTime); }); it("returns false for state with undefined values treated as fresh", () => { - healthMap.set(testUrl, { consecutiveFailures: 0, cooloffUntil: 0 }); + healthMap.set(testKey, { consecutiveFailures: 0, cooloffUntil: 0 }); - expect(isInCooloff(healthMap, testUrl)).toBe(false); + expect(isInCooloff(healthMap, testUrl, testOwner)).toBe(false); }); }); describe("recordOracleSuccess", () => { let healthMap: OracleHealthMap; const testUrl = "https://oracle.example.com"; + const testOwner = "0x1234567890123456789012345678901234567890"; + const testKey = `${testUrl}-${testOwner}`; beforeEach(() => { healthMap = new Map(); }); it("creates new state entry for unknown URL", () => { - recordOracleSuccess(healthMap, testUrl); + recordOracleSuccess(healthMap, testUrl, testOwner); - const state = healthMap.get(testUrl); + const state = healthMap.get(testKey); expect(state).toBeDefined(); expect(state?.consecutiveFailures).toBe(0); expect(state?.cooloffUntil).toBe(0); }); it("resets consecutive failures to zero", () => { - healthMap.set(testUrl, { consecutiveFailures: 5, cooloffUntil: 0 }); + healthMap.set(testKey, { consecutiveFailures: 5, cooloffUntil: 0 }); - recordOracleSuccess(healthMap, testUrl); + recordOracleSuccess(healthMap, testUrl, testOwner); - const state = healthMap.get(testUrl); + const state = healthMap.get(testKey); expect(state?.consecutiveFailures).toBe(0); }); it("clears cooloff period", () => { - healthMap.set(testUrl, { + healthMap.set(testKey, { consecutiveFailures: 10, cooloffUntil: Date.now() + 60000, }); - recordOracleSuccess(healthMap, testUrl); + recordOracleSuccess(healthMap, testUrl, testOwner); - const state = healthMap.get(testUrl); + const state = healthMap.get(testKey); expect(state?.consecutiveFailures).toBe(0); expect(state?.cooloffUntil).toBe(0); }); @@ -730,43 +739,57 @@ describe("recordOracleSuccess", () => { const url1 = "https://oracle1.example.com"; const url2 = "https://oracle2.example.com"; - healthMap.set(url1, { consecutiveFailures: 3, cooloffUntil: 1000 }); - healthMap.set(url2, { consecutiveFailures: 5, cooloffUntil: 2000 }); + healthMap.set(`${url1}-${testOwner}`, { consecutiveFailures: 3, cooloffUntil: 1000 }); + healthMap.set(`${url2}-${testOwner}`, { consecutiveFailures: 5, cooloffUntil: 2000 }); - recordOracleSuccess(healthMap, url1); + recordOracleSuccess(healthMap, url1, testOwner); - expect(healthMap.get(url1)?.consecutiveFailures).toBe(0); - expect(healthMap.get(url1)?.cooloffUntil).toBe(0); - expect(healthMap.get(url2)?.consecutiveFailures).toBe(5); - expect(healthMap.get(url2)?.cooloffUntil).toBe(2000); + expect(healthMap.get(`${url1}-${testOwner}`)?.consecutiveFailures).toBe(0); + expect(healthMap.get(`${url1}-${testOwner}`)?.cooloffUntil).toBe(0); + expect(healthMap.get(`${url2}-${testOwner}`)?.consecutiveFailures).toBe(5); + expect(healthMap.get(`${url2}-${testOwner}`)?.cooloffUntil).toBe(2000); }); it("overwrites existing state completely", () => { - healthMap.set(testUrl, { + healthMap.set(testKey, { consecutiveFailures: 100, cooloffUntil: 999999, }); - recordOracleSuccess(healthMap, testUrl); + recordOracleSuccess(healthMap, testUrl, testOwner); - const state = healthMap.get(testUrl); + const state = healthMap.get(testKey); expect(state).toEqual({ consecutiveFailures: 0, cooloffUntil: 0 }); }); it("can be called multiple times without side effects", () => { - recordOracleSuccess(healthMap, testUrl); - recordOracleSuccess(healthMap, testUrl); - recordOracleSuccess(healthMap, testUrl); + recordOracleSuccess(healthMap, testUrl, testOwner); + recordOracleSuccess(healthMap, testUrl, testOwner); + recordOracleSuccess(healthMap, testUrl, testOwner); + + const state = healthMap.get(testKey); + expect(state?.consecutiveFailures).toBe(0); + expect(state?.cooloffUntil).toBe(0); + }); + + it("preserves cached fetch results when resetting state", () => { + const cache = new Map([["0xhash", { blockNumber: 1n, result: {} as any }]]); + healthMap.set(testKey, { consecutiveFailures: 3, cooloffUntil: 123, cache }); + + recordOracleSuccess(healthMap, testUrl, testOwner); - const state = healthMap.get(testUrl); + const state = healthMap.get(testKey); expect(state?.consecutiveFailures).toBe(0); expect(state?.cooloffUntil).toBe(0); + expect(state?.cache).toBe(cache); }); }); describe("recordOracleFailure", () => { let healthMap: OracleHealthMap; const testUrl = "https://oracle.example.com"; + const testOwner = "0x1234567890123456789012345678901234567890"; + const testKey = `${testUrl}-${testOwner}`; beforeEach(() => { healthMap = new Map(); @@ -779,54 +802,54 @@ describe("recordOracleFailure", () => { }); it("creates new state entry for unknown URL", () => { - recordOracleFailure(healthMap, testUrl); + recordOracleFailure(healthMap, testUrl, testOwner); - const state = healthMap.get(testUrl); + const state = healthMap.get(testKey); expect(state).toBeDefined(); expect(state?.consecutiveFailures).toBe(1); expect(state?.cooloffUntil).toBe(0); }); it("increments consecutive failures for existing URL", () => { - healthMap.set(testUrl, { consecutiveFailures: 2, cooloffUntil: 0 }); + healthMap.set(testKey, { consecutiveFailures: 2, cooloffUntil: 0 }); - recordOracleFailure(healthMap, testUrl); + recordOracleFailure(healthMap, testUrl, testOwner); - const state = healthMap.get(testUrl); + const state = healthMap.get(testKey); expect(state?.consecutiveFailures).toBe(3); }); it("enters cooloff when reaching threshold", () => { - healthMap.set(testUrl, { + healthMap.set(testKey, { consecutiveFailures: OracleConstants.COOLOFF_THRESHOLD - 1, cooloffUntil: 0, }); - recordOracleFailure(healthMap, testUrl); + recordOracleFailure(healthMap, testUrl, testOwner); - const state = healthMap.get(testUrl); + const state = healthMap.get(testKey); expect(state?.consecutiveFailures).toBe(OracleConstants.COOLOFF_THRESHOLD); expect(state?.cooloffUntil).toBe(Date.now() + OracleConstants.COOLOFF_DURATION_MS); }); it("updates cooloff time when exceeding threshold", () => { - healthMap.set(testUrl, { + healthMap.set(testKey, { consecutiveFailures: OracleConstants.COOLOFF_THRESHOLD, cooloffUntil: 0, }); - recordOracleFailure(healthMap, testUrl); + recordOracleFailure(healthMap, testUrl, testOwner); - const state = healthMap.get(testUrl); + const state = healthMap.get(testKey); expect(state?.consecutiveFailures).toBe(OracleConstants.COOLOFF_THRESHOLD + 1); expect(state?.cooloffUntil).toBe(Date.now() + OracleConstants.COOLOFF_DURATION_MS); }); it("does not set cooloff before reaching threshold", () => { - recordOracleFailure(healthMap, testUrl); - recordOracleFailure(healthMap, testUrl); + recordOracleFailure(healthMap, testUrl, testOwner); + recordOracleFailure(healthMap, testUrl, testOwner); - const state = healthMap.get(testUrl); + const state = healthMap.get(testKey); expect(state?.consecutiveFailures).toBe(2); expect(state?.cooloffUntil).toBe(0); }); @@ -835,25 +858,51 @@ describe("recordOracleFailure", () => { const url1 = "https://oracle1.example.com"; const url2 = "https://oracle2.example.com"; - recordOracleFailure(healthMap, url1); - recordOracleFailure(healthMap, url1); - recordOracleFailure(healthMap, url2); + recordOracleFailure(healthMap, url1, testOwner); + recordOracleFailure(healthMap, url1, testOwner); + recordOracleFailure(healthMap, url2, testOwner); - expect(healthMap.get(url1)?.consecutiveFailures).toBe(2); - expect(healthMap.get(url2)?.consecutiveFailures).toBe(1); + expect(healthMap.get(`${url1}-${testOwner}`)?.consecutiveFailures).toBe(2); + expect(healthMap.get(`${url2}-${testOwner}`)?.consecutiveFailures).toBe(1); }); it("preserves existing cooloff time when below threshold after reset", () => { const existingCooloff = Date.now() + 5000; - healthMap.set(testUrl, { + healthMap.set(testKey, { consecutiveFailures: 1, cooloffUntil: existingCooloff, }); - recordOracleFailure(healthMap, testUrl); + recordOracleFailure(healthMap, testUrl, testOwner); - const state = healthMap.get(testUrl); + const state = healthMap.get(testKey); expect(state?.consecutiveFailures).toBe(2); expect(state?.cooloffUntil).toBe(existingCooloff); }); + + it("does not enter cooloff for max owner profile", () => { + healthMap.set(testKey, { + consecutiveFailures: OracleConstants.COOLOFF_THRESHOLD - 1, + cooloffUntil: 0, + }); + + recordOracleFailure(healthMap, testUrl, testOwner, true); + + // cooloff duration is 0 for max owner profiles, so cooloffUntil + // is set to now which immediately counts as expired + const state = healthMap.get(testKey); + expect(state?.consecutiveFailures).toBe(OracleConstants.COOLOFF_THRESHOLD); + expect(state?.cooloffUntil).toBe(Date.now() + OracleConstants.COOLOFF_MAX_PROFILE_OWNER); + expect(isInCooloff(healthMap, testUrl, testOwner)).toBe(false); + }); + + it("keys cooloff state per owner independently", () => { + const otherOwner = "0x9999999999999999999999999999999999999999"; + for (let i = 0; i < OracleConstants.COOLOFF_THRESHOLD; i++) { + recordOracleFailure(healthMap, testUrl, testOwner); + } + + expect(isInCooloff(healthMap, testUrl, testOwner)).toBe(true); + expect(isInCooloff(healthMap, testUrl, otherOwner)).toBe(false); + }); }); diff --git a/src/oracle/fetch.ts b/src/oracle/fetch.ts index 2c3dca39..d3755d9d 100644 --- a/src/oracle/fetch.ts +++ b/src/oracle/fetch.ts @@ -27,6 +27,7 @@ export async function fetchSignedContext( url: string, request: OracleOrderRequest, healthMap: OracleHealthMap, + isMaxOwnerProfile?: boolean, ): Promise> { if (!OracleConstants.isKnown(url)) { return Result.err( @@ -34,7 +35,8 @@ export async function fetchSignedContext( ); } - 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), ); @@ -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", @@ -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( @@ -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; @@ -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); } diff --git a/src/oracle/index.test.ts b/src/oracle/index.test.ts index c6cbfda8..ad094232 100644 --- a/src/oracle/index.test.ts +++ b/src/oracle/index.test.ts @@ -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, @@ -70,6 +76,7 @@ describe("fetchOracleContext", () => { counterparty: "0x0000000000000000000000000000000000000000", }, mockState.oracleHealth, + false, ); }); @@ -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, + ); + }); }); diff --git a/src/oracle/index.ts b/src/oracle/index.ts index 169270da..fad1297d 100644 --- a/src/oracle/index.ts +++ b/src/oracle/index.ts @@ -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> { const oracleUrl = orderDetails.oracleUrl; if (!oracleUrl) return Result.ok(undefined); @@ -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, { @@ -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 }); + } + if (result.isErr()) { return Result.err(result.error); } diff --git a/src/oracle/types.ts b/src/oracle/types.ts index ec7813e7..f6026341 100644 --- a/src/oracle/types.ts +++ b/src/oracle/types.ts @@ -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 { @@ -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 = [ @@ -23,7 +28,47 @@ export namespace OracleConstants { } } -export type OracleHealthMap = Map; +/** 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 }>; +}; + +/** Keeps oracles health state per oracle url and owner */ +export type OracleHealthMap = Map; +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. diff --git a/src/order/quote.ts b/src/order/quote.ts index eff4d63f..49d78ef9 100644 --- a/src/order/quote.ts +++ b/src/order/quote.ts @@ -36,7 +36,7 @@ export async function quoteSingleOrderV3( blockNumber?: bigint, gas?: bigint, ) { - const oracleResult = await fetchOracleContext.call(state, orderDetails); + const oracleResult = await fetchOracleContext.call(state, orderDetails, blockNumber); if (oracleResult.isErr()) { throw oracleResult.error; } @@ -49,7 +49,6 @@ export async function quoteSingleOrderV3( functionName: "quote", args: [TakeOrder.getQuoteConfig(orderDetails.takeOrder.struct)], }), - blockNumber, gas, }) .catch((error) => { @@ -81,7 +80,7 @@ export async function quoteSingleOrderV4( blockNumber?: bigint, gas?: bigint, ) { - const oracleResult = await fetchOracleContext.call(state, orderDetails); + const oracleResult = await fetchOracleContext.call(state, orderDetails, blockNumber); if (oracleResult.isErr()) { throw oracleResult.error; } @@ -94,7 +93,6 @@ export async function quoteSingleOrderV4( functionName: "quote2", args: [TakeOrder.getQuoteConfig(orderDetails.takeOrder.struct)], }), - blockNumber, gas, }) .catch((error) => { diff --git a/src/state/index.ts b/src/state/index.ts index 533a8b39..08e69dc8 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -225,7 +225,7 @@ export class SharedState { writeRpc?: RpcState; /** List of latest successful transactions gas costs */ gasCosts: bigint[] = []; - /** Oracle endpoint health tracking for cooloff */ + /** Oracle endpoint health tracking for cooloff and fetch results caching */ oracleHealth: OracleHealthMap = new Map(); constructor(config: SharedStateConfig) {