Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"dockerComposeFile": "./docker-compose.yml",
"service": "cex-broker",
"workspaceFolder": "/home/vscode/cex-broker",
"initializeCommand": ".devcontainer/init.sh",
"mounts": [
// Mount the SSH key for the local machine to make git fetch/push work in the container
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached,readonly"
Expand Down
2 changes: 2 additions & 0 deletions .devcontainer/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ services:
dockerfile: Dockerfile
container_name: cex-broker-dev
volumes:
# This is a workaround to get the main repo path from the .git file when in a git worktree.
- ${MAIN_REPO:-/tmp/fake-path}:${MAIN_REPO:-/tmp/fake-path}
- ../:/home/vscode/cex-broker:cached
command: sleep infinity
networks:
Expand Down
10 changes: 10 additions & 0 deletions .devcontainer/init.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/bin/sh

# Get the main repo path from the .git file when in a git worktree.
if [ -e .git ] && [ -f .git ]; then
MAIN_REPO=$(sed -E 's|^gitdir:\s*(.*/\.git)/.*|\1|' .git)
echo "MAIN_REPO=$MAIN_REPO" > .devcontainer/.env
else
rm -f .devcontainer/.env
fi

2 changes: 1 addition & 1 deletion .sandbox/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ RUN apt-get update -y \
&& apt-get install -y --no-install-recommends ca-certificates curl \
&& rm -rf /var/lib/apt/lists/*

RUN bun install --global @usherlabs/cex-broker@0.1.21
RUN bun install --global @usherlabs/cex-broker@0.2.0

COPY --chmod=0755 ./.sandbox/entrypoint.sh /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
Expand Down
289 changes: 289 additions & 0 deletions POLICY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
## Policy configuration (`policy.json`)

This broker uses a JSON policy file to restrict:

- **Withdrawals**: permitted exchanges, networks, and destination address whitelist
- **Orders**: permitted exchanges/pairs, plus optional directional conversion limits

The policy is loaded at startup. If you start the broker with a **policy file path**, the file is watched and policies are reloaded when the file changes.

## Quick start

- **Example policy**: `policy/policy.json`
- **CLI usage**:

```bash
bun run start-broker --policy policy/policy.json --port 8086 --whitelist 127.0.0.1
```

## File shape

Top-level keys (all required):

| Key | Type | Description |
|-----|------|-------------|
| `withdraw` | `{ rule: WithdrawRuleEntry[] }` | Withdrawal restrictions (at least one rule entry required) |
| `deposit` | `{}` | Placeholder object — not enforced today |
| `order` | `{ rule: OrderRule }` | Order/conversion restrictions |

Canonical type: `PolicyConfig` in `src/types.ts`.

```json
{
"withdraw": {
"rule": [
{ "exchange": "BINANCE", "network": "ARBITRUM", "whitelist": [] }
]
},
"deposit": {},
"order": { "rule": { "markets": [], "limits": [] } }
}
```

## Reloading policies (hot reload)

When started with a policy file path:

- the policy file is watched for changes
- when changed, the broker reloads the policy and **restarts the gRPC server** to apply it

Operational note: this restart may briefly interrupt in-flight requests.

---

## Withdraw policy (`withdraw.rule`)

Withdraw requests are rejected unless all of the below pass.

### `withdraw.rule: WithdrawRuleEntry[]`

**Required.** Must contain at least one entry.

Each entry scopes a set of withdrawal permissions to an `exchange` + `network` combination. When a withdraw request arrives, the broker finds the highest-priority matching rule entry and validates the destination address against that entry.

#### Rule matching priority

When multiple entries match a request, the broker selects the single highest-priority entry:

| Priority | `exchange` | `network` | Description |
|----------|-----------|-----------|-------------|
| 4 (highest) | exact match | exact match | Fully specific rule |
| 3 | exact match | `"*"` | Exchange-specific, any network |
| 2 | `"*"` | exact match | Network-specific, any exchange |
| 1 (lowest) | `"*"` | `"*"` | Global catch-all |

If no entry matches, the request is rejected.

---

### `withdraw.rule[].exchange`

| | |
|---|---|
| **Type** | `string` |
| **Required** | Yes |
| **Normalisation** | Trimmed, uppercased |

Accepted values:

- **An exchange identifier** — e.g. `"BINANCE"`, `"KRAKEN"`, `"BYBIT"`. Must correspond to a supported CCXT exchange.
- **`"*"`** — wildcard; matches any exchange.

---

### `withdraw.rule[].network`

| | |
|---|---|
| **Type** | `string` |
| **Required** | Yes |
| **Normalisation** | Trimmed, uppercased |

Accepted values:

- **A network/chain identifier** — e.g. `"ARBITRUM"`, `"BEP20"`, `"ETH"`, `"SOL"`. The value must match what the exchange uses for that chain.
- **`"*"`** — wildcard; matches any network.

Even if the policy allows a network, the selected exchange must also support that network for the currency or the request will still fail at execution time.

---

### `withdraw.rule[].whitelist`

| | |
|---|---|
| **Type** | `string[]` |
| **Required** | Yes (may be empty, but that would reject all addresses) |
| **Normalisation** | Each entry is trimmed and lowercased |

Accepted values:

- An array of destination addresses (e.g. `"0x9d467fa9062b6e9b1a46e26007ad82db116c67cb"`).
- Matching is **exact** after both the policy value and the incoming address are lowercased.
- Recommendation: store all addresses in lowercase in the JSON for readability and diffs.

---

### Full withdraw example

```json
{
"withdraw": {
"rule": [
{
"exchange": "BINANCE",
"network": "ARBITRUM",
"whitelist": ["0x9d467fa9062b6e9b1a46e26007ad82db116c67cb"]
},
{
"exchange": "BINANCE",
"network": "*",
"whitelist": ["0x9d467fa9062b6e9b1a46e26007ad82db116c67cb"]
},
{
"exchange": "*",
"network": "BEP20",
"whitelist": ["0x9d467fa9062b6e9b1a46e26007ad82db116c67cb"]
},
{
"exchange": "*",
"network": "*",
"whitelist": ["0x9d467fa9062b6e9b1a46e26007ad82db116c67cb"]
}
]
}
}
```

In this example, a BINANCE + ARBITRUM withdraw uses the first rule (priority 4). A BINANCE + SOL withdraw falls to the second rule (priority 3). A KRAKEN + BEP20 withdraw uses the third rule (priority 2). Everything else hits the global catch-all (priority 1).

Common rejection reasons:

- no matching exchange + network entry
- address not whitelisted

---

## Order policy (`order.rule`)

Order creation is rejected unless all of the below pass:

1. **Market rule match**: `markets` contains at least one pattern that matches the requested exchange + pair
2. **Exchange symbol support**: the exchange supports either `FROM/TO` or `TO/FROM`
3. **Limits (optional)**: if `limits` is present and non-empty, the requested conversion direction exists and the requested amount is within min/max

---

### `order.rule.markets`

| | |
|---|---|
| **Type** | `string[]` |
| **Required** | Yes |
| **Normalisation** | Each entry is trimmed, uppercased |

Market pattern formats:

| Pattern | Example | Matches |
|---------|---------|---------|
| `"*"` | `"*"` | Any exchange, any pair |
| `"<EXCHANGE>:*"` | `"BINANCE:*"` | Any pair on the specified exchange |
| `"*:<BASE>/<QUOTE>"` | `"*:BTC/ETH"` | The specified pair on any exchange |
| `"<EXCHANGE>:<BASE>/<QUOTE>"` | `"BINANCE:ETH/USDT"` | The specified pair on the specified exchange |

Matching behaviour:

- **Case-insensitive** (both the pattern and the request are uppercased before comparison).
- Pair matching is **symmetric**: `BINANCE:BTC/ETH` matches requests for both `BTC→ETH` and `ETH→BTC`.
- Any pattern that does not contain a `:` separator (other than the bare `"*"`) is ignored.

Examples:

```json
{ "markets": ["*"] }
```

```json
{ "markets": ["BINANCE:*", "KRAKEN:ETH/USDT"] }
```

```json
{ "markets": ["*:BTC/ETH"] }
```

---

### `order.rule.limits`

| | |
|---|---|
| **Type** | `Array<{ from: string, to: string, min: number, max: number }>` |
| **Required** | No (defaults to `[]` if omitted) |

Optional directional conversion limits.

Each object in the array:

| Field | Type | Required | Normalisation | Description |
|-------|------|----------|---------------|-------------|
| `from` | `string` | Yes | Uppercased | Source token symbol, e.g. `"USDT"` |
| `to` | `string` | Yes | Uppercased | Destination token symbol, e.g. `"ETH"` |
| `min` | `number` | Yes | — | Inclusive lower bound on the `from` amount |
| `max` | `number` | Yes | — | Inclusive upper bound on the `from` amount |

Key behaviour:

- **If omitted or empty**, no amount or direction restrictions are applied — any matched market is allowed.
- **If non-empty**, each order must match a `from`/`to` entry for the requested direction. Unmatched directions are rejected.
- **Limits are directional**: `USDT→ETH` and `ETH→USDT` are separate entries. You must add both if you want to allow both directions.
- Limits apply to the request's **from amount** (the amount of `fromToken` the caller is converting), even if the broker flips the symbol direction for execution.
- If the exchange only supports the reverse symbol, the broker computes a base amount via `amount / price`; this requires `price > 0`.

Example:

```json
{
"order": {
"rule": {
"markets": ["BINANCE:*"],
"limits": [
{ "from": "USDT", "to": "ETH", "min": 1, "max": 100000 },
{ "from": "ETH", "to": "USDT", "min": 0.5, "max": 5 }
]
}
}
}
```

---

## Deposit policy (`deposit`)

| | |
|---|---|
| **Type** | `{}` (empty object) |
| **Required** | Yes |

`deposit` is required in the current policy schema, but it is **not enforced** today.

At present, deposits are effectively always allowed; this field is reserved for future deposit rule support. Set it to `{}`.

---

## Validation & error handling

### Schema validation

The broker validates the policy JSON against a Joi schema when loading it.

- All top-level keys (`withdraw`, `deposit`, `order`) must be present.
- `withdraw.rule` must be a non-empty array (at least one entry).
- Every required field must be present and of the correct type.
- If validation fails, the policy load fails and the broker will not start (or will log an error on reload).

### Troubleshooting tips

- **Withdraw address rejected**: ensure the address is in the matching `withdraw.rule[].whitelist` entry (lowercase recommended).
- **Withdraw exchange/network rejected**: ensure there is a `withdraw.rule[]` entry whose `exchange` and `network` match (or wildcard-match) the request.
- **Order rejected (market)**: ensure `order.rule.markets` contains a matching pattern for the exchange + pair.
- **Order rejected (limits)**: if `limits` is non-empty, ensure there's an entry for the exact `from` → `to` direction and the amount is within bounds.
Loading