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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
101 changes: 70 additions & 31 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,10 @@ export class ClobClient {

private cachedVersion?: number;

private versionRequest?: Promise<number>;

private readonly marketInfoRequests = new Map<string, Promise<MarketDetails>>();

readonly retryOnError?: boolean;

readonly throwOnError?: boolean;
Expand Down Expand Up @@ -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<number> {
const response = await this.get(`${this.host}/version`);
// default to v2
return response?.version ?? 2;
return this.versionRequest ?? this.startVersionRequest();
}

private startVersionRequest(): Promise<number> {
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<number> {
Expand Down Expand Up @@ -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<MarketDetails> {
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<MarketDetails> {
const result: MarketDetails = await this.get(
`${this.host}${GET_CLOB_MARKET}${conditionID}`,
);
Expand Down Expand Up @@ -1041,14 +1083,10 @@ export class ClobClient {
postOnly = false,
deferExec = false,
): Promise<OrderResponse> {
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<T extends OrderType.FOK | OrderType.FAK = OrderType.FOK>(
Expand All @@ -1057,14 +1095,10 @@ export class ClobClient {
orderType: T = OrderType.FOK as T,
deferExec = false,
): Promise<OrderResponse> {
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(
Expand Down Expand Up @@ -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]);
}

Expand Down Expand Up @@ -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<unknown>) {
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<T>(attempt: () => Promise<T>): Promise<T> {
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);
Expand Down
196 changes: 196 additions & 0 deletions tests/client/orderMetadataCache.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof mockGet>, path: string) =>
get.mock.calls.filter(([url]) => String(url).includes(path)).length;
const posts = (client: ClobClient) =>
((client as any).post as ReturnType<typeof vi.fn>).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 = <T>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>(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<unknown>();
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<unknown>();
const second = deferred<unknown>();
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]);
});
});
Loading