Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
65 changes: 33 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,51 +1,52 @@
# builder-signing-sdk

A TypeScript SDK for creating authenticated builder headers

A TypeScript SDK for creating authenticated Polymarket builder headers.

## Installation

```bash
pnpm install @polymarket/builder-signing-sdk
pnpm add @polymarket/builder-signing-sdk
```

## Quick Start
## Quick start

Use `BuilderConfig` with local builder API credentials when the signing secret may safely remain in the application process:

```typescript
import { BuilderSigner } from '@polymarket/builder-signing-sdk';

// Create a builder config for signing

// Local
const builderConfig = new BuilderConfig(
{
localBuilderCreds: {
key: "xxxxxxx-xxx-xxxx-xxx-xxxxxxxxx",
secret: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
passphrase: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
},
import { BuilderConfig } from '@polymarket/builder-signing-sdk';

const builderConfig = new BuilderConfig({
localBuilderCreds: {
key: process.env.POLY_BUILDER_API_KEY!,
secret: process.env.POLY_BUILDER_SECRET!,
passphrase: process.env.POLY_BUILDER_PASSPHRASE!,
},
);
});

const headers = await builderConfig.generateBuilderHeaders(
'POST' // HTTP method
'/order', // API endpoint path
'{"marketId": "0x123"}' // Request body
'POST',
'/order',
'{"marketId":"0x123"}',
);
```

Use a remote signer when the signing secret is isolated in a separate service. Remote signer URLs must use HTTPS and must not include URL credentials. The optional token is sent as a bearer token.

// Remote
const builderConfig = new BuilderConfig(
{
remoteBuilderConfig: {
url: remoteSignerUrl,
token: `${process.env.MY_AUTH_TOKEN}`
}
```typescript
import { BuilderConfig } from '@polymarket/builder-signing-sdk';

const builderConfig = new BuilderConfig({
remoteBuilderConfig: {
url: process.env.POLY_REMOTE_SIGNER_URL!,
token: process.env.POLY_REMOTE_SIGNER_TOKEN,
},
);
});

const headers = await builderConfig.generateBuilderHeaders(
'POST' // HTTP method
'/order', // API endpoint path
'{"marketId": "0x123"}' // Request body
'POST',
'/order',
'{"marketId":"0x123"}',
);
```
```

Do not commit API keys, signing secrets, passphrases, or remote signer tokens.
108 changes: 59 additions & 49 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,16 @@ import {
BuilderHeaderPayload,
BuilderType,
RemoteBuilderConfig,
RemoteSignerPayload
RemoteSignerPayload,
} from "./types.js";
import { AxiosRequestHeaders } from 'axios';
import { AxiosRequestHeaders } from "axios";

export class BuilderConfig {
readonly remoteBuilderConfig?: RemoteBuilderConfig;
readonly localBuilderCreds?: BuilderApiKeyCreds;
readonly signer?: BuilderSigner;

constructor(config?: {
constructor(config?: {
remoteBuilderConfig?: RemoteBuilderConfig;
localBuilderCreds?: BuilderApiKeyCreds;
}) {
Expand All @@ -34,18 +34,16 @@ export class BuilderConfig {
if (config.localBuilderCreds !== undefined) {
if (!BuilderConfig.hasValidLocalCreds(config.localBuilderCreds)) {
throw new Error("invalid local builder credentials!");
}
}
this.localBuilderCreds = config.localBuilderCreds;
this.signer = new BuilderSigner(config.localBuilderCreds);
}
}
}

/**
* Helper function to generate builder headers using the configured credential method
* @param method
* @param path
* @param body
* Helper function to generate builder headers using the configured credential method.
* Raw errors are deliberately caught and suppressed to avoid leaking sensitive bearer tokens.
*/
public async generateBuilderHeaders(
method: string,
Expand All @@ -57,33 +55,36 @@ export class BuilderConfig {

const builderType = this.getBuilderType();

if (builderType == BuilderType.LOCAL) {
return Promise.resolve(this.signer?.createBuilderHeaderPayload(method, path, body, timestamp));
}

if (builderType == BuilderType.REMOTE) {
const url: string = (this.remoteBuilderConfig as RemoteBuilderConfig).url;
// Execute a POST to the remote signer url with the header arguments
if (builderType === BuilderType.LOCAL) {
return this.signer?.createBuilderHeaderPayload(method, path, body, timestamp);
}

if (builderType === BuilderType.REMOTE) {
const remoteConfig = this.remoteBuilderConfig as RemoteBuilderConfig;
const url: string = remoteConfig.url;

const payload: RemoteSignerPayload = {
method: method,
path: path,
body: body,
timestamp: timestamp,
method,
path,
body,
timestamp,
};

try {
const token = (this.remoteBuilderConfig as RemoteBuilderConfig).token;
return await post(url, {
data: payload,
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
} as AxiosRequestHeaders,
});
} catch (err) {
console.error("error calling remote signer", err);
const token = remoteConfig.token;
const headers: AxiosRequestHeaders = {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
} as AxiosRequestHeaders;

// Transmit payload and headers directly to the HTTP helper
return await post<BuilderHeaderPayload>(url, payload, { headers });
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
} catch {
// Suppress raw Axios errors to prevent leaking authorization headers to logs
return undefined;
}
}

return undefined;
}

Expand All @@ -95,7 +96,7 @@ export class BuilderConfig {
const local = this.localBuilderCreds;
const remote = this.remoteBuilderConfig;
if (local && remote) {
// If both present, prefer local
// Local credentials take precedence when both are provided
return BuilderType.LOCAL;
}
if (local) {
Expand All @@ -109,26 +110,35 @@ export class BuilderConfig {

private static hasValidLocalCreds(creds?: BuilderApiKeyCreds): boolean {
if (!creds) return false;

const { key, secret, passphrase } = creds;
if (!key || !key.trim()) return false;
if (!secret || !secret.trim()) return false;
if (!passphrase || !passphrase.trim()) return false;

if (!key.trim()) return false;

if (!secret.trim()) return false;

if (!passphrase.trim()) return false;

return true;
}

private static hasValidRemoteUrl(remoteUrl?: string): boolean {
if (!remoteUrl?.trim()) return false;
return remoteUrl.startsWith("http://") || remoteUrl.startsWith("https://");
}

private ensureValid(): void {
if (this.getBuilderType() === BuilderType.UNAVAILABLE) {
throw new Error("invalid builder creds configured!");
}
}
}

/**
* Enforces HTTPS protocol and forbids embedded user credentials (user:password@)
*/
private static hasValidRemoteUrl(remoteUrl?: string): boolean {
if (!remoteUrl || !remoteUrl.trim()) return false;
try {
const parsedUrl = new URL(remoteUrl);
return (
parsedUrl.protocol === "https:" &&
parsedUrl.username === "" &&
parsedUrl.password === ""
);
} catch {
return false;
}
}

private ensureValid(): void {
if (this.getBuilderType() === BuilderType.UNAVAILABLE) {
throw new Error("invalid builder creds configured!");
}
}
}
76 changes: 38 additions & 38 deletions src/signer.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,38 @@
import { buildHmacSignature } from "./signing/index.js";
import { BuilderApiKeyCreds, BuilderHeaderPayload } from "./types.js";


export class BuilderSigner {
readonly creds: BuilderApiKeyCreds;

constructor(creds: BuilderApiKeyCreds) {
this.creds = creds;
}

public createBuilderHeaderPayload(
method: string,
path: string,
body?: string,
timestamp?: number,
): BuilderHeaderPayload {
let ts = Math.floor(Date.now() / 1000);
if (timestamp !== undefined) {
ts = timestamp;
}

const builderSig = buildHmacSignature(
this.creds.secret,
ts,
method,
path,
body,
);

return {
POLY_BUILDER_API_KEY: this.creds.key,
POLY_BUILDER_PASSPHRASE: this.creds.passphrase,
POLY_BUILDER_SIGNATURE: builderSig,
POLY_BUILDER_TIMESTAMP: `${ts}`,
}
}
}
import { buildHmacSignature } from "./signing/index.js";
import { BuilderApiKeyCreds, BuilderHeaderPayload } from "./types.js";
export class BuilderSigner {
readonly creds: BuilderApiKeyCreds;
constructor(creds: BuilderApiKeyCreds) {
this.creds = creds;
}
public createBuilderHeaderPayload(
method: string,
path: string,
body?: string,
timestamp?: number,
): BuilderHeaderPayload {
let ts = Math.floor(Date.now() / 1000);
if (timestamp !== undefined) {
ts = timestamp;
}
const builderSig = buildHmacSignature(
this.creds.secret,
ts,
method,
path,
body,
);
return {
POLY_BUILDER_API_KEY: this.creds.key,
POLY_BUILDER_PASSPHRASE: this.creds.passphrase,
POLY_BUILDER_SIGNATURE: builderSig,
POLY_BUILDER_TIMESTAMP: `${ts}`,
}
}
}
29 changes: 26 additions & 3 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ describe("builder config", () => {
builderConfig = new BuilderConfig({localBuilderCreds: creds});
expect(builderConfig.getBuilderType()).toBe(BuilderType.LOCAL);

builderConfig = new BuilderConfig({remoteBuilderConfig: {url: "http://localhost:3000/sign"} })
builderConfig = new BuilderConfig({remoteBuilderConfig: {url: "https://localhost:3000/sign"} })
expect(builderConfig.getBuilderType()).toBe(BuilderType.REMOTE);

builderConfig = new BuilderConfig()
expect(builderConfig.getBuilderType()).toBe(BuilderType.UNAVAILABLE);

// if both local is preferred
builderConfig = new BuilderConfig({localBuilderCreds: creds, remoteBuilderConfig: {url: "http://localhost:3000/sign"}})
builderConfig = new BuilderConfig({localBuilderCreds: creds, remoteBuilderConfig: {url: "https://localhost:3000/sign"}})
expect(builderConfig.getBuilderType()).toBe(BuilderType.LOCAL);
});

Expand Down Expand Up @@ -72,7 +72,7 @@ describe("builder config", () => {
});

it("generateHeaders - remote", async () => {
const remoteSignerUrl = "http://localhost:3000/sign";
const remoteSignerUrl = "https://localhost:3000/sign";
const mockResponse: BuilderHeaderPayload = {
POLY_BUILDER_API_KEY: "test-api-key",
POLY_BUILDER_TIMESTAMP: "1758744060",
Expand Down Expand Up @@ -109,4 +109,27 @@ describe("builder config", () => {
expect(headers!.POLY_BUILDER_SIGNATURE).toBe("test-signature");
});

it("rejects insecure or credential-bearing remote signer URLs", () => {
expect(() => new BuilderConfig({
remoteBuilderConfig: { url: "http://signer.example/sign", token: "token" },
})).toThrow("invalid remote url!");
expect(() => new BuilderConfig({
remoteBuilderConfig: { url: "https://user:password@signer.example/sign" },
})).toThrow("invalid remote url!");
expect(() => new BuilderConfig({
remoteBuilderConfig: { url: "not-a-url" },
})).toThrow("invalid remote url!");
});

it("does not log remote signer errors", async () => {
vi.spyOn(httpHelpers, "post").mockRejectedValue(new Error("authorization token leaked"));
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
const builderConfig = new BuilderConfig({
remoteBuilderConfig: { url: "https://signer.example/sign", token: "token" },
});

await expect(builderConfig.generateBuilderHeaders("POST", "/order")).resolves.toBeUndefined();
expect(consoleError).not.toHaveBeenCalled();
});

});
Loading