Skip to content

Commit 5b3688b

Browse files
committed
feat: add limit order preparation workflow
1 parent eb2032e commit 5b3688b

10 files changed

Lines changed: 388 additions & 95 deletions

File tree

docs/sdk-direction.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ The first shipping target is `@polymarket/client`. Its job is to make Polymarket
2929
- Add a cache layer for `tickSize`, fee bps, and `negRisk` metadata.
3030
- Consider moving `SignedOrder` into `packages/client/src/types.ts` so it is easier to reuse across order actions and wallet helpers.
3131
- Revisit whether the Gamma `GET /markets` `active` flag should be modeled in the SDK, since it is accepted in practice but omitted from the current OpenAPI contract and current client request type.
32+
- Review `throw new Error` usage in `packages/client/src/actions/orders/market.ts` and decide whether those order-action failures should use a more specific SDK error shape.
3233

3334
## Package Direction
3435

packages/bindings/src/clob/order-response.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export const OrderResponseSchema = z.object({
77
status: z.string(),
88
success: z.boolean(),
99
takingAmount: z.string(),
10-
transactionsHashes: z.array(z.string()),
10+
transactionsHashes: z.array(z.string()).default([]),
1111
});
1212

1313
export type OrderResponse = z.infer<typeof OrderResponseSchema>;

packages/client/src/actions/orders/context.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { TickSizeValue } from '@polymarket/bindings';
2+
import type { EvmAddress } from '@polymarket/types';
23
import { invariant } from '@polymarket/types';
4+
import type { SecureClient } from '../../clients';
5+
import { fetchFeeRate } from '../clob';
6+
import { fetchPublicProfile } from '../profiles';
37

48
export type RoundingConfig = {
59
amount: number;
@@ -21,3 +25,31 @@ export function resolveRoundingConfig(tickSize: TickSizeValue): RoundingConfig {
2125

2226
invariant(false, `Unsupported tick size: ${tickSize}`);
2327
}
28+
29+
export async function resolveFeeRateBps(
30+
client: SecureClient,
31+
tokenId: string,
32+
): Promise<number> {
33+
return fetchFeeRate(client, {
34+
tokenId,
35+
});
36+
}
37+
38+
export async function resolveFunderAddress(
39+
client: SecureClient,
40+
): Promise<EvmAddress> {
41+
const profile = await fetchPublicProfile(client, {
42+
address: client.address,
43+
});
44+
45+
return profile.proxyWallet ?? client.address;
46+
}
47+
48+
export function resolveExchangeAddress(
49+
client: SecureClient,
50+
negRisk: boolean,
51+
): EvmAddress {
52+
return negRisk
53+
? client.environment.negRiskExchange
54+
: client.environment.standardExchange;
55+
}
Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
export * from './context';
2-
export * from './market';
3-
export * from './orders';
41
export * from './post';
52
export * from './prepare';
6-
export * from './typed-data';
73
export * from './types';
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { EvmAddressSchema, type TickSizeValue } from '@polymarket/bindings';
2+
import {
3+
OrderSide,
4+
OrderSideSchema,
5+
OrderType,
6+
type SignatureType,
7+
} from '@polymarket/bindings/clob';
8+
import type { EvmAddress } from '@polymarket/types';
9+
import { z } from 'zod';
10+
import type { SecureClient } from '../../clients';
11+
import { UserInputError } from '../../errors';
12+
import { fetchNegRisk, fetchTickSize } from '../clob';
13+
import {
14+
resolveExchangeAddress,
15+
resolveFeeRateBps,
16+
resolveFunderAddress,
17+
resolveRoundingConfig,
18+
} from './context';
19+
import {
20+
decimalPlaces,
21+
parseAmount,
22+
roundDown,
23+
roundNormal,
24+
roundUp,
25+
} from './math';
26+
import type { OrderDraft, PrepareLimitOrderRequest } from './types';
27+
28+
export const PrepareLimitOrderParamsSchema = z
29+
.object({
30+
tokenId: z.string(),
31+
price: z.number().positive(),
32+
size: z.number().positive(),
33+
side: OrderSideSchema,
34+
taker: EvmAddressSchema.optional(),
35+
expiration: z.number().int().nonnegative().optional(),
36+
orderType: z
37+
.union([z.literal(OrderType.GTC), z.literal(OrderType.GTD)])
38+
.default(OrderType.GTC),
39+
})
40+
.superRefine((params, context) => {
41+
if (params.orderType === OrderType.GTD) {
42+
if (params.expiration === undefined) {
43+
context.addIssue({
44+
code: 'custom',
45+
message: 'GTD orders require an expiration timestamp.',
46+
path: ['expiration'],
47+
});
48+
return;
49+
}
50+
51+
const minimumExpiration = Math.floor(Date.now() / 1000) + 60;
52+
53+
if (params.expiration <= minimumExpiration) {
54+
context.addIssue({
55+
code: 'custom',
56+
message: 'GTD expiration must be at least 60 seconds in the future.',
57+
path: ['expiration'],
58+
});
59+
}
60+
61+
return;
62+
}
63+
64+
if (params.expiration !== undefined) {
65+
context.addIssue({
66+
code: 'custom',
67+
message: 'Expiration is only supported for GTD orders.',
68+
path: ['expiration'],
69+
});
70+
}
71+
}) satisfies z.ZodType<PrepareLimitOrderRequest>;
72+
73+
export type PrepareLimitOrderDraftParams = z.output<
74+
typeof PrepareLimitOrderParamsSchema
75+
>;
76+
77+
type ResolveLimitOrderContextParams = {
78+
price: number;
79+
tokenId: string;
80+
};
81+
82+
export async function prepareLimitOrderDraft(
83+
client: SecureClient,
84+
params: PrepareLimitOrderDraftParams,
85+
): Promise<OrderDraft> {
86+
const context = await resolveLimitOrderContext(client, {
87+
price: params.price,
88+
tokenId: params.tokenId,
89+
});
90+
const amounts = computeLimitOrderAmounts({
91+
price: context.price,
92+
side: params.side,
93+
size: params.size,
94+
tickSize: context.tickSize,
95+
});
96+
97+
return {
98+
chainId: client.environment.chainId,
99+
exchangeAddress: context.exchangeAddress,
100+
expiration: params.expiration ?? 0,
101+
feeRateBps: context.feeRateBps,
102+
funderAddress: context.funderAddress,
103+
offeredAmount: amounts.offeredAmount,
104+
orderType: params.orderType,
105+
side: params.side,
106+
signatureType: context.signatureType,
107+
signer: context.signerAddress,
108+
allowedTaker: params.taker,
109+
requestedAmount: amounts.requestedAmount,
110+
tokenId: params.tokenId,
111+
};
112+
}
113+
114+
type LimitOrderContext = {
115+
exchangeAddress: EvmAddress;
116+
feeRateBps: number;
117+
funderAddress: EvmAddress;
118+
negRisk: boolean;
119+
price: number;
120+
signatureType: SignatureType;
121+
signerAddress: EvmAddress;
122+
tickSize: TickSizeValue;
123+
};
124+
125+
async function resolveLimitOrderContext(
126+
client: SecureClient,
127+
params: ResolveLimitOrderContextParams,
128+
): Promise<LimitOrderContext> {
129+
const signerAddress = client.address;
130+
const signatureType = client.signatureType;
131+
const funderAddress = await resolveFunderAddress(client);
132+
const tickSize = await fetchTickSize(client, {
133+
tokenId: params.tokenId,
134+
});
135+
const feeRateBps = await resolveFeeRateBps(client, params.tokenId);
136+
const negRisk = await fetchNegRisk(client, {
137+
tokenId: params.tokenId,
138+
});
139+
140+
return {
141+
exchangeAddress: resolveExchangeAddress(client, negRisk),
142+
feeRateBps,
143+
funderAddress,
144+
negRisk,
145+
price: resolvePrice(params.price, tickSize),
146+
signatureType,
147+
signerAddress,
148+
tickSize,
149+
};
150+
}
151+
152+
function computeLimitOrderAmounts(params: {
153+
price: number;
154+
side: OrderSide;
155+
size: number;
156+
tickSize: TickSizeValue;
157+
}): {
158+
offeredAmount: bigint;
159+
requestedAmount: bigint;
160+
} {
161+
const roundConfig = resolveRoundingConfig(params.tickSize);
162+
const rawPrice = roundNormal(params.price, roundConfig.price);
163+
164+
if (params.side === OrderSide.BUY) {
165+
const rawTakerAmount = roundDown(params.size, roundConfig.size);
166+
let rawMakerAmount = rawTakerAmount * rawPrice;
167+
168+
if (decimalPlaces(rawMakerAmount) > roundConfig.amount) {
169+
rawMakerAmount = roundUp(rawMakerAmount, roundConfig.amount + 4);
170+
171+
if (decimalPlaces(rawMakerAmount) > roundConfig.amount) {
172+
rawMakerAmount = roundDown(rawMakerAmount, roundConfig.amount);
173+
}
174+
}
175+
176+
return {
177+
offeredAmount: parseAmount(rawMakerAmount),
178+
requestedAmount: parseAmount(rawTakerAmount),
179+
};
180+
}
181+
182+
const rawMakerAmount = roundDown(params.size, roundConfig.size);
183+
let rawTakerAmount = rawMakerAmount * rawPrice;
184+
185+
if (decimalPlaces(rawTakerAmount) > roundConfig.amount) {
186+
rawTakerAmount = roundUp(rawTakerAmount, roundConfig.amount + 4);
187+
188+
if (decimalPlaces(rawTakerAmount) > roundConfig.amount) {
189+
rawTakerAmount = roundDown(rawTakerAmount, roundConfig.amount);
190+
}
191+
}
192+
193+
return {
194+
offeredAmount: parseAmount(rawMakerAmount),
195+
requestedAmount: parseAmount(rawTakerAmount),
196+
};
197+
}
198+
199+
function resolvePrice(price: number, tickSize: TickSizeValue): number {
200+
const roundConfig = resolveRoundingConfig(tickSize);
201+
202+
if (price < tickSize || price > 1 - tickSize) {
203+
throw new UserInputError(
204+
`Price must be between ${tickSize} and ${1 - tickSize} for tick size ${tickSize}.`,
205+
);
206+
}
207+
208+
if (decimalPlaces(price) > roundConfig.price) {
209+
throw new UserInputError(
210+
`Price must conform to tick size ${tickSize} with at most ${roundConfig.price} decimal places.`,
211+
);
212+
}
213+
214+
return roundNormal(price, roundConfig.price);
215+
}

0 commit comments

Comments
 (0)