Skip to content

Commit c267429

Browse files
committed
feat: add workflow-based client authentication
1 parent 4871352 commit c267429

15 files changed

Lines changed: 690 additions & 8 deletions

File tree

packages/bindings/src/clob/api-key.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,9 @@ export const ApiKeyResponseSchema = z.object({
77
});
88

99
export type ApiKeyResponse = z.infer<typeof ApiKeyResponseSchema>;
10+
11+
export const ApiKeysResponseSchema = z.object({
12+
apiKeys: z.array(z.string()),
13+
});
14+
15+
export type ApiKeysResponse = z.infer<typeof ApiKeysResponseSchema>;

packages/client/package.json

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,19 @@
1313
"./actions": {
1414
"types": "./dist/actions/index.d.ts",
1515
"default": "./dist/actions/index.js"
16+
},
17+
"./viem": {
18+
"types": "./dist/viem/index.d.ts",
19+
"default": "./dist/viem/index.js"
1620
}
1721
},
1822
"typesVersions": {
1923
"*": {
2024
"actions": [
2125
"./dist/actions/index.d.ts"
26+
],
27+
"viem": [
28+
"./dist/viem/index.d.ts"
2229
]
2330
}
2431
},
@@ -32,12 +39,21 @@
3239
},
3340
"devDependencies": {
3441
"tsup": "^8.5.1",
35-
"typescript": "^6.0.2"
42+
"typescript": "^6.0.2",
43+
"viem": "^2.46.3"
3644
},
3745
"license": "MIT",
3846
"publishConfig": {
3947
"access": "public"
4048
},
49+
"peerDependencies": {
50+
"viem": "^2.46.3"
51+
},
52+
"peerDependenciesMeta": {
53+
"viem": {
54+
"optional": true
55+
}
56+
},
4157
"dependencies": {
4258
"@polymarket/bindings": "workspace:*",
4359
"@polymarket/types": "workspace:*",
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import {
2+
type ApiKeyResponse,
3+
ApiKeyResponseSchema,
4+
type ApiKeysResponse,
5+
ApiKeysResponseSchema,
6+
} from '@polymarket/bindings/clob';
7+
import { type EvmAddress, type Signature, unwrap } from '@polymarket/types';
8+
import { createL2AuthTypedDataPayload } from '../authentication';
9+
import type { PublicClient, SecureClient } from '../clients';
10+
import {
11+
type RateLimitError,
12+
RequestRejectedError,
13+
SigningError,
14+
type TransportError,
15+
type UnexpectedResponseError,
16+
} from '../errors';
17+
import { validateWith } from '../response';
18+
19+
export { createL2AuthTypedDataPayload };
20+
21+
export type CreateL2AuthRequest = {
22+
chainId: number;
23+
nonce?: number;
24+
};
25+
26+
export type L2AuthRequest = {
27+
address: EvmAddress;
28+
nonce: number;
29+
signature: Signature;
30+
timestamp: number;
31+
};
32+
33+
export type CreateApiKeyError =
34+
| RateLimitError
35+
| RequestRejectedError
36+
| TransportError
37+
| UnexpectedResponseError;
38+
39+
/**
40+
* Creates a new API key from a signed L1 auth payload.
41+
*
42+
* @remarks
43+
* This is a low-level auth action that most SDK consumers will not need.
44+
*
45+
* @example
46+
* ```ts
47+
* const apiKey = await createApiKey(client, request);
48+
* ```
49+
*
50+
* @throws {@link CreateApiKeyError}
51+
* Thrown when the request is rejected, rate limited, interrupted by transport issues, or returns an unexpected response.
52+
*/
53+
export async function createApiKey(
54+
client: PublicClient,
55+
request: L2AuthRequest,
56+
): Promise<ApiKeyResponse> {
57+
return unwrap(
58+
client.clob
59+
.post('auth/api-key', {
60+
headers: toL1Headers(request),
61+
})
62+
.andThen(validateWith(ApiKeyResponseSchema)),
63+
);
64+
}
65+
66+
export type DeriveApiKeyError =
67+
| RateLimitError
68+
| RequestRejectedError
69+
| TransportError
70+
| UnexpectedResponseError;
71+
72+
/**
73+
* Derives an existing API key from a signed L1 auth payload.
74+
*
75+
* @remarks
76+
* This is a low-level auth action that most SDK consumers will not need.
77+
*
78+
* @example
79+
* ```ts
80+
* const apiKey = await deriveApiKey(client, request);
81+
* ```
82+
*
83+
* @throws {@link DeriveApiKeyError}
84+
* Thrown when the request is rejected, rate limited, interrupted by transport issues, or returns an unexpected response.
85+
*/
86+
export async function deriveApiKey(
87+
client: PublicClient,
88+
request: L2AuthRequest,
89+
): Promise<ApiKeyResponse> {
90+
return unwrap(
91+
client.clob
92+
.get('auth/derive-api-key', {
93+
headers: toL1Headers(request),
94+
})
95+
.andThen(validateWith(ApiKeyResponseSchema)),
96+
);
97+
}
98+
99+
export type CreateOrDeriveApiKeyError =
100+
| RateLimitError
101+
| RequestRejectedError
102+
| TransportError
103+
| UnexpectedResponseError;
104+
105+
/**
106+
* Derives an API key and falls back to creation when one does not exist yet.
107+
*
108+
* @remarks
109+
* This is a low-level auth action that most SDK consumers will not need.
110+
*
111+
* @example
112+
* ```ts
113+
* const apiKey = await createOrDeriveApiKey(client, request);
114+
* ```
115+
*
116+
* @throws {@link CreateOrDeriveApiKeyError}
117+
* Thrown when the request is rejected, rate limited, interrupted by transport issues, or returns an unexpected response.
118+
*/
119+
export async function createOrDeriveApiKey(
120+
client: PublicClient,
121+
request: L2AuthRequest,
122+
): Promise<ApiKeyResponse> {
123+
try {
124+
return await deriveApiKey(client, request);
125+
} catch (error) {
126+
if (!(error instanceof RequestRejectedError) || error.status !== 400) {
127+
throw error;
128+
}
129+
}
130+
131+
try {
132+
return await createApiKey(client, request);
133+
} catch (error) {
134+
if (!(error instanceof RequestRejectedError) || error.status !== 400) {
135+
throw error;
136+
}
137+
}
138+
139+
return deriveApiKey(client, request);
140+
}
141+
142+
export type FetchApiKeysError =
143+
| RateLimitError
144+
| RequestRejectedError
145+
| SigningError
146+
| TransportError
147+
| UnexpectedResponseError;
148+
149+
/**
150+
* Fetches all API keys associated with the authenticated client.
151+
*
152+
* @remarks
153+
* This is a low-level auth action that most SDK consumers will not need.
154+
*
155+
* @example
156+
* ```ts
157+
* const apiKeys = await fetchApiKeys(client);
158+
* ```
159+
*
160+
* @throws {@link FetchApiKeysError}
161+
* Thrown when request signing fails, or the request is rejected, rate limited, interrupted by transport issues, or returns an unexpected response.
162+
*/
163+
export async function fetchApiKeys(
164+
client: SecureClient,
165+
): Promise<ApiKeysResponse['apiKeys']> {
166+
const path = '/auth/api-keys';
167+
168+
const response = await unwrap(
169+
client.clob
170+
.get(path.slice(1), {
171+
headers: await toL2Headers(client, {
172+
method: 'GET',
173+
requestPath: path,
174+
}),
175+
})
176+
.andThen(validateWith(ApiKeysResponseSchema)),
177+
);
178+
179+
return response.apiKeys;
180+
}
181+
182+
function toL1Headers(auth: L2AuthRequest): HeadersInit {
183+
return {
184+
POLY_ADDRESS: auth.address,
185+
POLY_NONCE: `${auth.nonce}`,
186+
POLY_SIGNATURE: auth.signature,
187+
POLY_TIMESTAMP: `${auth.timestamp}`,
188+
};
189+
}
190+
191+
async function toL2Headers(
192+
client: SecureClient,
193+
request: { method: string; requestPath: string; body?: string },
194+
): Promise<HeadersInit> {
195+
try {
196+
const timestamp = Math.floor(Date.now() / 1000);
197+
198+
return {
199+
POLY_ADDRESS: client.address,
200+
POLY_API_KEY: client.credentials.apiKey,
201+
POLY_PASSPHRASE: client.credentials.passphrase,
202+
POLY_SIGNATURE: await buildPolyHmacSignature(
203+
client.credentials.secret,
204+
timestamp,
205+
request.method,
206+
request.requestPath,
207+
request.body,
208+
),
209+
POLY_TIMESTAMP: `${timestamp}`,
210+
};
211+
} catch (error) {
212+
throw SigningError.fromError(
213+
error,
214+
'Could not sign the authenticated request',
215+
);
216+
}
217+
}
218+
219+
async function buildPolyHmacSignature(
220+
secret: string,
221+
timestamp: number,
222+
method: string,
223+
requestPath: string,
224+
body?: string,
225+
): Promise<string> {
226+
let message = `${timestamp}${method}${requestPath}`;
227+
228+
if (body !== undefined) {
229+
message += body;
230+
}
231+
232+
const cryptoKey = await globalThis.crypto.subtle.importKey(
233+
'raw',
234+
base64ToArrayBuffer(secret),
235+
{ name: 'HMAC', hash: 'SHA-256' },
236+
false,
237+
['sign'],
238+
);
239+
const signature = await globalThis.crypto.subtle.sign(
240+
'HMAC',
241+
cryptoKey,
242+
new TextEncoder().encode(message),
243+
);
244+
245+
return toUrlSafeBase64(arrayBufferToBase64(signature));
246+
}
247+
248+
function base64ToArrayBuffer(base64: string): ArrayBuffer {
249+
const sanitizedBase64 = base64
250+
.replace(/-/g, '+')
251+
.replace(/_/g, '/')
252+
.replace(/[^A-Za-z0-9+/=]/g, '');
253+
const binaryString = atob(sanitizedBase64);
254+
const bytes = new Uint8Array(binaryString.length);
255+
256+
for (let index = 0; index < binaryString.length; index += 1) {
257+
bytes[index] = binaryString.charCodeAt(index);
258+
}
259+
260+
return bytes.buffer;
261+
}
262+
263+
function arrayBufferToBase64(buffer: ArrayBuffer): string {
264+
const bytes = new Uint8Array(buffer);
265+
let binary = '';
266+
267+
for (const byte of bytes) {
268+
binary += String.fromCharCode(byte);
269+
}
270+
271+
return btoa(binary);
272+
}
273+
274+
function toUrlSafeBase64(value: string): string {
275+
return value.replace(/\+/g, '-').replace(/\//g, '_');
276+
}

packages/client/src/actions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './activity';
2+
export * from './auth';
23
export * from './clob';
34
export * from './comments';
45
export * from './events';

packages/client/src/auth.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { fetchApiKeys } from './actions';
3+
import { createPublicClient } from './clients';
4+
import { createTestWalletClient } from './testing';
5+
import { authenticateWith } from './viem';
6+
7+
describe('Auth', () => {
8+
describe('authenticate', () => {
9+
it('authenticates a secure client from an authentication workflow', async () => {
10+
const publicClient = createPublicClient();
11+
const walletClient = createTestWalletClient();
12+
13+
const secureClient = await publicClient
14+
.beginAuthentication()
15+
.then(authenticateWith(walletClient));
16+
17+
await expect(fetchApiKeys(secureClient)).resolves.toBeDefined();
18+
});
19+
});
20+
});

0 commit comments

Comments
 (0)