diff --git a/.gitignore b/.gitignore index ad46b30..276b249 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,11 @@ typings/ # next.js build output .next + +# Build artifacts from the REST v3 examples (compiled locally, never committed) +.build/ +.dart_tool/ +target/ +.gradle/ +rest-v3/dotnet/bin/ +rest-v3/dotnet/obj/ diff --git a/README.md b/README.md index 7950e57..dc05ae0 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ For more information about the SDK, please refer to the [SDK documentation](http - [Swift](https://github.com/foxbit-group/foxbit-api-samples/tree/main/rest-v3/swift) - [WebSocket v2](https://github.com/foxbit-group/foxbit-api-samples/tree/main/websocket-v2) - [JavaScript](https://github.com/foxbit-group/foxbit-api-samples/tree/main/websocket-v2/javascript) +- [WebSocket v3](https://github.com/foxbit-group/foxbit-api-samples/tree/main/websocket-v3) + - [JavaScript](https://github.com/foxbit-group/foxbit-api-samples/tree/main/websocket-v3/javascript) ## Getting Started @@ -67,7 +69,9 @@ export FOXBIT_API_KEY=your_api_key_here export FOXBIT_API_SECRET=your_api_secret_here ``` -Make sure to replace `your_api_key_here` and `your_api_secret_here` with the actual values provided by Foxbit. +Make sure to replace `your_api_key_here` and `your_api_secret_here` with the actual values provided by Foxbit. Alternatively, keep them in a `.env` file (already git-ignored) and pass `--env-file .env` to `docker run`. + +Every REST v3 example ships a pinned `Dockerfile`, so Docker is all you need to run any of them. See the [REST v3 guide](rest-v3/README.md) for the common example flow, how request signing works (including the two gotchas behind most `Invalid signature` errors), and troubleshooting tips. ## Support diff --git a/rest-v3-sdk/javascript/Dockerfile b/rest-v3-sdk/javascript/Dockerfile new file mode 100644 index 0000000..1be5b76 --- /dev/null +++ b/rest-v3-sdk/javascript/Dockerfile @@ -0,0 +1,12 @@ +# Pinned to a full, existing multi-arch tag (do not use `latest`). +FROM node:24.18.0-alpine3.24 + +WORKDIR /app + +# Install dependencies from the exact lockfile for reproducible builds. +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts + +COPY examples.js ./ + +CMD ["node", "examples.js"] diff --git a/rest-v3-sdk/javascript/README.md b/rest-v3-sdk/javascript/README.md index 8d4f1cd..8f1e3e5 100644 --- a/rest-v3-sdk/javascript/README.md +++ b/rest-v3-sdk/javascript/README.md @@ -1,36 +1,53 @@ -# Foxbit API REST v3 JavaScript SDK Examples +# Foxbit REST API v3 — JavaScript SDK Example [![npm version](https://img.shields.io/npm/v/@foxbit-group/rest-api.svg?style=flat)](https://www.npmjs.com/package/@foxbit-group/rest-api) -> **SDK Oficial:** [@foxbit-group/rest-api](https://www.npmjs.com/package/@foxbit-group/rest-api) +A minimal Node.js example of the [Foxbit REST API v3](https://docs.foxbit.com.br/rest/v3/) built on the official SDK, [`@foxbit-group/rest-api`](https://www.npmjs.com/package/@foxbit-group/rest-api). It runs a complete flow in 7 steps: -This directory contains JavaScript examples demonstrating how to interact with the Foxbit API REST v3 using the official Foxbit SDK. These scripts cover a range of functionalities, from fetching market data to placing orders and managing your account, now leveraging the SDK for easier integration and improved reliability. +1. `GET /rest/v3/me` — authenticated request with no parameters. +2. `GET /rest/v3/markets/btcbrl/orderbook?depth=1` — public market data to read the best bid. +3. Compute a limit price at 50% of the best bid, floored to an integer (`btcbrl` uses `price_increment: 1.0`). +4. `POST /rest/v3/orders` — create a LIMIT BUY for 0.0001 BTC at the computed price. +5. Wait 2 seconds. +6. `GET /rest/v3/orders?market_symbol=btcbrl&state=ACTIVE` — list active orders. +7. `PUT /rest/v3/orders/cancel` — cancel the order created in step 4. -## Prerequisites +> **Warning:** this example creates a REAL order on your account (LIMIT BUY 0.0001 BTC at 50% of the market price — inside the exchange price band, but far too low to ever execute) and cancels it right after. -Before you begin, ensure you have the following prerequisites installed on your system: +## Requirements -- Node.js: These examples are written for Node.js, a JavaScript runtime built on Chrome's V8 JavaScript engine. Ensure you have the latest stable version installed. -- NPM (Node Package Manager): Comes with Node.js, used for managing dependencies. +- Docker (recommended), or +- Node.js >= 18 to run natively. -## Getting Started +## Credentials -1. **Install Dependencies**: Navigate to the JavaScript SDK examples directory in your terminal and run `npm install` to install the necessary dependencies, including the Foxbit SDK. +Create an API key at and export it: ```bash -npm install +export FOXBIT_API_KEY="your-api-key" +export FOXBIT_API_SECRET="your-api-secret" ``` -2. **Configure API Keys**: You must read the [main README file located at the root of the project](https://github.com/foxbit-group/foxbit-api-samples?tab=readme-ov-file#getting-started) for general information on setting up your environment, including configuring your API keys as environment variables. +Alternatively, put both variables in a `.env` file and use `--env-file .env` with Docker. + +## Run with Docker + +```bash +docker build -t foxbit-sample-sdk-javascript . +docker run --rm -e FOXBIT_API_KEY -e FOXBIT_API_SECRET foxbit-sample-sdk-javascript +# or: docker run --rm --env-file .env foxbit-sample-sdk-javascript +``` -3. **Running the Examples**: To run the example, navigate to the project directory in the terminal and execute the following command: +## Run natively ```bash -node examples.js +npm install +npm start +# or: node examples.js ``` -## Additional Notes +## How request signing works -These examples are meant to serve as a starting point and now utilize the official Foxbit SDK for all API interactions. It's recommended to review and test the code thoroughly before using it in a production environment. +Every authenticated request must be signed with HMAC-SHA256 and carry the headers `X-FB-ACCESS-KEY`, `X-FB-ACCESS-TIMESTAMP` (UNIX time in milliseconds) and `X-FB-ACCESS-SIGNATURE`. -For detailed API documentation, refer to the [Foxbit API Documentation](https://docs.foxbit.com.br/rest/v3/). +**The official SDK handles all of this for you.** When you build a `Configuration` with your `apiKey`/`apiSecret`, the SDK computes the prehash (`timestamp + method + path + queryString + rawBody`), signs it and attaches the headers on every call — so this example contains no manual signing code. If you need to implement signing yourself, see the dependency-free examples under [`rest-v3/`](../../rest-v3) and the full documentation at . diff --git a/rest-v3-sdk/javascript/examples.js b/rest-v3-sdk/javascript/examples.js index cae3509..36dd728 100644 --- a/rest-v3-sdk/javascript/examples.js +++ b/rest-v3-sdk/javascript/examples.js @@ -1,60 +1,113 @@ +"use strict"; + +/** + * Foxbit REST API v3 example using the official JavaScript SDK + * (@foxbit-group/rest-api). The SDK signs every authenticated request + * internally (HMAC-SHA256 headers), so no manual signing code is needed. + * + * Flow: + * 1. GET /rest/v3/me — current member info + * 2. GET /rest/v3/markets/btcbrl/orderbook?depth=1 — best bid (public data) + * 3. Compute a limit price at 50% of the best bid (floored to an integer) + * 4. POST /rest/v3/orders — LIMIT BUY at the computed price + * 5. Wait 2 seconds + * 6. GET /rest/v3/orders?market_symbol=btcbrl&state=ACTIVE + * 7. PUT /rest/v3/orders/cancel — cancel the created order + */ + const { Configuration, + MarketDataApi, MemberInfoApi, TradingApi, } = require("@foxbit-group/rest-api"); -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); +const MARKET_SYMBOL = "btcbrl"; + +// Fail fast if credentials are missing. Never print their values. +for (const name of ["FOXBIT_API_KEY", "FOXBIT_API_SECRET"]) { + if (!process.env[name]) { + console.error(`Missing required environment variable: ${name}`); + process.exit(1); + } +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +function logStep(title, response) { + console.log("-".repeat(50)); + console.log(title); + console.log(`Response (${response.status}): ${JSON.stringify(response.data)}`); } -(async () => { - try { - console.log("FOXBIT_API_KEY:", process.env.FOXBIT_API_KEY); - - const config = new Configuration({ - apiKey: process.env.FOXBIT_API_KEY, - apiSecret: process.env.FOXBIT_API_SECRET, - }); - - // Create instance of the API clients - const memberApi = new MemberInfoApi(config); - const tradingApi = new TradingApi(config); - - // Get the user information - const meResponse = await memberApi.currentMember(); - console.log("Response:", meResponse.data); - - // Request to create a new order - const orderResponse = await tradingApi.createOrder({ - createOrderRequest: { - market_symbol: "btcbrl", - side: "BUY", - type: "LIMIT", - price: "500000.0", - quantity: "0.0001", - }, - }); - console.log("Response:", orderResponse.data); - - await sleep(2000); - - // Get active orders - const ordersResponse = await tradingApi.listOrders({ - marketSymbol: "btcbrl", - state: "ACTIVE", - }); - console.log("Response:", ordersResponse.data); - - // Request to cancel the order - const cancelResponse = await tradingApi.cancelOrders({ - cancelOrdersRequest: { - type: "ID", - id: orderResponse.data.id, - }, - }); - console.log("Response:", cancelResponse.data); - } catch (error) { - console.error("Failed to process request.", error.response.data); +// Print only safe error details (HTTP status + response body). Never dump the +// whole Axios error object: its request config carries the signed auth headers. +function fail(error) { + if (error.response) { + const body = JSON.stringify(error.response.data); + console.error(`Request failed (${error.response.status}): ${body}`); + } else { + console.error(`Request failed: ${error.message}`); } -})(); + process.exit(1); +} + +async function main() { + const configuration = new Configuration({ + apiKey: process.env.FOXBIT_API_KEY, + apiSecret: process.env.FOXBIT_API_SECRET, + }); + + const memberApi = new MemberInfoApi(configuration); + const marketDataApi = new MarketDataApi(configuration); + const tradingApi = new TradingApi(configuration); + + // 1. Authenticated request: current member info. + const me = await memberApi.currentMember(); + logStep("GET /rest/v3/me", me); + + // 2. Public market data: fetch the top of the order book. + const orderbook = await marketDataApi.getOrderbook({ + marketSymbol: MARKET_SYMBOL, + depth: 1, + }); + logStep(`GET /rest/v3/markets/${MARKET_SYMBOL}/orderbook?depth=1`, orderbook); + const bestBid = Number(orderbook.data.bids[0][0]); + + // 3. Price the order at 50% of the best bid, rounded to an integer + // (btcbrl has price_increment 1.0). This stays inside the exchange's + // accepted price band — a hardcoded value like 10.0 is rejected with + // 422 "Price out of range" — while being far too low to ever execute. + const price = String(Math.floor(bestBid * 0.5)); + + // 4. Create the order at the computed price. + const created = await tradingApi.createOrder({ + createOrderRequest: { + market_symbol: MARKET_SYMBOL, + side: "BUY", + type: "LIMIT", + price, + quantity: "0.0001", + }, + }); + logStep("POST /rest/v3/orders", created); + const orderId = created.data.id; + + // 5. Give the matching engine a moment before listing orders. + await sleep(2000); + + // 6. List active orders — the order created above should be in the list. + const active = await tradingApi.listOrders({ + marketSymbol: MARKET_SYMBOL, + state: "ACTIVE", + }); + logStep(`GET /rest/v3/orders?market_symbol=${MARKET_SYMBOL}&state=ACTIVE`, active); + + // 7. Cancel the order created in step 4 by its id. + const canceled = await tradingApi.cancelOrders({ + cancelOrdersRequest: { type: "ID", id: orderId }, + }); + logStep("PUT /rest/v3/orders/cancel", canceled); +} + +main().catch(fail); diff --git a/rest-v3-sdk/javascript/package-lock.json b/rest-v3-sdk/javascript/package-lock.json index de56165..230c305 100644 --- a/rest-v3-sdk/javascript/package-lock.json +++ b/rest-v3-sdk/javascript/package-lock.json @@ -1,15 +1,17 @@ { - "name": "javascript", + "name": "foxbit-rest-v3-sdk-javascript-example", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "javascript", + "name": "foxbit-rest-v3-sdk-javascript-example", "version": "1.0.0", - "license": "ISC", "dependencies": { - "@foxbit-group/rest-api": "^0.1.3" + "@foxbit-group/rest-api": "0.1.3" + }, + "engines": { + "node": ">=18" } }, "node_modules/@foxbit-group/rest-api": { @@ -40,9 +42,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", @@ -135,9 +137,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0" diff --git a/rest-v3-sdk/javascript/package.json b/rest-v3-sdk/javascript/package.json index 9a40d2d..24bb749 100644 --- a/rest-v3-sdk/javascript/package.json +++ b/rest-v3-sdk/javascript/package.json @@ -1,19 +1,17 @@ { - "name": "javascript", + "name": "foxbit-rest-v3-sdk-javascript-example", "version": "1.0.0", - "description": "", + "private": true, + "description": "Foxbit REST API v3 example using the official JavaScript SDK (@foxbit-group/rest-api)", + "type": "commonjs", "main": "examples.js", + "engines": { + "node": ">=18" + }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "start": "node examples.js" }, - "author": "", - "license": "ISC", "dependencies": { - "@foxbit-group/rest-api": "^0.1.3" - }, - "overrides": { - "axios": "^1.16.0", - "follow-redirects": "^1.16.0", - "form-data": "^4.0.6" + "@foxbit-group/rest-api": "0.1.3" } } diff --git a/rest-v3-sdk/typescript/.dockerignore b/rest-v3-sdk/typescript/.dockerignore new file mode 100644 index 0000000..b6dddf6 --- /dev/null +++ b/rest-v3-sdk/typescript/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +npm-debug.log diff --git a/rest-v3-sdk/typescript/Dockerfile b/rest-v3-sdk/typescript/Dockerfile new file mode 100644 index 0000000..1d70e62 --- /dev/null +++ b/rest-v3-sdk/typescript/Dockerfile @@ -0,0 +1,16 @@ +# Build stage: install all dependencies and compile TypeScript to JavaScript. +FROM node:22.22.0-alpine3.23 AS build +WORKDIR /app +COPY package.json package-lock.json tsconfig.json ./ +RUN npm ci +COPY index.ts ./ +RUN npm run build + +# Runtime stage: install only production dependencies and run the compiled app. +FROM node:22.22.0-alpine3.23 AS runtime +WORKDIR /app +ENV NODE_ENV=production +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev && npm cache clean --force +COPY --from=build /app/dist ./dist +CMD ["node", "dist/index.js"] diff --git a/rest-v3-sdk/typescript/README.md b/rest-v3-sdk/typescript/README.md index 3c42ff1..598eafe 100644 --- a/rest-v3-sdk/typescript/README.md +++ b/rest-v3-sdk/typescript/README.md @@ -1,30 +1,77 @@ -# Foxbit API REST v3 TypeScript SDK Examples +# Foxbit REST API v3 — TypeScript Example (Official SDK) [![npm version](https://img.shields.io/npm/v/@foxbit-group/rest-api.svg?style=flat)](https://www.npmjs.com/package/@foxbit-group/rest-api) -> **SDK Oficial:** [@foxbit-group/rest-api](https://www.npmjs.com/package/@foxbit-group/rest-api) +This example integrates with the Foxbit REST API v3 using the official +[`@foxbit-group/rest-api`](https://www.npmjs.com/package/@foxbit-group/rest-api) +SDK. The SDK handles request signing for you, so this example focuses on a +clean, end-to-end trading flow. -This directory contains TypeScript examples demonstrating how to interact with the Foxbit API REST v3 using the official Foxbit SDK. These scripts cover a range of functionalities, from fetching market data to placing orders and managing your account, now leveraging the SDK for easier integration and improved reliability. +## What it does -## Getting Started +The program (`index.ts`) runs the following flow and exits non-zero on any error: -1. **Install Dependencies**: Navigate to the TypeScript SDK examples directory in your terminal and run: +1. `GET /rest/v3/me` — authenticated: fetch the account tied to the API key. +2. `GET /rest/v3/markets/btcbrl/orderbook?depth=1` — public: read the best bid. +3. Compute a limit price at 50% of the best bid, floored to an integer + (`btcbrl` uses `price_increment: 1.0`). +4. `POST /rest/v3/orders` — authenticated: place the order. +5. Wait 2 seconds. +6. `GET /rest/v3/orders?market_symbol=btcbrl&state=ACTIVE` — authenticated: + the new order should be listed. +7. `PUT /rest/v3/orders/cancel` — authenticated: cancel the order by id. + +> **Heads up:** step 4 places a **real** order (LIMIT BUY of `0.0001` BTC at +> 50% of the current market price). Pricing off the live market keeps the order +> inside the exchange price band (a hardcoded value such as `10.0` is rejected +> with HTTP 422 "Price out of range") while staying far enough below market that +> it never executes. Step 7 cancels it. + +## Requirements + +- **Docker** (recommended) — no local toolchain needed. +- Optional native run: **Node.js >= 18**. + +## Credentials + +Create an API key at and expose it +as environment variables: ```bash -npm install -g typescript ts-node -npm install +export FOXBIT_API_KEY="your-api-key" +export FOXBIT_API_SECRET="your-api-secret" ``` -2. **Configure API Keys**: You must read the [main README file located at the root of the project](https://github.com/foxbit-group/foxbit-api-samples?tab=readme-ov-file#getting-started) for general information on setting up your environment, including configuring your API keys as environment variables. +Or place them in a `.env` file and pass it with `--env-file` (see below). The +program fails fast with a clear message if either variable is missing. + +## Run with Docker + +```bash +docker build -t foxbit-sample-sdk-typescript . + +# Pass the variables from your shell... +docker run --rm -e FOXBIT_API_KEY -e FOXBIT_API_SECRET foxbit-sample-sdk-typescript + +# ...or from a .env file: +docker run --rm --env-file .env foxbit-sample-sdk-typescript +``` -3. **Running the Examples**: To run the example, navigate to the project directory in the terminal and execute the following command: +## Run natively ```bash -ts-node examples.ts +npm ci +npm run build +npm start ``` -## Additional Notes +## How request signing works -These examples are meant to serve as a starting point and now utilize the official Foxbit SDK for all API interactions. It's recommended to review and test the code thoroughly before using it in a production environment. +Every authenticated request must be signed with HMAC-SHA256 over a canonical +prehash (`timestamp + method + path + decoded query string + raw body`). The +`@foxbit-group/rest-api` SDK builds this prehash and signs each request +internally — you only provide the API key and secret to `Configuration`. Public +endpoints such as the order book require no authentication. -For detailed API documentation, refer to the [Foxbit API Documentation](https://docs.foxbit.com.br/rest/v3/). +For the full API reference, see the +[Foxbit API documentation](https://docs.foxbit.com.br/rest/v3/). diff --git a/rest-v3-sdk/typescript/examples.ts b/rest-v3-sdk/typescript/examples.ts deleted file mode 100644 index bc4a394..0000000 --- a/rest-v3-sdk/typescript/examples.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { - Configuration, - MemberInfoApi, - TradingApi, -} from "@foxbit-group/rest-api"; - -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -(async () => { - try { - console.log("FOXBIT_API_KEY:", process.env.FOXBIT_API_KEY); - - const config = new Configuration({ - apiKey: process.env.FOXBIT_API_KEY, - apiSecret: process.env.FOXBIT_API_SECRET, - }); - - // Create instance of the API clients - const memberApi = new MemberInfoApi(config); - const tradingApi = new TradingApi(config); - - // Get the user information - const meResponse = await memberApi.currentMember(); - console.log("Response:", meResponse.data); - - // Request to create a new order - const orderResponse = await tradingApi.createOrder({ - createOrderRequest: { - market_symbol: "btcbrl", - side: "BUY", - type: "LIMIT", - price: "500000.0", - quantity: "0.0001", - }, - }); - console.log("Response:", orderResponse.data); - - await sleep(2000); - - // Get active orders - const ordersResponse = await tradingApi.listOrders({ - marketSymbol: "btcbrl", - state: "ACTIVE", - }); - console.log("Response:", ordersResponse.data); - - // Request to cancel the order - const cancelResponse = await tradingApi.cancelOrders({ - cancelOrdersRequest: { - type: "ID", - id: orderResponse.data.id, - }, - }); - console.log("Response:", cancelResponse.data); - } catch (error: any) { - console.error("Failed to process request.", error.response.data); - } -})(); diff --git a/rest-v3-sdk/typescript/index.ts b/rest-v3-sdk/typescript/index.ts new file mode 100644 index 0000000..e5784d6 --- /dev/null +++ b/rest-v3-sdk/typescript/index.ts @@ -0,0 +1,121 @@ +import { + Configuration, + MarketDataApi, + MemberInfoApi, + TradingApi, +} from "@foxbit-group/rest-api"; + +const MARKET_SYMBOL = "btcbrl"; +const QUANTITY = "0.0001"; + +// Fail fast if credentials are missing, before making any request. +const apiKey = process.env.FOXBIT_API_KEY; +const apiSecret = process.env.FOXBIT_API_SECRET; +if (!apiKey || !apiSecret) { + console.error( + "Missing credentials. Set FOXBIT_API_KEY and FOXBIT_API_SECRET.\n" + + "Create an API key at https://app.foxbit.com.br/profile/api-key", + ); + process.exit(1); +} + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +// The SDK throws axios-style errors. Narrow them for readable logging +// without pulling axios in as a direct dependency. +interface HttpError { + response?: { status?: number; data?: unknown }; + message?: string; +} + +function describeError(error: unknown): string { + const err = error as HttpError; + if (err?.response) { + return `HTTP ${err.response.status ?? "?"}: ${JSON.stringify(err.response.data)}`; + } + return err?.message ?? String(error); +} + +function logStep(title: string, payload: unknown): void { + console.log("--------------------------------------------------"); + console.log(title); + console.log(JSON.stringify(payload, null, 2)); +} + +async function main(): Promise { + // The official SDK signs every authenticated request for us + // (HMAC-SHA256 over the canonical prehash). We only supply the + // API key/secret here; the signing details are internal to the SDK. + const config = new Configuration({ apiKey, apiSecret }); + const memberApi = new MemberInfoApi(config); + const marketApi = new MarketDataApi(config); + const tradingApi = new TradingApi(config); + + // 1. Authenticated request: fetch the account tied to the API key. + const me = await memberApi.currentMember(); + logStep("GET /rest/v3/me", me.data); + + // 2. Public request (no authentication required): read the top of the + // order book so we can price the order relative to the live market. + const orderbook = await marketApi.getOrderbook({ + marketSymbol: MARKET_SYMBOL, + depth: 1, + }); + // Each level is a [price, quantity] pair; the SDK types it loosely as + // string[], so we cast to the real shape. + const bids = orderbook.data.bids as unknown as string[][]; + const bestBid = bids[0]?.[0]; + if (!bestBid) { + throw new Error(`No bids available for market ${MARKET_SYMBOL}`); + } + + // 3. Price at 50% of the best bid, floored to an integer. The btcbrl + // market has price_increment 1.0, so the price must be a whole number. + // Pricing off the live market keeps us inside the exchange price band + // (a hardcoded value such as 10.0 is rejected with HTTP 422 "Price out + // of range") while staying far enough below market that the order never + // executes before we cancel it. + const price = Math.floor(Number(bestBid) * 0.5).toString(); + console.log("--------------------------------------------------"); + console.log(`Best bid: ${bestBid} -> limit price: ${price}`); + + // 4. Authenticated request: place a REAL limit buy order. + const created = await tradingApi.createOrder({ + createOrderRequest: { + market_symbol: MARKET_SYMBOL, + side: "BUY", + type: "LIMIT", + price, + quantity: QUANTITY, + }, + }); + logStep("POST /rest/v3/orders", created.data); + + const orderId = created.data.id; + if (orderId === undefined) { + throw new Error("Order created but no id was returned"); + } + + // 5. Give the matching engine a moment to register the order. + await sleep(2000); + + // 6. Authenticated request: list active orders. The order we just placed + // should appear in the result. + const active = await tradingApi.listOrders({ + marketSymbol: MARKET_SYMBOL, + state: "ACTIVE", + }); + logStep("GET /rest/v3/orders?market_symbol=btcbrl&state=ACTIVE", active.data); + + // 7. Authenticated request: cancel the order we created. + const canceled = await tradingApi.cancelOrders({ + cancelOrdersRequest: { type: "ID", id: orderId }, + }); + logStep("PUT /rest/v3/orders/cancel", canceled.data); +} + +main().catch((error) => { + console.error("Request failed:", describeError(error)); + process.exit(1); +}); diff --git a/rest-v3-sdk/typescript/package-lock.json b/rest-v3-sdk/typescript/package-lock.json index f7d6a42..eb59b8a 100644 --- a/rest-v3-sdk/typescript/package-lock.json +++ b/rest-v3-sdk/typescript/package-lock.json @@ -1,16 +1,22 @@ { - "name": "typescript", + "name": "foxbit-rest-v3-sdk-typescript-example", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "typescript", + "name": "foxbit-rest-v3-sdk-typescript-example", "version": "1.0.0", - "license": "ISC", + "license": "MIT", "dependencies": { - "@foxbit-group/rest-api": "^0.1.3", - "@types/node": "^24.0.14" + "@foxbit-group/rest-api": "0.1.3" + }, + "devDependencies": { + "@types/node": "22.20.0", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=18" } }, "node_modules/@foxbit-group/rest-api": { @@ -23,12 +29,13 @@ } }, "node_modules/@types/node": { - "version": "24.0.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.14.tgz", - "integrity": "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw==", + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.8.0" + "undici-types": "~6.21.0" } }, "node_modules/agent-base": { @@ -50,9 +57,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", + "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", @@ -362,10 +369,25 @@ "node": ">=10" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", - "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" } } diff --git a/rest-v3-sdk/typescript/package.json b/rest-v3-sdk/typescript/package.json index 8554f0f..aac510a 100644 --- a/rest-v3-sdk/typescript/package.json +++ b/rest-v3-sdk/typescript/package.json @@ -1,20 +1,22 @@ { - "name": "typescript", + "name": "foxbit-rest-v3-sdk-typescript-example", "version": "1.0.0", - "description": "", - "main": "index.js", - "dependencies": { - "@foxbit-group/rest-api": "^0.1.3", - "@types/node": "^24.0.14" + "description": "Foxbit REST API v3 TypeScript example using the official @foxbit-group/rest-api SDK", + "private": true, + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js" }, - "overrides": { - "axios": "^1.16.0", - "follow-redirects": "^1.16.0", - "form-data": "^4.0.6" + "engines": { + "node": ">=18" }, - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "dependencies": { + "@foxbit-group/rest-api": "0.1.3" + }, + "devDependencies": { + "@types/node": "22.20.0", + "typescript": "5.9.3" }, - "author": "", - "license": "ISC" + "license": "MIT" } diff --git a/rest-v3-sdk/typescript/tsconfig.json b/rest-v3-sdk/typescript/tsconfig.json index e075f97..0158cd1 100644 --- a/rest-v3-sdk/typescript/tsconfig.json +++ b/rest-v3-sdk/typescript/tsconfig.json @@ -1,109 +1,16 @@ { "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - - /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - - /* Language and Environment */ - "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ - // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ - - /* Modules */ - "module": "commonjs", /* Specify what module code is generated. */ - // "rootDir": "./", /* Specify the root folder within your source files. */ - // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - // "types": [], /* Specify type package names to be included without being referenced in a source file. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ - // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ - // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ - // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ - // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ - // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ - - /* JavaScript Support */ - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ - - /* Emit */ - // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - // "outDir": "./", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ - - /* Type Checking */ - "strict": true, /* Enable all strict type-checking options. */ - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "rootDir": ".", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": false, + "sourceMap": false + }, + "include": ["index.ts"] } diff --git a/rest-v3/README.md b/rest-v3/README.md index b20f573..c95b7de 100644 --- a/rest-v3/README.md +++ b/rest-v3/README.md @@ -1,42 +1,92 @@ -# Foxbit Exchange API Examples +# Foxbit REST API v3 — Examples -This repository demonstrates how the Foxbit exchange API works, showcasing various operations such as fetching market data, placing orders, and managing accounts. It serves as a guide for developers who want to integrate Foxbit's exchange functionality into their applications. +Sample code showing how to integrate with the [Foxbit REST API v3](https://docs.foxbit.com.br/rest/v3/) in 12 programming languages. Every example implements the same flow with the same request-signing logic, so you can pick your language and copy a known-good starting point. -## Features +## What every example does -- Fetch real-time market data including prices, trading volume, and order book. -- Place buy and sell orders programmatically. -- Check account balances and transaction history. +1. `GET /rest/v3/me` — fetches your account info (authenticated). +2. `GET /rest/v3/markets/btcbrl/orderbook?depth=1` — fetches the order book (public endpoint, no authentication required). +3. Computes a safe limit price: 50% of the current best bid. This keeps the order inside the API price band (prices too far from the market are rejected with HTTP 422) while being far too low to ever fill. +4. `POST /rest/v3/orders` — places a **real** LIMIT BUY order for 0.0001 BTC at that price (authenticated). +5. Waits 2 seconds. +6. `GET /rest/v3/orders?market_symbol=btcbrl&state=ACTIVE` — lists active orders; the new order shows up here (authenticated). +7. `PUT /rest/v3/orders/cancel` — cancels the order created in step 4 (authenticated). -## Getting Started +> **Warning**: step 4 places a real order on your account. It sits ~50% below the market and is cancelled by the example itself a few seconds later, but always double-check before running against an account with funds at play. -### Installation +## Languages -1. Clone this repository to your local machine: +| Language | Directory | +|----------|-----------| +| JavaScript (Node.js) | [javascript](javascript/) | +| TypeScript | [typescript](typescript/) | +| Python | [python](python/) | +| Go | [go](go/) | +| Ruby | [ruby](ruby/) | +| PHP | [php](php/) | +| Java | [java](java/) | +| Kotlin | [kotlin](kotlin/) | +| C# (.NET) | [dotnet](dotnet/) | +| C++ | [cpp](cpp/) | +| Dart | [dart](dart/) | +| Swift | [swift](swift/) | + +Each directory ships a pinned `Dockerfile`, so the recommended way to run any example is: ```bash -git clone git@github.com:foxbit-group/foxbit-api-samples.git +cd rest-v3/ +docker build -t foxbit-sample- . +docker run --rm -e FOXBIT_API_KEY -e FOXBIT_API_SECRET foxbit-sample- ``` -2. Navigate to the project directory: +You can also keep the credentials in a `.env` file (never commit it!) and use `docker run --rm --env-file ../../.env foxbit-sample-`. Native (non-Docker) instructions are in each language README. + +## Credentials + +Generate an API key and secret at [app.foxbit.com.br/profile/api-key](https://app.foxbit.com.br/profile/api-key) and export them: ```bash -cd foxbit-api-samples +export FOXBIT_API_KEY=your_api_key_here +export FOXBIT_API_SECRET=your_api_secret_here ``` -Now you can choose one language to start. Each one has your own README file that explains how run the code. -Please pay attention to the requirements. +## How request signing works -## Contributing +Authenticated endpoints require three headers: -Contributions are welcome! If you find any bugs or have suggestions for improvement, please open an issue or submit a pull request. +| Header | Value | +|--------|-------| +| `X-FB-ACCESS-KEY` | your API key | +| `X-FB-ACCESS-TIMESTAMP` | UNIX timestamp in **milliseconds** | +| `X-FB-ACCESS-SIGNATURE` | hex-encoded HMAC-SHA256 of the prehash string, keyed with your API secret | -## License +The prehash string is the concatenation: + +``` +preHash = timestamp + method + path + queryString + rawBody +``` + +For example: `1700000000000GET/rest/v3/ordersmarket_symbol=btcbrl&state=ACTIVE` -This project is licensed under the MIT License - see the [LICENSE](../LICENSE.md) file for details. +Two details cause almost every "Invalid signature" (HTTP 401, code 2002) error, and every example in this repository handles both correctly: + +1. **The query string is signed in decoded form, but sent percent-encoded.** The server rebuilds the prehash from the *decoded* parameter values, in the order they appear in the URL. If a value contains a space, sign `symbol=btc brl` but send `symbol=btc%20brl` (RFC 3986 encoding — space is `%20`, never `+`). Never let your HTTP library re-serialize the query independently from the string you signed: build both strings from the same ordered parameter list. +2. **The body is signed exactly as the bytes you send.** Serialize the JSON body **once**, sign that exact string, and send that exact string. If your HTTP library re-serializes the object (different key order, different whitespace), the signature breaks. Any JSON formatting is accepted, as long as the signed bytes equal the sent bytes. + +Public endpoints (market data such as the order book) need no authentication headers at all. + +The optional `X-FB-RECEIVE-WINDOW` header (1000–60000, in ms) narrows how far your timestamp may drift from the server clock. See the [official documentation](https://docs.foxbit.com.br/rest/v3/) for details, including the alternative Ed25519 signing scheme. + +## Troubleshooting + +- **401 `Invalid signature.` (code 2002)** — check the two gotchas above; print your prehash and compare it char by char with what you expect the server to rebuild. Also confirm the timestamp is in milliseconds and the same value goes into both the prehash and the header. +- **422 `Price out of range from market.` (code 5005)** — your limit price is too far from the current market. The examples avoid this by pricing relative to the live order book instead of hardcoding a value. +- **429** — you are being rate limited; back off and retry. + +## License -## Acknowledgments +This project is licensed under the MIT License — see the [LICENSE](../LICENSE.md) file for details. -These examples are meant to serve as a starting point. They demonstrate basic API interactions. It's recommended to review and test the code thoroughly before using it in a production environment. +## Disclaimer -For detailed API documentation, refer to the [Foxbit API Documentation](https://docs.foxbit.com.br/rest/v3/). +These examples are provided "as is" for educational purposes. Review and test the code thoroughly before using it in a production environment. diff --git a/rest-v3/cpp/Dockerfile b/rest-v3/cpp/Dockerfile index b38a2fd..60575a0 100644 --- a/rest-v3/cpp/Dockerfile +++ b/rest-v3/cpp/Dockerfile @@ -1,34 +1,29 @@ -FROM debian:bookworm AS builder +FROM debian:13.2 AS build -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - build-essential \ - curl \ - ca-certificates \ +RUN apt-get update && apt-get install -y --no-install-recommends \ + g++ \ libcurl4-openssl-dev \ libssl-dev \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* WORKDIR /src -RUN curl -sSL -o json.hpp \ - https://raw.githubusercontent.com/nlohmann/json/v3.11.2/single_include/nlohmann/json.hpp +# Pinned nlohmann/json single-header release. +ADD https://github.com/nlohmann/json/releases/download/v3.12.0/json.hpp ./json.hpp -COPY examples.cpp . +COPY main.cpp . -RUN g++ examples.cpp -o examples \ - -std=c++17 -O2 -s -lcurl -lssl -lcrypto +RUN g++ -std=c++17 -O2 -Wall -Wextra -o foxbit-example main.cpp -lcurl -lssl -lcrypto -FROM debian:bookworm-slim +FROM debian:13.2-slim -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - libcurl4 \ - libssl3 \ +RUN apt-get update && apt-get install -y --no-install-recommends \ + libcurl4t64 \ + libssl3t64 \ ca-certificates \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/lib/apt/lists/* WORKDIR /app -COPY --from=builder /src/examples . +COPY --from=build /src/foxbit-example . -ENTRYPOINT ["./examples"] +CMD ["./foxbit-example"] diff --git a/rest-v3/cpp/README.md b/rest-v3/cpp/README.md index 3797191..aa92d2b 100644 --- a/rest-v3/cpp/README.md +++ b/rest-v3/cpp/README.md @@ -1,28 +1,85 @@ -# Foxbit API REST v3 C++ Examples +# Foxbit REST API v3 — C++ Example -Here is the C++ examples for the Foxbit API REST v3. This section provides a series of scripts to help you understand how to interact with the Foxbit API using C++. These examples cover a range of functionalities from fetching market data to placing orders and managing your account. +A minimal, self-contained example of how to sign and send requests to the +[Foxbit REST API v3](https://docs.foxbit.com.br/rest/v3/). Running it executes +a full order lifecycle: -## Prerequisites +1. `GET /rest/v3/me` — fetch account information (authenticated). +2. `GET /rest/v3/markets/btcbrl/orderbook?depth=1` — read the public orderbook (no authentication). +3. Compute a safe order price: 50% of the best bid, rounded down to a whole number. +4. `POST /rest/v3/orders` — place a LIMIT BUY order for 0.0001 BTC. +5. Wait 2 seconds. +6. `GET /rest/v3/orders?market_symbol=btcbrl&state=ACTIVE` — list active orders. +7. `PUT /rest/v3/orders/cancel` — cancel the order created in step 4. -Before you begin, ensure you have the following prerequisites installed on your system: +> **Warning:** step 4 creates a REAL order on your account — a LIMIT BUY of +> 0.0001 BTC at 50% of the current market price. That price is inside the +> accepted price band but far too low to ever execute, and the order is +> canceled at the end of the flow. -- Docker +The example uses libcurl for HTTP, OpenSSL for HMAC-SHA256 and the +[nlohmann/json](https://github.com/nlohmann/json) single header (pinned in the +Dockerfile) for JSON. -## Getting Started +## Requirements -1. **Configure API Keys**: You must read the [main README file located at the root of the project](https://github.com/foxbit-group/foxbit-api-samples?tab=readme-ov-file#getting-started) for general information on setting up your environment, including configuring your API keys as environment variables. -2. **Running the Examples**: To run the example, navigate to the project directory in the terminal and execute the following command: +- [Docker](https://www.docker.com/) (recommended), or +- a C++17 compiler with libcurl and OpenSSL 3 development headers if you + prefer to build natively. + +## Credentials + +Create an API key at and export it: ```bash -docker build -t foxbit-cpp-examples . +export FOXBIT_API_KEY="your-api-key" +export FOXBIT_API_SECRET="your-api-secret" +``` -docker run --rm \ - -e FOXBIT_API_KEY=$FOXBIT_API_KEY \ - -e FOXBIT_API_SECRET=$FOXBIT_API_SECRET \ - foxbit-cpp-examples +Alternatively, put both variables in a `.env` file and pass it to Docker with +`--env-file .env`. + +## Run with Docker + +```bash +docker build -t foxbit-sample-cpp . +docker run --rm -e FOXBIT_API_KEY -e FOXBIT_API_SECRET foxbit-sample-cpp ``` -## Additional Notes +Or, using a `.env` file: + +```bash +docker run --rm --env-file .env foxbit-sample-cpp +``` + +## Run natively + +With g++, libcurl and OpenSSL development packages installed (e.g. on Debian: +`g++ libcurl4-openssl-dev libssl-dev`): + +```bash +curl -fsSLo json.hpp https://github.com/nlohmann/json/releases/download/v3.12.0/json.hpp +g++ -std=c++17 -O2 -Wall -Wextra -o foxbit-example main.cpp -lcurl -lssl -lcrypto +./foxbit-example +``` + +## How request signing works + +Every authenticated request carries three headers: `X-FB-ACCESS-KEY` (your API +key), `X-FB-ACCESS-TIMESTAMP` (UNIX time in milliseconds) and +`X-FB-ACCESS-SIGNATURE`. The signature is an HMAC-SHA256 (hex) of: + +``` +prehash = timestamp + HTTP method + path + query string + raw body +``` + +Two gotchas that cause most `401` errors: + +1. **The query string goes into the prehash DECODED** (raw values, no + percent-encoding), while the URL itself carries the RFC 3986 + percent-encoded form. Build both from the same ordered parameters. +2. **The body is verified byte for byte as sent.** Serialize the JSON body + exactly once, then sign and send that same string (here, a single + `dump()` whose result feeds both the signature and `CURLOPT_POSTFIELDS`). -These examples are meant to serve as a starting point. They demonstrate basic API interactions. It's recommended to review and test the code thoroughly before using it in a production environment. -For detailed API documentation, refer to the [Foxbit API Documentation](https://docs.foxbit.com.br/rest/v3/). +See the full documentation at . diff --git a/rest-v3/cpp/examples.cpp b/rest-v3/cpp/examples.cpp deleted file mode 100644 index aec2363..0000000 --- a/rest-v3/cpp/examples.cpp +++ /dev/null @@ -1,284 +0,0 @@ -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include -#include - -#include - -#include "json.hpp" // https://github.com/nlohmann/json -using json = nlohmann::json; - -const std::string API_BASE_URL = "https://api.foxbit.com.br"; - -/*-------------------------------------------------- - * Helpers - *------------------------------------------------*/ -std::string urlEncode(CURL* curl, const std::string& value) -{ - char* encoded = curl_easy_escape(curl, value.c_str(), - static_cast(value.size())); - std::string res(encoded); - curl_free(encoded); - return res; -} - -std::string buildQuery(CURL* curl, - const std::map& params) -{ - if (params.empty()) return ""; - std::ostringstream oss; - bool first = true; - for (const auto& [k,v] : params) - { - if (!first) oss << "&"; - first = false; - oss << urlEncode(curl, k) << "=" << urlEncode(curl, v); - } - return oss.str(); -} - -std::string buildRawQuery(const std::map& params) -{ - if (params.empty()) return ""; - std::ostringstream oss; - bool first = true; - for (const auto& kv : params) - { - if (!first) oss << "&"; - first = false; - oss << kv.first << "=" << kv.second; - } - return oss.str(); -} - -std::string hmacSha256(const std::string& key, const std::string& data) -{ - unsigned char* digest; - digest = HMAC(EVP_sha256(), - reinterpret_cast(key.data()), key.size(), - reinterpret_cast(data.data()), data.size(), - nullptr, nullptr); - - std::ostringstream oss; - for (int i = 0; i < 32; ++i) - oss << std::hex << std::setw(2) << std::setfill('0') - << static_cast(digest[i]); - return oss.str(); -} - -std::pair sign(const std::string& method, - const std::string& path, - const std::map& params, - const std::string& rawBody) -{ - auto now = std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()).count(); - std::string timestamp = std::to_string(now); - - // Build query string exactly as sent - std::string queryString = buildRawQuery(params); - - std::string preHash = timestamp + method + path + queryString + rawBody; - std::cout << "PreHash: " << preHash << '\n'; - - const char* secret = std::getenv("FOXBIT_API_SECRET"); - if (!secret) throw std::runtime_error("FOXBIT_API_SECRET not set"); - - std::string signature = hmacSha256(secret, preHash); - std::cout << "Signature: " << signature << '\n'; - - return {signature, timestamp}; -} - -/*-------------------------------------------------- - * libcurl write callback - *------------------------------------------------*/ -size_t writeCallback(void* contents, size_t size, size_t nmemb, void* userp) -{ - ((std::string*)userp)->append((char*)contents, size * nmemb); - return size * nmemb; -} - -/*-------------------------------------------------- - * Generic request helper - *------------------------------------------------*/ -std::string request(const std::string& method, - const std::string& path, - const std::map& params = {}, - const json* bodyJson = nullptr) -{ - std::cout << "--------------------------------------------------\n"; - std::cout << "Requesting: " << method << " " << path << '\n'; - - std::string rawBody = bodyJson ? bodyJson->dump() : ""; - auto [signature, timestamp] = sign(method, path, params, rawBody); - - CURL* curl = curl_easy_init(); - if (!curl) throw std::runtime_error("Failed to init curl"); - - std::string queryString = buildQuery(curl, params); - std::string url = API_BASE_URL + path; - if (!queryString.empty()) url += "?" + queryString; - - struct curl_slist* headers = nullptr; - const char* apiKey = std::getenv("FOXBIT_API_KEY"); - if (!apiKey) throw std::runtime_error("FOXBIT_API_KEY not set"); - - headers = curl_slist_append(headers, - ("X-FB-ACCESS-KEY: " + std::string(apiKey)).c_str()); - headers = curl_slist_append(headers, - ("X-FB-ACCESS-TIMESTAMP: " + timestamp).c_str()); - headers = curl_slist_append(headers, - ("X-FB-ACCESS-SIGNATURE: " + signature).c_str()); - headers = curl_slist_append(headers, "Content-Type: application/json"); - - curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); - curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); - curl_easy_setopt(curl, CURLOPT_CUSTOMREQUEST, method.c_str()); - curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback); - - if (bodyJson) - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, rawBody.c_str()); - - std::string responseStr; - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &responseStr); - - CURLcode res = curl_easy_perform(curl); - long httpCode = 0; - curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode); - - curl_slist_free_all(headers); - curl_easy_cleanup(curl); - - if (res != CURLE_OK) - throw std::runtime_error("curl error: " + - std::string(curl_easy_strerror(res))); - - if (httpCode != 200 && httpCode != 201) - { - std::cerr << "HTTP Status Code: " << httpCode - << ", Error Response Body: " << responseStr << '\n'; - throw std::runtime_error("request failed"); - } - - return responseStr; -} - -/*-------------------------------------------------- - * Convenience wrappers - *------------------------------------------------*/ -json createOrder(const std::string& marketSymbol, - const std::string& side, - const std::string& type, - const std::string& price, - const std::string& quantity) -{ - json order = { - {"market_symbol", marketSymbol}, - {"side", side}, - {"type", type}, - {"price", price}, - {"quantity", quantity} - }; - return json::parse(request("POST", "/rest/v3/orders", {}, &order)); -} - -json getActiveOrders(const std::string& marketSymbol) -{ - std::map params = { - {"market_symbol", marketSymbol}, - {"state", "ACTIVE"} - }; - return json::parse(request("GET", "/rest/v3/orders", params, nullptr)); -} - -json cancelOrder(const std::string& orderId) -{ - json payload = { - {"type", "ID"}, - {"id", orderId} - }; - return json::parse(request("PUT", "/rest/v3/orders/cancel", {}, &payload)); -} - -/*-------------------------------------------------- - * Main demo flow - *------------------------------------------------*/ -int main() -{ - try - { - std::cout << "FOXBIT_API_KEY: " << std::getenv("FOXBIT_API_KEY") << "\n"; - - // Get the user information - json meResponse = json::parse(request("GET", "/rest/v3/me")); - std::cout << "Response: " << meResponse.dump(2) << "\n"; - - // Get current price - const std::string marketSymbol = "btcbrl"; - json tickerResponse = json::parse(request("GET", "/rest/v3/markets/" + marketSymbol + "/ticker/24hr")); - json ticker = tickerResponse["data"].is_array() && !tickerResponse["data"].empty() - ? tickerResponse["data"][0] - : json::object(); - std::cout << "Response: " << ticker.dump(2) << "\n"; - - double lastPrice = 0.0; - if (ticker.contains("best") && ticker["best"].contains("bid") && ticker["best"]["bid"].contains("price")) - { - if (ticker["best"]["bid"]["price"].is_string()) - lastPrice = std::stod(ticker["best"]["bid"]["price"].get()); - else - lastPrice = ticker["best"]["bid"]["price"].get(); - } - double target = lastPrice * 0.9; // Calculate target price: 10% below the best bid price - std::ostringstream targetPriceStream; - targetPriceStream << std::fixed << std::setprecision(8) << target; - std::string targetPrice = targetPriceStream.str(); - - // Request to create a new order - json orderResponse = createOrder(marketSymbol, "BUY", "LIMIT", targetPrice, "0.0001"); - std::cout << "Response: " << orderResponse.dump(2) << "\n"; - - std::this_thread::sleep_for(std::chrono::milliseconds(2000)); - - // Get active orders - auto oneHourAgo = std::chrono::system_clock::now() - std::chrono::hours(1); - std::time_t t = std::chrono::system_clock::to_time_t(oneHourAgo); - std::tm tm{}; - #if defined(_WIN32) - gmtime_s(&tm, &t); - #else - gmtime_r(&t, &tm); - #endif - std::ostringstream iso; - iso << std::put_time(&tm, "%Y-%m-%dT%H:%M:%SZ"); - std::string oneHourAgoISO = iso.str(); - - std::map ordersParam = { - {"market_symbol", marketSymbol}, - {"state", "ACTIVE"}, - {"start_time", oneHourAgoISO} // Optional: included to test signature behavior with special chars - }; - json ordersResponse = json::parse(request("GET", "/rest/v3/orders", ordersParam, nullptr)); - std::cout << "Response: " << ordersResponse.dump(2) << "\n"; - - // Request to cancel the order - std::string orderId = orderResponse["id"].get(); - json cancelResponse = cancelOrder(orderId); - std::cout << "Response: " << cancelResponse.dump(2) << "\n"; - } - catch (const std::exception& ex) - { - std::cerr << "Failed to process request: " << ex.what() << '\n'; - return EXIT_FAILURE; - } - return EXIT_SUCCESS; -} diff --git a/rest-v3/cpp/main.cpp b/rest-v3/cpp/main.cpp new file mode 100644 index 0000000..4ed356a --- /dev/null +++ b/rest-v3/cpp/main.cpp @@ -0,0 +1,233 @@ +// Foxbit REST API v3 example. +// +// Runs a full order lifecycle against https://api.foxbit.com.br: fetches +// account info, reads the public orderbook, places a LIMIT BUY order priced +// far below the market, lists active orders and cancels the order. +// +// API docs: https://docs.foxbit.com.br/rest/v3/ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "json.hpp" // nlohmann/json single header (pinned in the Dockerfile) + +using json = nlohmann::json; + +// Ordered list of query parameters. The URL and the prehash must list the +// parameters in the same order, so an order-preserving structure is used. +using Params = std::vector>; + +const std::string API_BASE_URL = "https://api.foxbit.com.br"; + +std::string apiKey; // FOXBIT_API_KEY +std::string apiSecret; // FOXBIT_API_SECRET + +// Percent-encode one URL component per RFC 3986 (space is %20, never +). +std::string percentEncode(CURL* curl, const std::string& value) { + char* encoded = curl_easy_escape(curl, value.c_str(), static_cast(value.size())); + if (encoded == nullptr) { + throw std::runtime_error("curl_easy_escape failed"); + } + std::string result(encoded); + curl_free(encoded); + return result; +} + +// Build the percent-encoded query string that goes into the URL. +std::string encodedQuery(CURL* curl, const Params& params) { + std::string query; + for (const auto& [key, value] : params) { + if (!query.empty()) query += "&"; + query += percentEncode(curl, key) + "=" + percentEncode(curl, value); + } + return query; +} + +// Build the raw (decoded) query string that goes into the prehash. +std::string decodedQuery(const Params& params) { + std::string query; + for (const auto& [key, value] : params) { + if (!query.empty()) query += "&"; + query += key + "=" + value; + } + return query; +} + +// HMAC-SHA256 as a lowercase hex string (one-shot OpenSSL 3 EVP API). +std::string hmacSha256Hex(const std::string& key, const std::string& message) { + unsigned char digest[EVP_MAX_MD_SIZE]; + size_t digestLen = 0; + if (EVP_Q_mac(nullptr, "HMAC", nullptr, "SHA256", nullptr, + key.data(), key.size(), + reinterpret_cast(message.data()), message.size(), + digest, sizeof(digest), &digestLen) == nullptr) { + throw std::runtime_error("HMAC-SHA256 computation failed"); + } + std::ostringstream hex; + hex << std::hex << std::setfill('0'); + for (size_t i = 0; i < digestLen; ++i) { + hex << std::setw(2) << static_cast(digest[i]); + } + return hex.str(); +} + +// Return the HMAC-SHA256 signature (hex) for a request. +// +// prehash = timestamp + method + path + query + rawBody +// +// Gotcha 1: `query` must be the DECODED query string (raw values, no +// percent-encoding) even though the URL carries the encoded form -- the +// server rebuilds the prehash from decoded values. +// Gotcha 2: `rawBody` must be the exact string sent on the wire -- +// serialize the body once, then sign and send that same string. +std::string sign(const std::string& method, const std::string& path, + const std::string& query, const std::string& rawBody, + const std::string& timestamp) { + const std::string preHash = timestamp + method + path + query + rawBody; + std::cout << "PreHash: " << preHash << "\n"; + return hmacSha256Hex(apiSecret, preHash); +} + +// libcurl write callback: append the response body to a std::string. +size_t writeCallback(char* data, size_t size, size_t nmemb, void* userdata) { + static_cast(userdata)->append(data, size * nmemb); + return size * nmemb; +} + +// Send a request to the API and return the parsed JSON response. +// +// The encoded query (for the URL) and the decoded query (for the prehash) +// are built from the same ordered `params`, and the body is serialized +// exactly once, so the signed strings can never diverge from those sent. +json request(const std::string& method, const std::string& path, + const Params& params = {}, const json* body = nullptr, + bool authenticated = true) { + std::cout << std::string(50, '-') << "\n" << method << " " << path << "\n"; + + std::unique_ptr curl(curl_easy_init(), + curl_easy_cleanup); + if (!curl) { + throw std::runtime_error("curl_easy_init failed"); + } + + std::string url = API_BASE_URL + path; + const std::string urlQuery = encodedQuery(curl.get(), params); + if (!urlQuery.empty()) url += "?" + urlQuery; + + // Serialize the body exactly once; this same string is signed and sent. + const std::string rawBody = body != nullptr ? body->dump() : ""; + + std::vector headerLines = {"Content-Type: application/json"}; + if (authenticated) { + const auto now = std::chrono::system_clock::now().time_since_epoch(); + const std::string timestamp = std::to_string( + std::chrono::duration_cast(now).count()); + const std::string signature = + sign(method, path, decodedQuery(params), rawBody, timestamp); + headerLines.push_back("X-FB-ACCESS-KEY: " + apiKey); + headerLines.push_back("X-FB-ACCESS-TIMESTAMP: " + timestamp); + headerLines.push_back("X-FB-ACCESS-SIGNATURE: " + signature); + } + curl_slist* headerList = nullptr; + for (const auto& line : headerLines) { + headerList = curl_slist_append(headerList, line.c_str()); + } + std::unique_ptr headers( + headerList, curl_slist_free_all); + + std::string responseBody; + curl_easy_setopt(curl.get(), CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl.get(), CURLOPT_CUSTOMREQUEST, method.c_str()); + curl_easy_setopt(curl.get(), CURLOPT_HTTPHEADER, headers.get()); + curl_easy_setopt(curl.get(), CURLOPT_WRITEFUNCTION, writeCallback); + curl_easy_setopt(curl.get(), CURLOPT_WRITEDATA, &responseBody); + if (body != nullptr) { + // POSTFIELDS keeps a pointer to rawBody, which outlives the transfer. + curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDSIZE, + static_cast(rawBody.size())); + curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDS, rawBody.c_str()); + } + + const CURLcode result = curl_easy_perform(curl.get()); + if (result != CURLE_OK) { + throw std::runtime_error(std::string("curl: ") + curl_easy_strerror(result)); + } + long status = 0; + curl_easy_getinfo(curl.get(), CURLINFO_RESPONSE_CODE, &status); + std::cout << "Response (" << status << "): " << responseBody << "\n"; + if (status < 200 || status >= 300) { + throw std::runtime_error(method + " " + path + " failed with status " + + std::to_string(status)); + } + return json::parse(responseBody); +} + +int main() { + const char* keyEnv = std::getenv("FOXBIT_API_KEY"); + const char* secretEnv = std::getenv("FOXBIT_API_SECRET"); + if (keyEnv == nullptr || secretEnv == nullptr || *keyEnv == '\0' || *secretEnv == '\0') { + std::cerr << "Set the FOXBIT_API_KEY and FOXBIT_API_SECRET environment variables.\n"; + return EXIT_FAILURE; + } + apiKey = keyEnv; + apiSecret = secretEnv; + + curl_global_init(CURL_GLOBAL_DEFAULT); + int exitCode = EXIT_SUCCESS; + try { + // 1. Fetch account information (authenticated, no params). + request("GET", "/rest/v3/me"); + + // 2. Fetch the top of the btcbrl orderbook (public endpoint, no auth). + const json orderbook = request("GET", "/rest/v3/markets/btcbrl/orderbook", + {{"depth", "1"}}, nullptr, false); + const double bestBid = std::stod(orderbook["bids"][0][0].get()); + + // 3. Price the order at 50% of the best bid: inside the price band the + // API accepts (an absurd price like 10.0 is rejected with 422) yet + // far too low to ever fill. btcbrl uses price_increment 1.0, so the + // price must be a whole number. + const std::string price = + std::to_string(static_cast(std::floor(bestBid * 0.5))); + + // 4. Place a LIMIT BUY order for 0.0001 BTC. nlohmann/json sorts object + // keys on dump() -- harmless, because the exact string produced here + // is both signed and sent. + const json orderBody = {{"market_symbol", "btcbrl"}, + {"side", "BUY"}, + {"type", "LIMIT"}, + {"price", price}, + {"quantity", "0.0001"}}; + const json order = request("POST", "/rest/v3/orders", {}, &orderBody); + const std::string orderId = order["id"].get(); + + // 5. Give the order a moment to show up in the active list. + std::this_thread::sleep_for(std::chrono::seconds(2)); + + // 6. List active orders -- the order placed above should appear. + request("GET", "/rest/v3/orders", + {{"market_symbol", "btcbrl"}, {"state", "ACTIVE"}}); + + // 7. Cancel the order placed in step 4. + const json cancelBody = {{"type", "ID"}, {"id", orderId}}; + request("PUT", "/rest/v3/orders/cancel", {}, &cancelBody); + } catch (const std::exception& error) { + std::cerr << "Error: " << error.what() << "\n"; + exitCode = EXIT_FAILURE; + } + curl_global_cleanup(); + return exitCode; +} diff --git a/rest-v3/dart/Dockerfile b/rest-v3/dart/Dockerfile index d4a5138..491356c 100644 --- a/rest-v3/dart/Dockerfile +++ b/rest-v3/dart/Dockerfile @@ -1,11 +1,17 @@ -FROM dart:3.7.3 AS build +FROM dart:3.12.2 AS build WORKDIR /app -COPY pubspec.* ./ - +COPY pubspec.yaml pubspec.lock ./ RUN dart pub get -COPY . . +COPY bin/ bin/ +RUN dart compile exe bin/main.dart -o /app/example + +# Minimal runtime image: the AOT binary plus the Dart runtime libraries +# (which include the root certificates needed for HTTPS). +FROM scratch +COPY --from=build /runtime/ / +COPY --from=build /app/example /app/example -CMD ["dart", "run", "bin/main.dart"] +CMD ["/app/example"] diff --git a/rest-v3/dart/README.md b/rest-v3/dart/README.md index 1634fdf..c78aa04 100644 --- a/rest-v3/dart/README.md +++ b/rest-v3/dart/README.md @@ -1,28 +1,64 @@ -# Foxbit API REST v3 Dart Examples +# Foxbit REST API v3 — Dart Example -Here is the Dart examples for the Foxbit API REST v3. This section provides a series of scripts to help you understand how to interact with the Foxbit API using Dart. These examples cover a range of functionalities from fetching market data to placing orders and managing your account. +A minimal, self-contained example of integrating with the [Foxbit REST API v3](https://docs.foxbit.com.br/rest/v3/) in Dart. It runs the following flow: -## Prerequisites +1. `GET /rest/v3/me` — fetch account info (authenticated). +2. `GET /rest/v3/markets/btcbrl/orderbook?depth=1` — fetch the order book (public, no authentication). +3. Compute a safe limit price: 50% of the best bid, rounded down to an integer (`btcbrl` has a price increment of `1.0`). +4. `POST /rest/v3/orders` — place a limit buy order. +5. Wait 2 seconds. +6. `GET /rest/v3/orders?market_symbol=btcbrl&state=ACTIVE` — list active orders. +7. `PUT /rest/v3/orders/cancel` — cancel the order created in step 4. -Before you begin, ensure you have the following prerequisites installed on your system: +> **Warning**: this example creates a REAL order (LIMIT BUY of 0.0001 BTC at 50% of the market price — inside the accepted price band, but far too low to ever execute) and cancels it right after. -- Docker +## Requirements -## Getting Started +- Docker (recommended), or +- Dart SDK >= 3.5 to run natively. -1. **Configure API Keys**: You must read the [main README file located at the root of the project](https://github.com/foxbit-group/foxbit-api-samples?tab=readme-ov-file#getting-started) for general information on setting up your environment, including configuring your API keys as environment variables. -2. **Running the Examples**: To run the example, navigate to the project directory in the terminal and execute the following command: +## Credentials + +Create an API key at and export it: + +```bash +export FOXBIT_API_KEY=your_api_key +export FOXBIT_API_SECRET=your_api_secret +``` + +Alternatively, put both variables in a `.env` file and pass `--env-file .env` to `docker run`. + +## Run with Docker ```bash -docker build -t foxbit-dart-examples . +docker build -t foxbit-sample-dart . +docker run --rm -e FOXBIT_API_KEY -e FOXBIT_API_SECRET foxbit-sample-dart +``` + +Or, with a `.env` file: -docker run --rm \ - -e FOXBIT_API_KEY=$FOXBIT_API_KEY \ - -e FOXBIT_API_SECRET=$FOXBIT_API_SECRET \ - foxbit-dart-examples +```bash +docker run --rm --env-file .env foxbit-sample-dart ``` -## Additional Notes +## Run natively + +```bash +dart pub get +dart run bin/main.dart +``` + +## How request signing works + +Every authenticated request carries three headers: `X-FB-ACCESS-KEY` (your API key), `X-FB-ACCESS-TIMESTAMP` (UNIX time in milliseconds) and `X-FB-ACCESS-SIGNATURE`. The signature is an HMAC-SHA256 (lowercase hex) of: + +``` +timestamp + HTTP method + path + query string + raw body +``` + +Two gotchas that cause most `401 Unauthorized` errors: + +1. **The query string goes DECODED into the pre-hash but percent-encoded (RFC 3986) into the URL.** Sign `market_symbol=btc brl`, send `market_symbol=btc%20brl`. Build both strings from the same ordered parameter list so the pairs and their order always match. +2. **The body is signed exactly as the bytes sent on the wire.** Serialize the JSON once and use that same string for both the signature and the request body — serializing twice risks a formatting mismatch. -These examples are meant to serve as a starting point. They demonstrate basic API interactions. It's recommended to review and test the code thoroughly before using it in a production environment. -For detailed API documentation, refer to the [Foxbit API Documentation](https://docs.foxbit.com.br/rest/v3/). +Full documentation: diff --git a/rest-v3/dart/bin/main.dart b/rest-v3/dart/bin/main.dart index 6f679cb..d97a719 100644 --- a/rest-v3/dart/bin/main.dart +++ b/rest-v3/dart/bin/main.dart @@ -1,173 +1,156 @@ +// Foxbit REST API v3 example (Dart). +// +// Flow: fetch account info, read the public order book, place a limit buy +// order far below the market price, list active orders and cancel the order. +// +// Docs: https://docs.foxbit.com.br/rest/v3/ import 'dart:convert'; import 'dart:io'; import 'package:crypto/crypto.dart'; -import 'package:http/http.dart' as http; -const String apiBaseUrl = 'https://api.foxbit.com.br'; - -/// Signs the request using HMAC-SHA256 with your API secret. -Map signRequest( - String method, - String path, { - Map? params, - Map? body, +const baseUrl = 'https://api.foxbit.com.br'; + +final String apiKey = Platform.environment['FOXBIT_API_KEY'] ?? ''; +final String apiSecret = Platform.environment['FOXBIT_API_SECRET'] ?? ''; + +/// Query string with raw (decoded) values, used in the signature pre-hash. +/// The server reconstructs the pre-hash with DECODED values, so signing the +/// percent-encoded form would produce a 401. +String rawQueryString(Map params) => + params.entries.map((e) => '${e.key}=${e.value}').join('&'); + +/// RFC 3986 percent-encoded query string, used in the request URL. +/// Uri.encodeComponent encodes a space as %20. Do NOT use +/// Uri.encodeQueryComponent, which encodes it as '+'. +String encodedQueryString(Map params) => params.entries + .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') + .join('&'); + +/// HMAC-SHA256 signature (lowercase hex) over: +/// timestamp + method + path + decoded query string + raw body +String sign({ + required String secret, + required String timestamp, + required String method, + required String path, + required String query, // decoded (raw) values + required String body, // the exact string sent on the wire }) { - final rawQueryString = (params == null || params.isEmpty) - ? '' - : params.entries.map((e) => '${e.key}=${e.value}').join('&'); - - final rawBody = body != null ? jsonEncode(body) : ''; - final timestamp = DateTime.now().millisecondsSinceEpoch.toString(); - - final preHash = '$timestamp$method$path$rawQueryString$rawBody'; + final preHash = '$timestamp$method$path$query$body'; print('PreHash: $preHash'); - - final secret = utf8.encode(Platform.environment['FOXBIT_API_SECRET']!); - final signature = Hmac(sha256, secret).convert(utf8.encode(preHash)).toString(); - print('Signature: $signature'); - - return { - 'X-FB-ACCESS-KEY': Platform.environment['FOXBIT_API_KEY']!, - 'X-FB-ACCESS-TIMESTAMP': timestamp, - 'X-FB-ACCESS-SIGNATURE': signature, - 'Content-Type': 'application/json', - }; + return Hmac(sha256, utf8.encode(secret)).convert(utf8.encode(preHash)).toString(); } -/// Sends an HTTP request to the Foxbit API. -Future> request( +/// Sends a request to the Foxbit API and returns the decoded JSON response. +/// Exits the process on any non-2xx status. +/// +/// Signing gotchas: +/// 1. The query string goes DECODED into the pre-hash but percent-encoded +/// (RFC 3986) into the URL. Both are built from the same [params] map, +/// so they always contain the same pairs in the same order. +/// 2. The body is signed exactly as sent: it is serialized ONCE and the +/// same string is used for both the signature and the request body. +Future request( String method, String path, { - Map? params, + Map params = const {}, Map? body, + bool auth = true, }) async { - print('--------------------------------------------------'); - print('Requesting: $method $path'); - - final headers = signRequest(method, path, params: params, body: body); - - final uri = Uri.parse('$apiBaseUrl$path').replace(queryParameters: params); - - late http.Response resp; - if (method == 'GET') { - resp = await http.get(uri, headers: headers); - } else if (method == 'POST') { - resp = await http.post(uri, headers: headers, body: jsonEncode(body)); - } else if (method == 'PUT') { - resp = await http.put(uri, headers: headers, body: jsonEncode(body)); - } else { - throw ArgumentError('Unsupported HTTP method: $method'); + final encodedQuery = encodedQueryString(params); + final rawBody = body == null ? '' : jsonEncode(body); // serialize ONCE + + print('-' * 50); + print('$method $path${encodedQuery.isEmpty ? '' : '?$encodedQuery'}'); + + final headers = {'Content-Type': 'application/json'}; + if (auth) { + final timestamp = DateTime.now().millisecondsSinceEpoch.toString(); + headers['X-FB-ACCESS-KEY'] = apiKey; + headers['X-FB-ACCESS-TIMESTAMP'] = timestamp; + headers['X-FB-ACCESS-SIGNATURE'] = sign( + secret: apiSecret, + timestamp: timestamp, + method: method, + path: path, + query: rawQueryString(params), + body: rawBody, + ); } - if (resp.statusCode != 200 && resp.statusCode != 201) { - stderr.writeln('HTTP ${resp.statusCode}: ${resp.body}'); - throw HttpException('Request failed with status ${resp.statusCode}'); + final url = + Uri.parse('$baseUrl$path${encodedQuery.isEmpty ? '' : '?$encodedQuery'}'); + final client = HttpClient(); + try { + final httpRequest = await client.openUrl(method, url); + for (final entry in headers.entries) { + httpRequest.headers.set(entry.key, entry.value); + } + if (rawBody.isNotEmpty) { + final bytes = utf8.encode(rawBody); // the exact bytes that were signed + httpRequest.headers.contentLength = bytes.length; + httpRequest.add(bytes); + } + final response = await httpRequest.close(); + final responseBody = await response.transform(utf8.decoder).join(); + print('Response (${response.statusCode}): $responseBody'); + if (response.statusCode < 200 || response.statusCode >= 300) { + stderr.writeln('Request failed, aborting.'); + exit(1); + } + return responseBody.isEmpty ? null : jsonDecode(responseBody); + } finally { + client.close(); } - return jsonDecode(resp.body) as Map; -} - -Future> createOrder() { - return request( - 'POST', - '/rest/v3/orders', - body: { - 'market_symbol': 'btcbrl', - 'side': 'BUY', - 'type': 'LIMIT', - 'price': '500000.0', - 'quantity': '0.00001', - }, - ); -} - -Future> getActiveOrders() { - return request( - 'GET', - '/rest/v3/orders', - params: { - 'market_symbol': 'btcbrl', - 'state': 'ACTIVE', - }, - ).then((data) => data['data'] as List); -} - -Future> cancelOrder(String orderId) { - return request( - 'PUT', - '/rest/v3/orders/cancel', - body: { - 'type': 'ID', - 'id': orderId, - }, - ); } Future main() async { - if (Platform.environment['FOXBIT_API_KEY'] == null || - Platform.environment['FOXBIT_API_SECRET'] == null) { - stderr.writeln('Please set FOXBIT_API_KEY and FOXBIT_API_SECRET'); + if (apiKey.isEmpty || apiSecret.isEmpty) { + stderr.writeln( + 'Missing FOXBIT_API_KEY and/or FOXBIT_API_SECRET environment variables.'); exit(1); } - try { - print('FOXBIT_API_KEY: ${Platform.environment['FOXBIT_API_KEY']}'); - - // Get the user information - final meResponse = await request('GET', '/rest/v3/me'); - print('Response: $meResponse'); - - // Get current price - final marketSymbol = 'btcbrl'; - final tickerResponse = - await request('GET', '/rest/v3/markets/$marketSymbol/ticker/24hr'); - final tickerList = tickerResponse['data'] as List?; - final ticker = (tickerList != null && tickerList.isNotEmpty) ? tickerList[0] : null; - print('Response: $ticker'); - - // Request to create a new order - final lastPrice = double.parse(ticker['best']['bid']['price'] as String); - final targetPrice = (lastPrice * 0.9).toString(); - final order = { - 'market_symbol': marketSymbol, - 'side': 'BUY', - 'type': 'LIMIT', - 'price': targetPrice, - 'quantity': '0.0001', - }; - final orderResponse = - await request('POST', '/rest/v3/orders', body: order); - print('Response: $orderResponse'); - - await Future.delayed(const Duration(seconds: 2)); - - // Get active orders - final oneHourAgoISO = DateTime.now() - .toUtc() - .subtract(const Duration(hours: 1)) - .toIso8601String(); - final ordersParam = { - 'market_symbol': marketSymbol, - 'state': 'ACTIVE', - 'start_time': oneHourAgoISO, - }; - final ordersResponse = - await request('GET', '/rest/v3/orders', params: ordersParam); - print('Response: $ordersResponse'); - - // Request to cancel the order - final orderToCancel = { - 'type': 'ID', - 'id': orderResponse['id'], - }; - final cancelResponse = await request( - 'PUT', - '/rest/v3/orders/cancel', - body: orderToCancel, - ); - print('Response: $cancelResponse'); - } catch (error) { - stderr.writeln('Failed to process request.'); - exit(2); - } + // 1. Account info (authenticated request, no params). + await request('GET', '/rest/v3/me'); + + // 2. Order book (public endpoint -- no authentication headers). + final orderbook = await request( + 'GET', + '/rest/v3/markets/btcbrl/orderbook', + params: {'depth': '1'}, + auth: false, + ); + final bestBid = double.parse(orderbook['bids'][0][0] as String); + + // 3. Price the order at 50% of the best bid: inside the accepted price + // band (an absurd price like 10.0 is rejected with 422) yet far too low + // to ever execute. btcbrl has price_increment 1.0, so use an integer. + final price = (bestBid * 0.5).floor().toString(); + + // 4. Place a limit buy order and capture its id. + final order = await request('POST', '/rest/v3/orders', body: { + 'market_symbol': 'btcbrl', + 'side': 'BUY', + 'type': 'LIMIT', + 'price': price, + 'quantity': '0.0001', + }); + final orderId = order['id'] as String; + + // 5. Give the matching engine a moment to process the order. + await Future.delayed(const Duration(seconds: 2)); + + // 6. List active orders (the new order should show up). + await request('GET', '/rest/v3/orders', params: { + 'market_symbol': 'btcbrl', + 'state': 'ACTIVE', + }); + + // 7. Cancel the order by id. + await request('PUT', '/rest/v3/orders/cancel', body: { + 'type': 'ID', + 'id': orderId, + }); } diff --git a/rest-v3/dart/pubspec.lock b/rest-v3/dart/pubspec.lock new file mode 100644 index 0000000..02882fe --- /dev/null +++ b/rest-v3/dart/pubspec.lock @@ -0,0 +1,29 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf + url: "https://pub.dev" + source: hosted + version: "3.0.7" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 + url: "https://pub.dev" + source: hosted + version: "1.4.0" +sdks: + dart: ">=3.5.0 <4.0.0" diff --git a/rest-v3/dart/pubspec.yaml b/rest-v3/dart/pubspec.yaml index 112dcef..a76e35d 100644 --- a/rest-v3/dart/pubspec.yaml +++ b/rest-v3/dart/pubspec.yaml @@ -1,10 +1,10 @@ -name: foxbit_dart_examples -description: A simple Dart example for Foxbit REST v3 API integration. +name: foxbit_rest_v3_example +description: Foxbit REST API v3 integration example in Dart. version: 1.0.0 +publish_to: none environment: - sdk: '>=3.0.0 <4.0.0' + sdk: '>=3.5.0 <4.0.0' dependencies: - http: ^1.3.0 - crypto: ^3.0.2 + crypto: 3.0.7 diff --git a/rest-v3/dotnet/Dockerfile b/rest-v3/dotnet/Dockerfile new file mode 100644 index 0000000..340b876 --- /dev/null +++ b/rest-v3/dotnet/Dockerfile @@ -0,0 +1,13 @@ +# Build stage: compile the example with the .NET SDK. +FROM mcr.microsoft.com/dotnet/sdk:10.0.301-alpine3.24 AS build +WORKDIR /src +COPY FoxbitExample.csproj ./ +RUN dotnet restore FoxbitExample.csproj +COPY Program.cs ./ +RUN dotnet publish FoxbitExample.csproj -c Release -o /app --no-restore + +# Runtime stage: only the compiled app on top of the .NET runtime. +FROM mcr.microsoft.com/dotnet/runtime:10.0.9-alpine3.24 +WORKDIR /app +COPY --from=build /app ./ +ENTRYPOINT ["dotnet", "FoxbitExample.dll"] diff --git a/rest-v3/dotnet/FoxbitExample.csproj b/rest-v3/dotnet/FoxbitExample.csproj new file mode 100644 index 0000000..d97df4a --- /dev/null +++ b/rest-v3/dotnet/FoxbitExample.csproj @@ -0,0 +1,11 @@ + + + + Exe + net10.0 + enable + enable + true + + + diff --git a/rest-v3/dotnet/Program.cs b/rest-v3/dotnet/Program.cs index 5c8b592..697c6ad 100644 --- a/rest-v3/dotnet/Program.cs +++ b/rest-v3/dotnet/Program.cs @@ -1,134 +1,156 @@ -using System; -using System.Collections.Generic; -using System.Net; -using System.Net.Http; +// Foxbit REST API v3 — C# example. +// +// Signs requests with HMAC-SHA256 and walks through a simple order flow: +// authenticate, read the public orderbook, place a LIMIT order, list it, cancel it. +// +// Signing gotchas (validated against the live API): +// 1. The query string goes into the signature pre-hash DECODED (raw values, +// no percent-encoding), but is sent percent-encoded (RFC 3986) in the URL. +// 2. The body is signed exactly as sent: serialize it once and use the same +// string for both the signature and the request content. + +using System.Globalization; using System.Security.Cryptography; using System.Text; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Configuration; -using Newtonsoft.Json; +using System.Text.Json; -class Program +const string BaseUrl = "https://api.foxbit.com.br"; + +// Fail fast if credentials are missing. Never print them. +var apiKey = RequireEnv("FOXBIT_API_KEY"); +var apiSecret = RequireEnv("FOXBIT_API_SECRET"); + +using var http = new HttpClient { BaseAddress = new Uri(BaseUrl) }; + +try { - private static readonly HttpClient client = new HttpClient(); - private static IConfiguration Configuration; - private const string ApiBaseUrl = "https://api.foxbit.com.br"; - - static async Task Main(string[] args) + // 1. Check the credentials with an authenticated request. + await RequestAsync("GET", "/rest/v3/me"); + + // 2. Fetch the best bid from the public orderbook (no authentication needed). + var orderbookJson = await RequestAsync("GET", "/rest/v3/markets/btcbrl/orderbook", + query: [("depth", "1")], authenticated: false); + using var orderbook = JsonDocument.Parse(orderbookJson); + var bestBid = decimal.Parse(orderbook.RootElement.GetProperty("bids")[0][0].GetString()!, + CultureInfo.InvariantCulture); + + // 3. Bid at 50% of the best bid, rounded down to a whole number (the btcbrl + // price increment is 1.0). That keeps the order inside the exchange's + // accepted price band — an absurd price like 10.0 is rejected with a 422 — + // while staying far too low to ever execute. + var price = Math.Floor(bestBid / 2).ToString("F0", CultureInfo.InvariantCulture); + Console.WriteLine($"Best bid: {bestBid} BRL, order price: {price} BRL"); + + // 4. Place a LIMIT BUY order for 0.0001 BTC. + var orderJson = await RequestAsync("POST", "/rest/v3/orders", body: new Dictionary { - var builder = new ConfigurationBuilder() - .AddEnvironmentVariables(); - Configuration = builder.Build(); - - Console.WriteLine("FOXBIT_API_KEY: " + Configuration["FOXBIT_API_KEY"]); - - try - { - // Get user info - var meResponse = await RequestAsync("GET", "/rest/v3/me", null, null); - Console.WriteLine("Response: " + meResponse); - - // Create an order - var order = new - { - market_symbol = "btcbrl", - side = "BUY", - type = "LIMIT", - price = "10.0", - quantity = "0.0001", - }; - var orderResponse = await RequestAsync("POST", "/rest/v3/orders", null, order); - Console.WriteLine("Response: " + orderResponse); - - Thread.Sleep(2000); - - // Get active orders - var orderParams = new - { - market_symbol = "btcbrl", - state = "ACTIVE", - }; - var ordersResponse = await RequestAsync("GET", "/rest/v3/orders", orderParams, null); - Console.WriteLine("Response: " + ordersResponse); - - // Cancel the order - var orderToCancel = new - { - type = "ID", - id = JsonConvert.DeserializeObject(orderResponse).id - }; - var cancelResponse = await RequestAsync("PUT", "/rest/v3/orders/cancel", null, orderToCancel); - Console.WriteLine("Response: " + cancelResponse); - } - catch (Exception ex) - { - Console.WriteLine("Failed to process request: " + ex.Message); - } - } - - static string ConvertToQueryString(object paramsObj) + ["market_symbol"] = "btcbrl", + ["side"] = "BUY", + ["type"] = "LIMIT", + ["price"] = price, + ["quantity"] = "0.0001", + }); + using var order = JsonDocument.Parse(orderJson); + var orderId = order.RootElement.GetProperty("id").GetString()!; + + // 5. Give the exchange a moment to process the order. + await Task.Delay(2000); + + // 6. The new order should show up among the active orders. + await RequestAsync("GET", "/rest/v3/orders", + query: [("market_symbol", "btcbrl"), ("state", "ACTIVE")]); + + // 7. Cancel the order by its id. + await RequestAsync("PUT", "/rest/v3/orders/cancel", body: new Dictionary { - if (paramsObj == null) return string.Empty; - - var properties = paramsObj.GetType().GetProperties(); - var keyValuePairs = new List(); - foreach (var property in properties) - { - var value = property.GetValue(paramsObj, null); - if (value != null) - { - keyValuePairs.Add($"{WebUtility.UrlEncode(property.Name)}={WebUtility.UrlEncode(value.ToString())}"); - } - } - return string.Join("&", keyValuePairs); - } + ["type"] = "ID", + ["id"] = orderId, + }); + + return 0; +} +catch (HttpRequestException) +{ + // The status code and response body were already logged by RequestAsync. + return 1; +} - static string Sign(string method, string path, string queryString, string body, string timestamp) +// Sends a request, signing it when `authenticated` is true. Query parameters are +// an ordered list of raw (unencoded) key/value pairs: the same list produces both +// the decoded query string for the signature and the encoded one for the URL, so +// the two can never diverge. +async Task RequestAsync(string method, string path, + IReadOnlyList<(string Key, string Value)>? query = null, + object? body = null, bool authenticated = true) +{ + var decodedQuery = BuildQueryString(query, encoded: false); // goes into the pre-hash + var encodedQuery = BuildQueryString(query, encoded: true); // goes into the URL + // Serialize the body exactly once: the string that is signed is the one sent. + var rawBody = body is null ? "" : JsonSerializer.Serialize(body); + + Console.WriteLine(new string('-', 50)); + Console.WriteLine($"{method} {path}"); + + var uri = path + (encodedQuery.Length > 0 ? "?" + encodedQuery : ""); + var request = new HttpRequestMessage(new HttpMethod(method), uri); + if (rawBody.Length > 0) { - var preHash = $"{timestamp}{method}{path}{queryString}{body}"; - Console.WriteLine("PreHash: " + preHash); - - var secret = Configuration["FOXBIT_API_SECRET"]; - using (var hmac = new HMACSHA256(Encoding.ASCII.GetBytes(secret))) - { - var hash = hmac.ComputeHash(Encoding.ASCII.GetBytes(preHash)); - var signature = BitConverter.ToString(hash).Replace("-", "").ToLower(); - Console.WriteLine("Signature: " + signature); - return signature; - } + request.Content = new StringContent(rawBody, Encoding.UTF8, "application/json"); } - static async Task RequestAsync(string method, string path, object paramsObj, object bodyObj) + if (authenticated) { - Console.WriteLine("--------------------------------------------------"); - Console.WriteLine("Requesting: " + method + " " + path); - - var queryString = ConvertToQueryString(paramsObj); - var body = bodyObj != null ? JsonConvert.SerializeObject(bodyObj) : string.Empty; - var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); - var signature = Sign(method, path, queryString, body, timestamp); - var requestUri = $"{ApiBaseUrl}{path}{(string.IsNullOrEmpty(queryString) ? "" : "?" + queryString)}"; - Console.WriteLine("Full URI: " + requestUri); - - var request = new HttpRequestMessage(new HttpMethod(method), requestUri) - { - Content = new StringContent(body, Encoding.UTF8, "application/json") - }; - - request.Headers.Add("X-FB-ACCESS-KEY", Configuration["FOXBIT_API_KEY"]); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + .ToString(CultureInfo.InvariantCulture); + var signature = Sign(apiSecret, method, path, decodedQuery, rawBody, timestamp); + request.Headers.Add("X-FB-ACCESS-KEY", apiKey); request.Headers.Add("X-FB-ACCESS-TIMESTAMP", timestamp); request.Headers.Add("X-FB-ACCESS-SIGNATURE", signature); + } - var response = await client.SendAsync(request); - var responseContent = await response.Content.ReadAsStringAsync(); + using var response = await http.SendAsync(request); + var content = await response.Content.ReadAsStringAsync(); + Console.WriteLine($"Response ({(int)response.StatusCode}): {content}"); - if (!response.IsSuccessStatusCode) - { - Console.WriteLine($"HTTP Status Code: {response.StatusCode}, Error Response Body: {responseContent}"); - throw new HttpRequestException(responseContent); - } + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"{method} {path} failed with HTTP {(int)response.StatusCode}"); + } + return content; +} - return responseContent; +// Joins query parameters in order. With encoded=false the raw values are used +// (the decoded form the server signs); with encoded=true each key and value is +// RFC 3986 percent-encoded for the URL (space becomes %20, never '+'). +static string BuildQueryString(IReadOnlyList<(string Key, string Value)>? query, bool encoded) +{ + if (query is null || query.Count == 0) + { + return ""; + } + return string.Join("&", query.Select(p => encoded + ? $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(p.Value)}" + : $"{p.Key}={p.Value}")); +} + +// HMAC-SHA256 (lowercase hex) over: timestamp + method + path + decodedQuery + rawBody. +static string Sign(string secret, string method, string path, string decodedQuery, + string rawBody, string timestamp) +{ + var preHash = timestamp + method + path + decodedQuery + rawBody; + Console.WriteLine($"PreHash: {preHash}"); + var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes(preHash)); + return Convert.ToHexString(hash).ToLowerInvariant(); +} + +// Reads a required environment variable or exits with a clear error message. +static string RequireEnv(string name) +{ + var value = Environment.GetEnvironmentVariable(name); + if (string.IsNullOrEmpty(value)) + { + Console.Error.WriteLine($"Error: the {name} environment variable must be set."); + Environment.Exit(1); } + return value; } diff --git a/rest-v3/dotnet/README.md b/rest-v3/dotnet/README.md index 202806d..d693be6 100644 --- a/rest-v3/dotnet/README.md +++ b/rest-v3/dotnet/README.md @@ -1,29 +1,65 @@ -# Foxbit API REST v3 .NET C# Examples +# Foxbit REST API v3 — C# (.NET) Example -Here is the .NET C# examples for the Foxbit API REST v3. This section provides a series of scripts to help you understand how to interact with the Foxbit API using C#. These examples cover a range of functionalities from fetching market data to placing orders and managing your account. +A minimal, dependency-free C# example of the [Foxbit REST API v3](https://docs.foxbit.com.br/rest/v3/), using only the .NET base class library (`HttpClient`, `System.Text.Json`, `HMACSHA256`). -## Prerequisites +It runs the following flow: -Before you begin, ensure you have the following prerequisites installed on your system: +1. `GET /rest/v3/me` — check your credentials (authenticated). +2. `GET /rest/v3/markets/btcbrl/orderbook?depth=1` — fetch the best bid (public, no authentication). +3. Compute a limit price at 50% of the best bid, rounded down to a whole number. +4. `POST /rest/v3/orders` — place a LIMIT BUY order for 0.0001 BTC at that price. +5. Wait 2 seconds. +6. `GET /rest/v3/orders?market_symbol=btcbrl&state=ACTIVE` — list active orders (the new order shows up). +7. `PUT /rest/v3/orders/cancel` — cancel the order by its id. -- .NET SDK: These examples are written for C#, ensure you have the latest .NET SDK installed. +> **Warning:** this example places a REAL order on your account — a LIMIT BUY of 0.0001 BTC at 50% of the current market price. That price is inside the exchange's accepted price band but far too low to ever execute, and the order is cancelled at the end of the flow. -## Getting Started +## Requirements -1. **Install Dependencies**: Navigate to the DotNet examples directory in your terminal and install the necessary dependencies. +- Docker (recommended), or +- .NET SDK 10.0+ to run natively. + +## Credentials + +Create an API key at and export it: ```bash -dotnet restore dotnet.csproj +export FOXBIT_API_KEY="your-api-key" +export FOXBIT_API_SECRET="your-api-secret" ``` -2. **Configure API Keys**: You must read the [main README file located at the root of the project](https://github.com/foxbit-group/foxbit-api-samples?tab=readme-ov-file#getting-started) for general information on setting up your environment, including configuring your API keys as environment variables. -3. **Running the Examples**: To run the example, navigate to the project directory in the terminal and execute the following command: +Alternatively, put both variables in a `.env` file and pass it to Docker with `--env-file .env`. + +## Run with Docker + +```bash +docker build -t foxbit-sample-dotnet . +docker run --rm -e FOXBIT_API_KEY -e FOXBIT_API_SECRET foxbit-sample-dotnet +``` + +Or, using a `.env` file: + +```bash +docker run --rm --env-file .env foxbit-sample-dotnet +``` + +## Run natively ```bash dotnet run ``` -## Additional Notes +## How request signing works + +Every authenticated request carries three headers: `X-FB-ACCESS-KEY` (your API key), `X-FB-ACCESS-TIMESTAMP` (Unix time in milliseconds) and `X-FB-ACCESS-SIGNATURE`. The signature is an HMAC-SHA256 (lowercase hex) of: + +``` +timestamp + method + path + queryString + rawBody +``` + +Two details are easy to get wrong: + +1. **The query string goes into the pre-hash DECODED.** Sign the raw values (`market_symbol=btc brl`), but send them percent-encoded per RFC 3986 in the URL (`market_symbol=btc%20brl`, space as `%20`, never `+`). Signing the encoded form yields a 401. +2. **The body is signed exactly as sent.** Serialize the JSON body once and use that same string for both the signature and the request content. Serializing twice (or letting the HTTP client re-serialize) can change the bytes and yields a 401. -These examples are meant to serve as a starting point. They demonstrate basic API interactions. It's recommended to review and test the code thoroughly before using it in a production environment. -For detailed API documentation, refer to the [Foxbit API Documentation](https://docs.foxbit.com.br/rest/v3/). +See the full API documentation at . diff --git a/rest-v3/dotnet/dotnet.csproj b/rest-v3/dotnet/dotnet.csproj deleted file mode 100644 index edc3252..0000000 --- a/rest-v3/dotnet/dotnet.csproj +++ /dev/null @@ -1,18 +0,0 @@ - - - - Exe - net8.0 - enable - enable - - - - - - - - - - - diff --git a/rest-v3/dotnet/foxbit-api-samples.sln b/rest-v3/dotnet/foxbit-api-samples.sln deleted file mode 100644 index ea6c8f7..0000000 --- a/rest-v3/dotnet/foxbit-api-samples.sln +++ /dev/null @@ -1,30 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.002.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "rest-v3", "rest-v3", "{CB4F61C8-AC55-443E-8F90-1B8D7FF695CD}" -EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "dotnet", "dotnet.csproj", "{AB9CA64B-5CDB-4C36-BB87-0AFEA0C2DFF8}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {AB9CA64B-5CDB-4C36-BB87-0AFEA0C2DFF8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {AB9CA64B-5CDB-4C36-BB87-0AFEA0C2DFF8}.Debug|Any CPU.Build.0 = Debug|Any CPU - {AB9CA64B-5CDB-4C36-BB87-0AFEA0C2DFF8}.Release|Any CPU.ActiveCfg = Release|Any CPU - {AB9CA64B-5CDB-4C36-BB87-0AFEA0C2DFF8}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {AB9CA64B-5CDB-4C36-BB87-0AFEA0C2DFF8} = {CB4F61C8-AC55-443E-8F90-1B8D7FF695CD} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {60EE49A7-1636-4E53-9B27-E40E1487859D} - EndGlobalSection -EndGlobal diff --git a/rest-v3/go/Dockerfile b/rest-v3/go/Dockerfile new file mode 100644 index 0000000..bd6184a --- /dev/null +++ b/rest-v3/go/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.26.4-alpine3.24 AS build +WORKDIR /src +COPY go.mod main.go ./ +RUN CGO_ENABLED=0 go build -o /out/foxbit-example . + +FROM alpine:3.24.1 +# CA certificates are needed for TLS; copy them from the build image instead +# of installing a package. +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt +COPY --from=build /out/foxbit-example /usr/local/bin/foxbit-example +CMD ["foxbit-example"] diff --git a/rest-v3/go/README.md b/rest-v3/go/README.md index cd442f3..ffe4597 100644 --- a/rest-v3/go/README.md +++ b/rest-v3/go/README.md @@ -1,23 +1,74 @@ -# Foxbit API REST v3 GoLang Examples +# Foxbit REST API v3 — Go Example -Here is the GoLang examples for the Foxbit API REST v3. This section provides a series of scripts to help you understand how to interact with the Foxbit API using GoLang. These examples cover a range of functionalities from fetching market data to placing orders and managing your account. +A single-file, standard-library-only Go program that demonstrates how to sign +and send requests to the [Foxbit REST API v3](https://docs.foxbit.com.br/rest/v3/). -## Prerequisites +It runs the following flow: -Before you begin, ensure you have the following prerequisites installed on your system: +1. `GET /rest/v3/me` — fetch account information (authenticated). +2. `GET /rest/v3/markets/btcbrl/orderbook?depth=1` — fetch the order book (public, unauthenticated). +3. Compute an order price at 50% of the best bid. +4. `POST /rest/v3/orders` — create a LIMIT BUY order for 0.0001 BTC (authenticated). +5. Wait 2 seconds. +6. `GET /rest/v3/orders?market_symbol=btcbrl&state=ACTIVE` — list active orders (authenticated). +7. `PUT /rest/v3/orders/cancel` — cancel the order created in step 4 (authenticated). -- GoLang: These examples are written for Go, ensure you have the latest stable version installed. +> **Warning:** this example creates a REAL order on your account — a LIMIT BUY +> of 0.0001 BTC priced at 50% of the current market. That price is inside the +> accepted price band but far too low to ever execute, and the order is +> cancelled at the end of the flow. -## Getting Started +## Requirements -1. **Configure API Keys**: You must read the [main README file located at the root of the project](https://github.com/foxbit-group/foxbit-api-samples?tab=readme-ov-file#getting-started) for general information on setting up your environment, including configuring your API keys as environment variables. -2. **Running the Examples**: To run the example, navigate to the project directory in the terminal and execute the following command: +- Docker (recommended), or +- Go 1.26+ if you want to run it natively. + +## Credentials + +Create an API key at and export it: ```bash -go run examples.go +export FOXBIT_API_KEY="your-api-key" +export FOXBIT_API_SECRET="your-api-secret" ``` -## Additional Notes +Alternatively, put both variables in a `.env` file and pass it to Docker with +`--env-file .env`. + +## Run with Docker + +```bash +docker build -t foxbit-sample-go . +docker run --rm -e FOXBIT_API_KEY -e FOXBIT_API_SECRET foxbit-sample-go +# or: docker run --rm --env-file .env foxbit-sample-go +``` + +## Run natively + +```bash +go run . +``` + +## How request signing works + +Every authenticated request carries three headers: `X-FB-ACCESS-KEY` (your API +key), `X-FB-ACCESS-TIMESTAMP` (UNIX time in milliseconds) and +`X-FB-ACCESS-SIGNATURE`. The signature is a lowercase-hex HMAC-SHA256 of the +prehash string, keyed with your API secret: + +``` +preHash = timestamp + METHOD + path + queryString + rawBody +``` + +Two gotchas that cause most `401` errors: + +1. **The query string goes into the prehash DECODED.** Sign the raw values + (`market_symbol=btc brl`) in the same pair order as the URL, but send them + percent-encoded per RFC 3986 (`market_symbol=btc%20brl`, space is `%20`, + never `+`). +2. **The body is verified byte-for-byte.** Serialize the JSON body exactly + once and use that same string both in the prehash and as the request body. + Re-serializing (or letting the HTTP client re-encode it) can change the + bytes and invalidate the signature. -These examples are meant to serve as a starting point. They demonstrate basic API interactions. It's recommended to review and test the code thoroughly before using it in a production environment. -For detailed API documentation, refer to the [Foxbit API Documentation](https://docs.foxbit.com.br/rest/v3/). +See the full documentation at . diff --git a/rest-v3/go/examples.go b/rest-v3/go/examples.go deleted file mode 100644 index 625d4f5..0000000 --- a/rest-v3/go/examples.go +++ /dev/null @@ -1,183 +0,0 @@ -package main - -import ( - "bytes" - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "io/ioutil" - "log" - "net/http" - "net/url" - "os" - "strconv" - "time" -) - -const apiBaseUrl = "https://api.foxbit.com.br" - -func sign(method, path string, params map[string]string, body map[string]interface{}) (string, string) { - var queryString string - if params != nil { - queryValues := url.Values{} - for key, value := range params { - queryValues.Add(key, value) - } - queryString = queryValues.Encode() - } - - var rawBody []byte - var err error - if body != nil { - rawBody, err = json.Marshal(body) - if err != nil { - log.Fatal("Error encoding body to JSON:", err) - } - } - - timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) - preHash := timestamp + method + path + queryString + string(rawBody) - fmt.Println("PreHash:", preHash) - - h := hmac.New(sha256.New, []byte(os.Getenv("FOXBIT_API_SECRET"))) - h.Write([]byte(preHash)) - signature := hex.EncodeToString(h.Sum(nil)) - fmt.Println("Signature:", signature) - - return signature, timestamp -} - -func request(method, path string, params map[string]string, body map[string]interface{}) ([]byte, error) { - fmt.Println("--------------------------------------------------") - fmt.Println("Requesting:", method, path) - signature, timestamp := sign(method, path, params, body) - url := apiBaseUrl + path - - client := &http.Client{} - var req *http.Request - var err error - - if body != nil { - jsonBody, _ := json.Marshal(body) - req, err = http.NewRequest(method, url, bytes.NewBuffer(jsonBody)) - } else { - req, err = http.NewRequest(method, url, nil) - } - - if err != nil { - log.Fatal("Error creating request:", err) - } - - q := req.URL.Query() - for key, value := range params { - q.Add(key, value) - } - req.URL.RawQuery = q.Encode() - - req.Header.Add("X-FB-ACCESS-KEY", os.Getenv("FOXBIT_API_KEY")) - req.Header.Add("X-FB-ACCESS-TIMESTAMP", timestamp) - req.Header.Add("X-FB-ACCESS-SIGNATURE", signature) - req.Header.Add("Content-Type", "application/json") - - resp, err := client.Do(req) - if err != nil { - log.Fatal("Error on request:", err) - } - defer resp.Body.Close() - - bodyResp, err := ioutil.ReadAll(resp.Body) - if err != nil { - log.Fatal("Error reading response body:", err) - } - - if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { - fmt.Printf("HTTP Status Code: %d, Error Response Body: %s\n", resp.StatusCode, bodyResp) - return nil, fmt.Errorf("request failed with status code %d", resp.StatusCode) - } - - return bodyResp, nil -} - -func createOrder(marketSymbol, side, orderType, price, quantity string) ([]byte, error) { - order := map[string]interface{}{ - "market_symbol": marketSymbol, - "side": side, - "type": orderType, - "price": price, - "quantity": quantity, - } - response, err := request("POST", "/rest/v3/orders", nil, order) - if err != nil { - return nil, err - } - return response, nil -} - -func getActiveOrders(marketSymbol string) ([]byte, error) { - params := map[string]string{ - "market_symbol": marketSymbol, - "state": "ACTIVE", - } - response, err := request("GET", "/rest/v3/orders", params, nil) - if err != nil { - return nil, err - } - return response, nil -} - -func cancelOrder(orderID string) ([]byte, error) { - orderToCancel := map[string]interface{}{ - "type": "ID", - "id": orderID, - } - response, err := request("PUT", "/rest/v3/orders/cancel", nil, orderToCancel) - if err != nil { - return nil, err - } - return response, nil -} - -func main() { - fmt.Println("FOXBIT_API_KEY:", os.Getenv("FOXBIT_API_KEY")) - - // Get user information - mePath := "/rest/v3/me" - meResponse, err := request("GET", mePath, nil, nil) - if err != nil { - log.Fatal("Failed to get user information:", err) - } - fmt.Println("Response:", string(meResponse)) - - // Create an order - orderResponse, err := createOrder("btcbrl", "BUY", "LIMIT", "10.0", "0.0001") - if err != nil { - log.Fatal("Failed to create order:", err) - } - fmt.Println("Order Response:", string(orderResponse)) - - time.Sleep(2 * time.Second) - - // Get active orders - activeOrdersResponse, err := getActiveOrders("btcbrl") - if err != nil { - log.Fatal("Failed to get active orders:", err) - } - fmt.Println("Active Orders Response:", string(activeOrdersResponse)) - - // Get order information - var orderData map[string]interface{} - err = json.Unmarshal(orderResponse, &orderData) - if err != nil { - log.Fatal("Failed to parse order response:", err) - } - - // Cancel an order - orderID := orderData["id"].(string) - cancelResponse, err := cancelOrder(orderID) - if err != nil { - log.Fatal("Failed to cancel order:", err) - } - fmt.Println("Cancel Response:", string(cancelResponse)) -} diff --git a/rest-v3/go/go.mod b/rest-v3/go/go.mod index 4947c92..43a05d7 100644 --- a/rest-v3/go/go.mod +++ b/rest-v3/go/go.mod @@ -1,3 +1,3 @@ -module examples +module github.com/foxbit-group/foxbit-api-samples/rest-v3/go -go 1.19 +go 1.26 diff --git a/rest-v3/go/main.go b/rest-v3/go/main.go new file mode 100644 index 0000000..d30c855 --- /dev/null +++ b/rest-v3/go/main.go @@ -0,0 +1,224 @@ +// Foxbit REST API v3 example (Go, standard library only). +// +// Flow: fetch account info, read the public order book, place a LIMIT BUY +// order far below market, list active orders, then cancel the order. +package main + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" +) + +const baseURL = "https://api.foxbit.com.br" + +// param is one query-string key/value pair. Params are kept in a slice +// because Go maps have no defined order, and the query in the signed prehash +// must list pairs in the exact order they appear in the request URL. +type param struct { + key, value string +} + +// orderRequest is the body of POST /rest/v3/orders. +type orderRequest struct { + MarketSymbol string `json:"market_symbol"` + Side string `json:"side"` + Type string `json:"type"` + Price string `json:"price"` + Quantity string `json:"quantity"` +} + +// cancelRequest is the body of PUT /rest/v3/orders/cancel. +type cancelRequest struct { + Type string `json:"type"` + ID string `json:"id"` +} + +// encodeComponent percent-encodes a query component per RFC 3986: +// space becomes %20 (never +) and everything outside A-Za-z0-9-._~ is escaped. +func encodeComponent(s string) string { + return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") +} + +// buildQueryStrings renders the same ordered params twice: decoded (raw +// values, used only in the signature prehash) and percent-encoded (used in +// the request URL). Building both from one structure keeps them in sync. +func buildQueryStrings(params []param) (decoded, encoded string) { + decodedPairs := make([]string, 0, len(params)) + encodedPairs := make([]string, 0, len(params)) + for _, p := range params { + decodedPairs = append(decodedPairs, p.key+"="+p.value) + encodedPairs = append(encodedPairs, encodeComponent(p.key)+"="+encodeComponent(p.value)) + } + return strings.Join(decodedPairs, "&"), strings.Join(encodedPairs, "&") +} + +// sign returns the lowercase-hex HMAC-SHA256 of the prehash string +// timestamp + method + path + decodedQuery + rawBody. +// +// Signing gotchas: +// 1. The query string goes into the prehash DECODED (raw values), even +// though the URL sends it percent-encoded. +// 2. rawBody must be byte-for-byte the body sent on the wire, so the body +// is serialized exactly once and the same string is signed and sent. +func sign(secret, timestamp, method, path, decodedQuery, rawBody string) string { + preHash := timestamp + method + path + decodedQuery + rawBody + fmt.Println("PreHash:", preHash) + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(preHash)) + return hex.EncodeToString(mac.Sum(nil)) +} + +// request sends one API call and returns the response body. body may be nil; +// it is JSON-serialized exactly once, and that same string is both signed and +// sent. Public endpoints are called with authenticated=false (no signature). +func request(method, path string, params []param, body any, authenticated bool) ([]byte, error) { + decodedQuery, encodedQuery := buildQueryStrings(params) + + rawBody := "" + if body != nil { + encoded, err := json.Marshal(body) // the single Marshal: signed and sent as-is + if err != nil { + return nil, fmt.Errorf("encoding request body: %w", err) + } + rawBody = string(encoded) + } + + fullURL := baseURL + path + if encodedQuery != "" { + fullURL += "?" + encodedQuery + } + + fmt.Println("--------------------------------------------------") + fmt.Println(method, path) + + var bodyReader io.Reader + if rawBody != "" { + bodyReader = strings.NewReader(rawBody) + } + req, err := http.NewRequest(method, fullURL, bodyReader) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + if authenticated { + timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) + signature := sign(os.Getenv("FOXBIT_API_SECRET"), timestamp, method, path, decodedQuery, rawBody) + req.Header.Set("X-FB-ACCESS-KEY", os.Getenv("FOXBIT_API_KEY")) + req.Header.Set("X-FB-ACCESS-TIMESTAMP", timestamp) + req.Header.Set("X-FB-ACCESS-SIGNATURE", signature) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + + fmt.Printf("Response (%d): %s\n", resp.StatusCode, respBody) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, respBody) + } + return respBody, nil +} + +func run() error { + // Fail fast (before any request) if credentials are missing. + if os.Getenv("FOXBIT_API_KEY") == "" || os.Getenv("FOXBIT_API_SECRET") == "" { + return errors.New("FOXBIT_API_KEY and FOXBIT_API_SECRET environment variables must be set") + } + + // Step 1: fetch account information (authenticated, no params). + if _, err := request("GET", "/rest/v3/me", nil, nil, true); err != nil { + return err + } + + // Step 2: fetch the order book (public endpoint, no authentication). + bookResp, err := request("GET", "/rest/v3/markets/btcbrl/orderbook", []param{{"depth", "1"}}, nil, false) + if err != nil { + return err + } + var book struct { + Bids [][]string `json:"bids"` // [price, quantity] pairs as decimal strings + } + if err := json.Unmarshal(bookResp, &book); err != nil { + return fmt.Errorf("parsing order book response: %w", err) + } + if len(book.Bids) == 0 || len(book.Bids[0]) == 0 { + return errors.New("order book has no bids") + } + bestBid, err := strconv.ParseFloat(book.Bids[0][0], 64) + if err != nil { + return fmt.Errorf("parsing best bid %q: %w", book.Bids[0][0], err) + } + + // Step 3: price the order at 50% of the best bid. That keeps it inside + // the exchange's accepted price band (an absurd price like 10.0 is + // rejected with HTTP 422) while staying far too low to ever execute. + // btcbrl has price_increment 1.0, so the price is an integer string. + price := strconv.FormatFloat(math.Floor(bestBid*0.5), 'f', 0, 64) + fmt.Printf("Best bid: %s -> order price (50%%): %s\n", book.Bids[0][0], price) + + // Step 4: create the limit buy order (authenticated, JSON body). + orderResp, err := request("POST", "/rest/v3/orders", nil, orderRequest{ + MarketSymbol: "btcbrl", + Side: "BUY", + Type: "LIMIT", + Price: price, + Quantity: "0.0001", + }, true) + if err != nil { + return err + } + var order struct { + ID string `json:"id"` + } + if err := json.Unmarshal(orderResp, &order); err != nil { + return fmt.Errorf("parsing create-order response: %w", err) + } + if order.ID == "" { + return errors.New("create-order response has no id") + } + + // Step 5: give the exchange a moment to register the order. + time.Sleep(2 * time.Second) + + // Step 6: list active orders (authenticated, with query params). + params := []param{{"market_symbol", "btcbrl"}, {"state", "ACTIVE"}} + if _, err := request("GET", "/rest/v3/orders", params, nil, true); err != nil { + return err + } + + // Step 7: cancel the order created in step 4. + if _, err := request("PUT", "/rest/v3/orders/cancel", nil, cancelRequest{Type: "ID", ID: order.ID}, true); err != nil { + return err + } + + fmt.Println("--------------------------------------------------") + fmt.Println("Done: order", order.ID, "was created and cancelled.") + return nil +} + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, "Error:", err) + os.Exit(1) + } +} diff --git a/rest-v3/java/Dockerfile b/rest-v3/java/Dockerfile new file mode 100644 index 0000000..74a1ffc --- /dev/null +++ b/rest-v3/java/Dockerfile @@ -0,0 +1,13 @@ +# Build stage: compile and package a self-contained jar. +FROM maven:3.9.16-eclipse-temurin-21-noble AS build +WORKDIR /app +COPY pom.xml . +RUN mvn --batch-mode --quiet dependency:go-offline +COPY src ./src +RUN mvn --batch-mode --quiet package + +# Runtime stage: slim JRE-only image. +FROM eclipse-temurin:21.0.11_10-jre-alpine-3.23 +WORKDIR /app +COPY --from=build /app/target/foxbit-sample.jar ./foxbit-sample.jar +ENTRYPOINT ["java", "-jar", "foxbit-sample.jar"] diff --git a/rest-v3/java/README.md b/rest-v3/java/README.md index ac2990b..1cea3d4 100644 --- a/rest-v3/java/README.md +++ b/rest-v3/java/README.md @@ -1,30 +1,75 @@ -# Foxbit API REST v3 Java Examples +# Foxbit REST API v3 — Java Example -Here is the Java examples for the Foxbit API REST v3. This section provides a series of scripts to help you understand how to interact with the Foxbit API using Java. These examples cover a range of functionalities from fetching market data to placing orders and managing your account. +A minimal, self-contained Java example of how to authenticate and trade with the +[Foxbit REST API v3](https://docs.foxbit.com.br/rest/v3/). It performs the following flow: -## Prerequisites +1. `GET /rest/v3/me` — fetch account information (authenticated). +2. `GET /rest/v3/markets/btcbrl/orderbook?depth=1` — fetch the top of the order book (public endpoint, no authentication). +3. Compute a limit price at 50% of the best bid, floored to an integer (the `btcbrl` market has `price_increment: 1.0`). +4. `POST /rest/v3/orders` — create a LIMIT BUY order of 0.0001 BTC at that price. +5. Wait 2 seconds. +6. `GET /rest/v3/orders?market_symbol=btcbrl&state=ACTIVE` — list active orders (the new order shows up). +7. `PUT /rest/v3/orders/cancel` — cancel the order by id. -Before you begin, ensure you have the following prerequisites installed on your system: +> **Warning:** this example creates a REAL order on your account — a LIMIT BUY of +> 0.0001 BTC at 50% of the current market price. That price is inside the band accepted +> by the API but far too low to ever execute, and the order is cancelled at the end of +> the flow. -- Java: These examples are written for Java, ensure you have the latest .NET SDK installed. -- Maven: Maven is a build automation tool used primarily for Java projects. +## Requirements -## Getting Started +- [Docker](https://www.docker.com/) (recommended — no local toolchain needed), **or** +- Java 21+ and Maven 3.9+ to run natively. -1. **Install Dependencies**: Navigate to the java examples directory in your terminal and install the necessary dependencies. +## Credentials + +Create an API key at and export it: + +```bash +export FOXBIT_API_KEY="your-api-key" +export FOXBIT_API_SECRET="your-api-secret" +``` + +Alternatively, put both variables in a `.env` file and pass it to Docker with `--env-file .env`. + +## Run with Docker + +```bash +docker build -t foxbit-sample-java . +docker run --rm -e FOXBIT_API_KEY -e FOXBIT_API_SECRET foxbit-sample-java +``` + +Or, using a `.env` file: ```bash -mvn install +docker run --rm --env-file .env foxbit-sample-java ``` -2. **Configure API Keys**: You must read the [main README file located at the root of the project](https://github.com/foxbit-group/foxbit-api-samples?tab=readme-ov-file#getting-started) for general information on setting up your environment, including configuring your API keys as environment variables. -3. **Running the Examples**: To run the example, navigate to the project directory in the terminal and execute the following command: +## Run natively ```bash -mvn exec:java -Dexec.mainClass="br.com.foxbit.samples.FoxbitApiSamples" +mvn package +java -jar target/foxbit-sample.jar +``` + +## How request signing works + +Every authenticated request carries three headers: `X-FB-ACCESS-KEY` (your API key), +`X-FB-ACCESS-TIMESTAMP` (UNIX time in milliseconds) and `X-FB-ACCESS-SIGNATURE`. +The signature is an HMAC-SHA256 (lowercase hex) of the string: + ``` +timestamp + method + path + queryString + rawBody +``` + +Two gotchas cause most 401 errors: -## Additional Notes +1. **The query string goes into the prehash DECODED** (raw values, e.g. `q=btc brl`), + while the URL itself must carry it percent-encoded per RFC 3986 (`q=btc%20brl`, + space is `%20`, never `+`). Both forms must list the parameters in the same order. +2. **The body is verified against the exact bytes sent on the wire.** Serialize the + JSON body once and use that same string both to sign and to send — signing one + formatting and sending another (e.g. re-serialization by an HTTP library) breaks + the signature. -These examples are meant to serve as a starting point. They demonstrate basic API interactions. It's recommended to review and test the code thoroughly before using it in a production environment. -For detailed API documentation, refer to the [Foxbit API Documentation](https://docs.foxbit.com.br/rest/v3/). +See the full documentation at . diff --git a/rest-v3/java/pom.xml b/rest-v3/java/pom.xml index 35d1650..045731f 100644 --- a/rest-v3/java/pom.xml +++ b/rest-v3/java/pom.xml @@ -1,3 +1,4 @@ + @@ -5,23 +6,52 @@ br.com.foxbit.samples foxbit-api-samples - 1.0-SNAPSHOT + 1.0.0 + jar + + + 21 + UTF-8 + + - org.apache.httpcomponents - httpclient - 4.5.13 - - - com.googlecode.json-simple - json-simple - 1.1.1 - - - org.bouncycastle - bcprov-jdk15on - 1.78 + org.json + json + 20250517 + + + foxbit-sample + + + org.apache.maven.plugins + maven-compiler-plugin + 3.15.0 + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.2 + + + package + + shade + + + + + br.com.foxbit.samples.FoxbitApiSamples + + + + + + + + diff --git a/rest-v3/java/src/main/java/br/com/foxbit/samples/FoxbitApiSamples.java b/rest-v3/java/src/main/java/br/com/foxbit/samples/FoxbitApiSamples.java index e22bf05..4d092d4 100644 --- a/rest-v3/java/src/main/java/br/com/foxbit/samples/FoxbitApiSamples.java +++ b/rest-v3/java/src/main/java/br/com/foxbit/samples/FoxbitApiSamples.java @@ -1,143 +1,179 @@ package br.com.foxbit.samples; -import org.apache.http.client.methods.*; -import org.apache.http.entity.StringEntity; -import org.apache.http.impl.client.CloseableHttpClient; -import org.apache.http.impl.client.HttpClients; -import org.apache.http.util.EntityUtils; -import org.json.simple.JSONObject; -import org.json.simple.parser.JSONParser; +import org.json.JSONObject; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; +import java.io.IOException; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.net.URI; import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Map; +import java.security.GeneralSecurityException; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.SequencedMap; +import java.util.StringJoiner; + +/** + * Foxbit REST API v3 example: fetch account info, read the public order book, + * place a limit buy order far from the market and cancel it. + * + * Request signing has two classic gotchas: + * 1. The query string goes into the signature prehash DECODED (raw values), + * but is sent percent-encoded (RFC 3986) in the URL. + * 2. The body is verified against the exact bytes sent on the wire: + * serialize the JSON once and use that same string to sign and to send. + */ +public final class FoxbitApiSamples { -public class FoxbitApiSamples { private static final String API_BASE_URL = "https://api.foxbit.com.br"; private static final String API_KEY = System.getenv("FOXBIT_API_KEY"); private static final String API_SECRET = System.getenv("FOXBIT_API_SECRET"); - @SuppressWarnings("unchecked") + private static final HttpClient HTTP = HttpClient.newHttpClient(); + public static void main(String[] args) { - try { - System.out.println("FOXBIT_API_KEY: " + API_KEY); - - // Get the user information - String meResponse = request("GET", "/rest/v3/me", null, null); - System.out.println("Response: " + meResponse); - - // Request to create a new order - JSONObject order = new JSONObject(); - order.put("market_symbol", "btcbrl"); - order.put("side", "BUY"); - order.put("type", "LIMIT"); - order.put("price", "10.0"); - order.put("quantity", "0.0001"); - String orderResponse = request("POST", "/rest/v3/orders", null, order.toJSONString()); - System.out.println("Response: " + orderResponse); + if (API_KEY == null || API_KEY.isBlank() || API_SECRET == null || API_SECRET.isBlank()) { + System.err.println("Error: set the FOXBIT_API_KEY and FOXBIT_API_SECRET environment variables."); + System.exit(1); + } + try { + // Step 1: account information (authenticated request without params). + request("GET", "/rest/v3/me", null, null, true); + + // Step 2: top of the order book (public endpoint, no authentication). + var orderbookParams = new LinkedHashMap(); + orderbookParams.put("depth", "1"); + String orderbook = request("GET", "/rest/v3/markets/btcbrl/orderbook", orderbookParams, null, false); + String bestBid = new JSONObject(orderbook).getJSONArray("bids").getJSONArray(0).getString(0); + + // Step 3: price the order at 50% of the best bid. That stays inside the + // price band accepted by the API (an absurd price such as "10.0" is + // rejected with 422) while being far too low to ever execute. The btcbrl + // market has price_increment 1.0, so the price is formatted as an integer. + String price = new BigDecimal(bestBid) + .multiply(new BigDecimal("0.5")) + .setScale(0, RoundingMode.FLOOR) + .toPlainString(); + + // Step 4: create a limit buy order. The body is serialized ONCE and the + // resulting string is both signed and sent (gotcha 2). + String orderBody = new JSONObject() + .put("market_symbol", "btcbrl") + .put("side", "BUY") + .put("type", "LIMIT") + .put("price", price) + .put("quantity", "0.0001") + .toString(); + String created = request("POST", "/rest/v3/orders", null, orderBody, true); + String orderId = new JSONObject(created).getString("id"); + + // Step 5: give the matching engine a moment before listing. Thread.sleep(2000); - // Get active orders - Map ordersParam = new HashMap<>(); - ordersParam.put("market_symbol", "btcbrl"); - ordersParam.put("state", "ACTIVE"); - String ordersResponse = request("GET", "/rest/v3/orders", ordersParam, null); - System.out.println("Response: " + ordersResponse); - - // Parse response to get the order ID - JSONObject orderResponseJson = (JSONObject) new JSONParser().parse(orderResponse); - String orderId = (String) orderResponseJson.get("id"); - - // Request to cancel the order - JSONObject orderToCancel = new JSONObject(); - orderToCancel.put("type", "ID"); - orderToCancel.put("id", orderId); - String cancelResponse = request("PUT", "/rest/v3/orders/cancel", null, orderToCancel.toJSONString()); - System.out.println("Response: " + cancelResponse); + // Step 6: list active orders — the order created above shows up here. + var orderFilters = new LinkedHashMap(); + orderFilters.put("market_symbol", "btcbrl"); + orderFilters.put("state", "ACTIVE"); + request("GET", "/rest/v3/orders", orderFilters, null, true); + + // Step 7: cancel the order by its id. + String cancelBody = new JSONObject() + .put("type", "ID") + .put("id", orderId) + .toString(); + request("PUT", "/rest/v3/orders/cancel", null, cancelBody, true); + + System.out.println("--------------------------------------------------"); + System.out.println("Done: order " + orderId + " created and cancelled."); } catch (Exception e) { - e.printStackTrace(); + System.err.println("Error: " + e.getMessage()); + System.exit(1); } } - private static Map sign(String method, String path, Map params, String body) throws Exception { - StringBuilder queryString = new StringBuilder(); - if (params != null) { - for (Map.Entry param : params.entrySet()) { - if (queryString.length() > 0) { - queryString.append("&"); - } - queryString.append(URLEncoder.encode(param.getKey(), "UTF-8")) - .append("=") - .append(URLEncoder.encode(param.getValue(), "UTF-8")); - } + /** + * Sends a request and returns the response body on 2xx status. + * + * The encoded query (for the URL) and the decoded query (for the signature + * prehash) are built from the same insertion-ordered params, and rawBody is + * used verbatim for both signing and sending, so the signed and transmitted + * representations can never diverge. + */ + private static String request(String method, String path, SequencedMap params, + String rawBody, boolean authenticated) + throws IOException, InterruptedException, GeneralSecurityException { + QueryStrings query = buildQueryStrings(params); + String pathWithQuery = query.encoded().isEmpty() ? path : path + "?" + query.encoded(); + + System.out.println("--------------------------------------------------"); + System.out.println(method + " " + pathWithQuery); + + var builder = HttpRequest.newBuilder(URI.create(API_BASE_URL + pathWithQuery)) + .header("Content-Type", "application/json") + .method(method, rawBody == null + ? HttpRequest.BodyPublishers.noBody() + : HttpRequest.BodyPublishers.ofString(rawBody, StandardCharsets.UTF_8)); + + if (authenticated) { + long timestamp = System.currentTimeMillis(); + Signed signed = sign(method, path, query.decoded(), rawBody == null ? "" : rawBody, timestamp); + System.out.println("PreHash: " + signed.preHash()); + builder.header("X-FB-ACCESS-KEY", API_KEY) + .header("X-FB-ACCESS-TIMESTAMP", Long.toString(timestamp)) + .header("X-FB-ACCESS-SIGNATURE", signed.signature()); } - String rawBody = body != null ? body : ""; - long timestamp = System.currentTimeMillis(); - String preHash = timestamp + method + path + queryString + rawBody; - System.out.println("PreHash: " + preHash); - - Mac sha256_HMAC = Mac.getInstance("HmacSHA256"); - SecretKeySpec secret_key = new SecretKeySpec(API_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); - sha256_HMAC.init(secret_key); - byte[] hash = sha256_HMAC.doFinal(preHash.getBytes(StandardCharsets.UTF_8)); - - StringBuilder hexString = new StringBuilder(); - for (byte b : hash) { - String hex = Integer.toHexString(0xff & b); - if (hex.length() == 1) hexString.append('0'); - hexString.append(hex); + HttpResponse response = HTTP.send(builder.build(), HttpResponse.BodyHandlers.ofString()); + System.out.println("Response (" + response.statusCode() + "): " + response.body()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("HTTP " + response.statusCode() + " for " + method + " " + path); } - System.out.println("Signature: " + hexString.toString()); - - Map signatureData = new HashMap<>(); - signatureData.put("signature", hexString.toString()); - signatureData.put("timestamp", Long.toString(timestamp)); - signatureData.put("queryString", queryString.toString()); - - return signatureData; + return response.body(); } - private static String request(String method, String path, Map params, String body) throws Exception { - System.out.println("--------------------------------------------------"); + /** Decoded query string (goes into the signature) and its percent-encoded form (goes into the URL). */ + private record QueryStrings(String decoded, String encoded) {} - Map signatureData = sign(method, path, params, body); - String queryString = signatureData.get("queryString"); - StringBuilder urlBuilder = new StringBuilder(API_BASE_URL).append(path); - if (!queryString.isEmpty()) { - urlBuilder.append("?").append(queryString); + /** Builds both query string forms from the same insertion-ordered params. */ + private static QueryStrings buildQueryStrings(SequencedMap params) { + if (params == null || params.isEmpty()) { + return new QueryStrings("", ""); } - String url = urlBuilder.toString(); - System.out.println("Requesting: " + method + " " + url); - - HttpRequestBase request; - if ("GET".equalsIgnoreCase(method)) { - request = new HttpGet(url); - } else if ("POST".equalsIgnoreCase(method)) { - request = new HttpPost(url); - ((HttpPost) request).setEntity(new StringEntity(body)); - } else if ("PUT".equalsIgnoreCase(method)) { - request = new HttpPut(url); - ((HttpPut) request).setEntity(new StringEntity(body)); - } else { - throw new IllegalArgumentException("Unsupported HTTP method: " + method); + var decoded = new StringJoiner("&"); + var encoded = new StringJoiner("&"); + for (var param : params.entrySet()) { + decoded.add(param.getKey() + "=" + param.getValue()); + encoded.add(percentEncode(param.getKey()) + "=" + percentEncode(param.getValue())); } - String signature = signatureData.get("signature"); - String timestamp = signatureData.get("timestamp"); - request.setHeader("X-FB-ACCESS-KEY", API_KEY); - request.setHeader("X-FB-ACCESS-TIMESTAMP", timestamp); - request.setHeader("X-FB-ACCESS-SIGNATURE", signature); - request.setHeader("Content-Type", "application/json"); - - CloseableHttpClient client = HttpClients.createDefault(); - CloseableHttpResponse response = client.execute(request); - String responseString = EntityUtils.toString(response.getEntity()); - client.close(); - - return responseString; + return new QueryStrings(decoded.toString(), encoded.toString()); + } + + /** Percent-encodes one query component per RFC 3986 (space is %20, never +). */ + private static String percentEncode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20"); + } + + /** Prehash string and its HMAC-SHA256 signature (lowercase hex). */ + private record Signed(String preHash, String signature) {} + + /** + * Signs a request: HMAC-SHA256 over timestamp + method + path + decodedQuery + rawBody. + * The query string goes in DECODED (gotcha 1) and rawBody must be the exact + * string sent on the wire (gotcha 2). + */ + private static Signed sign(String method, String path, String decodedQuery, String rawBody, long timestamp) + throws GeneralSecurityException { + String preHash = timestamp + method + path + decodedQuery + rawBody; + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(API_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + String signature = HexFormat.of().formatHex(mac.doFinal(preHash.getBytes(StandardCharsets.UTF_8))); + return new Signed(preHash, signature); } } diff --git a/rest-v3/javascript/Dockerfile b/rest-v3/javascript/Dockerfile new file mode 100644 index 0000000..153ed96 --- /dev/null +++ b/rest-v3/javascript/Dockerfile @@ -0,0 +1,7 @@ +FROM node:24.18.0-alpine3.24 + +WORKDIR /app + +COPY package.json examples.js ./ + +CMD ["node", "examples.js"] diff --git a/rest-v3/javascript/README.md b/rest-v3/javascript/README.md index 0397eb2..d96c3d0 100644 --- a/rest-v3/javascript/README.md +++ b/rest-v3/javascript/README.md @@ -1,30 +1,59 @@ -# Foxbit API REST v3 JavaScript Examples +# Foxbit REST API v3 — JavaScript Example -Here is the JavaScript examples for the Foxbit API REST v3. This section provides a series of scripts to help you understand how to interact with the Foxbit API using JavaScript. These examples cover a range of functionalities from fetching market data to placing orders and managing your account. +A minimal, dependency-free Node.js example of the [Foxbit REST API v3](https://docs.foxbit.com.br/rest/v3/). It runs a complete flow in 7 steps: -## Prerequisites +1. `GET /rest/v3/me` — authenticated request with no parameters. +2. `GET /rest/v3/markets/btcbrl/orderbook?depth=1` — public request (no signature) to fetch the best bid. +3. Compute a limit price at 50% of the best bid, rounded to an integer. +4. `POST /rest/v3/orders` — create a LIMIT BUY order for 0.0001 BTC. +5. Wait 2 seconds. +6. `GET /rest/v3/orders?market_symbol=btcbrl&state=ACTIVE` — list active orders. +7. `PUT /rest/v3/orders/cancel` — cancel the order created in step 4. -Before you begin, ensure you have the following prerequisites installed on your system: +> **Warning:** this example creates a REAL order on your account (LIMIT BUY 0.0001 BTC at 50% of the market price — inside the exchange price band, but far too low to ever execute) and cancels it right after. -- Node.js: These examples are written for Node.js, a JavaScript runtime built on Chrome's V8 JavaScript engine. Ensure you have the latest stable version installed. -- NPM (Node Package Manager): Comes with Node.js, used for managing dependencies. +## Requirements -## Getting Started +- Docker (recommended), or +- Node.js >= 18 (native `fetch`) to run natively. No npm packages are needed. -1. **Install Dependencies**: Navigate to the JavaScript examples directory in your terminal and run `npm install` to install the necessary dependencies. +## Credentials + +Create an API key at and export it: + +```bash +export FOXBIT_API_KEY="your-api-key" +export FOXBIT_API_SECRET="your-api-secret" +``` + +Alternatively, put both variables in a `.env` file and use `--env-file .env` with Docker. + +## Run with Docker ```bash -npm install +docker build -t foxbit-sample-javascript . +docker run --rm -e FOXBIT_API_KEY -e FOXBIT_API_SECRET foxbit-sample-javascript +# or: docker run --rm --env-file .env foxbit-sample-javascript ``` -2. **Configure API Keys**: You must read the [main README file located at the root of the project](https://github.com/foxbit-group/foxbit-api-samples?tab=readme-ov-file#getting-started) for general information on setting up your environment, including configuring your API keys as environment variables. -3. **Running the Examples**: To run the example, navigate to the project directory in the terminal and execute the following command: +## Run natively ```bash node examples.js +# or: npm start ``` -## Additional Notes +## How request signing works + +Every authenticated request sends three headers: `X-FB-ACCESS-KEY`, `X-FB-ACCESS-TIMESTAMP` (UNIX time in milliseconds) and `X-FB-ACCESS-SIGNATURE`. The signature is an HMAC-SHA256 (hex) of: + +``` +timestamp + method + path + queryString + rawBody +``` + +Two gotchas that cause most `401` errors: + +1. **The query string enters the prehash DECODED** (raw values, e.g. `market_symbol=btc brl`), while the URL itself sends the values percent-encoded per RFC 3986 (`market_symbol=btc%20brl`). Build both from the same ordered structure. +2. **The body is verified byte-for-byte as sent.** Serialize the JSON body exactly once, sign that string and send that same string — never let the HTTP client re-serialize it. -These examples are meant to serve as a starting point. They demonstrate basic API interactions. It's recommended to review and test the code thoroughly before using it in a production environment. -For detailed API documentation, refer to the [Foxbit API Documentation](https://docs.foxbit.com.br/rest/v3/). +See the full documentation at . diff --git a/rest-v3/javascript/examples.js b/rest-v3/javascript/examples.js index 0534df5..ebb2fa5 100644 --- a/rest-v3/javascript/examples.js +++ b/rest-v3/javascript/examples.js @@ -1,103 +1,117 @@ -const CryptoJS = require('crypto-js'); -const axios = require('axios'); +// Foxbit REST API v3 example — plain Node.js, zero dependencies. +// Uses the native fetch (Node >= 18) and node:crypto for HMAC-SHA256. +// Docs: https://docs.foxbit.com.br/rest/v3/ -const apiBaseUrl = 'https://api.foxbit.com.br'; +import { createHmac } from 'node:crypto'; -function sign(method, path, params, body) { - let queryString = ''; - if (params) { - queryString = Object.keys(params).map((key) => { - return `${key}=${params[key]}`; - }).join('&'); - } +const BASE_URL = 'https://api.foxbit.com.br'; - let rawBody = ''; - if (body) { - rawBody = JSON.stringify(body); - } +const API_KEY = process.env.FOXBIT_API_KEY; +const API_SECRET = process.env.FOXBIT_API_SECRET; - const timestamp = Date.now(); - const preHash = `${timestamp}${method}${path}${queryString}${rawBody}`; - console.debug('PreHash:', preHash); - const signature = CryptoJS.HmacSHA256(preHash, process.env.FOXBIT_API_SECRET).toString(); - console.debug('Signature:', signature); +if (!API_KEY || !API_SECRET) { + console.error('Missing credentials: set the FOXBIT_API_KEY and FOXBIT_API_SECRET environment variables.'); + process.exit(1); +} - return { signature, timestamp }; +// Build both representations of the query string from the SAME ordered params: +// - decoded: raw values, used in the signature prehash +// - encoded: RFC 3986 percent-encoded values, used in the URL +// Deriving both from one structure makes it impossible for them to diverge. +function buildQueryStrings(params) { + const entries = Object.entries(params ?? {}); + const decoded = entries.map(([key, value]) => `${key}=${value}`).join('&'); + const encoded = entries + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&'); + return { decoded, encoded }; } -async function request(method, path, params, body) { - console.debug('--------------------------------------------------'); - console.debug('Requesting:', method, path); - const { signature, timestamp } = sign(method, path, params, body); - const url = `${apiBaseUrl}${path}`; - const headers = { - 'X-FB-ACCESS-KEY': process.env.FOXBIT_API_KEY, - 'X-FB-ACCESS-TIMESTAMP': timestamp.toString(), - 'X-FB-ACCESS-SIGNATURE': signature, - 'Content-Type': 'application/json', - }; - - try { - const config = { - method, - url, - params, - data: body, - headers: headers, - }; - const response = await axios(config); - return response; - } catch (error) { - if (error.response) { - console.error(`HTTP Status Code: ${error.response.status}, Error Response Body:`, error.response.data); - throw error; - } else { - throw error; - } - } +// HMAC-SHA256 (hex) over: timestamp + method + path + decodedQueryString + rawBody +// Gotcha #1: the query string enters the prehash with DECODED (raw) values, +// even though the URL sends them percent-encoded. +// Gotcha #2: rawBody must be byte-for-byte the string sent on the wire — +// serialize the body exactly once and sign that same string. +function sign(method, path, decodedQueryString, rawBody, timestamp) { + const preHash = `${timestamp}${method}${path}${decodedQueryString}${rawBody}`; + console.log(`PreHash: ${preHash}`); + return createHmac('sha256', API_SECRET).update(preHash).digest('hex'); } -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); +// Minimal request helper. `auth: false` skips the signature headers +// (some endpoints, like the order book, are public). +async function request(method, path, { params, body, auth = true } = {}) { + const { decoded, encoded } = buildQueryStrings(params); + const url = `${BASE_URL}${path}${encoded ? `?${encoded}` : ''}`; + // Serialized exactly once: the signed string and the sent bytes are the same. + const rawBody = body ? JSON.stringify(body) : ''; + + console.log('--------------------------------------------------'); + console.log(`${method} ${path}${encoded ? `?${encoded}` : ''}`); + + const headers = { 'Content-Type': 'application/json' }; + if (auth) { + const timestamp = Date.now().toString(); // milliseconds, same value as the header + headers['X-FB-ACCESS-KEY'] = API_KEY; + headers['X-FB-ACCESS-TIMESTAMP'] = timestamp; + headers['X-FB-ACCESS-SIGNATURE'] = sign(method, path, decoded, rawBody, timestamp); + } + + const response = await fetch(url, { method, headers, body: rawBody || undefined }); + const text = await response.text(); + console.log(`Response (${response.status}): ${text}`); + + if (!response.ok) { + throw new Error(`Request failed with HTTP ${response.status}`); + } + return text ? JSON.parse(text) : null; } -(async () => { - try { - console.log('FOXBIT_API_KEY:', process.env.FOXBIT_API_KEY); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function main() { + // 1. Authenticated request without params: current user info. + await request('GET', '/rest/v3/me'); - // Get the user information - const meResponse = await request('GET', '/rest/v3/me'); - console.log('Response:', meResponse.data); + // 2. Public endpoint (no signature needed): top of the btcbrl order book. + const orderbook = await request('GET', '/rest/v3/markets/btcbrl/orderbook', { + params: { depth: '1' }, + auth: false, + }); + const bestBid = Number(orderbook.bids[0][0]); - // Request to create a new order - const order = { + // 3. Price the order at 50% of the best bid, formatted as an integer + // (btcbrl has price_increment 1.0). 50% stays inside the exchange price + // band — absurd values like a hardcoded 10.0 are rejected with 422 — + // while remaining far too low to ever execute. + const price = String(Math.floor(bestBid * 0.5)); + + // 4. Create a LIMIT BUY order and capture its id. + const order = await request('POST', '/rest/v3/orders', { + body: { market_symbol: 'btcbrl', side: 'BUY', type: 'LIMIT', - price: '10.0', + price, quantity: '0.0001', - }; - const orderResponse = await request('POST', '/rest/v3/orders', null, order); - console.log('Response:', orderResponse.data); + }, + }); - await sleep(2000); + // 5. Give the matching engine a moment to register the order. + await sleep(2000); - // Get active orders - const orderParams = { - market_symbol: 'btcbrl', - state: 'ACTIVE', - }; - const activeOrdersResponse = await request('GET', '/rest/v3/orders', orderParams); - console.log('Response:', activeOrdersResponse.data); - - // Request to cancel the order - const orderToCancel = { - type: 'ID', - id: orderResponse.data.id - }; - const cancelResponse = await request('PUT', '/rest/v3/orders/cancel', null, orderToCancel); - console.log('Response:', cancelResponse.data); - } catch (error) { - console.error('Failed to process request.'); - } -})(); + // 6. List active orders — the order created above should appear. + await request('GET', '/rest/v3/orders', { + params: { market_symbol: 'btcbrl', state: 'ACTIVE' }, + }); + + // 7. Cancel the order by its id. + await request('PUT', '/rest/v3/orders/cancel', { + body: { type: 'ID', id: order.id }, + }); +} + +main().catch((error) => { + console.error(error.message); + process.exit(1); +}); diff --git a/rest-v3/javascript/package-lock.json b/rest-v3/javascript/package-lock.json deleted file mode 100644 index a063964..0000000 --- a/rest-v3/javascript/package-lock.json +++ /dev/null @@ -1,335 +0,0 @@ -{ - "name": "javascript", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "javascript", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "axios": "^1.16.0", - "crypto-js": "^4.2.0" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/crypto-js": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", - "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - } - } -} diff --git a/rest-v3/javascript/package.json b/rest-v3/javascript/package.json index 1954332..e45ed5b 100644 --- a/rest-v3/javascript/package.json +++ b/rest-v3/javascript/package.json @@ -1,15 +1,13 @@ { - "name": "javascript", + "name": "foxbit-rest-v3-javascript-example", "version": "1.0.0", - "description": "", - "main": "examples.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "private": true, + "description": "Foxbit REST API v3 example in plain Node.js (zero dependencies)", + "type": "module", + "engines": { + "node": ">=18" }, - "author": "", - "license": "ISC", - "dependencies": { - "axios": "^1.16.0", - "crypto-js": "^4.2.0" + "scripts": { + "start": "node examples.js" } } diff --git a/rest-v3/kotlin/Dockerfile b/rest-v3/kotlin/Dockerfile index 4dba39f..406e56f 100644 --- a/rest-v3/kotlin/Dockerfile +++ b/rest-v3/kotlin/Dockerfile @@ -1,17 +1,18 @@ -# ---------- build ---------- -FROM gradle:8-jdk17-alpine AS build +# ---------- Build stage ---------- +FROM gradle:8.14.3-jdk21 AS build WORKDIR /app -COPY . . +COPY settings.gradle.kts build.gradle.kts ./ +COPY src ./src -RUN gradle installDist --no-daemon +RUN gradle --no-daemon installDist -# ---------- runtime ---------- -FROM eclipse-temurin:17-jre-alpine +# ---------- Runtime stage ---------- +FROM eclipse-temurin:21.0.11_10-jre-alpine-3.23 WORKDIR /app -COPY --from=build /app/build/install/foxbit-kotlin-examples /app +COPY --from=build /app/build/install/foxbit-rest-v3-kotlin ./ -ENTRYPOINT ["/app/bin/foxbit-kotlin-examples"] +ENTRYPOINT ["/app/bin/foxbit-rest-v3-kotlin"] diff --git a/rest-v3/kotlin/README.md b/rest-v3/kotlin/README.md index cba7ca6..6ef7a6e 100644 --- a/rest-v3/kotlin/README.md +++ b/rest-v3/kotlin/README.md @@ -1,28 +1,61 @@ -# Foxbit API REST v3 Kotlin Examples +# Foxbit REST API v3 — Kotlin Example -Here is the Kotlin examples for the Foxbit API REST v3. This section provides a series of scripts to help you understand how to interact with the Foxbit API using Kotlin. These examples cover a range of functionalities from fetching market data to placing orders and managing your account. +A minimal, didactic example of integrating with the [Foxbit REST API v3](https://docs.foxbit.com.br/rest/v3/) in Kotlin, using only `java.net.http.HttpClient`, `javax.crypto` and a small JSON library (`org.json`). -## Prerequisites +It runs the following flow: -Before you begin, ensure you have the following prerequisites installed on your system: +1. `GET /rest/v3/me` — fetch account information (authenticated). +2. `GET /rest/v3/markets/btcbrl/orderbook?depth=1` — fetch the best bid (public endpoint, no authentication). +3. Compute a limit price at 50% of the best bid, rounded down to an integer. +4. `POST /rest/v3/orders` — create a LIMIT BUY order for 0.0001 BTC. +5. Wait 2 seconds. +6. `GET /rest/v3/orders?market_symbol=btcbrl&state=ACTIVE` — list active orders (the new order shows up). +7. `PUT /rest/v3/orders/cancel` — cancel the order by id. -- Docker +> **Warning:** this example creates a REAL order on your account (LIMIT BUY of 0.0001 BTC at 50% of the market price — inside the accepted price band but far too low to ever execute) and cancels it right after. -## Getting Started +## Requirements -1. **Configure API Keys**: You must read the [main README file located at the root of the project](https://github.com/foxbit-group/foxbit-api-samples?tab=readme-ov-file#getting-started) for general information on setting up your environment, including configuring your API keys as environment variables. -2. **Running the Examples**: To run the example, navigate to the project directory in the terminal and execute the following command: +- Docker (recommended), or +- JDK 21+ and Gradle 8.14+ to run natively. + +## Credentials + +Create an API key at and export it: ```bash -docker build -t foxbit-kotlin-examples . +export FOXBIT_API_KEY="your_api_key" +export FOXBIT_API_SECRET="your_api_secret" +``` + +Alternatively, put both variables in a `.env` file and pass it to Docker with `--env-file .env`. + +## Run with Docker -docker run --rm \ - -e FOXBIT_API_KEY=$FOXBIT_API_KEY \ - -e FOXBIT_API_SECRET=$FOXBIT_API_SECRET \ - foxbit-kotlin-examples +```bash +docker build -t foxbit-sample-kotlin . + +docker run --rm -e FOXBIT_API_KEY -e FOXBIT_API_SECRET foxbit-sample-kotlin +# or: docker run --rm --env-file .env foxbit-sample-kotlin ``` -## Additional Notes +## Run natively + +```bash +gradle run +``` + +## How request signing works + +Every authenticated request carries three headers: `X-FB-ACCESS-KEY` (the API key), `X-FB-ACCESS-TIMESTAMP` (UNIX time in milliseconds) and `X-FB-ACCESS-SIGNATURE`. The signature is the lowercase hex HMAC-SHA256 of: + +``` +timestamp + method + path + queryString + rawBody +``` + +Two gotchas trip most integrations: + +1. **The query string is signed DECODED, but sent percent-encoded.** The pre-hash uses raw values (`market_symbol=btc brl`), while the URL must carry them RFC 3986 percent-encoded (`market_symbol=btc%20brl`, space is `%20`, never `+`). Build both forms from the same ordered parameter list so they cannot diverge. +2. **The body is signed exactly as the bytes sent.** Serialize the JSON body once and use that same string for both the signature and the request payload — signing one formatting and sending another yields a 401. -These examples are meant to serve as a starting point. They demonstrate basic API interactions. It's recommended to review and test the code thoroughly before using it in a production environment. -For detailed API documentation, refer to the [Foxbit API Documentation](https://docs.foxbit.com.br/rest/v3/). +See the [official documentation](https://docs.foxbit.com.br/rest/v3/) for details. diff --git a/rest-v3/kotlin/build.gradle.kts b/rest-v3/kotlin/build.gradle.kts index 564a000..709ec4f 100644 --- a/rest-v3/kotlin/build.gradle.kts +++ b/rest-v3/kotlin/build.gradle.kts @@ -1,16 +1,23 @@ plugins { - kotlin("jvm") version "1.9.22" + kotlin("jvm") version "2.2.21" application } -group = "foxbit" +group = "br.com.foxbit" version = "1.0.0" -repositories { mavenCentral() } +repositories { + mavenCentral() +} dependencies { - implementation("com.squareup.okhttp3:okhttp:4.12.0") - implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.17.0") + implementation("org.json:json:20250517") +} + +kotlin { + jvmToolchain(21) } -application { mainClass.set("MainKt") } +application { + mainClass.set("MainKt") +} diff --git a/rest-v3/kotlin/settings.gradle.kts b/rest-v3/kotlin/settings.gradle.kts index f8255a8..38d6cb8 100644 --- a/rest-v3/kotlin/settings.gradle.kts +++ b/rest-v3/kotlin/settings.gradle.kts @@ -1,10 +1,8 @@ pluginManagement { repositories { - gradlePluginPortal() mavenCentral() + gradlePluginPortal() } } -dependencyResolutionManagement { - repositories { mavenCentral() } -} -rootProject.name = "foxbit-kotlin-examples" + +rootProject.name = "foxbit-rest-v3-kotlin" diff --git a/rest-v3/kotlin/src/main/kotlin/Main.kt b/rest-v3/kotlin/src/main/kotlin/Main.kt index 8fbcaa6..44696fc 100644 --- a/rest-v3/kotlin/src/main/kotlin/Main.kt +++ b/rest-v3/kotlin/src/main/kotlin/Main.kt @@ -1,162 +1,140 @@ -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import okhttp3.* -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.RequestBody.Companion.toRequestBody -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import java.net.URI +import java.net.URLEncoder +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpRequest.BodyPublishers +import java.net.http.HttpResponse.BodyHandlers +import java.nio.charset.StandardCharsets.UTF_8 import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec +import kotlin.math.floor import kotlin.system.exitProcess -import kotlin.time.Duration.Companion.seconds -import java.time.Instant +import org.json.JSONObject -private const val API_BASE_URL = "https://api.foxbit.com.br" -private val JSON = "application/json".toMediaType() +private const val BASE_URL = "https://api.foxbit.com.br" -private val apiKey = System.getenv("FOXBIT_API_KEY") ?: "" -private val apiSecret = System.getenv("FOXBIT_API_SECRET") ?: "" +private val apiKey = requireEnv("FOXBIT_API_KEY") +private val apiSecret = requireEnv("FOXBIT_API_SECRET") +private val httpClient: HttpClient = HttpClient.newHttpClient() -private val client = OkHttpClient() -private val mapper = jacksonObjectMapper() +private fun requireEnv(name: String): String { + val value = System.getenv(name) + if (value.isNullOrBlank()) { + System.err.println("Missing required environment variable: $name") + exitProcess(1) + } + return value +} -fun sign( +/** Percent-encodes a query key/value per RFC 3986 (space = %20, never +). */ +private fun percentEncode(value: String): String = + URLEncoder.encode(value, UTF_8).replace("+", "%20") + +/** + * Returns the HMAC-SHA256 (hex) of: timestamp + method + path + decodedQuery + rawBody. + * + * Gotcha #1: the query string goes into the pre-hash with RAW (decoded) values, + * even though the URL itself carries it percent-encoded. + */ +private fun sign( + secret: String, + timestamp: String, method: String, path: String, - params: Map? = null, - rawBody: String = "" -): Pair { - val queryString = params?.entries - ?.joinToString("&") { "${it.key}=${it.value}" } - ?: "" - val timestamp = System.currentTimeMillis().toString() - val preHash = "$timestamp$method$path$queryString$rawBody" + decodedQuery: String, + rawBody: String, +): String { + val preHash = "$timestamp$method$path$decodedQuery$rawBody" println("PreHash: $preHash") - val mac = Mac.getInstance("HmacSHA256") - mac.init(SecretKeySpec(apiSecret.toByteArray(), "HmacSHA256")) - val signature = mac.doFinal(preHash.toByteArray()) - .joinToString("") { "%02x".format(it) } - println("Signature: $signature") - return signature to timestamp + mac.init(SecretKeySpec(secret.toByteArray(UTF_8), "HmacSHA256")) + return mac.doFinal(preHash.toByteArray(UTF_8)).joinToString("") { "%02x".format(it) } } -fun request( +/** + * Sends a request and returns the response body, aborting on any non-2xx status. + * + * The encoded query (sent in the URL) and the decoded query (signed) are built + * from the same ordered parameter list, so they can never diverge. + * + * Gotcha #2: the body is signed exactly as the bytes sent — it is serialized to + * a string ONCE, and that same string is both signed and transmitted. + */ +private fun request( method: String, path: String, - params: Map? = null, - body: String? = null + params: List> = emptyList(), + body: JSONObject? = null, + auth: Boolean = true, ): String { - println("--------------------------------------------------") - println("Requesting: $method $path") - - val (signature, timestamp) = sign(method, path, params, body ?: "") - val urlBuilder = "$API_BASE_URL$path".toHttpUrlOrNull()!!.newBuilder() - params?.forEach { urlBuilder.addQueryParameter(it.key, it.value) } - val url = urlBuilder.build() - - val reqBody = body?.toRequestBody(JSON) - val request = Request.Builder() - .url(url) - .method(method, if (method == "GET") null else reqBody) - .addHeader("X-FB-ACCESS-KEY", apiKey) - .addHeader("X-FB-ACCESS-TIMESTAMP", timestamp) - .addHeader("X-FB-ACCESS-SIGNATURE", signature) - .addHeader("Content-Type", "application/json") - .build() - - client.newCall(request).execute().use { resp -> - val respBody = resp.body?.string() ?: "" - if (!resp.isSuccessful) { - println("HTTP Status Code: ${resp.code}, Error Response Body: $respBody") - exitProcess(1) - } - return respBody + val decodedQuery = params.joinToString("&") { (key, value) -> "$key=$value" } + val encodedQuery = params.joinToString("&") { (key, value) -> "${percentEncode(key)}=${percentEncode(value)}" } + val rawBody = body?.toString() ?: "" // single serialization: signed and sent as-is + + println("-".repeat(50)) + println("$method $path") + + val url = BASE_URL + path + if (encodedQuery.isEmpty()) "" else "?$encodedQuery" + val builder = HttpRequest.newBuilder(URI.create(url)) + .header("Content-Type", "application/json") + .method(method, if (rawBody.isEmpty()) BodyPublishers.noBody() else BodyPublishers.ofString(rawBody)) + + if (auth) { + val timestamp = System.currentTimeMillis().toString() + val signature = sign(apiSecret, timestamp, method, path, decodedQuery, rawBody) + builder + .header("X-FB-ACCESS-KEY", apiKey) + .header("X-FB-ACCESS-TIMESTAMP", timestamp) + .header("X-FB-ACCESS-SIGNATURE", signature) } -} -fun createOrder(): String { - val order = mapOf( - "market_symbol" to "btcbrl", - "side" to "BUY", - "type" to "LIMIT", - "price" to "450000.0", - "quantity" to "0.00001" - ) - return request( - method = "POST", - path = "/rest/v3/orders", - body = mapper.writeValueAsString(order) - ) -} - -fun getActiveOrders(): String = - request( - method = "GET", - path = "/rest/v3/orders", - params = mapOf("market_symbol" to "btcbrl", "state" to "ACTIVE") - ) - -fun cancelOrder(orderId: String): String { - val cancelBody = mapOf("type" to "ID", "id" to orderId) - return request( - method = "PUT", - path = "/rest/v3/orders/cancel", - body = mapper.writeValueAsString(cancelBody) - ) + val response = httpClient.send(builder.build(), BodyHandlers.ofString()) + println("Response (${response.statusCode()}): ${response.body()}") + if (response.statusCode() !in 200..299) { + System.err.println("Request failed with HTTP ${response.statusCode()}, aborting.") + exitProcess(1) + } + return response.body() } fun main() { - println("FOXBIT_API_KEY: $apiKey") - - // Get the user information - val meResponse = request("GET", "/rest/v3/me") - println("Response: $meResponse") - - // Get current price - val marketSymbol = "btcbrl" - val tickerResponse = request("GET", "/rest/v3/markets/$marketSymbol/ticker/24hr") - val tickerNode = mapper.readTree(tickerResponse).path("data").path(0) - println("Response: $tickerNode") - - // Request to create a new order - val lastPrice = tickerNode.path("best").path("bid").path("price").asText().toDouble() - val targetPrice = (lastPrice * 0.9).toString() // Calculate target price: 10% below the best bid price - val order = mapOf( - "market_symbol" to marketSymbol, - "side" to "BUY", - "type" to "LIMIT", - "price" to targetPrice, - "quantity" to "0.0001" - ) - val orderResponse = request( - method = "POST", - path = "/rest/v3/orders", - body = mapper.writeValueAsString(order) - ) - println("Response: $orderResponse") - - Thread.sleep(2000) - - // Get active orders - val oneHourAgoISO = Instant.ofEpochMilli(System.currentTimeMillis() - 60L * 60L * 1000L).toString() - val ordersParams = linkedMapOf( - "market_symbol" to marketSymbol, - "state" to "ACTIVE", - "start_time" to oneHourAgoISO // Optional: included to test signature behavior with special chars - ) - val ordersResponse = request( - method = "GET", - path = "/rest/v3/orders", - params = ordersParams - ) - println("Response: $ordersResponse") - - // Request to cancel the order - val orderId = mapper.readTree(orderResponse).path("id").asText() - val cancelBody = mapOf("type" to "ID", "id" to orderId) - val cancelResponse = request( - method = "PUT", - path = "/rest/v3/orders/cancel", - body = mapper.writeValueAsString(cancelBody) + // 1. Account information (authenticated request without params). + request("GET", "/rest/v3/me") + + // 2. Order book snapshot (public endpoint — note auth = false: no signature needed). + val orderbook = request( + "GET", "/rest/v3/markets/btcbrl/orderbook", + params = listOf("depth" to "1"), + auth = false, ) - println("Response: $cancelResponse") + val bestBid = JSONObject(orderbook).getJSONArray("bids").getJSONArray(0).getString(0) + + // 3. Bid at 50% of the best bid: inside the accepted price band (absurd values + // like a hardcoded 10.0 are rejected with 422 "Price out of range") yet far too + // low to ever execute. btcbrl has price_increment 1.0, so format as an integer. + val price = floor(bestBid.toDouble() * 0.5).toLong().toString() + println("Best bid: $bestBid -> limit order price: $price") + + // 4. Create a LIMIT BUY order. Key order in the JSON does not matter because + // the exact serialized string is what gets signed and sent. + val order = JSONObject() + .put("market_symbol", "btcbrl") + .put("side", "BUY") + .put("type", "LIMIT") + .put("price", price) + .put("quantity", "0.0001") + val created = request("POST", "/rest/v3/orders", body = order) + val orderId = JSONObject(created).getString("id") + + // 5. Give the matching engine a moment to register the order. + Thread.sleep(2_000) + + // 6. The new order must show up among the active ones. + request("GET", "/rest/v3/orders", params = listOf("market_symbol" to "btcbrl", "state" to "ACTIVE")) + + // 7. Cancel the order by its id. + request("PUT", "/rest/v3/orders/cancel", body = JSONObject().put("type", "ID").put("id", orderId)) + + println("-".repeat(50)) + println("Done: order $orderId created and cancelled.") } diff --git a/rest-v3/php/.gitignore b/rest-v3/php/.gitignore deleted file mode 100644 index 5657f6e..0000000 --- a/rest-v3/php/.gitignore +++ /dev/null @@ -1 +0,0 @@ -vendor \ No newline at end of file diff --git a/rest-v3/php/Dockerfile b/rest-v3/php/Dockerfile new file mode 100644 index 0000000..cf0e276 --- /dev/null +++ b/rest-v3/php/Dockerfile @@ -0,0 +1,7 @@ +FROM php:8.4.23-cli-alpine3.23 + +WORKDIR /app + +COPY examples.php . + +CMD ["php", "examples.php"] diff --git a/rest-v3/php/README.md b/rest-v3/php/README.md index 0a28c30..a354462 100644 --- a/rest-v3/php/README.md +++ b/rest-v3/php/README.md @@ -1,30 +1,79 @@ -# Foxbit API REST v3 PHP Examples +# Foxbit REST API v3 — PHP Example -Here is the PHP examples for the Foxbit API REST v3. This section provides a series of scripts to help you understand how to interact with the Foxbit API using PHP. These examples cover a range of functionalities from fetching market data to placing orders and managing your account. +A single-file example ([examples.php](examples.php)) that shows how to call the +[Foxbit REST API v3](https://docs.foxbit.com.br/rest/v3/) from PHP, using only +built-ins (ext-curl, `hash_hmac()`, `json_encode()`) — no Composer dependencies. -## Prerequisites +It runs the following flow: -Before you begin, ensure you have the following prerequisites installed on your system: +1. `GET /rest/v3/me` — account information (authenticated). +2. `GET /rest/v3/markets/btcbrl/orderbook?depth=1` — order book (public, no auth). +3. Compute the order price: 50% of the best bid, rounded down to an integer. +4. `POST /rest/v3/orders` — place a limit buy order. +5. Wait 2 seconds. +6. `GET /rest/v3/orders?market_symbol=btcbrl&state=ACTIVE` — list active orders. +7. `PUT /rest/v3/orders/cancel` — cancel the order created in step 4. -- PHP: These examples are written for Python, ensure you have the latest stable version installed. -- [Composer](https://getcomposer.org/): You will need Composer for dependency management. +> **Warning:** the example places a REAL order on your account — a LIMIT BUY of +> 0.0001 BTC priced at 50% of the market, which stays inside the accepted price +> band but far from execution — and cancels it right after. -## Getting Started +## Requirements -1. **Install Dependencies**: Navigate to the php examples directory in your terminal and install the necessary dependencies. +- [Docker](https://www.docker.com/) (recommended), or +- PHP >= 8.1 with ext-curl (optional, to run natively). + +## Credentials + +Create an API key at and export it: ```bash -composer install +export FOXBIT_API_KEY="your-api-key" +export FOXBIT_API_SECRET="your-api-secret" ``` -2. **Configure API Keys**: You must read the [main README file located at the root of the project](https://github.com/foxbit-group/foxbit-api-samples?tab=readme-ov-file#getting-started) for general information on setting up your environment, including configuring your API keys as environment variables. -3. **Running the Examples**: To run the example, navigate to the project directory in the terminal and execute the following command: +Alternatively, put both variables in a `.env` file and pass it to Docker with +`--env-file .env`. + +## Run with Docker + +```bash +docker build -t foxbit-sample-php . +docker run --rm -e FOXBIT_API_KEY -e FOXBIT_API_SECRET foxbit-sample-php +``` + +Or, using a `.env` file: + +```bash +docker run --rm --env-file .env foxbit-sample-php +``` + +## Run natively ```bash php examples.php ``` -## Additional Notes +## How request signing works + +Every authenticated request sends three headers: `X-FB-ACCESS-KEY` (your API +key), `X-FB-ACCESS-TIMESTAMP` (UNIX time in milliseconds) and +`X-FB-ACCESS-SIGNATURE`. The signature is a lowercase hex HMAC-SHA256 of: + +``` +timestamp + method + path + queryString + rawBody +``` + +Two details are easy to get wrong: + +1. **The query string goes into the pre-hash DECODED** (raw values, e.g. + `market_symbol=btc brl`), while the URL itself uses the RFC 3986 + percent-encoded form (`market_symbol=btc%20brl`). Signing the encoded form + results in HTTP 401. Both strings must list the parameters in the same + order, so the example builds them from the same array. +2. **The body is verified against the exact bytes sent.** Serialize the JSON + body once, sign that string and send that same string. Serializing twice + (e.g. once for the signature and again in the HTTP client) can produce + different bytes and an invalid signature. -These examples are meant to serve as a starting point. They demonstrate basic API interactions. It's recommended to review and test the code thoroughly before using it in a production environment. -For detailed API documentation, refer to the [Foxbit API Documentation](https://docs.foxbit.com.br/rest/v3/). +See the full documentation at . diff --git a/rest-v3/php/composer.json b/rest-v3/php/composer.json deleted file mode 100644 index 6e2efa5..0000000 --- a/rest-v3/php/composer.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "require": { - "guzzlehttp/guzzle": "^7.0" - } -} diff --git a/rest-v3/php/composer.lock b/rest-v3/php/composer.lock deleted file mode 100644 index 80de894..0000000 --- a/rest-v3/php/composer.lock +++ /dev/null @@ -1,706 +0,0 @@ -{ - "_readme": [ - "This file locks the dependencies of your project to a known state", - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", - "This file is @generated automatically" - ], - "content-hash": "045658d81f6d9d3243e731dda7bf04d1", - "packages": [ - { - "name": "guzzlehttp/guzzle", - "version": "7.8.1", - "source": { - "type": "git", - "url": "https://github.com/guzzle/guzzle.git", - "reference": "41042bc7ab002487b876a0683fc8dce04ddce104" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/41042bc7ab002487b876a0683fc8dce04ddce104", - "reference": "41042bc7ab002487b876a0683fc8dce04ddce104", - "shasum": "" - }, - "require": { - "ext-json": "*", - "guzzlehttp/promises": "^1.5.3 || ^2.0.1", - "guzzlehttp/psr7": "^1.9.1 || ^2.5.1", - "php": "^7.2.5 || ^8.0", - "psr/http-client": "^1.0", - "symfony/deprecation-contracts": "^2.2 || ^3.0" - }, - "provide": { - "psr/http-client-implementation": "1.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "ext-curl": "*", - "php-http/client-integration-tests": "dev-master#2c025848417c1135031fdf9c728ee53d0a7ceaee as 3.0.999", - "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.36 || ^9.6.15", - "psr/log": "^1.1 || ^2.0 || ^3.0" - }, - "suggest": { - "ext-curl": "Required for CURL handler support", - "ext-intl": "Required for Internationalized Domain Name (IDN) support", - "psr/log": "Required for using the Log middleware" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "files": [ - "src/functions_include.php" - ], - "psr-4": { - "GuzzleHttp\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Jeremy Lindblom", - "email": "jeremeamia@gmail.com", - "homepage": "https://github.com/jeremeamia" - }, - { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - } - ], - "description": "Guzzle is a PHP HTTP client library", - "keywords": [ - "client", - "curl", - "framework", - "http", - "http client", - "psr-18", - "psr-7", - "rest", - "web service" - ], - "support": { - "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.8.1" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", - "type": "tidelift" - } - ], - "time": "2023-12-03T20:35:24+00:00" - }, - { - "name": "guzzlehttp/promises", - "version": "2.0.2", - "source": { - "type": "git", - "url": "https://github.com/guzzle/promises.git", - "reference": "bbff78d96034045e58e13dedd6ad91b5d1253223" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/bbff78d96034045e58e13dedd6ad91b5d1253223", - "reference": "bbff78d96034045e58e13dedd6ad91b5d1253223", - "shasum": "" - }, - "require": { - "php": "^7.2.5 || ^8.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.36 || ^9.6.15" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Promise\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - } - ], - "description": "Guzzle promises library", - "keywords": [ - "promise" - ], - "support": { - "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.0.2" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", - "type": "tidelift" - } - ], - "time": "2023-12-03T20:19:20+00:00" - }, - { - "name": "guzzlehttp/psr7", - "version": "2.11.0", - "source": { - "type": "git", - "url": "https://github.com/guzzle/psr7.git", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f", - "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f", - "shasum": "" - }, - "require": { - "php": "^7.2.5 || ^8.0", - "psr/http-factory": "^1.0", - "psr/http-message": "^1.1 || ^2.0", - "ralouphie/getallheaders": "^3.0", - "symfony/deprecation-contracts": "^2.5 || ^3.0", - "symfony/polyfill-php80": "^1.24" - }, - "provide": { - "psr/http-factory-implementation": "1.0", - "psr/http-message-implementation": "1.0" - }, - "require-dev": { - "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "1.1.0", - "jshttp/mime-db": "1.54.0.1", - "phpunit/phpunit": "^8.5.52 || ^9.6.34" - }, - "suggest": { - "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" - }, - "type": "library", - "extra": { - "bamarni-bin": { - "bin-links": true, - "forward-command": false - } - }, - "autoload": { - "psr-4": { - "GuzzleHttp\\Psr7\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Graham Campbell", - "email": "hello@gjcampbell.co.uk", - "homepage": "https://github.com/GrahamCampbell" - }, - { - "name": "Michael Dowling", - "email": "mtdowling@gmail.com", - "homepage": "https://github.com/mtdowling" - }, - { - "name": "George Mponos", - "email": "gmponos@gmail.com", - "homepage": "https://github.com/gmponos" - }, - { - "name": "Tobias Nyholm", - "email": "tobias.nyholm@gmail.com", - "homepage": "https://github.com/Nyholm" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://github.com/sagikazarmark" - }, - { - "name": "Tobias Schultze", - "email": "webmaster@tubo-world.de", - "homepage": "https://github.com/Tobion" - }, - { - "name": "Márk Sági-Kazár", - "email": "mark.sagikazar@gmail.com", - "homepage": "https://sagikazarmark.hu" - } - ], - "description": "PSR-7 message implementation that also provides common utility methods", - "keywords": [ - "http", - "message", - "psr-7", - "request", - "response", - "stream", - "uri", - "url" - ], - "support": { - "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.11.0" - }, - "funding": [ - { - "url": "https://github.com/GrahamCampbell", - "type": "github" - }, - { - "url": "https://github.com/Nyholm", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", - "type": "tidelift" - } - ], - "time": "2026-06-02T12:30:48+00:00" - }, - { - "name": "psr/http-client", - "version": "1.0.3", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-client.git", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", - "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", - "shasum": "" - }, - "require": { - "php": "^7.0 || ^8.0", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Client\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP clients", - "homepage": "https://github.com/php-fig/http-client", - "keywords": [ - "http", - "http-client", - "psr", - "psr-18" - ], - "support": { - "source": "https://github.com/php-fig/http-client" - }, - "time": "2023-09-23T14:17:50+00:00" - }, - { - "name": "psr/http-factory", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-factory.git", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", - "shasum": "" - }, - "require": { - "php": ">=7.1", - "psr/http-message": "^1.0 || ^2.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", - "keywords": [ - "factory", - "http", - "message", - "psr", - "psr-17", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-factory" - }, - "time": "2024-04-15T12:06:14+00:00" - }, - { - "name": "psr/http-message", - "version": "2.0", - "source": { - "type": "git", - "url": "https://github.com/php-fig/http-message.git", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "description": "Common interface for HTTP messages", - "homepage": "https://github.com/php-fig/http-message", - "keywords": [ - "http", - "http-message", - "psr", - "psr-7", - "request", - "response" - ], - "support": { - "source": "https://github.com/php-fig/http-message/tree/2.0" - }, - "time": "2023-04-04T09:54:51+00:00" - }, - { - "name": "ralouphie/getallheaders", - "version": "3.0.3", - "source": { - "type": "git", - "url": "https://github.com/ralouphie/getallheaders.git", - "reference": "120b605dfeb996808c31b6477290a714d356e822" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", - "reference": "120b605dfeb996808c31b6477290a714d356e822", - "shasum": "" - }, - "require": { - "php": ">=5.6" - }, - "require-dev": { - "php-coveralls/php-coveralls": "^2.1", - "phpunit/phpunit": "^5 || ^6.5" - }, - "type": "library", - "autoload": { - "files": [ - "src/getallheaders.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ralph Khattar", - "email": "ralph.khattar@gmail.com" - } - ], - "description": "A polyfill for getallheaders.", - "support": { - "issues": "https://github.com/ralouphie/getallheaders/issues", - "source": "https://github.com/ralouphie/getallheaders/tree/develop" - }, - "time": "2019-03-08T08:55:37+00:00" - }, - { - "name": "symfony/deprecation-contracts", - "version": "v3.7.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", - "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", - "shasum": "" - }, - "require": { - "php": ">=8.1" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/contracts", - "name": "symfony/contracts" - }, - "branch-alias": { - "dev-main": "3.7-dev" - } - }, - "autoload": { - "files": [ - "function.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "A generic function and convention to trigger deprecation notices", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-04-13T15:52:40+00:00" - }, - { - "name": "symfony/polyfill-php80", - "version": "v1.37.0", - "source": { - "type": "git", - "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", - "shasum": "" - }, - "require": { - "php": ">=7.2" - }, - "type": "library", - "extra": { - "thanks": { - "url": "https://github.com/symfony/polyfill", - "name": "symfony/polyfill" - } - }, - "autoload": { - "files": [ - "bootstrap.php" - ], - "psr-4": { - "Symfony\\Polyfill\\Php80\\": "" - }, - "classmap": [ - "Resources/stubs" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Ion Bazan", - "email": "ion.bazan@gmail.com" - }, - { - "name": "Nicolas Grekas", - "email": "p@tchwork.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", - "homepage": "https://symfony.com", - "keywords": [ - "compatibility", - "polyfill", - "portable", - "shim" - ], - "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-04-10T16:19:22+00:00" - } - ], - "packages-dev": [], - "aliases": [], - "minimum-stability": "stable", - "stability-flags": {}, - "prefer-stable": false, - "prefer-lowest": false, - "platform": {}, - "platform-dev": {}, - "plugin-api-version": "2.9.0" -} diff --git a/rest-v3/php/examples.php b/rest-v3/php/examples.php index 3db467e..b027aec 100644 --- a/rest-v3/php/examples.php +++ b/rest-v3/php/examples.php @@ -1,103 +1,159 @@ $signature, 'timestamp' => $timestamp]; + return hash_hmac('sha256', $preHash, $secret); +} + +/** + * Builds both forms of the query string from the same ordered params: + * - encoded: RFC 3986 percent-encoded, for the request URL; + * - decoded: raw values, for the signature pre-hash. + * + * Both must list the pairs in the same order, so they are built together. + * Returns [encoded, decoded]. + */ +function buildQueryStrings(array $params): array +{ + $encodedPairs = []; + $decodedPairs = []; + foreach ($params as $key => $value) { + $encodedPairs[] = rawurlencode((string) $key) . '=' . rawurlencode((string) $value); + $decodedPairs[] = $key . '=' . $value; + } + + return [implode('&', $encodedPairs), implode('&', $decodedPairs)]; } -function request($method, $path, $params, $body) { - global $apiBaseUrl; +/** + * Sends a request and returns the decoded JSON response. + * Prints the error and exits with a non-zero code on any failure. + */ +function request(string $method, string $path, array $params = [], ?array $body = null, bool $auth = true): array +{ + [$encodedQuery, $decodedQuery] = buildQueryStrings($params); + + // Serialize the body exactly once: the signed string and the sent bytes + // must be identical, otherwise the server rejects the signature. + $rawBody = $body === null ? '' : json_encode($body, JSON_THROW_ON_ERROR); + + $url = API_BASE_URL . $path . ($encodedQuery === '' ? '' : '?' . $encodedQuery); logLine('--------------------------------------------------'); - logLine('Requesting: ' . $method . ' ' . $path); - $sign = sign($method, $path, $params, $body); - $client = new Client(); - $url = $apiBaseUrl . $path; - $headers = [ - 'X-FB-ACCESS-KEY' => getenv('FOXBIT_API_KEY'), - 'X-FB-ACCESS-TIMESTAMP' => $sign['timestamp'], - 'X-FB-ACCESS-SIGNATURE' => $sign['signature'], - 'Content-Type' => 'application/json', - ]; - - try { - $options = [ - 'headers' => $headers, - 'body' => json_encode($body), - 'query' => $params, - ]; - $response = $client->request($method, $url, $options); - return json_decode($response->getBody(), true); - } catch (RequestException $e) { - if ($e->hasResponse()) { - $response = $e->getResponse(); - error_log("HTTP Status Code: " . $response->getStatusCode() . ", Error Response Body: " . $response->getBody()); - } - throw $e; + logLine($method . ' ' . $path . ($encodedQuery === '' ? '' : '?' . $encodedQuery)); + + $headers = ['Content-Type: application/json']; + if ($auth) { + $timestamp = (string) (int) round(microtime(true) * 1000); + $signature = sign($method, $path, $decodedQuery, $rawBody, $timestamp, getenv('FOXBIT_API_SECRET')); + $headers[] = 'X-FB-ACCESS-KEY: ' . getenv('FOXBIT_API_KEY'); + $headers[] = 'X-FB-ACCESS-TIMESTAMP: ' . $timestamp; + $headers[] = 'X-FB-ACCESS-SIGNATURE: ' . $signature; } -} -function logLine($message) { - echo($message . "\n"); + $curl = curl_init($url); + curl_setopt_array($curl, [ + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_RETURNTRANSFER => true, + ]); + if ($rawBody !== '') { + curl_setopt($curl, CURLOPT_POSTFIELDS, $rawBody); + } + + $responseBody = curl_exec($curl); + if ($responseBody === false) { + fwrite(STDERR, 'Request failed: ' . curl_error($curl) . "\n"); + exit(1); + } + $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE); + curl_close($curl); + + logLine("Response ({$status}): {$responseBody}"); + if ($status < 200 || $status >= 300) { + fwrite(STDERR, "Request failed with HTTP {$status}.\n"); + exit(1); + } + + return json_decode($responseBody, true, 512, JSON_THROW_ON_ERROR); } -try { - logLine('FOXBIT_API_KEY: ' . getenv('FOXBIT_API_KEY')); - - // Get the user information - $meResponse = request('GET', '/rest/v3/me', [], []); - logLine('Response: ' . print_r($meResponse, true)); - - // Request to create a new order - $order = [ - 'market_symbol' => 'btcbrl', - 'side' => 'BUY', - 'type' => 'LIMIT', - 'price' => '10.0', - 'quantity' => '0.0001', - ]; - $orderResponse = request('POST', '/rest/v3/orders', null, $order); - logLine('Response: ' . print_r($orderResponse, true)); - - sleep(2); - - // Get active orders - $orderParams = [ - 'market_symbol' => 'btcbrl', - 'state' => 'ACTIVE', - ]; - $activeOrdersResponse = request('GET', '/rest/v3/orders', $orderParams, []); - logLine('Response: ' . print_r($activeOrdersResponse, true)); - - // Request to cancel the order - $orderToCancel = [ - 'type' => 'ID', - 'id' => $orderResponse['id'] - ]; - $cancelResponse = request('PUT', '/rest/v3/orders/cancel', null, $orderToCancel); - logLine('Response: ' . print_r($cancelResponse, true)); -} catch (Exception $e) { - error_log('Failed to process request.'); +// Fail fast if credentials are missing. Never print the key or the secret. +foreach (['FOXBIT_API_KEY', 'FOXBIT_API_SECRET'] as $envVar) { + if (getenv($envVar) === false || getenv($envVar) === '') { + fwrite(STDERR, "Error: environment variable {$envVar} is not set.\n"); + exit(1); + } } + +// 1. Get account information (authenticated). +request('GET', '/rest/v3/me'); + +// 2. Fetch the order book — a public endpoint, so no authentication headers. +$orderbook = request('GET', '/rest/v3/markets/btcbrl/orderbook', ['depth' => '1'], null, false); +$bestBid = $orderbook['bids'][0][0]; + +// 3. Price the order at 50% of the best bid, formatted as an integer +// (btcbrl has price_increment 1.0). The API enforces price bands, so an +// absurdly low hardcoded price such as "10.0" is rejected with HTTP 422; +// half the market price stays inside the band yet far from execution. +$price = (string) (int) floor((float) $bestBid * 0.5); +logLine("Best bid: {$bestBid} — order price (50% of it): {$price}"); + +// 4. Place a limit buy order. This is a real order; it is canceled in step 7. +$order = request('POST', '/rest/v3/orders', [], [ + 'market_symbol' => 'btcbrl', + 'side' => 'BUY', + 'type' => 'LIMIT', + 'price' => $price, + 'quantity' => '0.0001', +]); +$orderId = (string) $order['id']; + +// 5. Give the matching engine a moment to process the order. +sleep(2); + +// 6. List active orders — the order placed in step 4 should be in the list. +request('GET', '/rest/v3/orders', ['market_symbol' => 'btcbrl', 'state' => 'ACTIVE']); + +// 7. Cancel the order created in step 4. +request('PUT', '/rest/v3/orders/cancel', [], ['type' => 'ID', 'id' => $orderId]); + +logLine('Done.'); diff --git a/rest-v3/python/.gitignore b/rest-v3/python/.gitignore index 6e75c7b..a230a78 100644 --- a/rest-v3/python/.gitignore +++ b/rest-v3/python/.gitignore @@ -1 +1,2 @@ -foxbit-api-samples \ No newline at end of file +.venv/ +__pycache__/ diff --git a/rest-v3/python/Dockerfile b/rest-v3/python/Dockerfile new file mode 100644 index 0000000..591f19e --- /dev/null +++ b/rest-v3/python/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.14.6-slim-bookworm + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY example.py ./ + +CMD ["python", "example.py"] diff --git a/rest-v3/python/README.md b/rest-v3/python/README.md index a2e63ca..c506e74 100644 --- a/rest-v3/python/README.md +++ b/rest-v3/python/README.md @@ -1,36 +1,77 @@ -# Foxbit API REST v3 Python Examples +# Foxbit REST API v3 — Python Example -Here is the Python examples for the Foxbit API REST v3. This section provides a series of scripts to help you understand how to interact with the Foxbit API using Python. These examples cover a range of functionalities from fetching market data to placing orders and managing your account. +A minimal, self-contained example of how to sign and send requests to the +[Foxbit REST API v3](https://docs.foxbit.com.br/rest/v3/). Running it executes +a full order lifecycle: -## Prerequisites +1. `GET /rest/v3/me` — fetch account information (authenticated). +2. `GET /rest/v3/markets/btcbrl/orderbook?depth=1` — read the public orderbook (no authentication). +3. Compute a safe order price: 50% of the best bid, rounded down to a whole number. +4. `POST /rest/v3/orders` — place a LIMIT BUY order for 0.0001 BTC. +5. Wait 2 seconds. +6. `GET /rest/v3/orders?market_symbol=btcbrl&state=ACTIVE` — list active orders. +7. `PUT /rest/v3/orders/cancel` — cancel the order created in step 4. -Before you begin, ensure you have the following prerequisites installed on your system: +> **Warning:** step 4 creates a REAL order on your account — a LIMIT BUY of +> 0.0001 BTC at 50% of the current market price. That price is inside the +> accepted price band but far too low to ever execute, and the order is +> canceled at the end of the flow. -- Python: These examples are written for Python, ensure you have the latest stable version installed. +## Requirements -## Getting Started +- [Docker](https://www.docker.com/) (recommended), or +- Python 3.10+ if you prefer to run natively. -1. **Virtual Env**: Create and Activate the virtual env: +## Credentials + +Create an API key at and export it: ```bash -python3 -m venv foxbit-api-samples -source foxbit-api-samples/bin/activate +export FOXBIT_API_KEY="your-api-key" +export FOXBIT_API_SECRET="your-api-secret" ``` -2. **Install Dependencies**: Navigate to the Python examples directory in your terminal and install the necessary dependencies. +Alternatively, put both variables in a `.env` file and pass it to Docker with +`--env-file .env`. + +## Run with Docker ```bash -pip install -r requirements.txt +docker build -t foxbit-sample-python . +docker run --rm -e FOXBIT_API_KEY -e FOXBIT_API_SECRET foxbit-sample-python +``` + +Or, using a `.env` file: + +```bash +docker run --rm --env-file .env foxbit-sample-python ``` -3. **Configure API Keys**: You must read the [main README file located at the root of the project](https://github.com/foxbit-group/foxbit-api-samples?tab=readme-ov-file#getting-started) for general information on setting up your environment, including configuring your API keys as environment variables. -4. **Running the Examples**: To run the example, navigate to the project directory in the terminal and execute the following command: +## Run natively ```bash -python3 examples.py +python3 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +python example.py ``` -## Additional Notes +## How request signing works + +Every authenticated request carries three headers: `X-FB-ACCESS-KEY` (your API +key), `X-FB-ACCESS-TIMESTAMP` (UNIX time in milliseconds) and +`X-FB-ACCESS-SIGNATURE`. The signature is an HMAC-SHA256 (hex) of: + +``` +prehash = timestamp + HTTP method + path + query string + raw body +``` + +Two gotchas that cause most `401` errors: + +1. **The query string goes into the prehash DECODED** (raw values, no + percent-encoding), while the URL itself carries the RFC 3986 + percent-encoded form. Build both from the same ordered parameters. +2. **The body is verified byte for byte as sent.** Serialize the JSON body + exactly once, then sign and send that same string (in Python, pass it via + `data=`, never `json=`, so the HTTP library cannot re-serialize it). -These examples are meant to serve as a starting point. They demonstrate basic API interactions. It's recommended to review and test the code thoroughly before using it in a production environment. -For detailed API documentation, refer to the [Foxbit API Documentation](https://docs.foxbit.com.br/rest/v3/). +See the full documentation at . diff --git a/rest-v3/python/example.py b/rest-v3/python/example.py new file mode 100644 index 0000000..374c24c --- /dev/null +++ b/rest-v3/python/example.py @@ -0,0 +1,136 @@ +"""Foxbit REST API v3 example. + +Runs a full order lifecycle against https://api.foxbit.com.br: fetches +account info, reads the public orderbook, places a LIMIT BUY order priced +far below the market, lists active orders and cancels the order. + +API docs: https://docs.foxbit.com.br/rest/v3/ +""" + +import hashlib +import hmac +import json +import math +import os +import sys +import time +from urllib.parse import quote, urlencode + +import requests + +API_BASE_URL = "https://api.foxbit.com.br" +API_KEY = os.getenv("FOXBIT_API_KEY", "") +API_SECRET = os.getenv("FOXBIT_API_SECRET", "") + + +def encode_query(params): + """Encode params for the URL per RFC 3986 (space is %20, never +).""" + return urlencode(params, safe="", quote_via=quote) + + +def decoded_query(params): + """Build the raw (decoded) query string that goes into the prehash.""" + return "&".join(f"{key}={value}" for key, value in params.items()) + + +def sign(method, path, query, raw_body, timestamp): + """Return the HMAC-SHA256 signature (hex) for a request. + + prehash = timestamp + method + path + query + raw_body + + Gotcha 1: `query` must be the DECODED query string (raw values, no + percent-encoding) even though the URL carries the encoded form -- the + server rebuilds the prehash from decoded values. + Gotcha 2: `raw_body` must be the exact string sent on the wire -- + serialize the body once, then sign and send that same string. + """ + prehash = f"{timestamp}{method}{path}{query}{raw_body}" + print("PreHash:", prehash) + return hmac.new(API_SECRET.encode(), prehash.encode(), hashlib.sha256).hexdigest() + + +def request(method, path, params=None, body=None, authenticated=True): + """Send a request to the API and return the parsed JSON response. + + The encoded query (for the URL) and the decoded query (for the prehash) + are built from the same ordered `params`, and the body is serialized + exactly once, so the signed strings can never diverge from those sent. + """ + print("-" * 50) + print(method, path) + + method = method.upper() + url = API_BASE_URL + path + if params: + url += "?" + encode_query(params) + raw_body = json.dumps(body, separators=(",", ":")) if body is not None else "" + + headers = {"Content-Type": "application/json"} + if authenticated: + timestamp = str(int(time.time() * 1000)) + query = decoded_query(params) if params else "" + headers["X-FB-ACCESS-KEY"] = API_KEY + headers["X-FB-ACCESS-TIMESTAMP"] = timestamp + headers["X-FB-ACCESS-SIGNATURE"] = sign(method, path, query, raw_body, timestamp) + + # data= sends raw_body byte for byte; json= would re-serialize the body + # and could produce different bytes than the ones that were signed. + response = requests.request(method, url, headers=headers, data=raw_body or None) + print(f"Response ({response.status_code}): {response.text}") + if not 200 <= response.status_code < 300: + sys.exit(f"{method} {path} failed with status {response.status_code}") + return response.json() + + +def main(): + if not API_KEY or not API_SECRET: + sys.exit("Set the FOXBIT_API_KEY and FOXBIT_API_SECRET environment variables.") + + # 1. Fetch account information (authenticated, no params). + request("GET", "/rest/v3/me") + + # 2. Fetch the top of the btcbrl orderbook (public endpoint, no auth). + orderbook = request( + "GET", + "/rest/v3/markets/btcbrl/orderbook", + params={"depth": "1"}, + authenticated=False, + ) + best_bid = float(orderbook["bids"][0][0]) + + # 3. Price the order at 50% of the best bid: inside the price band the + # API accepts (an absurd price like 10.0 is rejected with 422) yet far + # too low to ever fill. btcbrl uses price_increment 1.0, so the price + # must be a whole number. + price = str(math.floor(best_bid * 0.5)) + + # 4. Place a LIMIT BUY order for 0.0001 BTC. + order = request( + "POST", + "/rest/v3/orders", + body={ + "market_symbol": "btcbrl", + "side": "BUY", + "type": "LIMIT", + "price": price, + "quantity": "0.0001", + }, + ) + order_id = order["id"] + + # 5. Give the order a moment to show up in the active list. + time.sleep(2) + + # 6. List active orders -- the order placed above should appear. + request( + "GET", + "/rest/v3/orders", + params={"market_symbol": "btcbrl", "state": "ACTIVE"}, + ) + + # 7. Cancel the order placed in step 4. + request("PUT", "/rest/v3/orders/cancel", body={"type": "ID", "id": order_id}) + + +if __name__ == "__main__": + main() diff --git a/rest-v3/python/examples.py b/rest-v3/python/examples.py deleted file mode 100644 index b7a7eb5..0000000 --- a/rest-v3/python/examples.py +++ /dev/null @@ -1,91 +0,0 @@ -import os -import json -import requests -import time -import hmac -import hashlib -from urllib.parse import urlencode - -api_key = os.getenv('FOXBIT_API_KEY') -api_secret = os.getenv('FOXBIT_API_SECRET') -api_base_url = 'https://api.foxbit.com.br' - -def sign(method, path, params, body): - queryString = '' - if params: - queryString = urlencode(params) - - rawBody = '' - if body: - rawBody = json.dumps(body) - - timestamp = str(int(time.time() * 1000)) - preHash = f"{timestamp}{method.upper()}{path}{queryString}{rawBody}" - print('PreHash:', preHash) - signature = hmac.new(api_secret.encode(), preHash.encode(), hashlib.sha256).hexdigest() - print('Signature:', signature) - - return signature, timestamp - -def request(method, path, params, body): - print('--------------------------------------------------') - print('Requesting:', method, path) - signature, timestamp = sign(method, path, params, body) - url = f"{api_base_url}{path}" - headers = { - 'X-FB-ACCESS-KEY': api_key, - 'X-FB-ACCESS-TIMESTAMP': timestamp, - 'X-FB-ACCESS-SIGNATURE': signature, - 'Content-Type': 'application/json', - } - - try: - response = requests.request(method, url, params=params, json=body, headers=headers) - response.raise_for_status() - return response.json() - except requests.HTTPError as http_err: - print(f"HTTP Status Code: {http_err.response.status_code}, Error Response Body:", http_err.response.json()) - raise - except Exception as err: - print(f"An error occurred: {err}") - raise - -if __name__ == '__main__': - try: - print('FOXBIT_API_KEY:', api_key) - - # Get user info - meResponse = request('GET', '/rest/v3/me', None, None) - print('Response:', meResponse) - - # Create a new order - order = { - 'market_symbol': 'btcbrl', - 'side': 'BUY', - 'type': 'LIMIT', - 'price': '10.0', - 'quantity': '0.0001', - } - orderResponse = request('POST', '/rest/v3/orders', None, order) - print('Response:', orderResponse) - - time.sleep(2) - - # Get active orders - ordersParams = { - 'market_symbol': 'btcbrl', - 'state': 'ACTIVE' - } - ordersResponse = request('GET', '/rest/v3/orders', ordersParams, None) - print('Response:', ordersResponse) - - # Cancel the order - orderToCancel = { - 'type': 'ID', - 'id': orderResponse['id'] - } - cancelResponse = request('PUT', '/rest/v3/orders/cancel', None, orderToCancel) - print('Response:', cancelResponse) - - except Exception as e: - print('Failed to process request.', str(e)) diff --git a/rest-v3/python/requirements.txt b/rest-v3/python/requirements.txt index f5729e0..a258782 100644 --- a/rest-v3/python/requirements.txt +++ b/rest-v3/python/requirements.txt @@ -1 +1 @@ -requests==2.33.0 +requests==2.34.2 diff --git a/rest-v3/ruby/Dockerfile b/rest-v3/ruby/Dockerfile new file mode 100644 index 0000000..3789a90 --- /dev/null +++ b/rest-v3/ruby/Dockerfile @@ -0,0 +1,7 @@ +FROM ruby:3.4.10-alpine3.24 + +WORKDIR /app + +COPY examples.rb . + +CMD ["ruby", "examples.rb"] diff --git a/rest-v3/ruby/Gemfile b/rest-v3/ruby/Gemfile deleted file mode 100644 index 6cc679a..0000000 --- a/rest-v3/ruby/Gemfile +++ /dev/null @@ -1,5 +0,0 @@ -source 'https://rubygems.org' - -gem 'faraday', '~> 2.14', '>= 2.14.2' -gem 'json', '~> 2.7', '>= 2.7.1' -gem 'openssl', '~> 3.2' diff --git a/rest-v3/ruby/Gemfile.lock b/rest-v3/ruby/Gemfile.lock deleted file mode 100644 index 729981f..0000000 --- a/rest-v3/ruby/Gemfile.lock +++ /dev/null @@ -1,27 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - faraday (2.14.2) - faraday-net_http (>= 2.0, < 3.5) - json - logger - faraday-net_http (3.4.4) - net-http (~> 0.5) - json (2.7.1) - logger (1.7.0) - net-http (0.9.1) - uri (>= 0.11.1) - openssl (3.2.0) - uri (1.1.1) - -PLATFORMS - arm64-darwin-21 - x86_64-linux - -DEPENDENCIES - faraday (~> 2.14, >= 2.14.2) - json (~> 2.7, >= 2.7.1) - openssl (~> 3.2) - -BUNDLED WITH - 2.3.3 diff --git a/rest-v3/ruby/README.md b/rest-v3/ruby/README.md index 112cf96..62df62b 100644 --- a/rest-v3/ruby/README.md +++ b/rest-v3/ruby/README.md @@ -1,29 +1,73 @@ -# Foxbit API REST v3 Ruby Examples +# Foxbit REST API v3 — Ruby Example -Here is the Ruby examples for the Foxbit API REST v3. This section provides a series of scripts to help you understand how to interact with the Foxbit API using Ruby. These examples cover a range of functionalities from fetching market data to placing orders and managing your account. +A minimal, dependency-free example (Ruby standard library only) of how to authenticate and trade with the [Foxbit REST API v3](https://docs.foxbit.com.br/rest/v3/). -## Prerequisites +The script runs the following flow: -Before you begin, ensure you have the following prerequisites installed on your system: +1. `GET /rest/v3/me` — fetch account information (authenticated). +2. `GET /rest/v3/markets/btcbrl/orderbook?depth=1` — fetch the order book (public, no authentication). +3. Compute an order price at 50% of the best bid. +4. `POST /rest/v3/orders` — create a LIMIT BUY order (0.0001 BTC). +5. Wait 2 seconds. +6. `GET /rest/v3/orders?market_symbol=btcbrl&state=ACTIVE` — list active orders. +7. `PUT /rest/v3/orders/cancel` — cancel the created order. -- Ruby: These examples are written for Ruby, ensure you have the latest stable version installed. +> **Warning:** this example creates a REAL order on your account — a LIMIT BUY of 0.0001 BTC priced at 50% of the current market. That price is inside the accepted price band but far too low to ever execute, and the order is cancelled at the end of the flow. -## Getting Started +## Requirements -1. **Install Dependencies**: Navigate to the Ruby examples directory in your terminal and run `bundle install` to install the necessary dependencies. +- [Docker](https://www.docker.com/) (recommended), or +- Ruby >= 3.2 (uses `CGI.escapeURIComponent`) to run natively. + +## Credentials + +Create an API key at and export it: + +```bash +export FOXBIT_API_KEY="your-api-key" +export FOXBIT_API_SECRET="your-api-secret" +``` + +Alternatively, put both variables in a `.env` file and pass it to Docker with `--env-file .env`. + +## Run with Docker ```bash -bundle install +docker build -t foxbit-sample-ruby . +docker run --rm -e FOXBIT_API_KEY -e FOXBIT_API_SECRET foxbit-sample-ruby ``` -2. **Configure API Keys**: You must read the [main README file located at the root of the project](https://github.com/foxbit-group/foxbit-api-samples?tab=readme-ov-file#getting-started) for general information on setting up your environment, including configuring your API keys as environment variables. -3. **Running the Examples**: To run the example, navigate to the project directory in the terminal and execute the following command: +Or, using a `.env` file: + +```bash +docker run --rm --env-file .env foxbit-sample-ruby +``` + +## Run natively ```bash ruby examples.rb ``` -## Additional Notes +## How request signing works + +Every authenticated request is signed with HMAC-SHA256 (hex, lowercase) using your API secret over the string: + +``` +preHash = timestamp + method + path + queryString + rawBody +``` + +- `timestamp`: UNIX time in **milliseconds** — the same value sent in the `X-FB-ACCESS-TIMESTAMP` header. +- `method`: uppercase HTTP verb (`GET`, `POST`, ...). +- `path`: e.g. `/rest/v3/orders` (no host, no query string). +- `queryString`: `key=value&key2=value2`, empty if there are no params. +- `rawBody`: the JSON request body as sent, empty if there is no body. + +The signature goes in the `X-FB-ACCESS-SIGNATURE` header, alongside `X-FB-ACCESS-KEY` (your API key) and `X-FB-ACCESS-TIMESTAMP`. + +Two gotchas that cause most `401` errors: + +1. **The query string enters the prehash with decoded (raw) values**, while the URL itself uses RFC 3986 percent-encoding (space = `%20`). Sign `market_symbol=btc brl`, send `market_symbol=btc%20brl`. Both must use the same parameter order. +2. **The body is verified against the exact bytes sent.** Serialize the JSON body once, sign that string and send that same string — never re-serialize. -These examples are meant to serve as a starting point. They demonstrate basic API interactions. It's recommended to review and test the code thoroughly before using it in a production environment. -For detailed API documentation, refer to the [Foxbit API Documentation](https://docs.foxbit.com.br/rest/v3/). +See the full documentation at . diff --git a/rest-v3/ruby/examples.rb b/rest-v3/ruby/examples.rb index c2cf451..026654d 100644 --- a/rest-v3/ruby/examples.rb +++ b/rest-v3/ruby/examples.rb @@ -1,86 +1,119 @@ -require 'rubygems' -require 'bundler/setup' +# frozen_string_literal: true -Bundler.require(:default) +# Foxbit REST API v3 example — Ruby standard library only. +# +# Flow: fetch account info, read the public order book, place a LIMIT BUY +# order far below market price, list active orders and cancel the order. +# +# Docs: https://docs.foxbit.com.br/rest/v3/ -def sign(method, path, params, body) - queryString = params.map { |key, value| "#{key}=#{URI.encode_www_form_component(value)}" }.join('&') if params - rawBody = body.to_json if body +require "net/http" +require "openssl" +require "json" +require "cgi" +require "uri" - timestamp = Time.now.to_i * 1000 # Convert to milliseconds - preHash = "#{timestamp}#{method.upcase}#{path}#{queryString}#{rawBody}" - puts 'PreHash:', preHash - digest = OpenSSL::Digest.new('sha256') - signature = OpenSSL::HMAC.hexdigest(digest, ENV['FOXBIT_API_SECRET'], preHash) - puts 'Signature:', signature +API_BASE = "https://api.foxbit.com.br" - { signature: signature, timestamp: timestamp } +API_KEY = ENV.fetch("FOXBIT_API_KEY", "") +API_SECRET = ENV.fetch("FOXBIT_API_SECRET", "") + +# Builds both representations of the query string from the same ordered params: +# - decoded: raw (unencoded) values, used in the signature prehash; +# - encoded: RFC 3986 percent-encoded (space => %20), used in the request URL. +def build_query(params) + return ["", ""] if params.nil? || params.empty? + + decoded = params.map { |key, value| "#{key}=#{value}" }.join("&") + encoded = params.map do |key, value| + "#{CGI.escapeURIComponent(key.to_s)}=#{CGI.escapeURIComponent(value.to_s)}" + end.join("&") + [decoded, encoded] end -def request(method, path, params, body) - puts '--------------------------------------------------' - puts 'Requesting:', method, path - sign_result = sign(method, path, params, body) - url = "https://api.foxbit.com.br#{path}" - headers = { - 'X-FB-ACCESS-KEY' => ENV['FOXBIT_API_KEY'], - 'X-FB-ACCESS-TIMESTAMP' => sign_result[:timestamp].to_s, - 'X-FB-ACCESS-SIGNATURE' => sign_result[:signature], - 'Content-Type' => 'application/json', - } - - conn = Faraday.new(url: url, headers: headers) - response = case method.downcase - when 'get' - conn.get { |req| req.params = params if params } - when 'post' - conn.post do |req| - req.body = body.to_json if body - end - when 'put' - conn.put do |req| - req.body = body.to_json if body - end - end +# Signs a request with HMAC-SHA256 over: +# timestamp + METHOD + path + decodedQuery + rawBody +# +# Gotcha 1: the query string enters the prehash with DECODED (raw) values, +# even though it is sent percent-encoded in the URL. +# Gotcha 2: rawBody must be the exact string sent on the wire — serialize +# the body once and sign those same bytes. +def sign(secret, method, path, decoded_query, raw_body, timestamp) + prehash = "#{timestamp}#{method}#{path}#{decoded_query}#{raw_body}" + signature = OpenSSL::HMAC.hexdigest("SHA256", secret, prehash) + [prehash, signature] +end + +# Performs an HTTP request, printing the prehash (when authenticated) and the +# response. Exits with a non-zero status on any non-2xx response. +def request(method, path, params: nil, body: nil, auth: true) + decoded_query, encoded_query = build_query(params) + raw_body = body.nil? ? "" : JSON.generate(body) # serialized exactly once + + uri = URI("#{API_BASE}#{path}") + uri.query = encoded_query unless encoded_query.empty? + + puts "-" * 50 + puts "#{method} #{path}" + + headers = { "Content-Type" => "application/json" } + if auth + timestamp = (Time.now.to_f * 1000).to_i.to_s + prehash, signature = sign(API_SECRET, method, path, decoded_query, raw_body, timestamp) + puts "PreHash: #{prehash}" + headers["X-FB-ACCESS-KEY"] = API_KEY + headers["X-FB-ACCESS-TIMESTAMP"] = timestamp + headers["X-FB-ACCESS-SIGNATURE"] = signature + end + + request_class = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post, "PUT" => Net::HTTP::Put }.fetch(method) + response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| + req = request_class.new(uri, headers) + req.body = raw_body unless raw_body.empty? + http.request(req) + end + + puts "Response (#{response.code}): #{response.body}" + abort "Request failed: #{method} #{path} returned HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess) JSON.parse(response.body) -rescue Faraday::Error => e - puts "Failed to process request: #{e.message}" - raise end -begin - puts 'FOXBIT_API_KEY:', ENV['FOXBIT_API_KEY'] - - # Get the user information - me_response = request('GET', '/rest/v3/me', {}, nil) - puts 'Response:', me_response - - # Request to create a new order - order = { - market_symbol: 'btcbrl', - side: 'BUY', - type: 'LIMIT', - price: '10.0', - quantity: '0.0001', - } - order_response = request('POST', '/rest/v3/orders', {}, order) - puts 'Response:', order_response - - sleep 2 - - # Get active orders - orders_param = { - market_symbol: 'btcbrl', - state: 'ACTIVE', - } - orders_response = request('GET', '/rest/v3/orders', orders_param, nil) - puts 'Response:', orders_response - - # Request to cancel the order - order_to_cancel = { type: :ID, id: order_response['id'] } - cancel_response = request('PUT', '/rest/v3/orders/cancel', {}, order_to_cancel) - puts 'Response:', cancel_response -rescue => e - puts "Failed to process request: #{e.message}" +if API_KEY.empty? || API_SECRET.empty? + abort "Error: set the FOXBIT_API_KEY and FOXBIT_API_SECRET environment variables." end + +# 1. Fetch account information (authenticated, no params). +request("GET", "/rest/v3/me") + +# 2. Fetch the order book (public endpoint — no authentication headers). +orderbook = request("GET", "/rest/v3/markets/btcbrl/orderbook", params: { "depth" => "1" }, auth: false) +best_bid = orderbook["bids"][0][0] # best bid price, as a decimal string + +# 3. Price the order at 50% of the best bid: within the accepted price band +# (absurd prices such as a hardcoded 10.0 are rejected with 422), yet far too +# low to ever execute. btcbrl has price_increment 1.0, so round to an integer. +price = (best_bid.to_f * 0.5).floor.to_s +puts "Best bid: #{best_bid} | Order price (50%): #{price}" + +# 4. Create a LIMIT BUY order. +order = request("POST", "/rest/v3/orders", body: { + "market_symbol" => "btcbrl", + "side" => "BUY", + "type" => "LIMIT", + "price" => price, + "quantity" => "0.0001" +}) +order_id = order.fetch("id") + +# 5. Give the order a moment to show up in listings. +sleep 2 + +# 6. List active orders — the order created above should appear. +request("GET", "/rest/v3/orders", params: { "market_symbol" => "btcbrl", "state" => "ACTIVE" }) + +# 7. Cancel the order by id. +request("PUT", "/rest/v3/orders/cancel", body: { "type" => "ID", "id" => order_id }) + +puts "-" * 50 +puts "Done: order #{order_id} was created, listed and cancelled." diff --git a/rest-v3/swift/Dockerfile b/rest-v3/swift/Dockerfile index 3e3a0eb..c5fb034 100644 --- a/rest-v3/swift/Dockerfile +++ b/rest-v3/swift/Dockerfile @@ -1,44 +1,17 @@ -FROM swift:6.1.0-focal AS builder - -WORKDIR /src - -RUN rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*.deb \ - && mkdir -p /var/lib/apt/lists/partial - -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - libssl-dev \ - libcurl4-openssl-dev \ - pkg-config \ - build-essential \ - cmake \ - git \ - && rm -rf /var/lib/apt/lists/* - -ENV PKG_CONFIG_PATH=/usr/lib/x86_64-linux-gnu/pkgconfig +# Build stage: compile a release binary with the full Swift toolchain. +FROM swift:6.2.4-noble AS build +WORKDIR /app -COPY Package.swift ./ +# Resolve pinned dependencies first so Docker caches this layer. +COPY Package.swift Package.resolved ./ RUN swift package resolve -COPY . . -RUN swift build -c release - -FROM debian:bookworm-slim - -ENV DEBIAN_FRONTEND=noninteractive TZ=UTC - -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - libcurl4 \ - libssl3 \ - tzdata \ - && apt-get clean && rm -rf /var/lib/apt/lists/* +COPY Sources ./Sources +RUN swift build -c release \ + && cp "$(swift build -c release --show-bin-path)/FoxbitExample" /app/FoxbitExample +# Runtime stage: slim image with only the Swift runtime libraries. +FROM swift:6.2.4-noble-slim WORKDIR /app -COPY --from=builder /src/.build/x86_64-unknown-linux-gnu/release/FoxbitExamples . -COPY --from=builder /usr/lib/swift/linux/lib*.so* /usr/lib/ -COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ - -RUN chmod +x ./FoxbitExamples - -ENTRYPOINT ["./FoxbitExamples"] +COPY --from=build /app/FoxbitExample . +ENTRYPOINT ["./FoxbitExample"] diff --git a/rest-v3/swift/Package.resolved b/rest-v3/swift/Package.resolved new file mode 100644 index 0000000..41b1803 --- /dev/null +++ b/rest-v3/swift/Package.resolved @@ -0,0 +1,23 @@ +{ + "pins" : [ + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", + "version" : "4.5.0" + } + } + ], + "version" : 2 +} diff --git a/rest-v3/swift/Package.swift b/rest-v3/swift/Package.swift index fd8dbd3..4a29f30 100644 --- a/rest-v3/swift/Package.swift +++ b/rest-v3/swift/Package.swift @@ -1,20 +1,19 @@ -// swift-tools-version:5.7 +// swift-tools-version:6.1 import PackageDescription let package = Package( - name: "FoxbitExamples", + name: "FoxbitExample", platforms: [ - .macOS(.v12) + .macOS(.v13) ], dependencies: [ - .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.25.2"), - .package(url: "https://github.com/apple/swift-crypto.git", from: "3.12.3") + // swift-crypto provides HMAC-SHA256 on Linux (CryptoKit is Apple-platform only). + .package(url: "https://github.com/apple/swift-crypto.git", exact: "4.5.0") ], targets: [ .executableTarget( - name: "FoxbitExamples", + name: "FoxbitExample", dependencies: [ - .product(name: "AsyncHTTPClient", package: "async-http-client"), .product(name: "Crypto", package: "swift-crypto") ], path: "Sources" diff --git a/rest-v3/swift/README.md b/rest-v3/swift/README.md index d1ac460..26746d0 100644 --- a/rest-v3/swift/README.md +++ b/rest-v3/swift/README.md @@ -1,28 +1,61 @@ -# Foxbit API REST v3 Swift Examples +# Foxbit REST API v3 — Swift Example -Here is the Swift examples for the Foxbit API REST v3. This section provides a series of scripts to help you understand how to interact with the Foxbit API using Swift. These examples cover a range of functionalities from fetching market data to placing orders and managing your account. +A minimal, self-contained example of how to call the [Foxbit REST API v3](https://docs.foxbit.com.br/rest/v3/) from Swift, including request signing. -## Prerequisites +It runs the following flow: -Before you begin, ensure you have the following prerequisites installed on your system: +1. `GET /rest/v3/me` — fetch account info (authenticated). +2. `GET /rest/v3/markets/btcbrl/orderbook?depth=1` — read the order book (public, no auth). +3. Compute a limit price at 50% of the best bid. +4. `POST /rest/v3/orders` — create a LIMIT BUY order for 0.0001 BTC. +5. Wait 2 seconds. +6. `GET /rest/v3/orders?market_symbol=btcbrl&state=ACTIVE` — list active orders. +7. `PUT /rest/v3/orders/cancel` — cancel the order created in step 4. -- Docker +> **Warning**: this example places a REAL order on your account — a LIMIT BUY of 0.0001 BTC at 50% of the current market price. That price is inside the accepted price band but far from ever executing, and the order is cancelled at the end of the flow. -## Getting Started +## Requirements -1. **Configure API Keys**: You must read the [main README file located at the root of the project](https://github.com/foxbit-group/foxbit-api-samples?tab=readme-ov-file#getting-started) for general information on setting up your environment, including configuring your API keys as environment variables. -2. **Running the Examples**: To run the example, navigate to the project directory in the terminal and execute the following command: +- Docker (recommended), or +- Swift 6.1+ toolchain (native run, optional) + +## Credentials + +Create an API key at and export it: ```bash -docker build -t foxbit-swift-examples . +export FOXBIT_API_KEY="your-api-key" +export FOXBIT_API_SECRET="your-api-secret" +``` + +Or put both variables in a `.env` file and pass it to Docker with `--env-file .env`. + +## Run with Docker -docker run --rm \ - -e FOXBIT_API_KEY=$FOXBIT_API_KEY \ - -e FOXBIT_API_SECRET=$FOXBIT_API_SECRET \ - foxbit-swift-examples +```bash +docker build -t foxbit-sample-swift . + +docker run --rm -e FOXBIT_API_KEY -e FOXBIT_API_SECRET foxbit-sample-swift +# or: docker run --rm --env-file .env foxbit-sample-swift ``` -## Additional Notes +## Run natively + +```bash +swift run +``` + +## How request signing works + +Every authenticated request sends three headers: `X-FB-ACCESS-KEY` (your API key), `X-FB-ACCESS-TIMESTAMP` (UNIX time in milliseconds) and `X-FB-ACCESS-SIGNATURE`. The signature is an HMAC-SHA256 (hex) of: + +``` +timestamp + method + path + queryString + rawBody +``` + +Two gotchas that cause most `401 Unauthorized` errors: + +1. **The query string goes into the pre-hash DECODED** (raw values, e.g. `market_symbol=btc brl`), while the URL itself carries it percent-encoded (RFC 3986, e.g. `market_symbol=btc%20brl`). Build both strings from the same ordered parameter list. +2. **The body is verified byte-for-byte as sent.** Serialize the JSON body exactly once and sign that same string — never re-serialize it when sending. -These examples are meant to serve as a starting point. They demonstrate basic API interactions. It's recommended to review and test the code thoroughly before using it in a production environment. -For detailed API documentation, refer to the [Foxbit API Documentation](https://docs.foxbit.com.br/rest/v3/). +See the full documentation at . diff --git a/rest-v3/swift/Sources/main.swift b/rest-v3/swift/Sources/main.swift index aa9b3ae..9032a9c 100644 --- a/rest-v3/swift/Sources/main.swift +++ b/rest-v3/swift/Sources/main.swift @@ -1,222 +1,190 @@ +// Foxbit REST API v3 — Swift example. +// +// Flow: fetch account info, read the public order book, place a LIMIT BUY +// order far below the market, list active orders, then cancel the order. +// +// Docs: https://docs.foxbit.com.br/rest/v3/ + import Foundation -import AsyncHTTPClient -import NIOCore -import NIOFoundationCompat -import Crypto -import NIOHTTP1 +#if canImport(FoundationNetworking) +import FoundationNetworking // URLSession on Linux +#endif +import Crypto // HMAC-SHA256 on Linux (CryptoKit is Apple-platform only) -let apiBaseUrl = "https://api.foxbit.com.br" +struct ExampleError: Error, CustomStringConvertible { + let description: String + init(_ description: String) { self.description = description } +} -struct FoxbitOrder: Codable { - let id: Int +// RFC 3986 percent-encoding: only unreserved characters (A-Z a-z 0-9 - . _ ~) +// are kept as-is; everything else is encoded (space -> %20, never "+"). +func percentEncode(_ value: String) -> String { + let unreserved = CharacterSet( + charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + ) + return value.addingPercentEncoding(withAllowedCharacters: unreserved) ?? value } -struct APIError: Error { - let message: String +// Query string with RAW (decoded) values — used ONLY in the signature pre-hash. +func decodedQuery(_ params: [(String, String)]) -> String { + params.map { "\($0.0)=\($0.1)" }.joined(separator: "&") } -func canonicalQueryString(_ params: [String: String]) -> String { - return params - .map { "\($0.key)=\($0.value)" } - .sorted() - .joined(separator: "&") +// Percent-encoded query string (RFC 3986) — used ONLY in the request URL. +// Both strings are built from the same ordered params, so they always match. +func encodedQuery(_ params: [(String, String)]) -> String { + params.map { "\(percentEncode($0.0))=\(percentEncode($0.1))" }.joined(separator: "&") } +// HMAC-SHA256 (hex) over: timestamp + method + path + decodedQuery + rawBody. +// Gotcha 1: the query string goes DECODED into the pre-hash, while the URL +// carries it percent-encoded. +// Gotcha 2: rawBody must be byte-for-byte the string sent on the wire — +// serialize the JSON once and sign that exact string. func sign( + secret: String, + timestamp: String, method: String, path: String, - queryString: String = "", - body: [String: Any]? = nil -) -> (signature: String, timestamp: String) { - let timestamp = String(Int(Date().timeIntervalSince1970 * 1000)) - - let rawBody: String = { - guard let body = body, - let data = try? JSONSerialization.data(withJSONObject: body), - let s = String(data: data, encoding: .utf8) - else { return "" } - return s - }() - - let preHash = "\(timestamp)\(method)\(path)\(queryString)\(rawBody)" - print("PreHash:", preHash) - - guard let secret = ProcessInfo.processInfo.environment["FOXBIT_API_SECRET"] else { - fatalError("FOXBIT_API_SECRET not set") - } + decodedQuery: String, + rawBody: String +) -> (preHash: String, signature: String) { + let preHash = timestamp + method + path + decodedQuery + rawBody let key = SymmetricKey(data: Data(secret.utf8)) - let signature = HMAC - .authenticationCode(for: Data(preHash.utf8), using: key) - .map { String(format: "%02hhx", $0) } - .joined() - print("Signature:", signature) - - return (signature, timestamp) + let mac = HMAC.authenticationCode(for: Data(preHash.utf8), using: key) + let signature = mac.map { String(format: "%02x", $0) }.joined() + return (preHash, signature) } func request( + apiKey: String, + apiSecret: String, method: String, path: String, - params: [String: String]? = nil, - body: [String: Any]? = nil + params: [(String, String)] = [], + rawBody: String? = nil, + authenticated: Bool = true ) async throws -> Data { - let qs = params.map(canonicalQueryString) ?? "" - let (signature, timestamp) = sign(method: method, path: path, queryString: qs, body: body) - - var fullUrl = apiBaseUrl + path - if !qs.isEmpty { - fullUrl += "?\(qs)" + let baseURL = "https://api.foxbit.com.br" + + print(String(repeating: "-", count: 50)) + print("\(method) \(path)") + + var urlString = baseURL + path + let query = encodedQuery(params) + if !query.isEmpty { urlString += "?" + query } + guard let url = URL(string: urlString) else { + throw ExampleError("Invalid URL: \(urlString)") } - - var req = try HTTPClient.Request(url: fullUrl, method: HTTPMethod(rawValue: method)) - req.headers.add(name: "X-FB-ACCESS-KEY", value: ProcessInfo.processInfo.environment["FOXBIT_API_KEY"] ?? "") - req.headers.add(name: "X-FB-ACCESS-TIMESTAMP", value: timestamp) - req.headers.add(name: "X-FB-ACCESS-SIGNATURE", value: signature) - req.headers.add(name: "Content-Type", value: "application/json") - - if let body = body { - req.body = .data(try JSONSerialization.data(withJSONObject: body)) + + var req = URLRequest(url: url) + req.httpMethod = method + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + if let rawBody { + req.httpBody = Data(rawBody.utf8) // exactly the string that gets signed } - - let client = HTTPClient(eventLoopGroupProvider: .createNew) - defer { try? client.syncShutdown() } - - let response = try await client.execute(request: req).get() - guard let buffer = response.body else { - throw APIError(message: "Empty response") + + if authenticated { + let timestamp = String(Int64(Date().timeIntervalSince1970 * 1000)) + let (preHash, signature) = sign( + secret: apiSecret, + timestamp: timestamp, + method: method, + path: path, + decodedQuery: decodedQuery(params), + rawBody: rawBody ?? "" + ) + print("PreHash: \(preHash)") + req.setValue(apiKey, forHTTPHeaderField: "X-FB-ACCESS-KEY") + req.setValue(timestamp, forHTTPHeaderField: "X-FB-ACCESS-TIMESTAMP") + req.setValue(signature, forHTTPHeaderField: "X-FB-ACCESS-SIGNATURE") } - return Data(buffer: buffer) + + let (data, response) = try await URLSession.shared.data(for: req) + let status = (response as? HTTPURLResponse)?.statusCode ?? 0 + print("Response (\(status)): \(String(data: data, encoding: .utf8) ?? "")") + guard (200..<300).contains(status) else { + throw ExampleError("HTTP \(status) on \(method) \(path)") + } + return data } -@main -struct FoxbitExamples { - static func main() async { - print("FOXBIT_API_KEY:", ProcessInfo.processInfo.environment["FOXBIT_API_KEY"] ?? "") - - // Get the user information - do { - let meResponse = try await request(method: "GET", path: "/rest/v3/me") - print("Response:", String(data: meResponse, encoding: .utf8) ?? "") - } catch { - print("Failed to process request.") - return - } - - // Get current price - let marketSymbol = "btcbrl" - let tickerData: Data - do { - tickerData = try await request( - method: "GET", - path: "/rest/v3/markets/\(marketSymbol)/ticker/24hr" - ) - // Print the first-level response like in TypeScript - print("Response:", String(data: tickerData, encoding: .utf8) ?? "") - } catch { - print("Failed to process request.") - return - } - - // Request to create a new order - let targetPrice: String - do { - // Parse best.bid.price (matching the TypeScript reference) - guard - let json = try JSONSerialization.jsonObject(with: tickerData) as? [String: Any], - let dataArr = json["data"] as? [[String: Any]], - let first = dataArr.first, - let best = first["best"] as? [String: Any], - let bid = best["bid"] as? [String: Any], - let priceStr = bid["price"] as? String, - let lastPrice = Double(priceStr) - else { - print("Failed to process request.") - return - } - let target = lastPrice * 0.9 // Calculate target price: 10% below the best bid price - targetPrice = String(format: "%.8f", target) - } catch { - print("Failed to process request.") - return - } - - let orderData: Data - do { - let order: [String: Any] = [ - "market_symbol": marketSymbol, - "side": "BUY", - "type": "LIMIT", - "price": targetPrice, - "quantity": "0.0001" - ] - let orderResponse = try await request( - method: "POST", - path: "/rest/v3/orders", - body: order - ) - orderData = orderResponse - print("Response:", String(data: orderData, encoding: .utf8) ?? "") - } catch { - print("Failed to process request.") - return - } - - // Sleep 2 seconds (simulate await sleep(2000)) - try? await Task.sleep(nanoseconds: 2_000_000_000) - - // Get active orders - do { - let oneHourAgoISO = ISO8601DateFormatter().string(from: Date(timeIntervalSinceNow: -3600)) - let ordersParams: [String: String] = [ - "market_symbol": marketSymbol, - "state": "ACTIVE", - "start_time": oneHourAgoISO // Optional: included to test signature behavior with special chars - ] - let ordersResponse = try await request( - method: "GET", - path: "/rest/v3/orders", - params: ordersParams - ) - print("Response:", String(data: ordersResponse, encoding: .utf8) ?? "") - } catch { - print("Failed to process request.") - return - } - - // Request to cancel the order - do { - // Extract order id (supports string or int id) - let orderId: String - if - let json = try? JSONSerialization.jsonObject(with: orderData) as? [String: Any], - let rawId = json["id"] - { - if let intId = rawId as? Int { - orderId = String(intId) - } else if let strId = rawId as? String { - orderId = strId - } else { - print("Failed to process request.") - return - } - } else { - print("Failed to process request.") - return - } - - let orderToCancel: [String: Any] = [ - "type": "ID", - "id": orderId - ] - let cancelResponse = try await request( - method: "PUT", - path: "/rest/v3/orders/cancel", - body: orderToCancel - ) - print("Response:", String(data: cancelResponse, encoding: .utf8) ?? "") - } catch { - print("Failed to process request.") - return - } +// Fail fast if credentials are missing. Never print them. +let env = ProcessInfo.processInfo.environment +guard let apiKey = env["FOXBIT_API_KEY"], !apiKey.isEmpty, + let apiSecret = env["FOXBIT_API_SECRET"], !apiSecret.isEmpty else { + FileHandle.standardError.write( + Data("Error: FOXBIT_API_KEY and FOXBIT_API_SECRET environment variables must be set.\n".utf8) + ) + exit(1) +} + +do { + // 1. Account info — authenticated request without params. + _ = try await request(apiKey: apiKey, apiSecret: apiSecret, method: "GET", path: "/rest/v3/me") + + // 2. Order book — public endpoint, no authentication headers needed. + let orderBookData = try await request( + apiKey: apiKey, + apiSecret: apiSecret, + method: "GET", + path: "/rest/v3/markets/btcbrl/orderbook", + params: [("depth", "1")], + authenticated: false + ) + guard let orderBook = try JSONSerialization.jsonObject(with: orderBookData) as? [String: Any], + let bids = orderBook["bids"] as? [[Any]], + let bestBidString = bids.first?.first as? String, + let bestBid = Double(bestBidString) else { + throw ExampleError("Could not read best bid from order book response") + } + + // 3. Price at 50% of the best bid: inside the accepted price band but far + // from ever executing. The API rejects absurd prices (e.g. 10.0) with + // 422. btcbrl has price_increment 1.0, so format it as an integer. + let price = String(Int((bestBid * 0.5).rounded(.down))) + + // 4. Create the order. The body is serialized ONCE; the same string is + // signed and sent. + let orderBody = + #"{"market_symbol":"btcbrl","side":"BUY","type":"LIMIT","price":"\#(price)","quantity":"0.0001"}"# + let orderData = try await request( + apiKey: apiKey, + apiSecret: apiSecret, + method: "POST", + path: "/rest/v3/orders", + rawBody: orderBody + ) + guard let order = try JSONSerialization.jsonObject(with: orderData) as? [String: Any], + let orderId = order["id"] as? String else { + throw ExampleError("Could not read order id from create-order response") } + + // 5. Give the matching engine a moment before listing. + try await Task.sleep(nanoseconds: 2_000_000_000) + + // 6. List active orders — the order created above should be present. + _ = try await request( + apiKey: apiKey, + apiSecret: apiSecret, + method: "GET", + path: "/rest/v3/orders", + params: [("market_symbol", "btcbrl"), ("state", "ACTIVE")] + ) + + // 7. Cancel the order created in step 4. + let cancelBody = #"{"type":"ID","id":"\#(orderId)"}"# + _ = try await request( + apiKey: apiKey, + apiSecret: apiSecret, + method: "PUT", + path: "/rest/v3/orders/cancel", + rawBody: cancelBody + ) + + print(String(repeating: "-", count: 50)) + print("Done: order \(orderId) created and cancelled.") +} catch { + FileHandle.standardError.write(Data("Error: \(error)\n".utf8)) + exit(1) } diff --git a/rest-v3/typescript/.dockerignore b/rest-v3/typescript/.dockerignore new file mode 100644 index 0000000..f06235c --- /dev/null +++ b/rest-v3/typescript/.dockerignore @@ -0,0 +1,2 @@ +node_modules +dist diff --git a/rest-v3/typescript/Dockerfile b/rest-v3/typescript/Dockerfile new file mode 100644 index 0000000..7a45cdd --- /dev/null +++ b/rest-v3/typescript/Dockerfile @@ -0,0 +1,14 @@ +# Build stage: install dev dependencies and compile TypeScript to JavaScript. +FROM node:22.22.0-alpine3.23 AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY tsconfig.json examples.ts ./ +RUN npm run build + +# Runtime stage: run the compiled JavaScript. The example has zero runtime +# dependencies (native fetch + node:crypto), so only the built output is copied. +FROM node:22.22.0-alpine3.23 +WORKDIR /app +COPY --from=build /app/dist ./dist +CMD ["node", "dist/examples.js"] diff --git a/rest-v3/typescript/README.md b/rest-v3/typescript/README.md index ea27a8e..dddd378 100644 --- a/rest-v3/typescript/README.md +++ b/rest-v3/typescript/README.md @@ -1,24 +1,90 @@ -# Foxbit API REST v3 TypeScript Examples +# Foxbit REST API v3 — TypeScript Example -Here is the TypeScript examples for the Foxbit API REST v3. This section provides a series of scripts to help you understand how to interact with the Foxbit API using TypeScript. These examples cover a range of functionalities from fetching market data to placing orders and managing your account. +A minimal, self-contained example of authenticating and trading with the +[Foxbit REST API v3](https://docs.foxbit.com.br/rest/v3/) in TypeScript. It uses +only the Node.js standard library at runtime (`fetch` + `node:crypto`); the sole +build-time dependencies are `typescript` and `@types/node`. -## Getting Started +## What it does -1. **Install Dependencies**: Navigate to the TypeScript examples directory in your terminal and run: +The example runs the following flow end to end: + +1. `GET /rest/v3/me` — authenticated, fetches the account profile. +2. `GET /rest/v3/markets/btcbrl/orderbook?depth=1` — public (no authentication), + reads the best bid. +3. Computes a price of `floor(bestBid * 0.5)` as an integer (btcbrl uses a price + increment of `1.0`). Half the market price stays inside the API price band but + is far too low to ever execute. +4. `POST /rest/v3/orders` — authenticated, creates a **real** LIMIT BUY order for + `0.0001 BTC` and captures its id. +5. Waits 2 seconds. +6. `GET /rest/v3/orders?market_symbol=btcbrl&state=ACTIVE` — authenticated, lists + the active order. +7. `PUT /rest/v3/orders/cancel` — authenticated, cancels the order by id. + +> **Warning:** step 4 places a real order on your account. It is priced far from +> the market so it will not execute, and step 7 cancels it immediately. + +## Requirements + +- [Docker](https://www.docker.com/) (recommended), or +- Node.js `>= 18` with TypeScript to run natively. + +## Credentials + +Create an API key at and expose it +through the environment: ```bash -npm install -g typescript ts-node -npm install +export FOXBIT_API_KEY="your-api-key" +export FOXBIT_API_SECRET="your-api-secret" +``` + +Alternatively, put both values in a `.env` file and pass it with `--env-file`. + +## Run with Docker + +```bash +docker build -t foxbit-sample-typescript . +docker run --rm -e FOXBIT_API_KEY -e FOXBIT_API_SECRET foxbit-sample-typescript ``` -2. **Configure API Keys**: You must read the [main README file located at the root of the project](https://github.com/foxbit-group/foxbit-api-samples?tab=readme-ov-file#getting-started) for general information on setting up your environment, including configuring your API keys as environment variables. -3. **Running the Examples**: To run the example, navigate to the project directory in the terminal and execute the following command: +Or, using a `.env` file: ```bash -ts-node examples.ts +docker run --rm --env-file .env foxbit-sample-typescript ``` -## Additional Notes +## Run natively + +```bash +npm install +npm run build +npm start +``` + +## How request signing works + +Every authenticated request is signed with HMAC-SHA256 (hex) over the string: + +``` +timestamp + method + path + queryString + rawBody +``` + +- `timestamp` is the UNIX time in milliseconds; the same value is sent in the + `X-FB-ACCESS-TIMESTAMP` header. +- The signature goes in `X-FB-ACCESS-SIGNATURE` and the API key in + `X-FB-ACCESS-KEY`. + +Two details are easy to get wrong: + +1. **The query string is signed decoded, but sent percent-encoded.** The prehash + uses raw values (`market_symbol=btc brl`) while the URL uses RFC 3986 encoding + (`market_symbol=btc%20brl`). Building both from the same ordered structure + keeps the key order identical. +2. **The body is signed exactly as the bytes sent.** Serialize the JSON once and + sign and send that same string — re-serializing for the request would break + the signature. -These examples are meant to serve as a starting point. They demonstrate basic API interactions. It's recommended to review and test the code thoroughly before using it in a production environment. -For detailed API documentation, refer to the [Foxbit API Documentation](https://docs.foxbit.com.br/rest/v3/). +See the [Foxbit API documentation](https://docs.foxbit.com.br/rest/v3/) for +details. diff --git a/rest-v3/typescript/examples.ts b/rest-v3/typescript/examples.ts index 9c15242..7d38c09 100644 --- a/rest-v3/typescript/examples.ts +++ b/rest-v3/typescript/examples.ts @@ -1,118 +1,160 @@ -import CryptoJS from 'crypto-js'; -import axios, { AxiosResponse } from 'axios'; +import { createHmac } from 'node:crypto'; -const apiBaseUrl = 'https://api.foxbit.com.br'; +// Foxbit REST API v3 base URL. +const API_URL = 'https://api.foxbit.com.br'; -interface SignReturn { - signature: string; - timestamp: number; -} +// Credentials come from the environment. Fail fast (before any request) with a +// clear message if they are missing. The key and secret are never printed. +const API_KEY = requireEnv('FOXBIT_API_KEY'); +const API_SECRET = requireEnv('FOXBIT_API_SECRET'); -function sign(method: string, path: string, params?: Record, body?: Record): SignReturn { - let queryString = ''; - if (params) { - queryString = Object.keys(params).map((key) => { - return `${key}=${params[key]}`; - }).join('&'); +function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) { + console.error(`Missing required environment variable: ${name}`); + process.exit(1); } + return value; +} - let rawBody = ''; - if (body) { - rawBody = JSON.stringify(body); - } +// Percent-encode a value following RFC 3986: unreserved characters +// (A-Z a-z 0-9 - _ . ~) stay as-is, everything else is percent-encoded and a +// space becomes %20 (never +). encodeURIComponent already does this except for +// ! ' ( ) *, which we encode explicitly to be strict. +function encodeRfc3986(value: string): string { + return encodeURIComponent(value).replace( + /[!'()*]/g, + (char) => '%' + char.charCodeAt(0).toString(16).toUpperCase(), + ); +} - const timestamp = Date.now(); - const preHash = `${timestamp}${method}${path}${queryString}${rawBody}`; - console.debug('PreHash:', preHash); - const signature = CryptoJS.HmacSHA256(preHash, process.env.FOXBIT_API_SECRET!).toString(); - console.debug('Signature:', signature); +// Build a query string from ordered params. The URL uses percent-encoded values; +// the prehash uses the raw (decoded) values. Both are built from the same object, +// so their key order can never diverge. +function toQueryString(params: Record, encode: boolean): string { + return Object.entries(params) + .map(([key, value]) => + encode ? `${encodeRfc3986(key)}=${encodeRfc3986(value)}` : `${key}=${value}`, + ) + .join('&'); +} + +// HMAC-SHA256 (hex) over: timestamp + method + path + decodedQuery + rawBody. +// +// Two signing gotchas, both validated against the live API: +// 1. The query string is signed DECODED (raw values), but sent percent-encoded +// in the URL. Signing the encoded form is rejected. +// 2. The body is signed exactly as the bytes sent on the wire, so it must be +// serialized only once and both signed and sent as the same string. +function sign( + method: string, + path: string, + decodedQuery: string, + rawBody: string, + timestamp: string, +): string { + const preHash = `${timestamp}${method}${path}${decodedQuery}${rawBody}`; + console.log('PreHash:', preHash); + const signature = createHmac('sha256', API_SECRET).update(preHash).digest('hex'); + return signature; +} - return { signature, timestamp }; +interface RequestOptions { + params?: Record; + body?: unknown; + auth?: boolean; } -async function request(method: string, path: string, params?: Record, body?: Record): Promise { - console.debug('--------------------------------------------------'); - console.debug('Requesting:', method, path); - const { signature, timestamp } = sign(method, path, params, body); - const url = `${apiBaseUrl}${path}`; - const headers = { - 'X-FB-ACCESS-KEY': process.env.FOXBIT_API_KEY!, - 'X-FB-ACCESS-TIMESTAMP': timestamp.toString(), - 'X-FB-ACCESS-SIGNATURE': signature, - 'Content-Type': 'application/json', - }; - - try { - const config = { - method, - url, - params, - data: body, - headers: headers, - }; - const response = await axios(config); - return response; - } catch (error: any) { - if (error.response) { - console.error(`HTTP Status Code: ${error.response.status}, Error Response Body:`, error.response.data); - throw error; - } else { - throw error; - } +async function request( + method: string, + path: string, + { params = {}, body, auth = true }: RequestOptions = {}, +): Promise { + const decodedQuery = toQueryString(params, false); + const encodedQuery = toQueryString(params, true); + // Serialize the body a single time; the same string is signed and sent. + const rawBody = body === undefined ? '' : JSON.stringify(body); + + const url = `${API_URL}${path}${encodedQuery ? `?${encodedQuery}` : ''}`; + const headers: Record = { 'Content-Type': 'application/json' }; + + console.log('--------------------------------------------------'); + console.log(`${method} ${path}`); + + if (auth) { + // UNIX timestamp in milliseconds; the same value is signed and sent. + const timestamp = Date.now().toString(); + headers['X-FB-ACCESS-KEY'] = API_KEY; + headers['X-FB-ACCESS-TIMESTAMP'] = timestamp; + headers['X-FB-ACCESS-SIGNATURE'] = sign(method, path, decodedQuery, rawBody, timestamp); + } + + const response = await fetch(url, { + method, + headers, + body: rawBody === '' ? undefined : rawBody, + }); + + const text = await response.text(); + console.log(`Response (${response.status}): ${text}`); + + // Any 2xx is a success (POST /orders answers 201). + if (!response.ok) { + throw new Error(`Request failed: ${method} ${path} -> HTTP ${response.status}`); } + return text ? JSON.parse(text) : null; } function sleep(ms: number): Promise { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } -(async () => { - try { - console.log('FOXBIT_API_KEY:', process.env.FOXBIT_API_KEY); +async function main(): Promise { + const marketSymbol = 'btcbrl'; + + // 1. Authenticated request with no params: fetch the account profile. + await request('GET', '/rest/v3/me'); - // Get the user information - const meResponse = await request('GET', '/rest/v3/me'); - console.log('Response:', meResponse.data); + // 2. Public request (no authentication): read the top of the order book. + const orderbook = await request('GET', `/rest/v3/markets/${marketSymbol}/orderbook`, { + params: { depth: '1' }, + auth: false, + }); - // Get current price - const marketSymbol = 'btcbrl'; - const tickerResponse = await request('GET', `/rest/v3/markets/${marketSymbol}/ticker/24hr`); - const ticker = tickerResponse.data?.data?.[0]; - console.log('Response:', ticker); + // 3. Price = floor(bestBid * 0.5), as an integer. btcbrl has price_increment 1.0, + // so the price must be a whole number. Half of the market price stays inside + // the API price band yet is far too low to ever execute (a hardcoded value + // like "10.0" would be rejected with 422 Price out of range). + const bestBid = Number(orderbook.bids[0][0]); + const price = Math.floor(bestBid * 0.5).toString(); - // Request to create a new order - const lastPrice = Number(ticker.best.bid.price); - const targetPrice = (lastPrice * 0.9).toString(); // Calculate target price: 10% below the best bid price - const order = { + // 4. Create a real LIMIT BUY order and capture its id. + const created = await request('POST', '/rest/v3/orders', { + body: { market_symbol: marketSymbol, side: 'BUY', type: 'LIMIT', - price: targetPrice, + price, quantity: '0.0001', - }; - const orderResponse = await request('POST', '/rest/v3/orders', undefined, order); - console.log('Response:', orderResponse.data); - - await sleep(2000); + }, + }); + const orderId: string = created.id; + + // 5. Give the engine a moment to register the order. + await sleep(2000); + + // 6. List active orders; the order created above should appear. + await request('GET', '/rest/v3/orders', { + params: { market_symbol: marketSymbol, state: 'ACTIVE' }, + }); + + // 7. Cancel the order by id. + await request('PUT', '/rest/v3/orders/cancel', { + body: { type: 'ID', id: orderId }, + }); +} - // Get active orders - const oneHourAgoISO = new Date(Date.now() - 60 * 60 * 1000).toISOString(); - const ordersParam = { - market_symbol: marketSymbol, - state: 'ACTIVE', - start_time: oneHourAgoISO, // Optional: included to test signature behavior with special chars - }; - const ordersResponse = await request('GET', '/rest/v3/orders', ordersParam); - console.log('Response:', ordersResponse.data); - - // Request to cancel the order - const orderToCancel = { - type: 'ID', - id: orderResponse.data.id - }; - const cancelResponse = await request('PUT', '/rest/v3/orders/cancel', undefined, orderToCancel); - console.log('Response:', cancelResponse.data); - } catch (error) { - console.error('Failed to process request.'); - } -})(); +main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/rest-v3/typescript/package-lock.json b/rest-v3/typescript/package-lock.json index 717dff7..2e710ae 100644 --- a/rest-v3/typescript/package-lock.json +++ b/rest-v3/typescript/package-lock.json @@ -1,368 +1,51 @@ { - "name": "typescript", + "name": "foxbit-rest-v3-typescript-example", "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "typescript", + "name": "foxbit-rest-v3-typescript-example", "version": "1.0.0", - "license": "ISC", - "dependencies": { - "@types/crypto-js": "^4.2.2", - "@types/node": "^20.11.19", - "asynckit": "^0.4.0", - "axios": "^1.16.0", - "combined-stream": "^1.0.8", - "crypto-js": "^4.2.0", - "delayed-stream": "^1.0.0", - "follow-redirects": "^1.16.0", - "form-data": "^4.0.6", - "mime-db": "^1.52.0", - "mime-types": "^2.1.35", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/@types/crypto-js": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.2.2.tgz", - "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==" - }, - "node_modules/@types/node": { - "version": "20.11.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.19.tgz", - "integrity": "sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "node_modules/axios": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", - "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.16.0", - "form-data": "^4.0.5", - "https-proxy-agent": "^5.0.1", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/axios/node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/crypto-js": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", - "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" + "devDependencies": { + "@types/node": "22.20.0", + "typescript": "5.9.3" }, "engines": { - "node": ">= 0.4" + "node": ">=18" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, "license": "MIT", "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" + "undici-types": "~6.21.0" } }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" }, "engines": { - "node": ">= 0.6" + "node": ">=14.17" } }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" - }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" } } } diff --git a/rest-v3/typescript/package.json b/rest-v3/typescript/package.json index e7148d7..d2c3dde 100644 --- a/rest-v3/typescript/package.json +++ b/rest-v3/typescript/package.json @@ -1,25 +1,19 @@ { - "name": "typescript", + "name": "foxbit-rest-v3-typescript-example", "version": "1.0.0", - "description": "", - "main": "index.js", - "dependencies": { - "@types/crypto-js": "^4.2.2", - "@types/node": "^20.11.19", - "asynckit": "^0.4.0", - "axios": "^1.16.0", - "combined-stream": "^1.0.8", - "crypto-js": "^4.2.0", - "delayed-stream": "^1.0.0", - "follow-redirects": "^1.16.0", - "form-data": "^4.0.6", - "mime-db": "^1.52.0", - "mime-types": "^2.1.35", - "proxy-from-env": "^1.1.0" + "description": "Foxbit REST API v3 integration example in TypeScript.", + "type": "module", + "main": "dist/examples.js", + "engines": { + "node": ">=18" }, "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "build": "tsc", + "start": "node dist/examples.js" }, - "author": "", - "license": "ISC" + "license": "MIT", + "devDependencies": { + "@types/node": "22.20.0", + "typescript": "5.9.3" + } } diff --git a/rest-v3/typescript/tsconfig.json b/rest-v3/typescript/tsconfig.json index e075f97..d288c87 100644 --- a/rest-v3/typescript/tsconfig.json +++ b/rest-v3/typescript/tsconfig.json @@ -1,109 +1,15 @@ { "compilerOptions": { - /* Visit https://aka.ms/tsconfig to read more about this file */ - - /* Projects */ - // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ - // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ - // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ - // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ - // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ - // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ - - /* Language and Environment */ - "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ - // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ - // "jsx": "preserve", /* Specify what JSX code is generated. */ - // "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */ - // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ - // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ - // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ - // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ - // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ - // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ - // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ - // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ - - /* Modules */ - "module": "commonjs", /* Specify what module code is generated. */ - // "rootDir": "./", /* Specify the root folder within your source files. */ - // "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ - // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ - // "types": [], /* Specify type package names to be included without being referenced in a source file. */ - // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ - // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ - // "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */ - // "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */ - // "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */ - // "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */ - // "resolveJsonModule": true, /* Enable importing .json files. */ - // "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */ - // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ - - /* JavaScript Support */ - // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ - // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ - // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ - - /* Emit */ - // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ - // "declarationMap": true, /* Create sourcemaps for d.ts files. */ - // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ - // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ - // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ - // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ - // "outDir": "./", /* Specify an output folder for all emitted files. */ - // "removeComments": true, /* Disable emitting comments. */ - // "noEmit": true, /* Disable emitting files from a compilation. */ - // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ - // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ - // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ - // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ - // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ - // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ - // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ - // "newLine": "crlf", /* Set the newline character for emitting files. */ - // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ - // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ - // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ - // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ - // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ - // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ - - /* Interop Constraints */ - // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ - // "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */ - // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ - "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ - // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ - "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ - - /* Type Checking */ - "strict": true, /* Enable all strict type-checking options. */ - // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ - // "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ - // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ - // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ - // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ - // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ - // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ - // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ - // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ - // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ - // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ - // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ - // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ - // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ - // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ - // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ - // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ - // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ - - /* Completeness */ - // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ - } + "target": "es2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "outDir": "dist", + "rootDir": ".", + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["examples.ts"] }