@carbon-copy/mcp 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -1
- package/.github/workflows/publish.yml +0 -25
- package/.husky/pre-commit +0 -1
- package/CLAUDE.md +0 -69
- package/src/__tests__/client.test.ts +0 -427
- package/src/__tests__/resources.test.ts +0 -263
- package/src/__tests__/tools.test.ts +0 -511
- package/src/client.ts +0 -225
- package/src/index.ts +0 -41
- package/src/resources/portfolio.ts +0 -30
- package/src/resources/traders.ts +0 -29
- package/src/tools/account.ts +0 -48
- package/src/tools/orders.ts +0 -88
- package/src/tools/portfolio.ts +0 -183
- package/src/tools/traders.ts +0 -369
- package/tsconfig.json +0 -17
- package/vitest.config.ts +0 -9
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carbon-copy/mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "An MCP server that gives AI agents programmatic access to Carbon Copy — a Polymarket copy-trading platform.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist"
|
|
8
|
+
],
|
|
6
9
|
"bin": {
|
|
7
10
|
"carboncopy-mcp": "dist/index.js"
|
|
8
11
|
},
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
name: Publish to npm
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
release:
|
|
5
|
-
types: [published]
|
|
6
|
-
|
|
7
|
-
jobs:
|
|
8
|
-
publish:
|
|
9
|
-
runs-on: ubuntu-latest
|
|
10
|
-
permissions:
|
|
11
|
-
contents: read
|
|
12
|
-
id-token: write
|
|
13
|
-
environment: npm
|
|
14
|
-
steps:
|
|
15
|
-
- uses: actions/checkout@v4
|
|
16
|
-
- uses: actions/setup-node@v4
|
|
17
|
-
with:
|
|
18
|
-
node-version: 22
|
|
19
|
-
registry-url: https://registry.npmjs.org
|
|
20
|
-
- run: npm ci
|
|
21
|
-
- run: npm run build
|
|
22
|
-
- run: npm test
|
|
23
|
-
- run: npm publish --access public --provenance
|
|
24
|
-
env:
|
|
25
|
-
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/.husky/pre-commit
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
npm run build && npm test
|
package/CLAUDE.md
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
# Carbon Copy MCP Server
|
|
2
|
-
|
|
3
|
-
MCP (Model Context Protocol) server that wraps the Carbon Copy REST API, enabling AI agents to manage Polymarket copy-trading portfolios.
|
|
4
|
-
|
|
5
|
-
## Build & Run
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
npm install
|
|
9
|
-
npm run build # TypeScript → dist/
|
|
10
|
-
npm run dev # Dev mode with watch
|
|
11
|
-
npx @carbon-copy/mcp # Run via npx (after publish)
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
## Configuration
|
|
15
|
-
|
|
16
|
-
Set `CARBONCOPY_API_KEY` env var (format: `cc_<64 hex chars>`).
|
|
17
|
-
|
|
18
|
-
```json
|
|
19
|
-
{
|
|
20
|
-
"mcpServers": {
|
|
21
|
-
"carboncopy": {
|
|
22
|
-
"command": "npx",
|
|
23
|
-
"args": ["@carbon-copy/mcp"],
|
|
24
|
-
"env": { "CARBONCOPY_API_KEY": "cc_..." }
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
## Structure
|
|
31
|
-
|
|
32
|
-
```
|
|
33
|
-
src/
|
|
34
|
-
index.ts # McpServer setup + stdio transport
|
|
35
|
-
client.ts # HTTP client for CC REST API
|
|
36
|
-
tools/
|
|
37
|
-
portfolio.ts # get_portfolio, get_history, get_positions
|
|
38
|
-
traders.ts # list/follow/update/unfollow/pause/resume
|
|
39
|
-
orders.ts # list_orders, get_order
|
|
40
|
-
account.ts # get_account, health
|
|
41
|
-
resources/
|
|
42
|
-
portfolio.ts # carboncopy://portfolio
|
|
43
|
-
traders.ts # carboncopy://traders
|
|
44
|
-
```
|
|
45
|
-
|
|
46
|
-
## Public API Ecosystem
|
|
47
|
-
|
|
48
|
-
Carbon Copy has three repos that form the public API surface:
|
|
49
|
-
|
|
50
|
-
| Repo | Purpose | Deploys to |
|
|
51
|
-
|---|---|---|
|
|
52
|
-
| `CarbonCopyInc/habakkuk` | App + Convex backend + REST API (source of truth) | Vercel + Convex |
|
|
53
|
-
| `CarbonCopyInc/docs` | Mintlify API documentation | Mintlify |
|
|
54
|
-
| `CarbonCopyInc/carboncopy-mcp` (this repo) | MCP server | npm (`@carbon-copy/mcp`) |
|
|
55
|
-
|
|
56
|
-
### Sync Rules
|
|
57
|
-
|
|
58
|
-
- **`habakkuk` is the source of truth** for API behavior. This server wraps it.
|
|
59
|
-
- This is a **thin fetch wrapper** — it does NOT parse or validate API response fields.
|
|
60
|
-
- Only needs updating when endpoints are added, removed, or renamed.
|
|
61
|
-
- Auth is handled via `CARBONCOPY_API_KEY` env var. No OAuth flows.
|
|
62
|
-
|
|
63
|
-
## Conventions
|
|
64
|
-
|
|
65
|
-
- Use `@modelcontextprotocol/sdk` (spec version 2025-11-25)
|
|
66
|
-
- All tools must include `annotations` (readOnlyHint, destructiveHint, etc.)
|
|
67
|
-
- All tools must return `structuredContent` alongside text content
|
|
68
|
-
- Use `zod` for input/output schemas
|
|
69
|
-
- Tool names use snake_case (`get_portfolio`, not `getPortfolio`)
|
|
@@ -1,427 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { CarbonCopyClient } from "../client.js";
|
|
3
|
-
|
|
4
|
-
// ---------------------------------------------------------------------------
|
|
5
|
-
// Helpers
|
|
6
|
-
// ---------------------------------------------------------------------------
|
|
7
|
-
|
|
8
|
-
type MockResponseOptions = {
|
|
9
|
-
ok?: boolean;
|
|
10
|
-
status?: number;
|
|
11
|
-
statusText?: string;
|
|
12
|
-
contentLength?: string;
|
|
13
|
-
body?: unknown;
|
|
14
|
-
bodyText?: string;
|
|
15
|
-
/** If true, cloned.json() rejects (tests the text() fallback path) */
|
|
16
|
-
cloneJsonThrows?: boolean;
|
|
17
|
-
};
|
|
18
|
-
|
|
19
|
-
function createMockResponse(opts: MockResponseOptions = {}) {
|
|
20
|
-
const {
|
|
21
|
-
ok = true,
|
|
22
|
-
status = 200,
|
|
23
|
-
statusText = "OK",
|
|
24
|
-
contentLength,
|
|
25
|
-
body = null,
|
|
26
|
-
bodyText = "",
|
|
27
|
-
cloneJsonThrows = false,
|
|
28
|
-
} = opts;
|
|
29
|
-
|
|
30
|
-
const headers: Record<string, string> = {};
|
|
31
|
-
if (contentLength !== undefined) headers["content-length"] = contentLength;
|
|
32
|
-
|
|
33
|
-
const jsonFn = vi.fn().mockResolvedValue(body);
|
|
34
|
-
const textFn = vi
|
|
35
|
-
.fn()
|
|
36
|
-
.mockResolvedValue(bodyText || (body !== null ? JSON.stringify(body) : ""));
|
|
37
|
-
|
|
38
|
-
// The clone is only used for error-path json parsing
|
|
39
|
-
const cloneJsonFn = cloneJsonThrows
|
|
40
|
-
? vi.fn().mockRejectedValue(new Error("not json"))
|
|
41
|
-
: vi.fn().mockResolvedValue(body);
|
|
42
|
-
|
|
43
|
-
const clone = vi.fn().mockReturnValue({ json: cloneJsonFn });
|
|
44
|
-
|
|
45
|
-
return {
|
|
46
|
-
ok,
|
|
47
|
-
status,
|
|
48
|
-
statusText,
|
|
49
|
-
headers: { get: (name: string) => headers[name.toLowerCase()] ?? null },
|
|
50
|
-
json: jsonFn,
|
|
51
|
-
text: textFn,
|
|
52
|
-
clone,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const BASE = "https://www.carboncopy.inc";
|
|
57
|
-
|
|
58
|
-
describe("CarbonCopyClient", () => {
|
|
59
|
-
let fetchMock: ReturnType<typeof vi.fn>;
|
|
60
|
-
let client: CarbonCopyClient;
|
|
61
|
-
|
|
62
|
-
beforeEach(() => {
|
|
63
|
-
fetchMock = vi.fn();
|
|
64
|
-
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
|
65
|
-
client = new CarbonCopyClient("cc_testkey");
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
afterEach(() => {
|
|
69
|
-
vi.restoreAllMocks();
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
// -------------------------------------------------------------------------
|
|
73
|
-
// Core request behaviour
|
|
74
|
-
// -------------------------------------------------------------------------
|
|
75
|
-
|
|
76
|
-
describe("request() — GET", () => {
|
|
77
|
-
it("sends correct URL and Authorization header, returns parsed JSON", async () => {
|
|
78
|
-
const data = { total: 42 };
|
|
79
|
-
fetchMock.mockResolvedValue(createMockResponse({ body: data }));
|
|
80
|
-
|
|
81
|
-
const result = await client.getPortfolio();
|
|
82
|
-
|
|
83
|
-
expect(fetchMock).toHaveBeenCalledOnce();
|
|
84
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
85
|
-
expect(url).toBe(`${BASE}/api/v1/portfolio`);
|
|
86
|
-
expect((init.headers as Record<string, string>)["Authorization"]).toBe(
|
|
87
|
-
"Bearer cc_testkey",
|
|
88
|
-
);
|
|
89
|
-
expect((init.headers as Record<string, string>)["Content-Type"]).toBe(
|
|
90
|
-
"application/json",
|
|
91
|
-
);
|
|
92
|
-
expect(init.method).toBe("GET");
|
|
93
|
-
expect(init.body).toBeUndefined();
|
|
94
|
-
expect(result).toEqual(data);
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
describe("request() — POST", () => {
|
|
99
|
-
it("serialises body to JSON and sends correct method", async () => {
|
|
100
|
-
const responseData = { id: "trader-1" };
|
|
101
|
-
fetchMock.mockResolvedValue(createMockResponse({ body: responseData }));
|
|
102
|
-
|
|
103
|
-
const payload = {
|
|
104
|
-
wallet: "0xabc",
|
|
105
|
-
copyPercentage: 50,
|
|
106
|
-
};
|
|
107
|
-
await client.followTrader(payload);
|
|
108
|
-
|
|
109
|
-
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
110
|
-
expect(init.method).toBe("POST");
|
|
111
|
-
expect(init.body).toBe(
|
|
112
|
-
JSON.stringify({
|
|
113
|
-
walletAddress: payload.wallet,
|
|
114
|
-
copyPercentage: payload.copyPercentage,
|
|
115
|
-
}),
|
|
116
|
-
);
|
|
117
|
-
});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
describe("request() — error handling", () => {
|
|
121
|
-
it("throws an error including status and response body when response is not ok", async () => {
|
|
122
|
-
const errBody = { message: "Unauthorized" };
|
|
123
|
-
fetchMock.mockResolvedValue(
|
|
124
|
-
createMockResponse({
|
|
125
|
-
ok: false,
|
|
126
|
-
status: 401,
|
|
127
|
-
statusText: "Unauthorized",
|
|
128
|
-
body: errBody,
|
|
129
|
-
}),
|
|
130
|
-
);
|
|
131
|
-
|
|
132
|
-
await expect(client.getPortfolio()).rejects.toThrow(
|
|
133
|
-
/Carbon Copy API error 401 Unauthorized/,
|
|
134
|
-
);
|
|
135
|
-
await expect(client.getPortfolio()).rejects.toThrow(/Unauthorized/);
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("falls back to res.text() when cloned.json() fails on error response", async () => {
|
|
139
|
-
fetchMock.mockResolvedValue(
|
|
140
|
-
createMockResponse({
|
|
141
|
-
ok: false,
|
|
142
|
-
status: 500,
|
|
143
|
-
statusText: "Internal Server Error",
|
|
144
|
-
bodyText: "raw error text",
|
|
145
|
-
cloneJsonThrows: true,
|
|
146
|
-
}),
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
await expect(client.getPortfolio()).rejects.toThrow(/raw error text/);
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
|
|
153
|
-
describe("request() — empty body handling", () => {
|
|
154
|
-
it("returns undefined for 204 No Content without calling res.json()", async () => {
|
|
155
|
-
const mock = createMockResponse({ status: 204 });
|
|
156
|
-
// ok defaults true but 204 has no body
|
|
157
|
-
fetchMock.mockResolvedValue({ ...mock, ok: true, status: 204 });
|
|
158
|
-
|
|
159
|
-
const result = await client.unfollowTrader("0xabc");
|
|
160
|
-
expect(result).toBeUndefined();
|
|
161
|
-
expect(mock.json).not.toHaveBeenCalled();
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it("returns undefined when Content-Length is 0", async () => {
|
|
165
|
-
const mock = createMockResponse({ contentLength: "0" });
|
|
166
|
-
fetchMock.mockResolvedValue(mock);
|
|
167
|
-
|
|
168
|
-
const result = await client.pauseTrader("0xabc");
|
|
169
|
-
expect(result).toBeUndefined();
|
|
170
|
-
expect(mock.json).not.toHaveBeenCalled();
|
|
171
|
-
});
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
// -------------------------------------------------------------------------
|
|
175
|
-
// buildQuery (tested via endpoint methods)
|
|
176
|
-
// -------------------------------------------------------------------------
|
|
177
|
-
|
|
178
|
-
describe("buildQuery", () => {
|
|
179
|
-
it("appends query string for defined values", async () => {
|
|
180
|
-
fetchMock.mockResolvedValue(createMockResponse({ body: [] }));
|
|
181
|
-
await client.getPortfolioHistory({ limit: 10, cursor: "abc" });
|
|
182
|
-
|
|
183
|
-
const [url] = fetchMock.mock.calls[0] as [string];
|
|
184
|
-
expect(url).toContain("limit=10");
|
|
185
|
-
expect(url).toContain("cursor=abc");
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
it("omits undefined values from query string", async () => {
|
|
189
|
-
fetchMock.mockResolvedValue(createMockResponse({ body: [] }));
|
|
190
|
-
await client.getPortfolioHistory({ limit: 5 });
|
|
191
|
-
|
|
192
|
-
const [url] = fetchMock.mock.calls[0] as [string];
|
|
193
|
-
expect(url).toContain("limit=5");
|
|
194
|
-
expect(url).not.toContain("cursor");
|
|
195
|
-
expect(url).not.toContain("since");
|
|
196
|
-
expect(url).not.toContain("until");
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it("omits query string entirely when params are all undefined", async () => {
|
|
200
|
-
fetchMock.mockResolvedValue(createMockResponse({ body: [] }));
|
|
201
|
-
await client.getPositions({});
|
|
202
|
-
|
|
203
|
-
const [url] = fetchMock.mock.calls[0] as [string];
|
|
204
|
-
expect(url).toBe(`${BASE}/api/v1/portfolio/positions`);
|
|
205
|
-
expect(url).not.toContain("?");
|
|
206
|
-
});
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
// -------------------------------------------------------------------------
|
|
210
|
-
// Endpoint methods — method + path
|
|
211
|
-
// -------------------------------------------------------------------------
|
|
212
|
-
|
|
213
|
-
describe("endpoint methods", () => {
|
|
214
|
-
beforeEach(() => {
|
|
215
|
-
fetchMock.mockResolvedValue(createMockResponse({ body: {} }));
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
it("getPortfolio() → GET /api/v1/portfolio", async () => {
|
|
219
|
-
await client.getPortfolio();
|
|
220
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
221
|
-
expect(url).toBe(`${BASE}/api/v1/portfolio`);
|
|
222
|
-
expect(init.method).toBe("GET");
|
|
223
|
-
});
|
|
224
|
-
|
|
225
|
-
it("getPortfolioHistory({limit:10, cursor:'abc'}) → GET /api/v1/portfolio/history?limit=10&cursor=abc", async () => {
|
|
226
|
-
await client.getPortfolioHistory({ limit: 10, cursor: "abc" });
|
|
227
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
228
|
-
expect(url).toBe(`${BASE}/api/v1/portfolio/history?limit=10&cursor=abc`);
|
|
229
|
-
expect(init.method).toBe("GET");
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
it("getPositions() → GET /api/v1/portfolio/positions", async () => {
|
|
233
|
-
await client.getPositions();
|
|
234
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
235
|
-
expect(url).toBe(`${BASE}/api/v1/portfolio/positions`);
|
|
236
|
-
expect(init.method).toBe("GET");
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
it("getTraders() → GET /api/v1/portfolio/traders", async () => {
|
|
240
|
-
await client.getTraders();
|
|
241
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
242
|
-
expect(url).toBe(`${BASE}/api/v1/portfolio/traders`);
|
|
243
|
-
expect(init.method).toBe("GET");
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it("discoverTraders({sortBy:'roi'}) → GET /api/v1/traders?sortBy=roi", async () => {
|
|
247
|
-
await client.discoverTraders({ sortBy: "roi" });
|
|
248
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
249
|
-
expect(url).toBe(`${BASE}/api/v1/traders?sortBy=roi`);
|
|
250
|
-
expect(init.method).toBe("GET");
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it("followTrader({...}) → POST /api/v1/portfolio/traders with body", async () => {
|
|
254
|
-
const body = {
|
|
255
|
-
wallet: "0xabc",
|
|
256
|
-
copyPercentage: 50,
|
|
257
|
-
maxCopyAmount: 100,
|
|
258
|
-
notificationsEnabled: true,
|
|
259
|
-
};
|
|
260
|
-
await client.followTrader(body);
|
|
261
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
262
|
-
expect(url).toBe(`${BASE}/api/v1/portfolio/traders`);
|
|
263
|
-
expect(init.method).toBe("POST");
|
|
264
|
-
expect(JSON.parse(init.body as string)).toEqual({
|
|
265
|
-
walletAddress: body.wallet,
|
|
266
|
-
copyPercentage: body.copyPercentage,
|
|
267
|
-
maxCopyAmount: body.maxCopyAmount,
|
|
268
|
-
notificationsEnabled: body.notificationsEnabled,
|
|
269
|
-
});
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
it("getTrader('0xabc') → GET /api/v1/portfolio/traders/0xabc", async () => {
|
|
273
|
-
await client.getTrader("0xabc");
|
|
274
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
275
|
-
expect(url).toBe(`${BASE}/api/v1/portfolio/traders/0xabc`);
|
|
276
|
-
expect(init.method).toBe("GET");
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
it("updateTrader('0xabc', {...}) → PATCH /api/v1/portfolio/traders/0xabc with body", async () => {
|
|
280
|
-
const body = { copyPercentage: 75, copyTradingEnabled: false };
|
|
281
|
-
await client.updateTrader("0xabc", body);
|
|
282
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
283
|
-
expect(url).toBe(`${BASE}/api/v1/portfolio/traders/0xabc`);
|
|
284
|
-
expect(init.method).toBe("PATCH");
|
|
285
|
-
expect(JSON.parse(init.body as string)).toEqual(body);
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
it("unfollowTrader('0xabc') → DELETE /api/v1/portfolio/traders/0xabc", async () => {
|
|
289
|
-
fetchMock.mockResolvedValue(
|
|
290
|
-
createMockResponse({ status: 204, ok: true }),
|
|
291
|
-
);
|
|
292
|
-
await client.unfollowTrader("0xabc");
|
|
293
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
294
|
-
expect(url).toBe(`${BASE}/api/v1/portfolio/traders/0xabc`);
|
|
295
|
-
expect(init.method).toBe("DELETE");
|
|
296
|
-
});
|
|
297
|
-
|
|
298
|
-
it("pauseTrader('0xabc') → POST /api/v1/portfolio/traders/0xabc/pause", async () => {
|
|
299
|
-
await client.pauseTrader("0xabc");
|
|
300
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
301
|
-
expect(url).toBe(`${BASE}/api/v1/portfolio/traders/0xabc/pause`);
|
|
302
|
-
expect(init.method).toBe("POST");
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
it("resumeTrader('0xabc') → POST /api/v1/portfolio/traders/0xabc/resume", async () => {
|
|
306
|
-
await client.resumeTrader("0xabc");
|
|
307
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
308
|
-
expect(url).toBe(`${BASE}/api/v1/portfolio/traders/0xabc/resume`);
|
|
309
|
-
expect(init.method).toBe("POST");
|
|
310
|
-
});
|
|
311
|
-
|
|
312
|
-
it("getTraderPerformance('0xabc') → GET /api/v1/traders/0xabc/performance", async () => {
|
|
313
|
-
await client.getTraderPerformance("0xabc");
|
|
314
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
315
|
-
expect(url).toBe(`${BASE}/api/v1/traders/0xabc/performance`);
|
|
316
|
-
expect(init.method).toBe("GET");
|
|
317
|
-
});
|
|
318
|
-
|
|
319
|
-
it("batchUpdateTraders([...]) → PATCH /api/v1/portfolio/traders/batch", async () => {
|
|
320
|
-
const updates = [{ walletAddress: "0xabc", copyPercentage: 70 }];
|
|
321
|
-
await client.batchUpdateTraders(updates);
|
|
322
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
323
|
-
expect(url).toBe(`${BASE}/api/v1/portfolio/traders/batch`);
|
|
324
|
-
expect(init.method).toBe("PATCH");
|
|
325
|
-
expect(JSON.parse(init.body as string)).toEqual({ updates });
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
it("getOrders({status:'filled'}) → GET /api/v1/orders?status=filled", async () => {
|
|
329
|
-
await client.getOrders({ status: "filled" });
|
|
330
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
331
|
-
expect(url).toBe(`${BASE}/api/v1/orders?status=filled`);
|
|
332
|
-
expect(init.method).toBe("GET");
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
it("getOrder('id123') → GET /api/v1/orders/id123", async () => {
|
|
336
|
-
await client.getOrder("id123");
|
|
337
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
338
|
-
expect(url).toBe(`${BASE}/api/v1/orders/id123`);
|
|
339
|
-
expect(init.method).toBe("GET");
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
it("getAccount() → GET /api/v1/account", async () => {
|
|
343
|
-
await client.getAccount();
|
|
344
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
345
|
-
expect(url).toBe(`${BASE}/api/v1/account`);
|
|
346
|
-
expect(init.method).toBe("GET");
|
|
347
|
-
});
|
|
348
|
-
|
|
349
|
-
it("health() → GET /api/v1/health", async () => {
|
|
350
|
-
await client.health();
|
|
351
|
-
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
352
|
-
expect(url).toBe(`${BASE}/api/v1/health`);
|
|
353
|
-
expect(init.method).toBe("GET");
|
|
354
|
-
});
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
// -------------------------------------------------------------------------
|
|
358
|
-
// URL encoding
|
|
359
|
-
// -------------------------------------------------------------------------
|
|
360
|
-
|
|
361
|
-
describe("URL encoding", () => {
|
|
362
|
-
beforeEach(() => {
|
|
363
|
-
fetchMock.mockResolvedValue(createMockResponse({ body: {} }));
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
it("encodes wallet addresses with special characters in path", async () => {
|
|
367
|
-
const wallet = "0xabc/def?foo=bar&baz=qux";
|
|
368
|
-
await client.getTrader(wallet);
|
|
369
|
-
const [url] = fetchMock.mock.calls[0] as [string];
|
|
370
|
-
expect(url).toContain(encodeURIComponent(wallet));
|
|
371
|
-
expect(url).not.toContain("?foo=bar");
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
it("encodes order IDs with special characters", async () => {
|
|
375
|
-
const id = "order/id?test=1";
|
|
376
|
-
await client.getOrder(id);
|
|
377
|
-
const [url] = fetchMock.mock.calls[0] as [string];
|
|
378
|
-
expect(url).toContain(encodeURIComponent(id));
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
it("encodes wallet addresses for updateTrader", async () => {
|
|
382
|
-
const wallet = "0xabc def";
|
|
383
|
-
await client.updateTrader(wallet, { copyPercentage: 10 });
|
|
384
|
-
const [url] = fetchMock.mock.calls[0] as [string];
|
|
385
|
-
expect(url).toContain(encodeURIComponent(wallet));
|
|
386
|
-
expect(url).toBe(
|
|
387
|
-
`${BASE}/api/v1/portfolio/traders/${encodeURIComponent(wallet)}`,
|
|
388
|
-
);
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
it("encodes wallet addresses for unfollowTrader", async () => {
|
|
392
|
-
const wallet = "0xabc def";
|
|
393
|
-
fetchMock.mockResolvedValue(
|
|
394
|
-
createMockResponse({ status: 204, ok: true }),
|
|
395
|
-
);
|
|
396
|
-
await client.unfollowTrader(wallet);
|
|
397
|
-
const [url] = fetchMock.mock.calls[0] as [string];
|
|
398
|
-
expect(url).toBe(
|
|
399
|
-
`${BASE}/api/v1/portfolio/traders/${encodeURIComponent(wallet)}`,
|
|
400
|
-
);
|
|
401
|
-
});
|
|
402
|
-
|
|
403
|
-
it("encodes wallet addresses for pauseTrader", async () => {
|
|
404
|
-
const wallet = "0xabc def";
|
|
405
|
-
await client.pauseTrader(wallet);
|
|
406
|
-
const [url] = fetchMock.mock.calls[0] as [string];
|
|
407
|
-
expect(url).toBe(
|
|
408
|
-
`${BASE}/api/v1/portfolio/traders/${encodeURIComponent(wallet)}/pause`,
|
|
409
|
-
);
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
it("encodes wallet addresses for resumeTrader", async () => {
|
|
413
|
-
const wallet = "0xabc def";
|
|
414
|
-
await client.resumeTrader(wallet);
|
|
415
|
-
const [url] = fetchMock.mock.calls[0] as [string];
|
|
416
|
-
expect(url).toBe(
|
|
417
|
-
`${BASE}/api/v1/portfolio/traders/${encodeURIComponent(wallet)}/resume`,
|
|
418
|
-
);
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
it("encodes query string values correctly", async () => {
|
|
422
|
-
await client.getPortfolioHistory({ cursor: "abc=def&ghi" });
|
|
423
|
-
const [url] = fetchMock.mock.calls[0] as [string];
|
|
424
|
-
expect(url).toContain("cursor=abc%3Ddef%26ghi");
|
|
425
|
-
});
|
|
426
|
-
});
|
|
427
|
-
});
|