diff --git a/README.md b/README.md index 7243407..1866479 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,30 @@ const resp = await client.createAndPostMarketOrder( console.log(resp); ``` +### Warm up order cache metadata + +The first order on a new client fetches the order version and the market's tick size, +neg-risk flag, and fee details before it can be signed. Both are public GET requests and +need no signer or credentials. To take them off the first trade, run them ahead of time +on the same client instance that will submit orders: + +```ts +await Promise.all([ + client.getVersion(), // adopted as the order version for this client + client.getClobMarketInfo(conditionID), // caches tick size, neg risk, and fees for both outcomes +]); +``` + +Each call asks the server and refreshes the caches, so repeating it costs one request. If +the version request fails, `getVersion` adopts the default version 2 and the first order +corrects it through the mismatch recovery built into order posting. With `throwOnError` +both calls throw `ApiError` instead, so treat a rejected warm-up as a background failure. +A new client instance starts with empty caches. Order creation and `getVersion` reuse a +version or market request that is still in flight instead of starting another one. Orders +never require these calls. An order placed before `getClobMarketInfo` resolves still +performs its own token-to-market lookup. Books, balances, allowances, credentials, and +builder fee rates are not cached by these calls. + ### Authentication The client has two authentication levels: diff --git a/src/client.ts b/src/client.ts index 19d9ed8..4cfa00b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -224,6 +224,10 @@ export class ClobClient { private cachedVersion?: number; + private versionRequest?: Promise; + + private readonly marketInfoRequests = new Map>(); + readonly retryOnError?: boolean; readonly throwOnError?: boolean; @@ -300,10 +304,30 @@ export class ClobClient { }); } + /** + * Fetches the current order version from the server and adopts it for the + * orders this client signs afterwards. Joins a version request that is + * already in flight. An error response yields the default version 2, which + * the first order corrects through the order version mismatch recovery. + * Throws ApiError when the client was created with throwOnError. + */ public async getVersion(): Promise { - const response = await this.get(`${this.host}/version`); - // default to v2 - return response?.version ?? 2; + return this.versionRequest ?? this.startVersionRequest(); + } + + private startVersionRequest(): Promise { + const request = this.get(`${this.host}/version`) + .then(response => { + // default to v2 + const version: number = response?.version ?? 2; + this.cachedVersion = version; + return version; + }) + .finally(() => { + if (this.versionRequest === request) this.versionRequest = undefined; + }); + this.versionRequest = request; + return request; } public async getServerTime(): Promise { @@ -340,7 +364,25 @@ export class ClobClient { return this.get(`${this.host}${GET_MARKET}${conditionID}`); } + /** + * Fetches the market parameters for a condition and caches the tick size, + * neg-risk flag, and fee details of both outcomes for later orders. Every + * call refreshes the caches. Concurrent calls for a condition share one request. + */ public async getClobMarketInfo(conditionID: string): Promise { + const pending = this.marketInfoRequests.get(conditionID); + if (pending) return pending; + + const request = this.fetchClobMarketInfo(conditionID); + this.marketInfoRequests.set(conditionID, request); + try { + return await request; + } finally { + this.marketInfoRequests.delete(conditionID); + } + } + + private async fetchClobMarketInfo(conditionID: string): Promise { const result: MarketDetails = await this.get( `${this.host}${GET_CLOB_MARKET}${conditionID}`, ); @@ -1041,14 +1083,10 @@ export class ClobClient { postOnly = false, deferExec = false, ): Promise { - let postOrderResponse: OrderResponse | undefined; - - await this._retryOnVersionUpdate(async () => { + return this._retryOnVersionUpdate(async () => { const order = await this.createOrder(userOrder, options); - postOrderResponse = await this.postOrder(order, orderType, postOnly, deferExec); + return this.postOrder(order, orderType, postOnly, deferExec); }); - - return postOrderResponse as OrderResponse; } public async createAndPostMarketOrder( @@ -1057,14 +1095,10 @@ export class ClobClient { orderType: T = OrderType.FOK as T, deferExec = false, ): Promise { - let postOrderMarketResponse: OrderResponse | undefined; - - await this._retryOnVersionUpdate(async () => { + return this._retryOnVersionUpdate(async () => { const order = await this.createMarketOrder(userMarketOrder, options); - postOrderMarketResponse = await this.postOrder(order, orderType, false, deferExec); + return this.postOrder(order, orderType, false, deferExec); }); - - return postOrderMarketResponse as OrderResponse; } public async getOpenOrders( @@ -1719,6 +1753,8 @@ export class ClobClient { this.tokenConditionMap[tokenID] = result.condition_id as string; } + // A market fetch may have filled the caches while the token was resolving. + if (tokenID in this.feeInfos) return; await this.getClobMarketInfo(this.tokenConditionMap[tokenID]); } @@ -1767,25 +1803,28 @@ export class ClobClient { return this.cachedVersion; } - // Query API and cache the result - const apiVersion = await this.getVersion(); - this.cachedVersion = apiVersion; - - return apiVersion; - } - - private async _retryOnVersionUpdate(retryFunc: () => Promise) { - const version = await this.resolveVersion(); - - for (let attempt = 0; attempt < 2; attempt++) { - await retryFunc(); - - // no need to retry if version is unchanged - if (version === (await this.resolveVersion())) break; + // Concurrent orders share the request in flight. A refresh after an order + // version mismatch asks the server again, since a pending answer may predate + // the change. Every request adopts its result when it settles. + if (!forceUpdate && this.versionRequest) return this.versionRequest; + return this.startVersionRequest(); + } + + // Runs a create-and-post attempt and repeats it once when the CLOB rejected + // the order for a version mismatch. postOrder refreshes the cached version on + // a mismatch, so the second attempt signs with the current version. + private async _retryOnVersionUpdate(attempt: () => Promise): Promise { + try { + const response = await attempt(); + if (!this._isOrderVersionMismatch(response as ClobErrorResponseBody)) return response; + } catch (err) { + const data = err instanceof ApiError ? (err.data as ClobErrorResponseBody) : undefined; + if (!this._isOrderVersionMismatch(data)) throw err; } + return attempt(); } - private _isOrderVersionMismatch(resp: ClobErrorResponseBody) { + private _isOrderVersionMismatch(resp?: ClobErrorResponseBody) { const error = resp?.error; if (!error) return false; const message = typeof error === "string" ? error : JSON.stringify(error); diff --git a/tests/client/orderMetadataCache.test.ts b/tests/client/orderMetadataCache.test.ts new file mode 100644 index 0000000..8309970 --- /dev/null +++ b/tests/client/orderMetadataCache.test.ts @@ -0,0 +1,196 @@ +import { Wallet } from "@ethersproject/wallet"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ClobClient } from "../../src/client.js"; +import { ApiError } from "../../src/errors.js"; +import type { SignedOrderV1 } from "../../src/order-utils/index.js"; +import { Chain, type MarketDetails, Side } from "../../src/types/index.js"; + +const market: MarketDetails = { + c: "market-a", + t: [ + { t: "a-yes", o: "Yes" }, + { t: "a-no", o: "No" }, + ], + mts: 0.01, + nr: true, + fd: { r: 0.25, e: 2 }, + r: null, +}; +const mismatch = { error: "order_version_mismatch" }; +const placed = { success: true, orderID: "0xorder", status: "matched" }; +// Signing and posting are stubbed, so only the shape of the order matters. +const signedOrder = { salt: "1", tokenId: "a-yes" } as unknown as SignedOrderV1; + +// Trading client with stubbed signing and http layers. `posts` are the responses of the +// first POSTs, after which every POST reports a placed order. +const makeClient = (posts: unknown[] = [], throwOnError = false) => { + const client = new ClobClient({ + host: "http://localhost:8080", + chain: Chain.AMOY, + signer: new Wallet("0x0000000000000000000000000000000000000000000000000000000000000001"), + creds: { key: "key", secret: "c2VjcmV0LXNlY3JldC1zZWNyZXQ=", passphrase: "passphrase" }, + throwOnError, + }); + vi.spyOn(client.orderBuilder, "buildOrder").mockResolvedValue(signedOrder); + vi.spyOn(client.orderBuilder, "buildMarketOrder").mockResolvedValue(signedOrder); + const post = vi.spyOn(client as any, "post").mockResolvedValue(placed); + for (const response of posts) post.mockResolvedValueOnce(response); + return client; +}; + +// Routes the private GET helper to canned metadata. `version` and `details` may return a +// pending promise or throw to model slow or failing endpoints. +const mockGet = ( + client: ClobClient, + version: () => unknown = () => ({ version: 2 }), + details: () => unknown = () => market, +) => + vi.spyOn(client as any, "get").mockImplementation(async (url: unknown) => { + if (String(url).endsWith("/version")) return version(); + if (String(url).includes("/clob-markets/")) return details(); + return { condition_id: "market-a" }; + }); + +const calls = (get: ReturnType, path: string) => + get.mock.calls.filter(([url]) => String(url).includes(path)).length; +const posts = (client: ClobClient) => + ((client as any).post as ReturnType).mock.calls.length; +const builtVersions = (client: ClobClient) => + vi.mocked(client.orderBuilder.buildMarketOrder).mock.calls.map(call => call[2]); +const marketOrder = (client: ClobClient, tokenID = "a-yes") => + client.createAndPostMarketOrder({ tokenID, amount: 10, side: Side.BUY, price: 0.5 }); +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise(res => { + resolve = res; + }); + return { promise, resolve }; +}; +const tick = () => new Promise(resolve => setTimeout(resolve, 10)); + +afterEach(() => vi.restoreAllMocks()); + +describe("order metadata caches", () => { + it("primes the version and market caches so orders skip metadata requests", async () => { + const client = makeClient(); + const get = mockGet(client, () => ({ version: 3 })); + + await Promise.all([client.getVersion(), client.getClobMarketInfo("market-a")]); + await marketOrder(client); + await marketOrder(client, "a-no"); + await client.createAndPostOrder({ tokenID: "a-yes", price: 0.5, size: 20, side: Side.BUY }); + + expect(get).toHaveBeenCalledTimes(2); + expect(builtVersions(client)).toEqual([3, 3]); + expect(client.tickSizes["a-no"]).toBe("0.01"); + expect(client.feeInfos["a-no"]).toEqual({ rate: 0.25, exponent: 2 }); + }); + + it("refreshes market parameters on every getClobMarketInfo call", async () => { + const client = makeClient(); + const details = vi + .fn() + .mockReturnValueOnce(market) + .mockReturnValue({ ...market, mts: 0.001 }); + mockGet(client, undefined, details); + + await client.getClobMarketInfo("market-a"); + await client.getClobMarketInfo("market-a"); + + expect(client.tickSizes["a-yes"]).toBe("0.001"); + }); + + it("adopts the default version when the version request fails and posts the order once", async () => { + const client = makeClient(); + const version = vi + .fn() + .mockReturnValueOnce({ error: "unavailable", status: 503 }) + .mockReturnValue({ version: 3 }); + const get = mockGet(client, version); + + await expect(client.getVersion()).resolves.toBe(2); + await expect(marketOrder(client)).resolves.toEqual(placed); + + expect(calls(get, "/version")).toBe(1); + expect(builtVersions(client)).toEqual([2]); + expect(posts(client)).toBe(1); + }); + + it("propagates ApiError from getVersion under throwOnError and leaves the cache empty", async () => { + const client = makeClient([], true); + const failure = new ApiError("unavailable", 503); + const version = vi.fn().mockRejectedValueOnce(failure).mockReturnValue({ version: 3 }); + const get = mockGet(client, version); + + await expect(client.getVersion()).rejects.toBe(failure); + await marketOrder(client); + + expect(calls(get, "/version")).toBe(2); + expect(builtVersions(client)).toEqual([3]); + }); + + it("shares one in-flight version request between concurrent orders and getVersion", async () => { + const client = makeClient(); + const version = deferred(); + const get = mockGet(client, () => version.promise); + + const orders = Promise.all([marketOrder(client), marketOrder(client, "a-no")]); + await vi.waitFor(() => expect(calls(get, "/version")).toBe(1)); + const poll = client.getVersion(); + version.resolve({ version: 3 }); + + await expect(poll).resolves.toBe(3); + await orders; + expect(calls(get, "/version")).toBe(1); + expect(calls(get, "/clob-markets/")).toBe(1); + expect(builtVersions(client)).toEqual([3, 3]); + }); + + it("retries every concurrent order with the refreshed version after a mismatch", async () => { + const client = makeClient([mismatch, mismatch]); + const first = deferred(); + const second = deferred(); + const version = vi + .fn() + .mockReturnValueOnce({ version: 2 }) + .mockReturnValueOnce(first.promise) + .mockReturnValue(second.promise); + const get = mockGet(client, version); + await Promise.all([client.getVersion(), client.getClobMarketInfo("market-a")]); + + const orders = Promise.all([marketOrder(client), marketOrder(client, "a-no")]); + // Both forced refreshes are in flight. The first settles while the second is pending. + await vi.waitFor(() => expect(calls(get, "/version")).toBe(3)); + first.resolve({ version: 3 }); + await tick(); + second.resolve({ version: 3 }); + + expect(await orders).toEqual([placed, placed]); + expect(builtVersions(client)).toEqual([2, 2, 3, 3]); + }); + + it("retries once after a mismatch under throwOnError", async () => { + const client = makeClient([mismatch], true); + mockGet( + client, + vi.fn().mockReturnValueOnce({ version: 2 }).mockReturnValue({ version: 3 }), + ); + + await expect(marketOrder(client)).resolves.toEqual(placed); + + expect(builtVersions(client)).toEqual([2, 3]); + }); + + it("returns the mismatch response when the retry is rejected again", async () => { + const client = makeClient([mismatch, mismatch]); + mockGet( + client, + vi.fn().mockReturnValueOnce({ version: 2 }).mockReturnValue({ version: 3 }), + ); + + await expect(marketOrder(client)).resolves.toEqual(mismatch); + + expect(posts(client)).toBe(2); + expect(builtVersions(client)).toEqual([2, 3]); + }); +});