diff --git a/lib/sushiswap b/lib/sushiswap index 1b2b675e..fe4be0e3 160000 --- a/lib/sushiswap +++ b/lib/sushiswap @@ -1 +1 @@ -Subproject commit 1b2b675effe0187889d3de66da2048e711a117e2 +Subproject commit fe4be0e3d02e297e55671dbe545c464a5017a0ba diff --git a/src/common/abis/orderbook.ts b/src/common/abis/orderbook.ts index efd7697d..10460f08 100644 --- a/src/common/abis/orderbook.ts +++ b/src/common/abis/orderbook.ts @@ -52,16 +52,16 @@ export namespace _v5 { export const Float = "bytes32" as const; export const IOV2 = `(address token, bytes32 vaultId)` as const; export const EvaluableV4 = `(address interpreter, address store, bytes bytecode)` as const; - export const SignedContextV1 = "(address signer, bytes32[] context, bytes signature)" as const; - export const TaskV2 = `(${EvaluableV4} evaluable, ${SignedContextV1}[] signedContext)` as const; + export const SignedContextV2 = "(address signer, bytes32[] context, bytes signature)" as const; + export const TaskV2 = `(${EvaluableV4} evaluable, ${SignedContextV2}[] signedContext)` as const; export const ClearStateChangeV2 = `(${Float} aliceOutput, ${Float} bobOutput, ${Float} aliceInput, ${Float} bobInput)` as const; export const OrderV4 = `(address owner, ${EvaluableV4} evaluable, ${IOV2}[] validInputs, ${IOV2}[] validOutputs, bytes32 nonce)` as const; export const TakeOrderConfigV4 = - `(${OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, ${SignedContextV1}[] signedContext)` as const; + `(${OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, ${SignedContextV2}[] signedContext)` as const; export const QuoteV2 = - `(${OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, ${SignedContextV1}[] signedContext)` as const; + `(${OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, ${SignedContextV2}[] signedContext)` as const; export const TakeOrdersConfigV4 = `(${Float} minimumInput, ${Float} maximumInput, ${Float} maximumIORatio, ${TakeOrderConfigV4}[] orders, bytes data)` as const; export const OrderConfigV4 = @@ -90,7 +90,7 @@ export namespace _v5 { `function addOrder3(${OrderConfigV4} calldata config, ${TaskV2}[] calldata tasks) external returns (bool stateChanged)` as const, `function quote2(${QuoteV2} calldata quoteConfig) external view returns (bool exists, ${Float} outputMax, ${Float} ioRatio)` as const, `function takeOrders3(${TakeOrdersConfigV4} calldata config) external returns (${Float} totalTakerInput, ${Float} totalTakerOutput)` as const, - `function clear3(${OrderV4} memory alice, ${OrderV4} memory bob, ${ClearConfigV2} calldata clearConfig, ${SignedContextV1}[] memory aliceSignedContext, ${SignedContextV1}[] memory bobSignedContext) external` as const, + `function clear3(${OrderV4} memory alice, ${OrderV4} memory bob, ${ClearConfigV2} calldata clearConfig, ${SignedContextV2}[] memory aliceSignedContext, ${SignedContextV2}[] memory bobSignedContext) external` as const, "function multicall(bytes[] calldata data) external returns (bytes[] memory results)", ] as const; export const Arb = [ @@ -105,16 +105,16 @@ export namespace _v6 { export const Float = "bytes32" as const; export const IOV2 = `(address token, bytes32 vaultId)` as const; export const EvaluableV4 = `(address interpreter, address store, bytes bytecode)` as const; - export const SignedContextV1 = "(address signer, bytes32[] context, bytes signature)" as const; - export const TaskV2 = `(${EvaluableV4} evaluable, ${SignedContextV1}[] signedContext)` as const; + export const SignedContextV2 = "(address signer, bytes32[] context, bytes signature)" as const; + export const TaskV2 = `(${EvaluableV4} evaluable, ${SignedContextV2}[] signedContext)` as const; export const ClearStateChangeV2 = `(${Float} aliceOutput, ${Float} bobOutput, ${Float} aliceInput, ${Float} bobInput)` as const; export const OrderV4 = `(address owner, ${EvaluableV4} evaluable, ${IOV2}[] validInputs, ${IOV2}[] validOutputs, bytes32 nonce)` as const; export const TakeOrderConfigV4 = - `(${OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, ${SignedContextV1}[] signedContext)` as const; + `(${OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, ${SignedContextV2}[] signedContext)` as const; export const QuoteV2 = - `(${OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, ${SignedContextV1}[] signedContext)` as const; + `(${OrderV4} order, uint256 inputIOIndex, uint256 outputIOIndex, ${SignedContextV2}[] signedContext)` as const; export const TakeOrdersConfigV5 = `(${Float} minimumIO, ${Float} maximumIO, ${Float} maximumIORatio, bool IOIsInput, ${TakeOrderConfigV4}[] orders, bytes data)` as const; export const OrderConfigV4 = @@ -144,7 +144,7 @@ export namespace _v6 { `function addOrder4(${OrderConfigV4} calldata config, ${TaskV2}[] calldata tasks) external returns (bool stateChanged)` as const, `function quote2(${QuoteV2} calldata quoteConfig) external view returns (bool exists, ${Float} outputMax, ${Float} ioRatio)` as const, `function takeOrders4(${TakeOrdersConfigV5} calldata config) external returns (${Float} totalTakerInput, ${Float} totalTakerOutput)` as const, - `function clear3(${OrderV4} memory alice, ${OrderV4} memory bob, ${ClearConfigV2} calldata clearConfig, ${SignedContextV1}[] memory aliceSignedContext, ${SignedContextV1}[] memory bobSignedContext) external` as const, + `function clear3(${OrderV4} memory alice, ${OrderV4} memory bob, ${ClearConfigV2} calldata clearConfig, ${SignedContextV2}[] memory aliceSignedContext, ${SignedContextV2}[] memory bobSignedContext) external` as const, "function multicall(bytes[] calldata data) external returns (bytes[] memory results)", ] as const; export const Arb = [ @@ -219,7 +219,7 @@ export namespace OrderbookAbi { export const Float = _v5.Float; export const IO = _v5.IOV2; export const Evaluable = _v5.EvaluableV4; - export const SignedContext = _v5.SignedContextV1; + export const SignedContext = _v5.SignedContextV2; export const Task = _v5.TaskV2; export const ClearStateChange = _v5.ClearStateChangeV2; export const Order = _v5.OrderV4; @@ -267,7 +267,7 @@ export namespace OrderbookAbi { export const Float = _v6.Float; export const IO = _v6.IOV2; export const Evaluable = _v6.EvaluableV4; - export const SignedContext = _v6.SignedContextV1; + export const SignedContext = _v6.SignedContextV2; export const Task = _v6.TaskV2; export const ClearStateChange = _v6.ClearStateChangeV2; export const Order = _v6.OrderV4; diff --git a/src/core/process/order.ts b/src/core/process/order.ts index 766fe964..693ae983 100644 --- a/src/core/process/order.ts +++ b/src/core/process/order.ts @@ -64,6 +64,9 @@ export async function processOrder( spanAttributes["details.pair"] = tokenPair; spanAttributes["details.orderbook"] = orderDetails.orderbook; spanAttributes["details.owner"] = orderDetails.takeOrder.struct.order.owner.toLowerCase(); + if (orderDetails.oracleUrl) { + spanAttributes["details.oracle"] = orderDetails.oracleUrl; + } const quoteOrderTime = performance.now(); try { diff --git a/src/oracle/error.ts b/src/oracle/error.ts new file mode 100644 index 00000000..e8b4cf47 --- /dev/null +++ b/src/oracle/error.ts @@ -0,0 +1,35 @@ +import { RainSolverBaseError } from "../error"; + +/** Enumerates the possible error types that can occur within the Oracle functionalities */ +export enum OracleErrorType { + UnknownUrl, + Cooloff, + RequestFailed, + FetchError, + InvalidResponseType, +} + +/** + * Represents an error type for the Oracle functionalities. + * This error class extends the `RainSolverBaseError` error class, with the `type` + * property indicates the specific category of the error, as defined by the + * `OracleErrorType` enum. The optional `cause` property can be used to + * attach the original error or any relevant context that led to this error. + * + * @example + * ```typescript + * // without cause + * throw new OracleError("msg", OracleErrorType); + * + * // with cause + * throw new OracleError("msg", OracleErrorType, originalError); + * ``` + */ +export class OracleError extends RainSolverBaseError { + type: OracleErrorType; + constructor(message: string, type: OracleErrorType, cause?: any) { + super(message, cause); + this.type = type; + this.name = "OracleError"; + } +} diff --git a/src/oracle/fetch.test.ts b/src/oracle/fetch.test.ts new file mode 100644 index 00000000..65752b25 --- /dev/null +++ b/src/oracle/fetch.test.ts @@ -0,0 +1,859 @@ +import { Order } from "../order"; +import axios, { AxiosError } from "axios"; +import { OracleErrorType } from "./error"; +import { OracleConstants, OracleHealthMap, OracleOrderRequest } from "./types"; +import { describe, it, expect, vi, beforeEach, afterEach, assert, Mock } from "vitest"; +import { + isInCooloff, + extractOracleUrl, + fetchSignedContext, + recordOracleSuccess, + recordOracleFailure, +} from "./fetch"; + +// Mock axios +vi.mock("axios", async () => { + const actual = await vi.importActual("axios"); + return { + default: { + ...actual.default, + post: vi.fn(), + isAxiosError: vi.fn(), + }, + AxiosError: actual.AxiosError, + }; +}); + +describe("fetchSignedContext", () => { + let healthMap: OracleHealthMap; + const testUrl = "https://oracle.example.com"; + + const mockOrderRequest: OracleOrderRequest = { + order: { + type: Order.Type.V4, + owner: "0x1234567890123456789012345678901234567890", + evaluable: { + interpreter: "0x1234567890123456789012345678901234567890", + store: "0x1234567890123456789012345678901234567890", + bytecode: "0x00", + }, + validInputs: [ + { + token: "0x1234567890123456789012345678901234567890", + vaultId: "0x0000000000000000000000000000000000000000000000000000000000000001", + }, + ], + validOutputs: [ + { + token: "0x1234567890123456789012345678901234567890", + vaultId: "0x0000000000000000000000000000000000000000000000000000000000000001", + }, + ], + nonce: "0x0000000000000000000000000000000000000000000000000000000000000123", + }, + inputIOIndex: 0, + outputIOIndex: 0, + counterparty: "0x1234567890123456789012345678901234567890", + }; + + const validSignedContext = { + signer: "0x000000000000000000000000abcdef1234567890", + context: [ + "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000002", + ], + signature: "0xsignature", + }; + + beforeEach(() => { + healthMap = new Map(); + vi.clearAllMocks(); + + // Mock OracleConstants.isKnown to return true for test URL + vi.spyOn(OracleConstants, "isKnown").mockReturnValue(true); + }); + + it("returns error when URL is unknown", async () => { + vi.spyOn(OracleConstants, "isKnown").mockReturnValue(false); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.Cooloff); + expect(result.error.message).toContain("unknown"); + }); + + it("returns error when URL is in cooloff", async () => { + healthMap.set(testUrl, { + consecutiveFailures: 5, + cooloffUntil: Date.now() + 60000, + }); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.Cooloff); + expect(result.error.message).toContain("cooloff"); + }); + + it("returns valid SignedContextV2 on successful response", async () => { + (axios.post as Mock).mockResolvedValueOnce({ + data: [validSignedContext], + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, + }); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isOk()); + expect(result.value).toEqual(validSignedContext); + }); + + it("records success in health map on valid response", async () => { + healthMap.set(testUrl, { consecutiveFailures: 3, cooloffUntil: 0 }); + (axios.post as Mock).mockResolvedValueOnce({ + data: [validSignedContext], + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, + }); + + await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(0); + expect(state?.cooloffUntil).toBe(0); + }); + + it("returns error on response error (500)", async () => { + const axiosError = new AxiosError( + "Request failed with status code 500", + "ERR_BAD_RESPONSE", + {} as any, + {}, + { + status: 500, + statusText: "Internal Server Error", + data: {}, + headers: {}, + config: {} as any, + }, + ); + + (axios.post as Mock).mockRejectedValueOnce(axiosError); + (axios.isAxiosError as any as Mock).mockReturnValue(true); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.RequestFailed); + expect(result.error.message).toContain("500"); + expect(result.error.message).toContain("Internal Server Error"); + }); + + it("records failure in health map on response error", async () => { + const axiosError = new AxiosError( + "Request failed with status code 500", + "ERR_BAD_RESPONSE", + {} as any, + {}, + { + status: 500, + statusText: "Internal Server Error", + data: {}, + headers: {}, + config: {} as any, + }, + ); + + (axios.post as Mock).mockRejectedValueOnce(axiosError); + (axios.isAxiosError as any as Mock).mockReturnValue(true); + + await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(1); + }); + + it("returns error on network error", async () => { + const axiosError = new AxiosError("Network Error", "ERR_NETWORK", {} as any, {}, undefined); + axiosError.request = {}; + + (axios.post as Mock).mockRejectedValueOnce(axiosError); + (axios.isAxiosError as any as Mock).mockReturnValue(true); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.FetchError); + expect(result.error.message).toContain("Network Error"); + }); + + it("records failure in health map on network error", async () => { + const axiosError = new AxiosError("Network Error", "ERR_NETWORK", {} as any, {}, undefined); + axiosError.request = {}; + + (axios.post as Mock).mockRejectedValueOnce(axiosError); + (axios.isAxiosError as any as Mock).mockReturnValue(true); + + await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(1); + }); + + it("returns error on invalid response shape", async () => { + (axios.post as Mock).mockResolvedValueOnce({ + data: { invalid: "response" }, + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, + }); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.InvalidResponseType); + }); + + it("records failure in health map on invalid response shape", async () => { + (axios.post as Mock).mockResolvedValueOnce({ + data: { invalid: "response" }, + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, + }); + + await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(1); + }); + + it("sends correct request headers", async () => { + (axios.post as Mock).mockResolvedValueOnce({ + data: [validSignedContext], + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, + }); + + await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + expect(axios.post).toHaveBeenCalledWith( + testUrl, + expect.any(Uint8Array), + expect.objectContaining({ + headers: { "Content-Type": "application/octet-stream" }, + timeout: OracleConstants.ORACLE_TIMEOUT_MS, + responseType: "json", + }), + ); + }); + + it("sends body as Uint8Array", async () => { + (axios.post as Mock).mockResolvedValueOnce({ + data: [validSignedContext], + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, + }); + + await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + const callArgs = (axios.post as Mock).mock.calls[0]; + expect(callArgs[1]).toBeInstanceOf(Uint8Array); + }); + + it("handles non-AxiosError exceptions gracefully", async () => { + const genericError = new Error("Generic error"); + (axios.post as Mock).mockRejectedValueOnce(genericError); + (axios.isAxiosError as any as Mock).mockReturnValue(false); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.FetchError); + expect(result.error.message).toContain("Generic error"); + }); + + it("handles response with missing signer", async () => { + (axios.post as Mock).mockResolvedValueOnce({ + data: [ + { + context: ["0x01"], + signature: "0xsig", + }, + ], + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, + }); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.InvalidResponseType); + }); + + it("handles response with missing context", async () => { + (axios.post as Mock).mockResolvedValueOnce({ + data: [ + { + signer: "0x1234", + signature: "0xsig", + }, + ], + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, + }); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.InvalidResponseType); + }); + + it("handles response with missing signature", async () => { + (axios.post as Mock).mockResolvedValueOnce({ + data: [ + { + signer: "0x1234", + context: ["0x01"], + }, + ], + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, + }); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.InvalidResponseType); + }); + + it("handles 404 response", async () => { + const axiosError = new AxiosError( + "Request failed with status code 404", + "ERR_BAD_REQUEST", + {} as any, + {}, + { + status: 404, + statusText: "Not Found", + data: {}, + headers: {}, + config: {} as any, + }, + ); + + (axios.post as Mock).mockRejectedValueOnce(axiosError); + (axios.isAxiosError as any as Mock).mockReturnValue(true); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.RequestFailed); + expect(result.error.message).toContain("404"); + }); + + it("handles 400 Bad Request response", async () => { + const axiosError = new AxiosError( + "Request failed with status code 400", + "ERR_BAD_REQUEST", + {} as any, + {}, + { + status: 400, + statusText: "Bad Request", + data: { error: "Invalid request body" }, + headers: {}, + config: {} as any, + }, + ); + + (axios.post as Mock).mockRejectedValueOnce(axiosError); + (axios.isAxiosError as any as Mock).mockReturnValue(true); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.RequestFailed); + expect(result.error.message).toContain("400"); + }); + + it("processes expired cooloff correctly", async () => { + // Set expired cooloff + healthMap.set(testUrl, { + consecutiveFailures: 5, + cooloffUntil: Date.now() - 1000, + }); + + (axios.post as Mock).mockResolvedValueOnce({ + data: [validSignedContext], + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, + }); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isOk()); + }); + + it("handles timeout error", async () => { + const axiosError = new AxiosError( + "timeout of 5000ms exceeded", + "ECONNABORTED", + {} as any, + {}, + undefined, + ); + axiosError.request = {}; + + (axios.post as Mock).mockRejectedValueOnce(axiosError); + (axios.isAxiosError as any as Mock).mockReturnValue(true); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.FetchError); + }); + + it("handles cancelled request", async () => { + const axiosError = new AxiosError( + "Request cancelled", + "ERR_CANCELED", + {} as any, + {}, + undefined, + ); + axiosError.request = {}; + + (axios.post as Mock).mockRejectedValueOnce(axiosError); + (axios.isAxiosError as any as Mock).mockReturnValue(true); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.FetchError); + }); + + it("handles non-Error string exceptions", async () => { + (axios.post as Mock).mockRejectedValueOnce("string error"); + (axios.isAxiosError as any as Mock).mockReturnValue(false); + + const result = await fetchSignedContext(testUrl, mockOrderRequest, healthMap); + + assert(result.isErr()); + expect(result.error.type).toBe(OracleErrorType.FetchError); + expect(result.error.message).toContain("string error"); + }); +}); + +describe("extractOracleUrl", () => { + const endMarker = "011b"; + + const buildMetaHex = (url: string): string => { + const urlHex = Buffer.from(url).toString("hex"); + // Simulates CBOR structure: + return "a200" + urlHex + endMarker + OracleConstants.RaindexSignedContextOracleV1; + }; + + it("returns undefined for empty string", () => { + expect(extractOracleUrl("")).toBeUndefined(); + }); + + it("returns undefined for null/undefined input", () => { + expect(extractOracleUrl(null as any)).toBeUndefined(); + expect(extractOracleUrl(undefined as any)).toBeUndefined(); + }); + + it("returns undefined when magic number is not present", () => { + const urlHex = Buffer.from("https://oracle.example.com").toString("hex"); + expect(extractOracleUrl(urlHex)).toBeUndefined(); + }); + + it("extracts https URL from valid meta hex", () => { + const url = "https://oracle.example.com"; + const metaHex = buildMetaHex(url); + + expect(extractOracleUrl(metaHex)).toBe(url); + }); + + it("extracts http URL from valid meta hex", () => { + const url = "http://oracle.example.com"; + const metaHex = buildMetaHex(url); + + expect(extractOracleUrl(metaHex)).toBe(url); + }); + + it("handles 0x prefix", () => { + const url = "https://oracle.example.com"; + const metaHex = "0x" + buildMetaHex(url); + + expect(extractOracleUrl(metaHex)).toBe(url); + }); + + it("returns undefined when URL protocol is not found", () => { + const noProtocolUrl = "oracle.example.com"; + const urlHex = Buffer.from(noProtocolUrl).toString("hex"); + const metaHex = "a200" + urlHex + endMarker + OracleConstants.RaindexSignedContextOracleV1; + + expect(extractOracleUrl(metaHex)).toBeUndefined(); + }); + + it("returns undefined when end marker is missing", () => { + const url = "https://oracle.example.com"; + const urlHex = Buffer.from(url).toString("hex"); + const metaHex = "a200" + urlHex + OracleConstants.RaindexSignedContextOracleV1; + + expect(extractOracleUrl(metaHex)).toBeUndefined(); + }); + + it("returns undefined when end marker is before URL", () => { + const url = "https://oracle.example.com"; + const urlHex = Buffer.from(url).toString("hex"); + // Place end marker before URL + const metaHex = endMarker + "a200" + urlHex + OracleConstants.RaindexSignedContextOracleV1; + + expect(extractOracleUrl(metaHex)).toBeUndefined(); + }); + + it("extracts URL with path and query parameters", () => { + const url = "https://oracle.example.com/api/v1?key=value"; + const metaHex = buildMetaHex(url); + + expect(extractOracleUrl(metaHex)).toBe(url); + }); + + it("extracts URL with port number", () => { + const url = "https://oracle.example.com:8080/endpoint"; + const metaHex = buildMetaHex(url); + + expect(extractOracleUrl(metaHex)).toBe(url); + }); + + it("handles additional data after magic number", () => { + const url = "https://oracle.example.com"; + const metaHex = buildMetaHex(url) + "deadbeef"; + + expect(extractOracleUrl(metaHex)).toBe(url); + }); + + it("handles additional data before URL", () => { + const url = "https://oracle.example.com"; + const urlHex = Buffer.from(url).toString("hex"); + const metaHex = + "deadbeefa200" + urlHex + endMarker + OracleConstants.RaindexSignedContextOracleV1; + + expect(extractOracleUrl(metaHex)).toBe(url); + }); + + it("uses last occurrence of https when multiple present", () => { + const url1 = "https://first.example.com"; + const url2 = "https://second.example.com"; + const url1Hex = Buffer.from(url1).toString("hex"); + const url2Hex = Buffer.from(url2).toString("hex"); + // Structure with two URLs, should pick the last one before magic + const metaHex = + url1Hex + "a200" + url2Hex + endMarker + OracleConstants.RaindexSignedContextOracleV1; + + expect(extractOracleUrl(metaHex)).toBe(url2); + }); + + it("returns undefined for malformed hex that cannot be decoded", () => { + // Invalid hex characters after URL marker but before end marker + const httpsHex = Buffer.from("https://").toString("hex"); + // Incomplete/invalid URL hex + const metaHex = + "a200" + httpsHex + "zzzz" + endMarker + OracleConstants.RaindexSignedContextOracleV1; + + // This should still find https:// and try to decode, but the result + // will include invalid characters - Buffer.from handles this gracefully + const result = extractOracleUrl(metaHex); + expect(result).toBeDefined(); + expect(result?.startsWith("https://")).toBe(true); + }); + + it("handles real-world CBOR structure", () => { + // Simulating a more realistic CBOR map structure + const url = "https://api.raindex.io/oracle"; + const urlHex = Buffer.from(url).toString("hex"); + const length = (urlHex.length / 2).toString(16).padStart(2, "0"); + // a2 = map with 2 items, 00 = key 0, 58 = byte string with 1-byte length + const metaHex = + "a20058" + length + urlHex + endMarker + OracleConstants.RaindexSignedContextOracleV1; + + expect(extractOracleUrl(metaHex)).toBe(url); + }); +}); + +describe("isInCooloff", () => { + let healthMap: OracleHealthMap; + const testUrl = "https://oracle.example.com"; + + beforeEach(() => { + healthMap = new Map(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-03T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns false for unknown URL", () => { + expect(isInCooloff(healthMap, testUrl)).toBe(false); + }); + + it("returns false when cooloffUntil is 0", () => { + healthMap.set(testUrl, { consecutiveFailures: 5, cooloffUntil: 0 }); + + expect(isInCooloff(healthMap, testUrl)).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 }); + + expect(isInCooloff(healthMap, testUrl)).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 }); + + expect(isInCooloff(healthMap, testUrl)).toBe(false); + expect(healthMap.get(testUrl)?.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 }); + + expect(isInCooloff(healthMap, testUrl)).toBe(false); + expect(healthMap.get(testUrl)?.cooloffUntil).toBe(0); + }); + + it("preserves consecutiveFailures when resetting expired cooloff", () => { + const pastTime = Date.now() - 1000; + healthMap.set(testUrl, { consecutiveFailures: 10, cooloffUntil: pastTime }); + + isInCooloff(healthMap, testUrl); + + expect(healthMap.get(testUrl)?.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 }); + + expect(isInCooloff(healthMap, url1)).toBe(true); + expect(isInCooloff(healthMap, url2)).toBe(false); + }); + + it("does not modify state when in active cooloff", () => { + const futureTime = Date.now() + 60000; + healthMap.set(testUrl, { consecutiveFailures: 5, cooloffUntil: futureTime }); + + isInCooloff(healthMap, testUrl); + + const state = healthMap.get(testUrl); + 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 }); + + expect(isInCooloff(healthMap, testUrl)).toBe(false); + }); +}); + +describe("recordOracleSuccess", () => { + let healthMap: OracleHealthMap; + const testUrl = "https://oracle.example.com"; + + beforeEach(() => { + healthMap = new Map(); + }); + + it("creates new state entry for unknown URL", () => { + recordOracleSuccess(healthMap, testUrl); + + const state = healthMap.get(testUrl); + 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 }); + + recordOracleSuccess(healthMap, testUrl); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(0); + }); + + it("clears cooloff period", () => { + healthMap.set(testUrl, { + consecutiveFailures: 10, + cooloffUntil: Date.now() + 60000, + }); + + recordOracleSuccess(healthMap, testUrl); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(0); + expect(state?.cooloffUntil).toBe(0); + }); + + it("handles multiple URLs independently", () => { + 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 }); + + recordOracleSuccess(healthMap, url1); + + 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); + }); + + it("overwrites existing state completely", () => { + healthMap.set(testUrl, { + consecutiveFailures: 100, + cooloffUntil: 999999, + }); + + recordOracleSuccess(healthMap, testUrl); + + const state = healthMap.get(testUrl); + 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); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(0); + expect(state?.cooloffUntil).toBe(0); + }); +}); + +describe("recordOracleFailure", () => { + let healthMap: OracleHealthMap; + const testUrl = "https://oracle.example.com"; + + beforeEach(() => { + healthMap = new Map(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-03T12:00:00Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("creates new state entry for unknown URL", () => { + recordOracleFailure(healthMap, testUrl); + + const state = healthMap.get(testUrl); + 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 }); + + recordOracleFailure(healthMap, testUrl); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(3); + }); + + it("enters cooloff when reaching threshold", () => { + healthMap.set(testUrl, { + consecutiveFailures: OracleConstants.COOLOFF_THRESHOLD - 1, + cooloffUntil: 0, + }); + + recordOracleFailure(healthMap, testUrl); + + const state = healthMap.get(testUrl); + 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, { + consecutiveFailures: OracleConstants.COOLOFF_THRESHOLD, + cooloffUntil: 0, + }); + + recordOracleFailure(healthMap, testUrl); + + const state = healthMap.get(testUrl); + 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); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(2); + expect(state?.cooloffUntil).toBe(0); + }); + + it("handles multiple URLs independently", () => { + const url1 = "https://oracle1.example.com"; + const url2 = "https://oracle2.example.com"; + + recordOracleFailure(healthMap, url1); + recordOracleFailure(healthMap, url1); + recordOracleFailure(healthMap, url2); + + expect(healthMap.get(url1)?.consecutiveFailures).toBe(2); + expect(healthMap.get(url2)?.consecutiveFailures).toBe(1); + }); + + it("preserves existing cooloff time when below threshold after reset", () => { + const existingCooloff = Date.now() + 5000; + healthMap.set(testUrl, { + consecutiveFailures: 1, + cooloffUntil: existingCooloff, + }); + + recordOracleFailure(healthMap, testUrl); + + const state = healthMap.get(testUrl); + expect(state?.consecutiveFailures).toBe(2); + expect(state?.cooloffUntil).toBe(existingCooloff); + }); +}); diff --git a/src/oracle/fetch.ts b/src/oracle/fetch.ts new file mode 100644 index 00000000..2c3dca39 --- /dev/null +++ b/src/oracle/fetch.ts @@ -0,0 +1,170 @@ +import axios from "axios"; +import { Result } from "../common"; +import { SignedContextV2 } from "../order/types/v4"; +import { OracleError, OracleErrorType } from "./error"; +import { encodeAbiParameters, hexToBytes } from "viem"; +import { + OracleConstants, + OracleHealthMap, + OracleOrderRequest, + OracleSingleAbiParams, +} from "./types"; + +/** + * Fetch signed context from an oracle endpoint (single request format). + * + * POSTs abi.encode(OrderV4, uint256, uint256, address) and expects + * a JSON SignedContextV2 object back. + * + * Single attempt with a hard timeout — no retries, no in-loop delays. + * Uses the provided health map for cooloff tracking. + * + * @param url - Oracle URL + * @param request - Order to request orcale for + * @param healthMap - Oracle endpoint health tracking for cooloff + */ +export async function fetchSignedContext( + url: string, + request: OracleOrderRequest, + healthMap: OracleHealthMap, +): Promise> { + if (!OracleConstants.isKnown(url)) { + return Result.err( + new OracleError(`Oracle ${url} is unknown, skipping`, OracleErrorType.Cooloff), + ); + } + + if (isInCooloff(healthMap, url)) { + return Result.err( + new OracleError(`Oracle ${url} is in cooloff, skipping`, OracleErrorType.Cooloff), + ); + } + + const encoded = encodeAbiParameters(OracleSingleAbiParams, [ + [ + { + order: request.order, + inputIOIndex: BigInt(request.inputIOIndex), + outputIOIndex: BigInt(request.outputIOIndex), + counterparty: request.counterparty, + }, + ], + ]); + const body = hexToBytes(encoded); + + try { + const response = await axios.post(url, body, { + headers: { + "Content-Type": "application/octet-stream", + }, + timeout: OracleConstants.ORACLE_TIMEOUT_MS, + responseType: "json", + }); + + // Validate shape of response + if (SignedContextV2.isValidList(response.data)) { + recordOracleSuccess(healthMap, url); + return Result.ok(response.data[0]); + } else { + recordOracleFailure(healthMap, url); + return Result.err( + new OracleError( + "Oracle response is not a valid SignedContextV2 list", + OracleErrorType.InvalidResponseType, + response.data, + ), + ); + } + } catch (err) { + recordOracleFailure(healthMap, url); + + // default error if not AxiosError type + let error = new OracleError( + `Oracle fetch error: ${err instanceof Error ? err.message : String(err)}`, + OracleErrorType.FetchError, + err, + ); + + if (axios.isAxiosError(err)) { + if (err.response) { + error = new OracleError( + `Oracle request failed with: ${err.response.status} ${err.response.statusText}`, + OracleErrorType.RequestFailed, + err, + ); + } + } + return Result.err(error); + } +} + +/** + * Extract oracle URL from order meta bytes. + * + * Searches for the RaindexSignedContextOracleV1 CBOR item identified by + * magic number 0xff7a1507ba4419ca and extracts the URL payload. + * + * @param metaHex - Hex string of meta bytes (e.g. "0x1234...") + * @returns Oracle URL if found, null otherwise + */ +export function extractOracleUrl(metaHex: string): string | undefined { + if (!metaHex) return undefined; + metaHex = metaHex.toLowerCase(); + const hex = metaHex.startsWith("0x") ? metaHex.slice(2) : metaHex; + + const magicIdx = hex.indexOf(OracleConstants.RaindexSignedContextOracleV1); + if (magicIdx === -1) return undefined; + + // The URL is encoded as a CBOR byte string before the magic in the same + // CBOR map: a2 00 58 01 1b + // Find "https://" or "http://" in hex before the magic + const httpsHex = Buffer.from("https://").toString("hex"); + const httpHex = Buffer.from("http://").toString("hex"); + + const searchRegion = hex.substring(0, magicIdx); + let urlStartIdx = searchRegion.lastIndexOf(httpsHex); + if (urlStartIdx === -1) urlStartIdx = searchRegion.lastIndexOf(httpHex); + if (urlStartIdx === -1) return undefined; + + // URL ends before the "01 1b" marker (CBOR key 1, uint64 prefix) that precedes the magic + const endMarker = "011b"; + const endIdx = searchRegion.lastIndexOf(endMarker); + if (endIdx === -1 || endIdx < urlStartIdx) return undefined; + + const urlHex = hex.substring(urlStartIdx, endIdx); + try { + return Buffer.from(urlHex, "hex").toString("utf8"); + } catch { + return 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); + if (!state || state.cooloffUntil === 0) return false; + if (Date.now() >= state.cooloffUntil) { + state.cooloffUntil = 0; + return false; + } + return true; +} + +/** Records the sucess in orcale health map */ +export function recordOracleSuccess(healthMap: OracleHealthMap, url: string) { + healthMap.set(url, { consecutiveFailures: 0, 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 }; + 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`, + // ); + } + healthMap.set(url, state); +} diff --git a/src/oracle/index.test.ts b/src/oracle/index.test.ts new file mode 100644 index 00000000..c6cbfda8 --- /dev/null +++ b/src/oracle/index.test.ts @@ -0,0 +1,103 @@ +import { Result } from "../common"; +import { SharedState } from "../state"; +import { fetchOracleContext } from "./index"; +import { Order, Pair } from "../order/types"; +import { fetchSignedContext } from "./fetch"; +import { OracleError, OracleErrorType } from "./error"; +import { assert, describe, it, expect, vi, beforeEach, Mock } from "vitest"; + +// Mock the fetchSignedContext function +vi.mock("./fetch", () => ({ + fetchSignedContext: vi.fn(), +})); + +describe("fetchOracleContext", () => { + let mockState: SharedState; + let mockOrderDetails: Pair; + + beforeEach(() => { + mockState = { + oracleHealth: new Map(), + } as SharedState; + + mockOrderDetails = { + oracleUrl: "https://example.com", + takeOrder: { + struct: { + order: { + type: Order.Type.V4, + }, + inputIOIndex: 0, + outputIOIndex: 0, + signedContext: [], + }, + }, + } as any; + }); + + it("returns ok when no oracle URL is present", async () => { + mockOrderDetails.oracleUrl = undefined; + const result = await fetchOracleContext.call(mockState, mockOrderDetails); + + assert(result.isOk()); + expect(result.value).toBeUndefined(); + expect(fetchSignedContext as Mock).not.toHaveBeenCalled(); + }); + + it("returns ok when Order V3", async () => { + mockOrderDetails.takeOrder.struct.order.type = Order.Type.V3; + const result = await fetchOracleContext.call(mockState, mockOrderDetails); + + assert(result.isOk()); + expect(result.value).toBeUndefined(); + expect(fetchSignedContext as Mock).not.toHaveBeenCalled(); + }); + + it("returns correctly call fetchSignedContext when Order V4 when it returns error", async () => { + const error = new OracleError("some error", OracleErrorType.FetchError); + (fetchSignedContext as Mock).mockResolvedValueOnce(Result.err(error)); + const result = await fetchOracleContext.call(mockState, mockOrderDetails); + + assert(result.isErr()); + expect(result.error).toEqual(error); + expect(fetchSignedContext as Mock).toHaveBeenNthCalledWith( + 1, + mockOrderDetails.oracleUrl, + { + order: mockOrderDetails.takeOrder.struct.order, + inputIOIndex: mockOrderDetails.takeOrder.struct.inputIOIndex, + outputIOIndex: mockOrderDetails.takeOrder.struct.outputIOIndex, + counterparty: "0x0000000000000000000000000000000000000000", + }, + mockState.oracleHealth, + ); + }); + + it("returns correctly call fetchSignedContext when Order V4 when it returns ok", async () => { + const validSignedContext = { + signer: "0x000000000000000000000000abcdef1234567890", + context: [ + "0x0000000000000000000000000000000000000000000000000000000000000001", + "0x0000000000000000000000000000000000000000000000000000000000000002", + ], + signature: "0xsignature", + }; + (fetchSignedContext as Mock).mockResolvedValueOnce(Result.ok(validSignedContext)); + const result = await fetchOracleContext.call(mockState, mockOrderDetails); + + assert(result.isOk()); + expect(result.value).toBeUndefined(); + expect(fetchSignedContext as Mock).toHaveBeenNthCalledWith( + 1, + mockOrderDetails.oracleUrl, + { + order: mockOrderDetails.takeOrder.struct.order, + inputIOIndex: mockOrderDetails.takeOrder.struct.inputIOIndex, + outputIOIndex: mockOrderDetails.takeOrder.struct.outputIOIndex, + counterparty: "0x0000000000000000000000000000000000000000", + }, + mockState.oracleHealth, + ); + expect(mockOrderDetails.takeOrder.struct.signedContext).toEqual([validSignedContext]); + }); +}); diff --git a/src/oracle/index.ts b/src/oracle/index.ts new file mode 100644 index 00000000..169270da --- /dev/null +++ b/src/oracle/index.ts @@ -0,0 +1,42 @@ +import { Result } from "../common"; +import { OracleError } from "./error"; +import { SharedState } from "../state"; +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. + * + * @returns Result that callers decide how to handle failures. + */ +export async function fetchOracleContext( + this: SharedState, + orderDetails: Pair, +): Promise> { + const oracleUrl = orderDetails.oracleUrl; + if (!oracleUrl) return Result.ok(undefined); + + // Oracle signed context only supported for V4 orders + const order = orderDetails.takeOrder.struct.order; + if (order.type !== Order.Type.V4) return Result.ok(undefined); + + const result = await fetchSignedContext( + oracleUrl, + { + order: order as Order.V4, + 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/types.ts b/src/oracle/types.ts new file mode 100644 index 00000000..1fc0cd8c --- /dev/null +++ b/src/oracle/types.ts @@ -0,0 +1,84 @@ +import { Order } from "../order"; + +/** Provides constants and functionalities for interacting with oracles */ +export namespace OracleConstants { + /** RaindexSignedContextOracleV1 magic number */ + export const RaindexSignedContextOracleV1 = "ff7a1507ba4419ca" as const; + + /** Consecutive failures before entering cooloff */ + export const COOLOFF_THRESHOLD = 3 as const; + /** 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 = 5 * 60 * 1_000; + + /** List of known oracle URLs */ + export const KnownUrls = ["https://st0x-oracle-server.fly.dev/context"] as const; + + export function isKnown(url: string): boolean { + return KnownUrls.some((v) => url.startsWith(v)); + } +} + +export type OracleHealthMap = Map; + +/** + * Oracle request entry — mirrors the spec's (OrderV4, uint256, uint256, address) tuple. + * Only V4 orders support oracle signed context. + */ +export interface OracleOrderRequest { + order: Order.V4; + inputIOIndex: number; + outputIOIndex: number; + counterparty: `0x${string}`; +} + +/** + * ABI parameter definition for a single 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 OracleSingleAbiParams = [ + { + 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; diff --git a/src/order/index.test.ts b/src/order/index.test.ts index ae54d6c0..71f9925a 100644 --- a/src/order/index.test.ts +++ b/src/order/index.test.ts @@ -1414,6 +1414,7 @@ describe("Test OrderManager", () => { sellTokenDecimals: 6, buyTokenVaultBalance: 1000n, sellTokenVaultBalance: 2000n, + oracleUrl: undefined, takeOrder: { id: "0xHash", struct: { diff --git a/src/order/index.ts b/src/order/index.ts index 93307c4c..583fc784 100644 --- a/src/order/index.ts +++ b/src/order/index.ts @@ -459,7 +459,7 @@ 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, blockNumber, this.quoteGas); } /** diff --git a/src/order/quote.test.ts b/src/order/quote.test.ts index 1ef678ea..29992a7c 100644 --- a/src/order/quote.test.ts +++ b/src/order/quote.test.ts @@ -1,4 +1,5 @@ import { ChainId } from "sushi"; +import { SharedState } from "../state"; import { BundledOrders, Order, Pair } from "./types"; import { decodeFunctionResult, PublicClient } from "viem"; import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; @@ -18,9 +19,11 @@ vi.mock("./types", async (importOriginal) => ({ })); describe("Test quoteSingleOrder", () => { - const client = { - call: vi.fn().mockResolvedValue({ data: "0x" }), - } as any as PublicClient; + const state = { + client: { + call: vi.fn().mockResolvedValue({ data: "0x" }), + } as any as PublicClient, + } as any as SharedState; beforeEach(() => { vi.clearAllMocks(); @@ -35,13 +38,13 @@ describe("Test quoteSingleOrder", () => { }, }, } as any; - await quoteSingleOrder(orderDetails, client); + await quoteSingleOrder(orderDetails, state); expect(orderDetails.takeOrder.quote).toEqual({ maxOutput: 100n, ratio: 2n, }); - expect(client.call).toHaveBeenCalled(); + expect(state.client.call).toHaveBeenCalled(); }); it("should set quote on the takeOrder when data is returned", async () => { @@ -58,21 +61,23 @@ describe("Test quoteSingleOrder", () => { }, }, } as any; - await quoteSingleOrder(orderDetails, client); + await quoteSingleOrder(orderDetails, state); expect(orderDetails.takeOrder.quote).toEqual({ maxOutput: 100n, ratio: 2n, }); - expect(client.call).toHaveBeenCalled(); + expect(state.client.call).toHaveBeenCalled(); }); }); describe("Test quoteSingleOrderV3", () => { let orderDetails: Pair; - const client = { - call: vi.fn().mockResolvedValueOnce({ data: "0x" }), - } as any as PublicClient; + const state = { + client: { + call: vi.fn().mockResolvedValue({ data: "0x" }), + } as any as PublicClient, + } as any as SharedState; beforeEach(() => { vi.clearAllMocks(); @@ -87,18 +92,18 @@ describe("Test quoteSingleOrderV3", () => { }); it("should set quote on the takeOrder when data is returned", async () => { - await quoteSingleOrderV3(orderDetails, client); + await quoteSingleOrderV3(orderDetails, state); expect(orderDetails.takeOrder.quote).toEqual({ maxOutput: 100n, ratio: 2n, }); - expect(client.call).toHaveBeenCalled(); + expect(state.client.call).toHaveBeenCalled(); }); it("should reject if no data is returned", async () => { - (client.call as Mock).mockResolvedValueOnce({ data: undefined }); - await expect(quoteSingleOrderV3(orderDetails, client)).rejects.toMatch( + (state.client.call as Mock).mockResolvedValueOnce({ data: undefined }); + await expect(quoteSingleOrderV3(orderDetails, state)).rejects.toMatch( /Failed to quote order/, ); }); @@ -106,9 +111,11 @@ describe("Test quoteSingleOrderV3", () => { describe("Test quoteSingleOrderV4", () => { let orderDetails: Pair; - const client = { - call: vi.fn().mockResolvedValue({ data: "0x" }), - } as any as PublicClient; + const state = { + client: { + call: vi.fn().mockResolvedValue({ data: "0x" }), + } as any as PublicClient, + } as any as SharedState; beforeEach(() => { vi.clearAllMocks(); @@ -128,18 +135,18 @@ describe("Test quoteSingleOrderV4", () => { "0xffffffee00000000000000000000000000000000000000000000000000000064", "0xffffffee00000000000000000000000000000000000000000000000000000002", ]); - await quoteSingleOrderV4(orderDetails, client); + await quoteSingleOrderV4(orderDetails, state); expect(orderDetails.takeOrder.quote).toEqual({ maxOutput: 100n, ratio: 2n, }); - expect(client.call).toHaveBeenCalled(); + expect(state.client.call).toHaveBeenCalled(); }); it("should reject if no data is returned", async () => { - (client.call as Mock).mockResolvedValueOnce({ data: undefined }); - await expect(quoteSingleOrderV4(orderDetails, client)).rejects.toMatch( + (state.client.call as Mock).mockResolvedValueOnce({ data: undefined }); + await expect(quoteSingleOrderV4(orderDetails, state)).rejects.toMatch( /Failed to quote order/, ); }); @@ -150,7 +157,7 @@ describe("Test quoteSingleOrderV4", () => { "0xinvalid", "0x0000000000000000000000000000000000000000000000000000000000000001", ]); - await expect(quoteSingleOrderV4(orderDetails, client)).rejects.toContain( + await expect(quoteSingleOrderV4(orderDetails, state)).rejects.toContain( "Invalid hex string", ); }); @@ -161,7 +168,7 @@ describe("Test quoteSingleOrderV4", () => { "0x0000000000000000000000000000000000000000000000000000000000000001", "0xinvalid", ]); - await expect(quoteSingleOrderV4(orderDetails, client)).rejects.toContain( + await expect(quoteSingleOrderV4(orderDetails, state)).rejects.toContain( "Invalid hex string", ); }); diff --git a/src/order/quote.ts b/src/order/quote.ts index 0504f33f..eff4d63f 100644 --- a/src/order/quote.ts +++ b/src/order/quote.ts @@ -1,44 +1,47 @@ import { ChainId } from "sushi"; import { SharedState } from "../state"; import { AppOptions } from "../config"; +import { fetchOracleContext } from "../oracle"; import { ABI, normalizeFloat } from "../common"; import { BundledOrders, Pair, TakeOrder } from "./types"; -import { decodeFunctionResult, encodeFunctionData, PublicClient } from "viem"; +import { decodeFunctionResult, encodeFunctionData } from "viem"; /** * 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, state, blockNumber, gas); } else { - return quoteSingleOrderV4(orderDetails, viemClient, blockNumber, gas); + return quoteSingleOrderV4(orderDetails, 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, ) { - const { data } = await viemClient + const oracleResult = await fetchOracleContext.call(state, orderDetails); + if (oracleResult.isErr()) { + throw oracleResult.error; + } + + const { data } = await state.client .call({ to: orderDetails.orderbook as `0x${string}`, data: encodeFunctionData({ @@ -71,18 +74,19 @@ 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, ) { - const { data } = await viemClient + const oracleResult = await fetchOracleContext.call(state, orderDetails); + if (oracleResult.isErr()) { + throw oracleResult.error; + } + + const { data } = await state.client .call({ to: orderDetails.orderbook as `0x${string}`, data: encodeFunctionData({ diff --git a/src/order/types/index.ts b/src/order/types/index.ts index 6fad1e39..04693910 100644 --- a/src/order/types/index.ts +++ b/src/order/types/index.ts @@ -144,6 +144,8 @@ export type PairBase = { sellTokenDecimals: number; sellTokenSymbol: string; sellTokenVaultBalance: bigint; + /** Oracle URL extracted from order meta, if present */ + oracleUrl: string | undefined; }; export type Pair = PairV3 | PairV4; export namespace Pair { diff --git a/src/order/types/v3.ts b/src/order/types/v3.ts index bd310ddd..d97944dc 100644 --- a/src/order/types/v3.ts +++ b/src/order/types/v3.ts @@ -3,6 +3,7 @@ import { SgOrder } from "../../subgraph"; import { ABI, Result } from "../../common"; import { Order, PairBase, TakeOrderDetailsBase } from "."; import { decodeAbiParameters, DecodeAbiParametersErrorType } from "viem"; +import { extractOracleUrl } from "../../oracle/fetch"; // these types are used in orderbook v4 @@ -121,6 +122,7 @@ export namespace PairV3 { sellTokenSymbol: outputSymbol, sellTokenDecimals: outputDecimals, sellTokenVaultBalance: BigInt(outputBalance), + oracleUrl: orderDetails.meta ? extractOracleUrl(orderDetails.meta) : undefined, takeOrder: { id: orderHash, struct: { diff --git a/src/order/types/v4.test.ts b/src/order/types/v4.test.ts index b71d00bd..33b14af2 100644 --- a/src/order/types/v4.test.ts +++ b/src/order/types/v4.test.ts @@ -1,6 +1,6 @@ import assert from "assert"; import { Order, OrderbookVersions } from "./index"; -import { V4, PairV4 } from "./v4"; +import { V4, PairV4, SignedContextV2 } from "./v4"; import { SgOrder } from "../../subgraph"; import { decodeAbiParameters } from "viem"; import { normalizeFloat, Result } from "../../common"; @@ -187,3 +187,327 @@ describe("PairV4.fromArgs", () => { expect(result.error.readableMsg).toContain("failed to normalize"); }); }); + +describe("SignedContextV2", () => { + describe("test isValid() function", () => { + it("returns true for valid SignedContextV2 object", () => { + const valid = { + signer: "0x1234567890abcdef", + context: ["0x01", "0x02"], + signature: "0xabcdef", + }; + expect(SignedContextV2.isValid(valid)).toBe(true); + }); + + it("returns true for valid object with empty context array", () => { + const valid = { + signer: "0x1234", + context: [], + signature: "0xsig", + }; + expect(SignedContextV2.isValid(valid)).toBe(true); + }); + + it("returns false for null", () => { + expect(SignedContextV2.isValid(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(SignedContextV2.isValid(undefined)).toBe(false); + }); + + it("returns false for non-object types", () => { + expect(SignedContextV2.isValid("string")).toBe(false); + expect(SignedContextV2.isValid(123)).toBe(false); + expect(SignedContextV2.isValid(true)).toBe(false); + }); + + it("returns false when signer is missing", () => { + const invalid = { + context: ["0x01"], + signature: "0xsig", + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); + + it("returns false when signer is not a string", () => { + const invalid = { + signer: 12345, + context: ["0x01"], + signature: "0xsig", + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); + + it("returns false when context is missing", () => { + const invalid = { + signer: "0x1234", + signature: "0xsig", + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); + + it("returns false when context is not an array", () => { + const invalid = { + signer: "0x1234", + context: "not-an-array", + signature: "0xsig", + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); + + it("returns false when signature is missing", () => { + const invalid = { + signer: "0x1234", + context: ["0x01"], + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); + + it("returns false when signature is not a string", () => { + const invalid = { + signer: "0x1234", + context: ["0x01"], + signature: 12345, + }; + expect(SignedContextV2.isValid(invalid)).toBe(false); + }); + + it("returns true when extra properties are present", () => { + const valid = { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + extraField: "extra", + }; + expect(SignedContextV2.isValid(valid)).toBe(true); + }); + + it("returns false for empty object", () => { + expect(SignedContextV2.isValid({})).toBe(false); + }); + + it("returns false for array", () => { + expect(SignedContextV2.isValid([])).toBe(false); + }); + }); + + describe("test isValidList() function", () => { + it("returns true for valid array of SignedContextV2 objects", () => { + const validList = [ + { + signer: "0x1234567890abcdef", + context: ["0x01", "0x02"], + signature: "0xabcdef", + }, + { + signer: "0xabcdef1234567890", + context: ["0x03"], + signature: "0x123456", + }, + ]; + expect(SignedContextV2.isValidList(validList)).toBe(true); + }); + + it("returns true for single item array", () => { + const validList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + ]; + expect(SignedContextV2.isValidList(validList)).toBe(true); + }); + + it("returns true for empty array", () => { + expect(SignedContextV2.isValidList([])).toBe(true); + }); + + it("returns true for array with empty context arrays", () => { + const validList = [ + { + signer: "0x1234", + context: [], + signature: "0xsig", + }, + { + signer: "0x5678", + context: [], + signature: "0xsig2", + }, + ]; + expect(SignedContextV2.isValidList(validList)).toBe(true); + }); + + it("returns false for null", () => { + expect(SignedContextV2.isValidList(null)).toBe(false); + }); + + it("returns false for undefined", () => { + expect(SignedContextV2.isValidList(undefined)).toBe(false); + }); + + it("returns false for non-array types", () => { + expect(SignedContextV2.isValidList("string")).toBe(false); + expect(SignedContextV2.isValidList(123)).toBe(false); + expect(SignedContextV2.isValidList(true)).toBe(false); + }); + + it("returns false for object instead of array", () => { + const obj = { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }; + expect(SignedContextV2.isValidList(obj)).toBe(false); + }); + + it("returns false when array contains invalid item (missing signer)", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + { + context: ["0x02"], + signature: "0xsig2", + }, + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); + + it("returns false when array contains invalid item (missing context)", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + { + signer: "0x5678", + signature: "0xsig2", + }, + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); + + it("returns false when array contains invalid item (missing signature)", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + { + signer: "0x5678", + context: ["0x02"], + }, + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); + + it("returns false when array contains non-object item", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + "not an object", + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); + + it("returns false when array contains null item", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + null, + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); + + it("returns false when array contains item with invalid signer type", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + { + signer: 12345, + context: ["0x02"], + signature: "0xsig2", + }, + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); + + it("returns false when array contains item with invalid context type", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + { + signer: "0x5678", + context: "not-an-array", + signature: "0xsig2", + }, + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); + + it("returns false when array contains item with invalid signature type", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + { + signer: "0x5678", + context: ["0x02"], + signature: 12345, + }, + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); + + it("returns true when all items have extra properties", () => { + const validList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + extra: "field1", + }, + { + signer: "0x5678", + context: ["0x02"], + signature: "0xsig2", + another: "field2", + }, + ]; + expect(SignedContextV2.isValidList(validList)).toBe(true); + }); + + it("returns false when first item is valid but second is invalid", () => { + const invalidList = [ + { + signer: "0x1234", + context: ["0x01"], + signature: "0xsig", + }, + {}, + ]; + expect(SignedContextV2.isValidList(invalidList)).toBe(false); + }); + }); +}); diff --git a/src/order/types/v4.ts b/src/order/types/v4.ts index d56f16bd..b5efaedd 100644 --- a/src/order/types/v4.ts +++ b/src/order/types/v4.ts @@ -3,6 +3,7 @@ import { SgOrder, SubgraphVersions } from "../../subgraph"; import { WasmEncodedError } from "@rainlanguage/float"; import { Order, PairBase, TakeOrderDetailsBase } from "."; import { ABI, normalizeFloat, Result } from "../../common"; +import { extractOracleUrl } from "../../oracle/fetch"; import { decodeAbiParameters, DecodeAbiParametersErrorType } from "viem"; // these types are used in orderbook v5 @@ -56,11 +57,40 @@ export namespace V4 { } } +export type SignedContextV2 = { + signer: `0x${string}`; + context: `0x${string}`[]; + signature: `0x${string}`; +}; +export namespace SignedContextV2 { + /** Validates if the given value is a list of SignedContextV2 type */ + export function isValidList(value: any): value is SignedContextV2[] { + return !( + typeof value !== "object" || + value === null || + !Array.isArray(value) || + value.some((v) => !isValid(v)) + ); + } + + /** Validates if the given value is of SignedContextV2 type */ + export function isValid(value: any): value is SignedContextV2 { + return !( + typeof value !== "object" || + value === null || + typeof value.signer !== "string" || + !Array.isArray(value.context) || + value.context.some((v: any) => typeof v !== "string") || + typeof value.signature !== "string" + ); + } +} + export type TakeOrderV4 = { order: V4; inputIOIndex: number; outputIOIndex: number; - signedContext: any[]; + signedContext: SignedContextV2[]; }; export type TakeOrderDetailsV4 = TakeOrderDetailsBase & { @@ -140,6 +170,7 @@ export namespace PairV4 { sellTokenSymbol: outputSymbol, sellTokenDecimals: outputDecimals, sellTokenVaultBalance: outputBalanceRes.value, + oracleUrl: orderDetails.meta ? extractOracleUrl(orderDetails.meta) : undefined, takeOrder: { id: orderHash, struct: { diff --git a/src/state/index.ts b/src/state/index.ts index 683fb0f6..533a8b39 100644 --- a/src/state/index.ts +++ b/src/state/index.ts @@ -9,6 +9,7 @@ import { SushiRouter } from "../router/sushi"; import { AddressProvider } from "@balancer/sdk"; import { WalletConfig } from "../wallet/config"; import { Result, TokenDetails } from "../common"; +import { OracleHealthMap } from "../oracle/types"; import { RainSolverRouter } from "../router/router"; import { SubgraphConfig } from "../subgraph/config"; import { RainSolverBaseError } from "../error/types"; @@ -224,6 +225,8 @@ export class SharedState { writeRpc?: RpcState; /** List of latest successful transactions gas costs */ gasCosts: bigint[] = []; + /** Oracle endpoint health tracking for cooloff */ + oracleHealth: OracleHealthMap = 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 { diff --git a/src/subgraph/types.ts b/src/subgraph/types.ts index 481a8875..d882404e 100644 --- a/src/subgraph/types.ts +++ b/src/subgraph/types.ts @@ -5,6 +5,7 @@ export type SgOrder = { owner: string; orderHash: string; orderBytes: string; + meta?: string; active: boolean; nonce: string; orderbook: {