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
327 changes: 166 additions & 161 deletions packages/sdk-typescript/package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions packages/sdk-typescript/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@gemini-markets/sdk",
"version": "0.1.0",
"version": "0.1.1",
"description": "Gemini exchange TypeScript SDK — browser and server entry points with HMAC, OAuth (PKCE), REST, and WebSocket support.",
"type": "module",
"license": "Apache-2.0",
Expand Down Expand Up @@ -89,7 +89,7 @@
"@types/node": "^22",
"@types/ws": "^8.18.1",
"esbuild": "^0.28.2",
"miniflare": "5.20260825.0-alpha",
"miniflare": "^5.20260921.0-alpha",
"openapi-typescript": "7.13.0",
"tsx": "^4",
"typescript": "^5.7",
Expand Down
1 change: 0 additions & 1 deletion packages/sdk-typescript/scripts/verify-multi-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,6 @@ try {
workers: [{
config: {
name: "sdk-browser",
type: "worker",
compatibilityDate: "2024-01-01",
},
legacy: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -248,9 +248,26 @@ async function assertSigned(request: Request): Promise<void> {
request.init.headers["X-GEMINI-SIGNATURE"],
await hmacSha384Hex("secret", encoded),
);
assert.equal(request.init.headers["Content-Length"], "0");
assert.equal(request.init.headers["Content-Type"], "text/plain");
assert.equal(request.init.body, undefined);
// Operations with a real request body (requestBody: true) now send it as a literal
// HTTP body too, not just signed into X-GEMINI-PAYLOAD (PREDICT-9072) — assert
// internal consistency between the content headers and body presence, since this
// generic helper covers both body-bearing and bodyless operations across every domain.
//
// Deliberately not derived from the signed payload's own keys: a requestBody:true
// operation called with zero actual fields (e.g. getRoles, oauth revoke) signs
// exactly {request, nonce} — indistinguishable, by payload content alone, from a
// requestBody:false query-only operation. Resolving that needs the operation's own
// metadata (requestBody flag) cross-referenced per call site, which is real scope
// beyond this helper — precise, per-operation regression coverage for the fix
// itself already lives in transport/http.ts's own test file (a dedicated no-body
// guard and a createCombo integration test), not here.
if (request.init.body !== undefined) {
Comment thread
karanach319 marked this conversation as resolved.
assert.equal(request.init.headers["Content-Type"], "application/json");
assert.equal(request.init.headers["Content-Length"], undefined);
} else {
assert.equal(request.init.headers["Content-Length"], "0");
assert.equal(request.init.headers["Content-Type"], "text/plain");
}
}

test("generated REST operation metadata covers the new module surfaces", () => {
Expand Down Expand Up @@ -544,6 +561,10 @@ test("Perpetuals wrappers shape public, authenticated JSON, and file requests",
account: "primary",
nonce: 1004,
});
// The signed GET keeps its params in the payload only — native fetch rejects a
// GET with a body — while the signed POSTs also send them as a literal body.
assert.equal(requests[5]?.init.body, undefined);
assert.deepEqual(JSON.parse(requests[4]!.init.body!), { account: "primary" });
assert.deepEqual(file.bytes, fileBytes);
assert.equal(
file.contentType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,39 @@ void test("generated body fields cannot replace authentication headers", async (
);
});

void test("createCombo sends legs as a literal HTTP body, not just signed into the payload (PREDICT-9072)", async () => {
const requests: Request[] = [];
const auth = new HmacAuth({ apiKey: "key", apiSecret: "secret", now: () => 1000 });
const rest = new PredictionMarketsRest(new HttpTransport({
env: "sandbox",
auth,
fetchImpl: async (url, init) => {
requests.push({ url, init });
return jsonResponse(
200,
'{"alreadyExisted":false,"combo":{"id":1,"canonicalLegKey":"k","instrumentRegistered":false,"legCount":2,"legs":[]}}',
);
},
}));

const legs = [
{ contractId: "111", requiredOutcome: "Yes" as const },
{ contractId: "222", requiredOutcome: "No" as const },
];
await rest.createCombo({ legs });

const { init } = requests[0]!;
assert.equal(init.headers["Content-Type"], "application/json");
assert.equal(init.headers["Content-Length"], undefined);
assert.deepEqual(init.body ? JSON.parse(init.body) : undefined, { legs });

// The signed payload still carries request/nonce alongside legs — the literal
// body above is a narrower, separate copy of just the operation's own fields.
const payload = parseBoundaryRecord(fromBase64(init.headers["X-GEMINI-PAYLOAD"]!));
assert.deepEqual(Object.keys(payload).sort(), ["legs", "nonce", "request"]);
assert.equal(payload.request, "/v1/prediction-markets/combos");
});

void test("T5e wrappers keep position filters in the query and volume fields in the signed body", async () => {
const requests: Request[] = [];
const auth = new HmacAuth({ apiKey: "key", apiSecret: "secret", now: () => 1000 });
Expand Down
127 changes: 123 additions & 4 deletions packages/sdk-typescript/src/transport/http.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";
import { test } from "node:test";
import { fileURLToPath } from "node:url";

Expand Down Expand Up @@ -29,6 +31,7 @@ import {
serializeError,
} from "../errors.js";
import { fromBase64 } from "../utils/encoding.js";
import { PerpetualsRest } from "../generated/perpetuals/rest.js";
import type { BoundaryRecord, BoundaryValue } from "../utils/boundary-value.js";
import { parseBoundaryRecord } from "../tests/support/http-fixtures.js";

Expand Down Expand Up @@ -133,11 +136,15 @@ test("private request shapes the Gemini payload envelope", async () => {
assert.equal(url, "https://api.sandbox.gemini.com/v1/prediction-markets/order");
assert.equal(init.method, "POST");

// Fixed private-REST headers.
assert.equal(init.headers["Content-Length"], "0");
assert.equal(init.headers["Content-Type"], "text/plain");
// A request with real params now also gets them as a literal HTTP body — not just
// signed into the payload header — so servers that do a real json.Decode(r.Body)
// (e.g. combos) don't reject an empty body with a 400 (PREDICT-9072). The literal
// body carries only the operation's own fields, never the signed envelope's
// request/nonce.
assert.equal(init.headers["Content-Type"], "application/json");
assert.equal(init.headers["Content-Length"], undefined);
assert.equal(init.headers["Cache-Control"], "no-cache");
assert.equal(init.body, undefined, "private REST parameters belong only in the signed payload");
assert.deepEqual(init.body ? JSON.parse(init.body) : undefined, { symbol: "BTCUSD", amount: "1.5" });

// The payload is base64(JSON) with request + nonce + params.
const b64 = init.headers["X-GEMINI-PAYLOAD"];
Expand All @@ -154,6 +161,118 @@ test("private request shapes the Gemini payload envelope", async () => {
assert.equal(init.headers["X-GEMINI-SIGNATURE"], `sig(${b64})`);
});

test("private request with no params sends no body, keeping the fixed no-body headers (PREDICT-9072 no-regression guard)", async () => {
const { fetchImpl, last } = recordingFetch({ status: 200, body: '{"result":"ok"}' });
const client = new HttpTransport({ env: "sandbox", auth: stubAuth, fetchImpl });

await client.request({
method: "POST",
path: "/v1/positions",
});

const { init } = last();
assert.equal(init.headers["Content-Length"], "0");
assert.equal(init.headers["Content-Type"], "text/plain");
assert.equal(init.body, undefined, "an operation with no params must not send a literal body");
});

test("private request body serializes a bigint param losslessly, via the same stringifyJson used for the signed payload", async () => {
const { fetchImpl, last } = recordingFetch({ status: 200, body: '{"result":"ok"}' });
const client = new HttpTransport({ env: "sandbox", auth: stubAuth, fetchImpl });

await client.request({
method: "POST",
path: "/v1/prediction-markets/combos",
params: { contractId: 123456789012345678n },
});

const { init } = last();
assert.equal(init.body, '{"contractId":123456789012345678}');
});

// Fake fetchImpls don't enforce fetch's rule that GET/HEAD requests cannot carry a
// body, so these go through the runtime's native fetch against a local server.
async function withLocalServer(
respond: (req: IncomingMessage, res: ServerResponse) => void,
run: (baseUrl: string, received: () => { method?: string; url?: string; headers: IncomingMessage["headers"]; body: string }) => Promise<void>,
): Promise<void> {
let received: { method?: string; url?: string; headers: IncomingMessage["headers"]; body: string } | undefined;
const server = createServer((req, res) => {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
received = { method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString("utf8") };
respond(req, res);
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address() as AddressInfo;
await run(`http://127.0.0.1:${port}`, () => {
if (!received) throw new Error("server never received a request");
return received;
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}

test("native fetch: signed GET file report (perpetuals.getFundingPaymentReportFile) sends no body and succeeds", async () => {
const fileBytes = new Uint8Array([1, 2, 3, 4]);
await withLocalServer(
(_req, res) => {
res.writeHead(200, {
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"Content-Disposition": "attachment; filename=funding-payment-report.xlsx",
});
res.end(Buffer.from(fileBytes));
},
async (baseUrl, received) => {
const perpetuals = new PerpetualsRest(new HttpTransport({ env: "sandbox", baseUrl, auth: stubAuth }));

const file = await perpetuals.getFundingPaymentReportFile({
fromDate: "2026-01-01",
toDate: "2026-01-31",
numRows: 10,
account: "primary",
});

assert.deepEqual(file.bytes, fileBytes);
const req = received();
assert.equal(req.method, "GET");
assert.equal(req.url, "/v1/perpetuals/fundingpaymentreport/records.xlsx?fromDate=2026-01-01&toDate=2026-01-31&numRows=10");
assert.equal(req.body, "", "a GET must never carry a literal body");
assert.equal(req.headers["content-type"], "text/plain");
const signed = JSON.parse(fromBase64(String(req.headers["x-gemini-payload"])));
assert.equal(signed.account, "primary", "GET params stay in the signed payload");
},
);
});

test("native fetch: signed POST with params delivers the literal JSON body", async () => {
await withLocalServer(
(_req, res) => {
res.writeHead(200, { "Content-Type": "application/json" });
res.end('{"result":"ok"}');
},
async (baseUrl, received) => {
const client = new HttpTransport({ env: "sandbox", baseUrl, auth: stubAuth });

await client.request({
method: "POST",
path: "/v1/prediction-markets/combos",
params: { legs: [{ symbol: "GEMI-A", side: "yes" }] },
});

const req = received();
assert.equal(req.method, "POST");
assert.equal(req.headers["content-type"], "application/json");
assert.deepEqual(JSON.parse(req.body), { legs: [{ symbol: "GEMI-A", side: "yes" }] });
assert.equal(req.headers["content-length"], String(Buffer.byteLength(req.body)));
},
);
});

test("declared query serialization preserves array and object wire formats", async () => {
let requestedUrl = "";
const client = new HttpTransport({
Expand Down
25 changes: 18 additions & 7 deletions packages/sdk-typescript/src/transport/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1046,20 +1046,30 @@ export class HttpTransport {
if (reservedHeader) {
throw new SdkError(`AuthStrategy returned reserved header ${reservedHeader}`);
}
// Some newer server-side handlers (e.g. combos) do a real `json.Decode(r.Body)`
// and reject an empty body with a 400, unlike older private endpoints that read
// exclusively from the signed X-GEMINI-PAYLOAD header. Send the operation's own
// fields (never the signed envelope's `request`/`nonce`) as a second, literal
// copy whenever there's an actual body to send, so both endpoint styles work.
// GET is excluded: native fetch rejects a body on GET/HEAD, and signed GET
// operations (e.g. perpetuals.getFundingPaymentReportFile) carry their params
// in the signed payload only, exactly as before.
const body = stableParams !== undefined && method !== "GET" ? stringifyJson(stableParams) : undefined;
// Add auth headers first so the fixed envelope headers always win.
// This prevents an auth strategy from replacing the payload or content headers.
const headers = {
...stableHeaders,
...credentials,
"Content-Length": "0",
"Content-Type": "text/plain",
...(body !== undefined
? { "Content-Type": "application/json" }
: { "Content-Length": "0", "Content-Type": "text/plain" }),
"Cache-Control": "no-cache",
"X-GEMINI-PAYLOAD": b64,
...(options.responseContract
? { Accept: options.responseContract.responseContentTypes.join(", ") }
: null),
} satisfies RequestHeaders;
return headers;
return { headers, body };
};

return this.send<T>(
Expand Down Expand Up @@ -1104,7 +1114,7 @@ export class HttpTransport {
return this.send<T>(
options.method,
withQuery(options.path, options.query, options.queryParameters),
async () => stableHeaders,
async () => ({ headers: stableHeaders }),
options.responseInt64Paths,
options.responseMode,
options.responseContract,
Expand Down Expand Up @@ -1136,7 +1146,7 @@ export class HttpTransport {
private async send<T = BoundaryValue>(
method: HttpMethod,
path: string,
buildHeaders: (signal?: AbortSignal) => Promise<Record<string, string>>,
buildRequest: (signal?: AbortSignal) => Promise<{ headers: Record<string, string>; body?: string }>,
responseInt64Paths: readonly Int64Path[] = [],
responseMode: RestResponseMode = "json",
responseContract?: RestResponseContract,
Expand Down Expand Up @@ -1189,8 +1199,9 @@ export class HttpTransport {
const execution = deadline(requestOptions, this.timeoutMs);
try { for (let attempt = 0; ; attempt++) {
let headers: Record<string, string>;
let requestBody: string | undefined;
try {
headers = await withSignal(buildHeaders(execution.signal), execution.signal);
({ headers, body: requestBody } = await withSignal(buildRequest(execution.signal), execution.signal));
} catch (cause) {
emit("error", "request.failure", responseMetadata(undefined, attempt), undefined, cause);
throw cause;
Expand All @@ -1214,7 +1225,7 @@ export class HttpTransport {
const requestStartTime = Date.now();
try {
response = await withSignal(
this.fetchImpl(requestUrl, { method, headers, signal: execution.signal, redirect: "manual" }),
this.fetchImpl(requestUrl, { method, headers, body: requestBody, signal: execution.signal, redirect: "manual" }),
execution.signal,
);
} catch (cause) {
Expand Down
Loading