diff --git a/.env.sample b/.env.sample index 3244daa..27a320a 100644 --- a/.env.sample +++ b/.env.sample @@ -1,5 +1,5 @@ -# Required for CLI and Optional for the Broker Library +# Required for CLI and broker CEX_BROKER_BYBIT_API_KEY=*********************** CEX_BROKER_BINANCE_API_KEY=**************************************** @@ -8,12 +8,39 @@ CEX_BROKER_BINANCE_API_SECRET=************************************************** LOG_LEVEL=debug CEX_BROKER_SANDBOX_MODE=true -# OpenTelemetry Configuration -# Use OTEL_EXPORTER_OTLP_ENDPOINT for the full OTLP endpoint (preferred) +# OpenTelemetry (optional) OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 OTEL_SERVICE_NAME=cex-broker # Or use legacy host/port configuration # CEX_BROKER_OTEL_HOST=otel-collector # CEX_BROKER_OTEL_PORT=4318 -# CEX_BROKER_OTEL_PROTOCOL=http \ No newline at end of file +# CEX_BROKER_OTEL_PROTOCOL=http + +# Travel-rule deposit auto-clear reconciler (only used when a policy has +# travelRule.rule[].deposits.enabled). One RPC URL per Binance network code +# (suffix must match the deposit's `network`, e.g. ARBITRUM), used to prove a +# frozen deposit's on-chain sender before auto-submitting its questionnaire. A +# frozen deposit on a network with no RPC configured is left frozen (fail-closed). +# These are intentionally NOT CEX_BROKER_-prefixed so the credential scan skips them. +# TRAVEL_RULE_RPC_URL_ARBITRUM=https://arb1.arbitrum.io/rpc +# Optional overrides (defaults shown): +# TRAVEL_RULE_DEPOSIT_POLL_ACTIVE_SECS=60 +# TRAVEL_RULE_DEPOSIT_POLL_IDLE_SECS=600 +# TRAVEL_RULE_QUESTIONNAIRE_COUNTRY=AU + +# ClickHouse research / market data archive (optional) +# Broker → forwarder +CEX_BROKER_ARCHIVE_ENABLED=false +CEX_BROKER_ARCHIVE_FORWARDER_URL=http://localhost:8090/archive +# When enabled, this must point to persistent writable storage. A path only in +# the container filesystem is not durable across container replacement. +CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH=./archive-loss.jsonl +CEX_BROKER_DEPLOYMENT_ID=local-dev +# OHLCV collector: required JSON array config and reconnect bootstrap coverage. +# 1000 one-minute bars cover roughly 16 hours on every (re)subscription. +CEX_BROKER_OHLCV_ARCHIVE_BOOTSTRAP_LIMIT=1000 +CEX_BROKER_OHLCV_COLLECTOR_CONFIG=./ohlcv-subscriptions.json +# ClickHouse (forwarder, candle-viewer, Python) +CLICKHOUSE_HOST=localhost +CLICKHOUSE_PORT=8123 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcf8ee4..3cc8171 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,9 @@ name: CI on: push: - branches: [master] + branches: [master, develop] pull_request: - branches: [master] + branches: [master, develop] jobs: test: @@ -31,4 +31,10 @@ jobs: run: bunx @biomejs/biome lint . - name: Run tests - run: bun test \ No newline at end of file + run: bun test + + # Gate PRs on the same strict build the publish workflow runs on tag push + # (dts-bundle-generator + strict tsc). Without this, develop can merge code + # that only fails at publish time, blocking releases. + - name: Build project + run: bun run build diff --git a/.github/workflows/publish-archive-forwarder.yml b/.github/workflows/publish-archive-forwarder.yml new file mode 100644 index 0000000..2849fdc --- /dev/null +++ b/.github/workflows/publish-archive-forwarder.yml @@ -0,0 +1,57 @@ +# Publishes the archive-forwarder image (services/archive-forwarder). +# The forwarder is repo-runtime only — it is NOT part of the npm package +# (files: ["dist"]), so it ships as its own container image. +# +# Triggers: semver tags (alongside the broker publish) and manual dispatch +# (to publish from a branch without cutting an npm release). + +name: Publish Archive Forwarder + +on: + push: + tags: + - 'v[0-9]*.[0-9]*.[0-9]*' + - 'v[0-9]*.[0-9]*.[0-9]*-*' + workflow_dispatch: + +env: + IMAGE_NAME: ghcr.io/usherlabs/cex-broker-archive-forwarder + +permissions: + contents: read + packages: write + +jobs: + publish-docker: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=match,pattern=v(\d+\.\d+\.\d+.*),group=1 + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: services/archive-forwarder/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} diff --git a/.github/workflows/publish-ohlcv-collector.yml b/.github/workflows/publish-ohlcv-collector.yml new file mode 100644 index 0000000..72112b6 --- /dev/null +++ b/.github/workflows/publish-ohlcv-collector.yml @@ -0,0 +1,57 @@ +# Publishes the OHLCV collector image (services/ohlcv-collector). +# The collector is repo-runtime only — it is NOT part of the npm package +# (files: ["dist"]), so it ships as its own container image. +# +# Triggers: semver tags (alongside the broker publish) and manual dispatch +# (to publish from a branch without cutting an npm release). + +name: Publish OHLCV Collector + +on: + push: + tags: + - 'v[0-9]*.[0-9]*.[0-9]*' + - 'v[0-9]*.[0-9]*.[0-9]*-*' + workflow_dispatch: + +env: + IMAGE_NAME: ghcr.io/usherlabs/cex-broker-ohlcv-collector + +permissions: + contents: read + packages: write + +jobs: + publish-ohlcv-collector: + runs-on: ubuntu-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.IMAGE_NAME }} + tags: | + type=match,pattern=v(\d+\.\d+\.\d+.*),group=1 + type=sha + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: services/ohlcv-collector/Dockerfile + push: true + tags: ${{ steps.meta.outputs.tags }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 2a0dac5..2e7d1ac 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,6 +7,10 @@ on: tags: - 'v[0-9]*.[0-9]*.[0-9]*' # Matches semver tags in the format of v1.2.3 - 'v[0-9]*.[0-9]*.[0-9]*-*' # Matches semver tags in the format of v1.2.3-beta + # Recovery path: republish from a branch when a tag's run failed for + # workflow-only reasons (a rerun executes the workflow file at the tag's + # commit, so a fixed workflow can never rerun under the original tag). + workflow_dispatch: env: IMAGE_NAME: ghcr.io/usherlabs/cex-broker @@ -32,12 +36,17 @@ jobs: - name: Setup Node.js for npm publishing uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 24 check-latest: true registry-url: "https://registry.npmjs.org" - - name: Ensure latest npm - run: npm install -g npm@latest + # This package publishes via npm trusted publishing (OIDC, id-token + # permission) — no NPM_TOKEN secret exists. OIDC needs npm >= 11.5.1, + # newer than any runner-bundled npm; without it the placeholder token is + # sent and the registry PUT 404s. Pinned to a major because npm@latest + # broke every tag run when npm 12 dropped the then-pinned Node 20. + - name: Install npm with trusted-publishing support + run: npm install -g npm@12 - name: Install dependencies run: bun install @@ -74,14 +83,13 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.IMAGE_NAME }} - tags: | - type=match,pattern=v(\d+\.\d+\.\d+.*),group=1 - + # The release convention tags the version-bump commit, so package.json + # is the version authority on both tag pushes and dispatch republishes + # (a git-tag-derived name is empty on workflow_dispatch). + - name: Read package version + id: version + run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT" + - name: Build and push Docker image uses: docker/build-push-action@v6 with: @@ -89,5 +97,5 @@ jobs: file: Dockerfile push: true tags: | - ${{ env.IMAGE_NAME }}:${{ fromJSON(steps.meta.outputs.json).tag-names[0] }} + ${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} ${{ env.IMAGE_NAME }}:latest diff --git a/.gitignore b/.gitignore index a4df0bf..332a746 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,12 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .cache *.tsbuildinfo +# research outputs +research/python/examples/output/ +research/output/ +__pycache__ +*.egg-info + # IntelliJ based IDEs .idea @@ -36,5 +42,10 @@ build /proto/** src/proto/*.ts src/proto/**/*.ts +!src/proto/node.descriptor.ts + +src/assets/proto/** -src/assets/proto/** \ No newline at end of file +# Worktrees, Agents +.codex +.emdash diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..44e0cbd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +## Agent Orchestrator (ao) Session + +You are running inside an Agent Orchestrator managed workspace. + +## Source layout (cex-broker) + +- `src/server.ts` — gRPC registration and handler wiring only; do not add domain logic here. +- `src/handlers/` — RPC dispatch (`execute-action/`, `subscribe/`). +- `src/helpers/` — domain and shared utilities (`shared/`, `grpc/`, `order-book.ts`, etc.). +- Dependency direction: `server` → `handlers` → `helpers`. Helpers must not import from `server` or `handlers`. +- Import concrete helper modules (e.g. `helpers/deposit`); avoid growing `helpers/index.ts` with server utilities. +Session metadata is updated automatically via shell wrappers. + +If automatic updates fail, you can manually update metadata: +```bash +~/.ao/bin/ao-metadata-helper.sh # sourced automatically +# Then call: update_ao_metadata +``` diff --git a/Dockerfile b/Dockerfile index c6c4f9f..2aaca42 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,13 @@ 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.2.6 +COPY package.json bun.lock ./ +COPY patches ./patches +RUN bun install --frozen-lockfile -CMD ["cex-broker"] +COPY build.ts proto-gen.sh tsconfig.json ./ +COPY scripts ./scripts +COPY src ./src +RUN bun run build + +CMD ["bun", "./dist/commands/cli.js"] diff --git a/POLICY.md b/POLICY.md index d7b7a21..8da7642 100644 --- a/POLICY.md +++ b/POLICY.md @@ -110,7 +110,17 @@ 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. +The broker normalizes common operator aliases before matching policy: + +| Operator alias | Broker network id | +|----------------|-------------------| +| `ARB`, `ARBITRUM` | `ARBITRUM` | +| `ETH`, `ERC20`, `ETHEREUM` | `ETHEREUM` | +| `BNB`, `BSC`, `BEP20` | `BNB` | + +Even if the policy allows a normalized network, the selected exchange must also +support that network for the currency or the request will still fail at +execution time. --- @@ -191,6 +201,46 @@ Common rejection reasons: - address not whitelisted - token not in `coins` for the matched rule +### Narrow Binance/MEXC USDC BEP20 corridor example + +Use a dedicated policy for treasury corridors instead of relying on broad +exchange/network rules. The example +`policy/policy.binance-mexc-usdc-bep20.example.json` permits only USDC over the +normalized `BNB` network family (`BNB`, `BSC`, or `BEP20`) between Binance and +MEXC: + +```json +{ + "withdraw": { + "rule": [ + { + "exchange": "BINANCE", + "network": "BEP20", + "coins": ["USDC"], + "whitelist": ["0x1111111111111111111111111111111111111111"] + }, + { + "exchange": "MEXC", + "network": "BEP20", + "coins": ["USDC"], + "whitelist": ["0x2222222222222222222222222222222222222222"] + } + ] + }, + "deposit": { + "rule": [ + { "exchange": "MEXC", "network": "BEP20", "coins": ["USDC"] }, + { "exchange": "BINANCE", "network": "BEP20", "coins": ["USDC"] } + ] + } +} +``` + +This policy does not authorize volatile inventory transfer. If a treasury +ceremony chooses remote volatile acquisition, that acquired-asset transfer must +be explicitly requested, live-discovered, policy-approved, cost-gated, and +attested by the caller. + --- ## Order policy (`order.rule`) diff --git a/README.md b/README.md index 6dd41d0..940a4a4 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,102 @@ OTEL_SERVICE_NAME=cex-broker **Note**: Only configure API keys for exchanges you plan to use. The system will automatically detect and initialize configured exchanges. +### Research / Backtest (ClickHouse Path B) + +Archive subscribe streams (OHLCV, orderbook, trades, ticker) to ClickHouse via the **archive forwarder**, visualize candles in the browser, run Python backtests, and optionally feed Hummingbot from the same warehouse. + +- **Overview:** [research/README.md](research/README.md) +- **Full guide:** [docs/research-backtest.md](docs/research-backtest.md) + +Quick start: + +```bash +docker network create fiet-sandbox || true +docker compose -f docker/clickhouse-research.compose.yml up -d +bun run start-archive-forwarder # if not using compose forwarder service +SYMBOLS=BTC/USDT,BNB/USDT,DOGE/USDT bun run start-archive-watch +CLICKHOUSE_PORT=8123 bun run start-candle-viewer # http://localhost:8091 +``` + +Dev watchers: `dev:candle-viewer`, `dev:archive-forwarder`, `dev:archive-watch` (see [research/README.md](research/README.md)). + +Key env vars: `CEX_BROKER_ARCHIVE_ENABLED=true`, `CEX_BROKER_ARCHIVE_FORWARDER_URL`, `CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH`, and `CEX_BROKER_DEPLOYMENT_ID`. The archive is disabled for every enable value except the exact string `true`. Production durability requires the dead-letter file to reside on persistent writable storage or a mounted volume; a container-local ephemeral path is not durable. + +#### Wallet-authenticated exchanges + +Some exchanges (for example Hyperliquid, Vertex, Paradex, and Derive) authenticate with an on-chain wallet instead of exchange-issued API keys. The broker keeps the same `API_KEY` / `API_SECRET` interface and maps credentials internally based on each exchange's CCXT `requiredCredentials`: + +- `CEX_BROKER__API_KEY` → wallet address (`0x…`) +- `CEX_BROKER__API_SECRET` → private key (`0x…` hex) + +Example: + +```env +CEX_BROKER_HYPERLIQUID_API_KEY=0x1234567890abcdef1234567890abcdef12345678 +CEX_BROKER_HYPERLIQUID_API_SECRET=0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890 +``` + +gRPC metadata uses the same parity interface: `api-key` carries the wallet address and `api-secret` carries the private key. + +Detection is automatic from CCXT `requiredCredentials`. A `dex: true` flag does not imply wallet auth; exchanges such as WOOFi Pro and Modetrade still use API keys. + +Treat `API_SECRET` values for wallet exchanges as signing keys with the same operational security as API secrets. + +#### Spot vs perp (`marketType`) + +The broker **defaults to spot everywhere** unless a request explicitly opts into perps/futures. This overrides exchange-level CCXT defaults (for example Hyperliquid's internal `defaultType: swap`). + +Pass `marketType` in action payloads (string map) or subscribe `options`: + +| `marketType` | Meaning | +|--------------|---------| +| omitted / `spot` | Spot markets and spot balances | +| `swap` or `perp` | Perpetuals (resolves symbols like `ETH/USDC:USDC` on Hyperliquid) | +| `future` | Dated futures where supported | + +Examples: + +```json +// CreateOrder payload +{ + "fromToken": "ETH", + "toToken": "USDC", + "amount": "1", + "price": "2500", + "marketType": "swap", + "params": { "slippage": "0.05" } +} +``` + +```json +// FetchBalances payload +{ "marketType": "swap", "balanceType": "total" } +``` + +Policy markets support optional suffixes: + +- `HYPERLIQUID:ETH/USDC@swap` — perp only +- `HYPERLIQUID:ETH/USDC@spot` — spot only +- `HYPERLIQUID:ETH/USDC:USDC` — explicit unified perp symbol +- `BINANCEUSDM:ETH/USDT` — use the futures exchange id directly + +For split futures exchanges (`binanceusdm`, `krakenfutures`, `kucoinfutures`), register the futures `cex` id separately. Fund movement between spot and futures wallets uses `Action.Call` or exchange-specific transfer actions. + +#### Perp configuration actions + +Two capability-gated actions complement `Action.Call`: + +| Action | Value | Requires CCXT | Purpose | +|--------|-------|---------------|---------| +| `GetPerpConfigState` | `14` | `fetchPositions` | Read positions and per-symbol leverage/margin mode | +| `SetPerpConfigState` | `15` | `setLeverage` | Set leverage (and margin mode) for a symbol | + +`GetPerpConfigState` payload: optional `symbol`, optional `params` (JSON). + +`SetPerpConfigState` payload: `symbol`, `leverage`, optional `marginMode` (`cross` | `isolated`), optional `params`. + +Exchanges without the required capability return gRPC `UNIMPLEMENTED`. Use `Action.Call` for other perp operations (`transfer`, `addMargin`, `closePosition`, etc.). + **Metrics (OpenTelemetry)**: Metrics are exported via OTLP. If neither `OTEL_EXPORTER_OTLP_ENDPOINT` nor `CEX_BROKER_OTEL_HOST` (or legacy `CEX_BROKER_CLICKHOUSE_HOST`) is set, metrics are disabled. When enabled, the broker sends metrics to the configured OTLP endpoint (e.g. an OpenTelemetry Collector). ### Policy Configuration @@ -86,6 +182,10 @@ Configure trading policies in `policy/policy.json`. - **Full reference**: see `POLICY.md` (supported options, matching rules, reload behaviour, and troubleshooting) - **Example policy**: `policy/policy.json` +- **Treasury corridor example**: `policy/policy.binance-mexc-usdc-bep20.example.json` + permits only USDC over the normalized BNB/BSC/BEP20 network between Binance + and MEXC; use explicit ceremony-time overrides for non-default acquired-asset + transfers. ```json { @@ -220,6 +320,78 @@ message ActionResponse { - `FetchCurrency` (9): Get currency metadata (networks, fees, etc.) for a symbol - `Call` (10): Generic method invocation on the underlying broker instance. Provide `functionName`, optional `args` array, and optional `params` object. +#### Order Book Call Methods + +`Call` also supports broker-defined order-book methods for HB strategy compatibility. These methods use the `method` payload field and return JSON in `ActionResponse.result`. + +```typescript +// Discover order-book capability +const capabilityRequest = { + action: 10, // Call + cex: "mexc", + symbol: "ARB/USDT", + payload: { + method: "fetch_order_book_capability", + depthLimit: "100", + constructionMode: "sampled_top_n_snapshot" + } +}; + +// Fetch current top-N order-book snapshot +const snapshotRequest = { + action: 10, // Call + cex: "binance", + symbol: "BTC/USDT", + payload: { + method: "fetch_order_book_snapshot", + depthLimit: "100" + } +}; + +// Request historical sampled snapshots +const historicalRequest = { + action: 10, // Call + cex: "mexc", + symbol: "ARB/USDT", + payload: { + method: "fetch_historical_order_book_snapshots", + start: "2026-06-02T00:00:00Z", + end: "2026-06-02T00:01:00Z", + cadence: "1s", + depthLimit: "100", + constructionMode: "sampled_top_n_snapshot" + } +}; +``` + +Current snapshot responses include top-level `bids` and `asks` arrays plus metadata: + +```json +{ + "bids": [[100.0, 1.0]], + "asks": [[101.0, 2.0]], + "timestamp": 1760000000000, + "receivedTimestamp": 1760000000100, + "exchange": "binance", + "symbol": "BTC/USDT", + "sequence": 123, + "depthLimit": 100 +} +``` + +If historical sampled top-N depth is unavailable, the broker returns a typed unsupported result instead of a gRPC transport failure: + +```json +{ + "exchange": "mexc", + "symbol": "ARB/USDT", + "unsupported": true, + "unsupportedReason": "historical_order_book_provider_unsupported" +} +``` + +Capability responses are conservative: current snapshot and live stream support reflect available broker/provider methods, historical sampled top-N support is only true when implemented for the requested parameters, and exact L2 reconstruction remains false until a validated snapshot-plus-delta reconstruction path exists. + **Example Usage:** ```typescript @@ -302,12 +474,17 @@ message SubscribeResponse { ``` **Available Subscription Types:** -- `ORDERBOOK` (0): Real-time order book updates -- `TRADES` (1): Live trade feed -- `TICKER` (2): Ticker information updates -- `OHLCV` (3): Candlestick data (configurable timeframe) -- `BALANCE` (4): Account balance updates -- `ORDERS` (5): Order status updates +- `NO_ACTION` (0): Compatibility default; resolved to `ORDERBOOK` +- `ORDERBOOK` (1): Real-time order book updates +- `TRADES` (2): Live trade feed +- `TICKER` (3): Ticker information updates +- `OHLCV` (4): Candlestick data (configurable timeframe) +- `BALANCE` (5): Account balance updates +- `ORDERS` (6): Order status updates + +For backward compatibility, omitted, `NO_ACTION`, or invalid subscription type values are resolved to `ORDERBOOK`. + +For Binance spot account streams, `BALANCE` and `ORDERS` use Binance's WebSocket API user-data subscription (`userDataStream.subscribe.signature`). They use the broker account selected by request metadata and do not rely on the retired Spot listenKey REST lifecycle. **Example Usage:** @@ -316,15 +493,17 @@ message SubscribeResponse { const orderbookRequest = { cex: "binance", symbol: "BTC/USDT", - type: 0, // ORDERBOOK - options: {} + type: 1, // ORDERBOOK + options: { + depthLimit: "100" + } }; // Subscribe to OHLCV with custom timeframe const ohlcvRequest = { cex: "binance", symbol: "BTC/USDT", - type: 3, // OHLCV + type: 4, // OHLCV options: { timeframe: "1h" } @@ -521,11 +700,43 @@ The following metrics are exported as OTLP counters and histograms: - `execute_action_success_total` (counter): Successful ExecuteAction requests - `execute_action_errors_total` (counter): Failed ExecuteAction requests - `execute_action_duration_ms` (histogram): ExecuteAction latency +- `cex_market_action_executions_total` (counter): CreateOrder/GetOrderDetails execution telemetry events, tagged by action, exchange, account label, symbol, side, order type, status, and result +- `cex_market_action_requested_quantity` (histogram): Requested base quantity when known +- `cex_market_action_requested_notional` (histogram): Requested notional from payload amount * price when known +- `cex_market_action_executed_base_quantity` (histogram): Executed base quantity reported by the exchange +- `cex_market_action_executed_quote_quantity` (histogram): Executed quote quantity/cost reported by the exchange +- `cex_market_action_average_execution_price` (histogram): Exchange-reported or derived average execution price +- `cex_market_action_filled_amount` (histogram): Filled amount reported by the exchange +- `cex_market_action_remaining_amount` (histogram): Remaining amount reported by the exchange +- `cex_market_action_fee_amount` (histogram): Fee amount when provided by the exchange +- `cex_market_action_fee_rate` (histogram): Fee rate when provided by the exchange - `subscribe_requests_total` (counter): Total Subscribe requests - `subscribe_errors_total` (counter): Failed Subscribe requests - `subscribe_duration_ms` (histogram): Subscribe stream duration -All metrics include attributes: `action`, `cex`, `symbol`, `error_type`, `service`. +General request metrics include attributes such as `action`, `cex`, `symbol`, `error_type`, and `service`. Market-action execution metrics intentionally use low-cardinality attributes only: `action`, `cex`, `account`, `symbol`, `side`, `order_type`, `status`, `result`, and `service`. + +### Market Action Accounting Telemetry + +Every successful `CreateOrder` response, successful `GetOrderDetails` response, rejected order response, and failed create-order attempt emits a structured log event named `cex_market_action_execution`. The event includes the low-cardinality metric attributes above plus join identifiers and accounting values: + +- Join identifiers: `orderId`, `clientOrderId`, `idempotencyId`, `makerActionId` +- Execution values: requested quantity/notional, executed base quantity, executed quote quantity/cost, average execution price, filled amount, remaining amount, fee amount, fee currency, fee rate +- Timing: exchange timestamp when present and broker observed timestamp + +Use metrics for aggregations and alerts. For the durable execution audit trail, the broker archives every order lifecycle event to `broker_execution.order_events` (and pre-order top-of-book to `broker_execution.market_metadata_snapshots`) through the **archive forwarder** — the same HTTP `/archive` → ClickHouse path used for `market_data.*`. Set `CEX_BROKER_ARCHIVE_ENABLED=true`, an explicit HTTP(S) `CEX_BROKER_ARCHIVE_FORWARDER_URL`, and a writable durable JSONL path in `CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH`; startup fails if either required sink configuration is missing or invalid. In production, that path must be on persistent writable storage or a mounted volume rather than the container's ephemeral filesystem. Queue shedding and rows that remain undeliverable during shutdown are written to that loss journal with their original `{table,row}` payload before being discarded. Setting `CEX_BROKER_ARCHIVE_OTEL_LOGS_ENABLED=true` additionally mirrors execution rows to OTel logs for observability, but OTel is never the archive sink of record. Analysts join Maker action rows to `broker_execution.order_events` using `maker_action_id`, `idempotency_id`, `client_order_id`, or the exchange `order_id`, then compare Maker propAMM execution price against `average_execution_price` and fees. Failed CreateOrder rows keep bounded exchange error detail in `error_message`; their telemetry-shaped `payload_json`, metrics, and ordinary telemetry logs remain redacted. The broker does not emit raw exchange payloads, API keys, secrets, or credentials in telemetry fields. + +### Telemetry Test Harness + +Order telemetry tests use `test/order-telemetry-fixtures.ts` to run the real gRPC server with mocked CCXT exchanges. The fixture can simulate create-order responses, order-detail responses, partial fills, rejected orders, failed create-order calls, and fee/no-fee exchange payloads without live credentials. + +Run only the focused telemetry suite: + +```bash +bun test test/order-telemetry.test.ts +``` + +To extend coverage for another exchange response shape, add a mocked CCXT order object to `createOrderExchangeFixture` usage in `test/order-telemetry.test.ts` and assert the captured `CapturingOtelMetrics` calls. ### Setting Up Metrics @@ -555,16 +766,18 @@ cex-broker/ │ ├── client.dev.ts # Development client │ ├── commands/ # CLI commands │ │ └── start-broker.ts # Broker startup command -│ ├── helpers/ # Utility functions -│ │ ├── index.ts # Policy validation helpers -│ │ ├── index.test.ts # Helper tests +│ ├── handlers/ # RPC dispatch (execute-action, subscribe) +│ ├── helpers/ # Domain utilities (shared/, grpc/, order-book, …) +│ │ ├── index.ts # Broker pool and policy helpers +│ │ ├── shared/ # Cross-cutting guards and errors +│ │ ├── grpc/ # Payload validation and status mapping │ │ └── logger.ts # Logging configuration │ ├── index.ts # Main broker class │ ├── proto/ # Generated protobuf types │ │ ├── cex_broker/ # Generated broker types │ │ ├── node.proto # Service definition │ │ └── node.ts # Type exports -│ ├── server.ts # gRPC server implementation +│ ├── server.ts # gRPC wiring only (delegates to handlers/) │ └── types.ts # TypeScript type definitions ├── proto/ # Protocol buffer definitions │ ├── cexBroker/ # Legacy generated types diff --git a/biome.json b/biome.json index 2d7f7d3..f7311ee 100644 --- a/biome.json +++ b/biome.json @@ -1,13 +1,21 @@ { - "$schema": "https://biomejs.dev/schemas/2.0.6/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", "vcs": { - "enabled": false, + "enabled": true, "clientKind": "git", - "useIgnoreFile": false + "useIgnoreFile": true }, "files": { "ignoreUnknown": true, - "includes": ["src/**", "policy/**", "test/**", "scripts/**", "examples/**"], + "includes": [ + "src/**", + "services/ohlcv-collector/**", + "policy/**", + "test/**", + "scripts/**", + "examples/**", + "!test/fixtures" + ], "experimentalScannerIgnores": ["src/proto/**"] }, "formatter": { diff --git a/build.ts b/build.ts index 7a416d9..a6da0ba 100644 --- a/build.ts +++ b/build.ts @@ -10,7 +10,7 @@ await Bun.build({ await Bun.build({ entrypoints: ["./src/index.ts"], outdir: "./dist", - target: "bun", + target: "node", external: [ "fs", "path", diff --git a/bun.lock b/bun.lock index 84a26a4..9a3bef9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,10 +1,10 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "fietcexbroker", "dependencies": { + "@clickhouse/client": "^1.22.0", "@grpc/grpc-js": "^1.13.4", "@grpc/proto-loader": "^0.7.15", "@loglayer/plugin-opentelemetry": "^3.0.2", @@ -25,11 +25,13 @@ "protobufjs": "^7.4.0", "serialize-error": "^13.0.1", "tslog": "^4.9.3", + "ws": "8.18.3", "zod": "^4.3.6", }, "devDependencies": { - "@biomejs/biome": "2.0.6", + "@biomejs/biome": "^2.4.16", "@types/bun": "latest", + "@types/ws": "^8.18.1", "bun-plugin-dts": "latest", "bun-types": "latest", "cpx": "^1.5.0", @@ -42,26 +44,31 @@ }, }, "patchedDependencies": { + "@usherlabs/ccxt@0.0.14": "patches/@usherlabs%2Fccxt@0.0.14.patch", "@protobufjs/inquire@1.1.0": "patches/@protobufjs%2Finquire@1.1.0.patch", }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.0.6", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.0.6", "@biomejs/cli-darwin-x64": "2.0.6", "@biomejs/cli-linux-arm64": "2.0.6", "@biomejs/cli-linux-arm64-musl": "2.0.6", "@biomejs/cli-linux-x64": "2.0.6", "@biomejs/cli-linux-x64-musl": "2.0.6", "@biomejs/cli-win32-arm64": "2.0.6", "@biomejs/cli-win32-x64": "2.0.6" }, "bin": { "biome": "bin/biome" } }, "sha512-RRP+9cdh5qwe2t0gORwXaa27oTOiQRQvrFf49x2PA1tnpsyU7FIHX4ZOFMtBC4QNtyWsN7Dqkf5EDbg4X+9iqA=="], + "@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.0.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-AzdiNNjNzsE6LfqWyBvcL29uWoIuZUkndu+wwlXW13EKcBHbbKjNQEZIJKYDc6IL+p7bmWGx3v9ZtcRyIoIz5A=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.0.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-wJjjP4E7bO4WJmiQaLnsdXMa516dbtC6542qeRkyJg0MqMXP0fvs4gdsHhZ7p9XWTAmGIjZHFKXdsjBvKGIJJQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.0.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZSVf6TYo5rNMUHIW1tww+rs/krol7U5A1Is/yzWyHVZguuB0lBnIodqyFuwCNqG9aJGyk7xIMS8HG0qGUPz0SA=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.0.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-CVPEMlin3bW49sBqLBg2x016Pws7eUXA27XYDFlEtponD0luYjg2zQaMJ2nOqlkKG9fqzzkamdYxHdMDc2gZFw=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.0.6", "", { "os": "linux", "cpu": "x64" }, "sha512-geM1MkHTV1Kh2Cs/Xzot9BOF3WBacihw6bkEmxkz4nSga8B9/hWy5BDiOG3gHDGIBa8WxT0nzsJs2f/hPqQIQw=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.0.6", "", { "os": "linux", "cpu": "x64" }, "sha512-mKHE/e954hR/hSnAcJSjkf4xGqZc/53Kh39HVW1EgO5iFi0JutTN07TSjEMg616julRtfSNJi0KNyxvc30Y4rQ=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.0.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-290V4oSFoKaprKE1zkYVsDfAdn0An5DowZ+GIABgjoq1ndhvNxkJcpxPsiYtT7slbVe3xmlT0ncdfOsN7KruzA=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.0.6", "", { "os": "win32", "cpu": "x64" }, "sha512-bfM1Bce0d69Ao7pjTjUS+AWSZ02+5UHdiAP85Th8e9yV5xzw6JrHXbL5YWlcEKQ84FIZMdDc7ncuti1wd2sdbw=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], + + "@clickhouse/client": ["@clickhouse/client@1.22.0", "", { "dependencies": { "@clickhouse/client-common": "1.22.0" } }, "sha512-iQAAM4VT9fO7mYVOkGB/Ul9Xxuf0atKn+GFceZqfE8xFakV8KOAQxR3tfNrXFMlJ8T+Q3gbrpfLFyj7/TbOwyA=="], + + "@clickhouse/client-common": ["@clickhouse/client-common@1.22.0", "", {}, "sha512-MQgXRhoYXut6GhRrTJlub42bnPX7+5Vm+5gHNR0zZXU5+EwZKsBgMXiWXPOerAmQd3weGKm8hzoeZJCfU3Cw2w=="], "@grpc/grpc-js": ["@grpc/grpc-js@1.13.4", "", { "dependencies": { "@grpc/proto-loader": "^0.7.13", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg=="], @@ -147,6 +154,8 @@ "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "@usherlabs/ccxt": ["@usherlabs/ccxt@0.0.14", "", { "dependencies": { "axios": "^1.10.0", "ws": "^8.8.1" } }, "sha512-idsJmfHZinnBosIzMFAF4Iw5LSpgKqoK+rQhkzMEJyiHO/Nxgk+NtT6YJQ37eTWLFhKTChBHhaG19EjB5xzltA=="], "@usherlabs/verity-client": ["@usherlabs/verity-client@0.1.1", "", { "dependencies": { "@types/eventsource": "^1.1.15", "axios": "^1.9.0", "eventsource": "^2.0.2", "tslog": "^4.10.2", "uuid": "^11.1.0" } }, "sha512-o0dENuELxm0+iDIKtHfG53AJMII12MMm/2oHmA1Uuysd/L7MdNw4BkOkHRn5qk6HJqBjpjROf0h3YcOpyY+pGw=="], diff --git a/docker/clickhouse-research.compose.yml b/docker/clickhouse-research.compose.yml new file mode 100644 index 0000000..16c8b0f --- /dev/null +++ b/docker/clickhouse-research.compose.yml @@ -0,0 +1,44 @@ +services: + clickhouse: + image: clickhouse/clickhouse-server:24.8 + container_name: cex-broker-clickhouse + ports: + - "8123:8123" + - "9000:9000" + volumes: + - clickhouse_data:/var/lib/clickhouse + - ../schema/clickhouse/market_data.sql:/docker-entrypoint-initdb.d/01_market_data.sql:ro + ulimits: + nofile: + soft: 262144 + hard: 262144 + + # Research/dev forwarder: bind-mounts the repo for live-reload against a + # host `bun install`. For a standalone prod image build + # services/archive-forwarder/Dockerfile instead (self-contained, no mount). + archive-forwarder: + image: oven/bun:1.2 + container_name: cex-broker-archive-forwarder + working_dir: /app + command: ["bun", "run", "services/archive-forwarder/index.ts"] + ports: + - "8090:8090" + environment: + ARCHIVE_FORWARDER_PORT: "8090" + CLICKHOUSE_HOST: clickhouse + CLICKHOUSE_PORT: "8123" + CLICKHOUSE_USER: default + CLICKHOUSE_PASSWORD: "" + CLICKHOUSE_DATABASE: market_data + volumes: + - ..:/app + depends_on: + - clickhouse + +volumes: + clickhouse_data: + +networks: + default: + name: fiet-sandbox + external: true diff --git a/docs/research-backtest.md b/docs/research-backtest.md new file mode 100644 index 0000000..f2df7b7 --- /dev/null +++ b/docs/research-backtest.md @@ -0,0 +1,242 @@ +# ClickHouse Research and Backtest (Path B) + +Research and backtest on **cex-broker archived market data** stored in ClickHouse. Hummingbot strategy params can be exported from Python and applied manually; an optional ClickHouse candle feed exists for live HB indicators. + +Entry point for the `research/` tree: [research/README.md](../research/README.md). + +## Architecture + +```text +cex-broker Subscribe (ORDERBOOK, OHLCV, TRADES, TICKER, …) + → BrokerExecutionArchiver + → archive-forwarder (POST /archive) + → ClickHouse market_data.* + ├─→ candle-viewer (browser chart) + ├─→ Python research toolkit + └─→ Hummingbot ClickHouse feed (optional) +``` + +### Tables + +| Table | Stream | Use | +|-------|--------|-----| +| `market_data.candles` | OHLCV | Forming + closed; view `candles_closed` for backtests | +| `market_data.orderbook_snapshots` | ORDERBOOK | TOB + L2 depth in one row per sample | +| `market_data.cex_trades` | TRADES | Trade prints | +| `market_data.cex_ticker_events` | TICKER | Ticker snapshots | + +Schema: [`schema/clickhouse/market_data.sql`](../schema/clickhouse/market_data.sql). +Example SQL: [`schema/clickhouse/research_queries.sql`](../schema/clickhouse/research_queries.sql). + +## Prerequisites + +- Bun (broker, forwarder, candle viewer) +- Python 3.11+ (research toolkit) +- Docker (optional local ClickHouse stack) + +Create the sandbox network once if using the compose file: + +```bash +docker network create fiet-sandbox || true +``` + +## 1. Start ClickHouse and archive forwarder + +From repo root: + +```bash +docker compose -f docker/clickhouse-research.compose.yml up -d +``` + +Or run manually: + +```bash +clickhouse-client --multiquery < schema/clickhouse/market_data.sql +CLICKHOUSE_PORT=8123 bun run start-archive-forwarder +``` + +Health check: + +```bash +curl http://localhost:8090/health +``` + +Dev watcher (restarts on schema/forwarder changes): + +```bash +CLICKHOUSE_PORT=8123 bun run dev:archive-forwarder +``` + +## 2. Start cex-broker with archive enabled + +```env +CEX_BROKER_ARCHIVE_ENABLED=true +CEX_BROKER_ARCHIVE_FORWARDER_URL=http://localhost:8090/archive +CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH=./archive-loss.jsonl +CEX_BROKER_MARKET_ARCHIVE_ENABLED=true +CEX_BROKER_DEPLOYMENT_ID=local-dev +``` + +For production durability, `CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH` must be on persistent writable storage or a mounted volume. A container-local ephemeral path does not preserve loss records across container replacement. + +If broker runs in Docker on the same compose network: + +```env +CEX_BROKER_ARCHIVE_FORWARDER_URL=http://archive-forwarder:8090/archive +``` + +Start broker (example): + +```bash +bun run start-broker --policy policy/policy.backtest.json --port 8086 --whitelistAll +``` + +Hot reload during broker development: `bun run start-broker-server`. + +## 3. Seed market data + +**Multi-stream watch** (recommended for the live chart and full archive): + +```bash +SYMBOLS=BTC/USDT,BNB/USDT,DOGE/USDT bun run start-archive-watch +``` + +Ingests ORDERBOOK, OHLCV @ `1m`, TRADES, and TICKER per symbol. + +Dev watcher: + +```bash +SYMBOLS=BTC/USDT,BNB/USDT,DOGE/USDT bun run dev:archive-watch +``` + +**OHLCV-only** seeder: + +```bash +CEX_BROKER_URL=localhost:8086 CEX=binance SYMBOL=BTC/USDT TIMEFRAME=1m \ + bun run examples/archive-ohlcv-subscribe.ts +``` + +Verify candles: + +```sql +SELECT count() +FROM market_data.candles_closed +WHERE exchange = 'binance' AND symbol = 'BTC/USDT' AND timeframe = '1m'; +``` + +Verify orderbook: + +```sql +SELECT count(), max(event_time_ms) +FROM market_data.orderbook_snapshots +WHERE exchange = 'binance' AND symbol = 'BTC/USDT'; +``` + +## 4. Run Python backtest + +See [research/python/README.md](../research/python/README.md). + +```bash +cd research/python +python -m venv .venv +source .venv/bin/activate +pip install -e ".[dev]" + +export CLICKHOUSE_HOST=localhost +export CLICKHOUSE_PORT=8123 +export CLICKHOUSE_DATABASE=market_data + +python examples/candle_backtest.py +``` + +Output: `research/python/examples/output/hummingbot_params.yaml` + +## 5. Tune Hummingbot separately + +Copy values from the exported YAML into your Hummingbot strategy config: + +| cex-broker / ClickHouse | Hummingbot | +|-------------------------|------------| +| `BTC/USDT` (CCXT) | `BTC-USDT` | +| `binance` | `binance` or `binance_perpetual` | +| `timeframe = 1m` | `candles_interval = 1m` | + +Hummingbot live **execution** still uses its own exchange connector unless you wire a custom setup. ClickHouse is the research warehouse and optional shared candle lane. + +Optional broker policy: [`policy/policy.backtest.json`](../policy/policy.backtest.json). + +## Symbol mapping (Python) + +```python +from cex_broker_research.symbols import ccxt_to_hb, hb_to_ccxt + +ccxt_to_hb("BTC/USDT") # "BTC-USDT" +hb_to_ccxt("BTC-USDT") # "BTC/USDT" +``` + +## Live candle chart (browser) + +See [research/candle-viewer/README.md](../research/candle-viewer/README.md). + +```bash +CLICKHOUSE_PORT=8123 bun run start-candle-viewer +# or: CLICKHOUSE_PORT=8123 bun run dev:candle-viewer +``` + +Open [http://localhost:8091](http://localhost:8091). The UI polls `/api/candles` every 500ms (default). Higher chart timeframes (`5m`, `15m`, `1h`) are rolled up from archived `1m` bars. + +Env: `CANDLE_VIEWER_PORT`, `CANDLE_VIEWER_SYMBOLS`, `CANDLE_VIEWER_TIMEFRAME`, `CANDLE_VIEWER_POLL_MS`. + +## Live Hummingbot candles (ClickHouse feed) + +Strategies can read **live archived OHLCV** from ClickHouse via Hummingbot's `MarketDataProvider`: + +```text +cex-broker Subscribe(OHLCV) → ClickHouse market_data.candles + → CexBrokerClickHouseCandles (poll) + → MarketDataProvider.get_candles_df(...) + → HB strategy / indicators +``` + +1. Ensure archive ingest is running (broker + forwarder + subscribe watch). +2. Register the feed at Hummingbot startup: + +```bash +python research/hummingbot/register_clickhouse_feed.py +``` + +3. In a strategy or v2 controller: + +```python +candles_df = self.market_data_provider.get_candles_df( + connector_name="cex_broker_clickhouse", + trading_pair="binance:BTC-USDT", + interval="1m", + max_records=500, +) +``` + +Trading pair formats: +- `binance:BTC-USDT` — explicit exchange + HB pair +- `BTC-USDT` — requires `CLICKHOUSE_CANDLES_EXCHANGE=binance` + +Env: `CLICKHOUSE_HOST`, `CLICKHOUSE_PORT`, optional `CLICKHOUSE_CANDLES_POLL_SEC`. + +See [research/hummingbot/README.md](../research/hummingbot/README.md). + +## Out of scope + +- cex-broker gRPC connector for Hummingbot execution +- Automated deployment of Hummingbot from research outputs + +## Troubleshooting + +| Symptom | Check | +|---------|-------| +| No rows in `candles` | Broker archive env, forwarder `/health`, archive watch or OHLCV subscribe running | +| No rows in `orderbook_snapshots` | ORDERBOOK in archive watch; `CEX_BROKER_ORDERBOOK_INTERVAL_MS` | +| Forwarder 500 errors | ClickHouse up, schema applied, column types match row payload | +| Empty Python DataFrame | Query `candles_closed` (closed bars only), symbol/timeframe match ingest | +| Chart not updating | Hard-refresh browser; confirm `/api/candles` returns changing `brokerVersion` on forming bar | +| Broker cannot reach forwarder | `CEX_BROKER_ARCHIVE_FORWARDER_URL` host/port | +| Broker rejects archive startup | Exact `CEX_BROKER_ARCHIVE_ENABLED=true`, valid forwarder URL, and writable `CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH` | diff --git a/examples/archive-watch-subscribe.ts b/examples/archive-watch-subscribe.ts new file mode 100644 index 0000000..c5622a4 --- /dev/null +++ b/examples/archive-watch-subscribe.ts @@ -0,0 +1,231 @@ +#!/usr/bin/env bun + +import * as grpc from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; +import path from "path"; +import type { SubscribeResponse__Output } from "../src/proto/cex_broker/SubscribeResponse"; +import { SubscriptionType } from "../src/proto/cex_broker/SubscriptionType"; +import type { ProtoGrpcType } from "../src/proto/node"; +import { PROTO_LOADER_OPTIONS } from "../src/proto-loader-options"; + +const PROTO_FILE = "../src/proto/node.proto"; +const brokerUrl = process.env.CEX_BROKER_URL ?? "localhost:8086"; +const cex = process.env.CEX ?? "binance"; +const timeframe = process.env.TIMEFRAME ?? "1m"; +const DEFAULT_RECONNECT_DELAY_MS = 5_000; +const parsedReconnectDelay = Number.parseInt( + process.env.ARCHIVE_WATCH_RECONNECT_MS ?? String(DEFAULT_RECONNECT_DELAY_MS), + 10, +); +const RECONNECT_DELAY_MS = + Number.isFinite(parsedReconnectDelay) && parsedReconnectDelay > 0 + ? parsedReconnectDelay + : DEFAULT_RECONNECT_DELAY_MS; + +function parseSymbols(): string[] { + const raw = + process.env.SYMBOLS?.trim() || + process.env.SYMBOL?.trim() || + "BTC/USDT,BNB/USDT,DOGE/USDT"; + return raw + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +const symbols = parseSymbols(); + +console.log("CEX Broker - Market Data Archive Watch"); +console.log("━".repeat(55)); +console.log(`Broker: ${brokerUrl}`); +console.log(`Exchange: ${cex}`); +console.log(`Symbols: ${symbols.join(", ")}`); +console.log( + `Streams per symbol: ORDERBOOK, OHLCV @ ${timeframe}, TRADES, TICKER`, +); +console.log("Runs until stopped (Ctrl+C). Reconnects automatically on errors."); +console.log("Ensure broker + archive-forwarder + ClickHouse are running.\n"); + +const packageDef = protoLoader.loadSync( + path.resolve(__dirname, PROTO_FILE), + PROTO_LOADER_OPTIONS, +); +const proto = grpc.loadPackageDefinition( + packageDef, +) as unknown as ProtoGrpcType; + +function createClient() { + return new proto.cex_broker.cex_service( + brokerUrl, + grpc.credentials.createInsecure(), + ); +} + +const activeStreams: grpc.ClientReadableStream[] = + []; +let shuttingDown = false; + +function removeStream( + stream: grpc.ClientReadableStream, +): void { + const index = activeStreams.indexOf(stream); + if (index >= 0) { + activeStreams.splice(index, 1); + } +} + +function scheduleReconnect(label: string, start: () => void) { + if (shuttingDown) { + return; + } + console.warn(`[${label}] reconnecting in ${RECONNECT_DELAY_MS}ms...`); + setTimeout(start, RECONNECT_DELAY_MS); +} + +function startOrderbookStream(symbol: string) { + const label = `ORDERBOOK ${symbol}`; + const stream = createClient().Subscribe({ + cex, + symbol, + type: SubscriptionType.ORDERBOOK, + options: {}, + }); + activeStreams.push(stream); + let updates = 0; + + stream.on("data", (response: SubscribeResponse__Output) => { + updates += 1; + if (updates % 60 === 0) { + console.log( + `[${label}] ${updates} frames (latest ts=${response.timestamp})`, + ); + } + }); + + stream.on("error", (error: grpc.ServiceError) => { + console.error(`[${label}] stream error:`, error.message); + removeStream(stream); + scheduleReconnect(label, () => startOrderbookStream(symbol)); + }); + + stream.on("end", () => { + console.warn(`[${label}] stream ended`); + removeStream(stream); + scheduleReconnect(label, () => startOrderbookStream(symbol)); + }); + + console.log(`[${label}] subscribed`); +} + +function startOhlcvStream(symbol: string) { + const label = `OHLCV ${symbol}`; + const stream = createClient().Subscribe({ + cex, + symbol, + type: SubscriptionType.OHLCV, + options: { timeframe }, + }); + activeStreams.push(stream); + let updates = 0; + + stream.on("data", (response: SubscribeResponse__Output) => { + updates += 1; + let summary = `update #${updates}`; + try { + const payload = JSON.parse(response.data) as unknown; + if (Array.isArray(payload)) { + const bars = Array.isArray(payload[0]) ? payload : [payload]; + const last = bars[bars.length - 1] as number[]; + if (Array.isArray(last) && last.length >= 6) { + summary = `bar ts=${last[0]} c=${last[4]}`; + } + } + } catch { + // keep summary + } + if (updates % 10 === 0) { + console.log(`[${label}] ${summary}`); + } + }); + + stream.on("error", (error: grpc.ServiceError) => { + console.error(`[${label}] stream error:`, error.message); + removeStream(stream); + scheduleReconnect(label, () => startOhlcvStream(symbol)); + }); + + stream.on("end", () => { + console.warn(`[${label}] stream ended`); + removeStream(stream); + scheduleReconnect(label, () => startOhlcvStream(symbol)); + }); + + console.log(`[${label}] subscribed @ ${timeframe}`); +} + +function startSimpleStream( + symbol: string, + streamName: "TRADES" | "TICKER", + subscriptionType: SubscriptionType, +): void { + const label = `${streamName} ${symbol}`; + const stream = createClient().Subscribe({ + cex, + symbol, + type: subscriptionType, + options: {}, + }); + activeStreams.push(stream); + let updates = 0; + + stream.on("data", (response: SubscribeResponse__Output) => { + updates += 1; + if (updates % 30 === 0) { + console.log( + `[${label}] ${updates} frames (latest ts=${response.timestamp})`, + ); + } + }); + + stream.on("error", (error: grpc.ServiceError) => { + console.error(`[${label}] stream error:`, error.message); + removeStream(stream); + scheduleReconnect(label, () => + startSimpleStream(symbol, streamName, subscriptionType), + ); + }); + + stream.on("end", () => { + console.warn(`[${label}] stream ended`); + removeStream(stream); + scheduleReconnect(label, () => + startSimpleStream(symbol, streamName, subscriptionType), + ); + }); + + console.log(`[${label}] subscribed`); +} + +for (const symbol of symbols) { + startOrderbookStream(symbol); + startOhlcvStream(symbol); + startSimpleStream(symbol, "TRADES", SubscriptionType.TRADES); + startSimpleStream(symbol, "TICKER", SubscriptionType.TICKER); +} + +process.on("SIGINT", () => { + console.log("\nStopping archive watch..."); + shuttingDown = true; + for (const stream of activeStreams) { + stream.cancel(); + } + process.exit(0); +}); + +process.on("SIGTERM", () => { + shuttingDown = true; + for (const stream of activeStreams) { + stream.cancel(); + } + process.exit(0); +}); diff --git a/examples/kraken-orderbook-demo.ts b/examples/kraken-orderbook-demo.ts index 61e6523..63e8376 100644 --- a/examples/kraken-orderbook-demo.ts +++ b/examples/kraken-orderbook-demo.ts @@ -6,19 +6,17 @@ import path from "path"; import type { SubscribeResponse__Output } from "../src/proto/cex_broker/SubscribeResponse"; import { SubscriptionType } from "../src/proto/cex_broker/SubscriptionType"; import type { ProtoGrpcType } from "../src/proto/node"; +import { PROTO_LOADER_OPTIONS } from "../src/proto-loader-options"; const PROTO_FILE = "../src/proto/node.proto"; console.log("CEX Broker - Kraken Orderbook Demo"); console.log("━".repeat(55)); -const packageDef = protoLoader.loadSync(path.resolve(__dirname, PROTO_FILE), { - keepCase: true, - longs: String, - enums: String, - defaults: true, - oneofs: true, -}); +const packageDef = protoLoader.loadSync( + path.resolve(__dirname, PROTO_FILE), + PROTO_LOADER_OPTIONS, +); const proto = grpc.loadPackageDefinition( packageDef, @@ -49,7 +47,9 @@ stream.on("data", (response: SubscribeResponse__Output) => { console.log("LIVE ORDERBOOK - ETH/USDT (Kraken)"); console.log("━".repeat(50)); console.log(`Update #${dataCount}`); - console.log(`${new Date(parseInt(response.timestamp)).toLocaleTimeString()}`); + console.log( + `${new Date(parseInt(response.timestamp, 10)).toLocaleTimeString()}`, + ); console.log(""); try { diff --git a/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/.openspec.yaml b/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/.openspec.yaml new file mode 100644 index 0000000..c53ef21 --- /dev/null +++ b/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-05 diff --git a/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/README.md b/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/README.md new file mode 100644 index 0000000..6dce5ad --- /dev/null +++ b/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/README.md @@ -0,0 +1,3 @@ +# cex-broker-server-modularization + +Establish canonical `src/helpers/` and `src/handlers/` layout, extract `server.ts` helpers, deduplicate shared primitives, and shrink `server.ts` to gRPC wiring and dispatch only. \ No newline at end of file diff --git a/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/design.md b/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/design.md new file mode 100644 index 0000000..3f5bd0f --- /dev/null +++ b/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/design.md @@ -0,0 +1,209 @@ +## Context + +The cex-broker gRPC service is implemented primarily in [`src/server.ts`](src/server.ts) (~2,306 lines). It already delegates some domains to [`src/helpers/`](src/helpers/)—notably [`order-book.ts`](src/helpers/order-book.ts) and [`order-telemetry.ts`](src/helpers/order-telemetry.ts)—but ~18 helper functions and two large RPC handlers (`ExecuteAction`, `Subscribe`) remain inline. [`src/helpers/index.ts`](src/helpers/index.ts) (~965 lines) holds broker pool, policy, and transfer logic; it is a separate consolidation target and out of scope unless incidentally touched. + +Duplicate primitives exist today: + +| Primitive | Locations | +|-----------|-----------| +| `isRecord` | `server.ts`, `order-book.ts` | +| `asRecord` (equivalent) | `order-telemetry.ts` | +| `getErrorMessage` | `server.ts`; inlined 6× in `Subscribe` | +| Error → gRPC status | `stableGrpcErrorCode`, `mapCcxtErrorToGrpcStatus` in `server.ts` only | + +Tests import `getServer` from `server.ts` and exercise RPC behavior via files such as `test/treasury-discovery-rpc.test.ts` and `test/order-book-rpc.test.ts`. Extractions must preserve those contracts. + +## Goals / Non-Goals + +**Goals:** + +- Define a **canonical folder layout** and dependency rules for all future broker development. +- Shrink `server.ts` to **registration + dispatch + cross-cutting wrappers** (auth, otel callback, logging), targeting **< 400 lines** at completion. +- Extract leaf helpers and domain modules with **unit tests**; keep RPC integration tests green per slice. +- Deduplicate shared primitives in one place (`helpers/shared/`). +- Enable phased handler extraction without a single large-bang refactor. + +**Non-Goals:** + +- Changing gRPC proto definitions or client-visible JSON contracts. +- Renaming or re-exporting the public package API beyond existing `getServer` usage. +- Splitting `helpers/index.ts` in this change (document as follow-up). +- Introducing a parallel `src/utils/` tree (extend `helpers/` instead). +- Behavior changes to order-book, treasury, deposit, or policy logic. + +## Decisions + +### 1. Extend `src/helpers/`, do not add `src/utils/` + +All shared and domain logic lives under `src/helpers/` with optional subfolders. This matches existing imports in `server.ts` and test layout. + +**Alternative considered:** `src/utils/` — rejected to avoid two conventions and import confusion. + +### 2. Canonical directory layout + +``` +src/ + server.ts # Thin: grpc load, getServer(), service map, delegates + handlers/ # RPC dispatch (phased) + execute-action/ + index.ts # Handler registry + deposit.ts + withdraw.ts + orders.ts + treasury-call.ts + pass-through.ts + subscribe/ # Optional phase 4 + index.ts + helpers/ + shared/ # Cross-module primitives + guards.ts # isRecord, asRecord + errors.ts # getErrorMessage, safeLogError + grpc/ # Transport-layer helpers + payload.ts # parsePayload (Zod) + status.ts # stableGrpcErrorCode, mapCcxtErrorToGrpcStatus, resolveGrpcError + treasury-discovery.ts + transfer-network.ts + deposit.ts + order-book.ts # existing + order-telemetry.ts # existing (+ background emit wrapper) + constants.ts # existing + logger.ts # existing + otel.ts # existing + index.ts # broker pool, policy (unchanged scope) + schemas/ # existing Zod payloads + types.ts # existing +``` + +**Dependency direction (enforced by convention and review):** + +```mermaid +flowchart LR + server[server.ts] + handlers[handlers/] + helpers[helpers/] + schemas[schemas/] + server --> handlers + server --> helpers + handlers --> helpers + helpers --> schemas + helpers --> types[types.ts] +``` + +- `helpers/**` MUST NOT import from `server.ts` or `handlers/**`. +- `handlers/**` MAY import `helpers/**` and `schemas/**`. +- Prefer **concrete imports** (`helpers/deposit`) over re-exporting everything through `helpers/index.ts`. + +### 3. Phased extraction order (low risk → high impact) + +| Phase | Deliverable | Risk | +|-------|-------------|------| +| 0 | `helpers/shared/*`; dedupe `order-book`, `order-telemetry`, Subscribe | Lowest | +| 1a | `helpers/grpc/payload.ts`, `status.ts` + unit tests | Low | +| 1b | `treasury-discovery`, `transfer-network`, `deposit`; telemetry wrapper | Low | +| 2 | `resolveGrpcError`, `requireParsedPayload`, broker resolution helpers | Medium | +| 3 | `handlers/execute-action/*` — one action cluster per PR | Medium | +| 4 | Subscribe modularization + shared `getErrorMessage` | Medium | + +Each phase is a **separate PR** (~300 lines diff max). Run `bun test` before merge. + +**Alternative considered:** Big-bang move of entire `ExecuteAction` switch — rejected due to reviewability and regression risk. + +### 4. Handler module contract (Phase 3+) + +Handlers receive an explicit context object; no hidden globals: + +```ts +export type ExecuteActionContext = { + call: grpc.ServerUnaryCall; + wrappedCallback: grpc.sendUnaryData; + action: ActionType; + policy: PolicyConfig; + brokers: Record; + metadata: Metadata; + normalizedCex: string; + cex: string; + symbol?: string; + selectedBrokerAccount?: BrokerAccount; + broker: Exchange; + verity: { proof: string }; + applyVerityToBroker: (target: Exchange) => void; + useVerity: boolean; + verityProverUrl: string; + otelMetrics?: OtelMetrics; +}; + +export type ActionHandler = (ctx: ExecuteActionContext) => Promise; +``` + +**Dispatch flow:** + +``` +getServer() + └─ registers createExecuteActionHandler(deps) on the gRPC service + +createExecuteActionHandler (per RPC) + ├─ auth + otel wrappedCallback + ├─ build ExecuteActionContext + ├─ Action.Call → handleOrderBookCall (prelude, may return early) + ├─ resolve broker + apply Verity + └─ dispatchExecuteAction(ctx) + └─ ACTION_HANDLERS[action](ctx) +``` + +`ACTION_HANDLERS` in `handlers/execute-action/registry.ts` maps each `Action` to a module handler. Order and pass-through actions share cluster routers (`handleOrders`, `handlePassThrough`) registered once per action enum value. New actions add one registry entry and one handler file. + +**Alternative considered:** Class-based `ServerService` — rejected; functional handlers match existing style and test fixtures. + +### 5. Module mapping from current `server.ts` helpers + +| Current helper (approx. lines) | Target module | +|-------------------------------|---------------| +| `parsePayload` | `helpers/grpc/payload.ts` | +| `getErrorMessage`, `safeLogError` | `helpers/shared/errors.ts` | +| `stableGrpcErrorCode`, `mapCcxtErrorToGrpcStatus` | `helpers/grpc/status.ts` | +| `handleTreasuryDiscoveryCall`, `fetchCurrencyMetadata`, `callArgs` | `helpers/treasury-discovery.ts` | +| `resolveTransferNetwork`, `buildTransferNetworkEvidence`, `networkAliasSet` | `helpers/transfer-network.ts` | +| `depositField`, `normalizeDepositStatus`, `depositMatchesTransaction`, `stringAmountEquals`, `normalizeAddress` | `helpers/deposit.ts` | +| `emitOrderExecutionTelemetryInBackground` | `helpers/order-telemetry.ts` | +| `isRecord` | `helpers/shared/guards.ts` | + +### 6. Testing strategy + +- **Unit tests** for pure modules: `test/shared-guards.test.ts`, `test/grpc-status.test.ts`, `test/deposit-helper.test.ts`. +- **RPC tests** unchanged: continue importing `getServer` from `server.ts`. +- No requirement to mock gRPC for extracted pure functions. + +### 7. Guardrails for future development + +Document in README or `AGENTS.md` (optional doc touch in implementation): + +- New domain logic → new file under `helpers/.ts` or `handlers/`. +- `server.ts` line budget: aim < 400 lines post-migration. +- Before adding a helper to `server.ts`, grep `src/` for an existing equivalent. +- Optional CI: script fails if `server.ts` exceeds N lines (Phase 5, not blocking Phase 0–3). + +## Risks / Trade-offs + +| Risk | Mitigation | +|------|------------| +| Circular imports between helpers | Enforce dependency direction; `shared/` has no domain imports | +| `helpers/index.ts` becomes a god barrel | Import concrete modules; do not add server helpers to `index.ts` | +| Handler extraction changes error codes subtly | One action per PR; run full RPC test file for that action | +| Large PR fatigue | Cap diff size; phases 0–2 before any handler move | +| Subscribe infinite loops harder to test | Extract loops but keep behavior identical; rely on existing stream tests | + +## Migration Plan + +1. Land Phase 0–1 without handler moves — behavior-identical refactor only. +2. Land Phase 2 — internal boilerplate reduction in `server.ts`. +3. Land Phase 3 starting with `Deposit` as the template handler; repeat per action. +4. Land Phase 4 for Subscribe. +5. Validate: `bun test`, `bun run check`, `openspec validate cex-broker-server-modularization --strict`. + +**Rollback:** Each phase is independently revertible via git revert; no schema or deployment migrations. + +## Open Questions + +- Whether to add a CI line-count check on `server.ts` in this change or a follow-up. +- Whether `handlers/` should live under `src/server/handlers/` for colocation — default is `src/handlers/` for top-level visibility. +- Timing of `helpers/index.ts` split into `broker-pool.ts` + `policy.ts` (separate OpenSpec change recommended). \ No newline at end of file diff --git a/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/proposal.md b/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/proposal.md new file mode 100644 index 0000000..abd7c16 --- /dev/null +++ b/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/proposal.md @@ -0,0 +1,39 @@ +## Why + +[`src/server.ts`](src/server.ts) has grown to roughly 2,300 lines and now mixes gRPC service wiring, cross-cutting error handling, treasury/discovery logic, deposit validation, and large `ExecuteAction` / `Subscribe` handler bodies. Domain helpers already live under [`src/helpers/`](src/helpers/) (for example `order-book.ts`, `order-telemetry.ts`), but new work continues to land in `server.ts`, and primitives such as `isRecord` and `getErrorMessage` are duplicated across files. Without an agreed layout and extraction process, the broker will become harder to review, test, and extend safely. + +## What Changes + +- Establish a **canonical source layout** for cex-broker: thin `server.ts`, domain modules under `src/helpers/`, optional `src/handlers/` for RPC dispatch, and `src/helpers/shared/` for cross-cutting primitives. +- **Extract** existing local helpers from `server.ts` into focused modules (gRPC payload/status, treasury discovery, transfer network resolution, deposit matching, telemetry wrappers) with no public API or gRPC contract changes. +- **Deduplicate** shared helpers (`isRecord`, `getErrorMessage`, error-to-gRPC mapping) used in `server.ts`, `order-book.ts`, `order-telemetry.ts`, and `Subscribe` stream handlers. +- Introduce **reusable RPC patterns** (`resolveGrpcError`, payload validation helpers, broker resolution) to shrink repeated boilerplate inside `ExecuteAction`. +- **Optionally migrate** `ExecuteAction` and `Subscribe` cases into `src/handlers/` in small, action-scoped PRs after leaf helpers are stable. +- Add **unit tests** for extracted pure helpers and keep existing RPC integration tests (`treasury-discovery-rpc`, `order-book-rpc`, `internal-transfer-rpc`, `order-telemetry`) green after each slice. +- Document **guardrails**: dependency direction (`server` → `handlers` → `helpers`), no imports from `server.ts` into helpers, prefer concrete module imports over growing `helpers/index.ts`, and a target size budget for `server.ts` (registration + dispatch only). + +No **BREAKING** changes to gRPC proto definitions, `getServer` export shape, or client-visible request/response contracts. + +## Capabilities + +### New Capabilities + +- `cex-broker-server-modularization`: Defines the broker's canonical folder structure, module boundaries, dependency rules, phased extraction process, deduplication requirements, and acceptance criteria for shrinking `server.ts` while preserving behavior and test coverage. + +### Modified Capabilities + +- None. This change is structural and organizational; existing functional specs (for example order-book depth sourcing) remain unchanged at the requirement level. + +## Impact + +- **Primary file:** [`src/server.ts`](src/server.ts) — reduced to service registration and handler delegation. +- **New / extended modules (illustrative):** + - `src/helpers/shared/guards.ts`, `errors.ts` + - `src/helpers/grpc/payload.ts`, `status.ts` + - `src/helpers/treasury-discovery.ts`, `transfer-network.ts`, `deposit.ts` + - `src/handlers/execute-action/` (phased) + - `src/helpers/subscribe/` or handler equivalents (phased) +- **Files updated for deduplication:** [`src/helpers/order-book.ts`](src/helpers/order-book.ts), [`src/helpers/order-telemetry.ts`](src/helpers/order-telemetry.ts). +- **Tests:** new unit tests under `test/` for shared/grpc/deposit helpers; existing RPC tests must pass unchanged. +- **Out of scope for this change:** splitting [`src/helpers/index.ts`](src/helpers/index.ts) (~965 lines) unless touched incidentally; proto or policy schema changes. +- **Precedent:** future features MUST place domain logic in the appropriate `helpers/` or `handlers/` module rather than expanding `server.ts`. \ No newline at end of file diff --git a/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/specs/cex-broker-server-modularization/spec.md b/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/specs/cex-broker-server-modularization/spec.md new file mode 100644 index 0000000..afc5a95 --- /dev/null +++ b/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/specs/cex-broker-server-modularization/spec.md @@ -0,0 +1,110 @@ +## ADDED Requirements + +### Requirement: Canonical source layout for broker code +The cex-broker repository SHALL organize server and domain logic according to a documented layout: thin `src/server.ts`, domain modules under `src/helpers/`, optional RPC handlers under `src/handlers/`, shared primitives under `src/helpers/shared/`, and gRPC transport helpers under `src/helpers/grpc/`. + +#### Scenario: server.ts contains only wiring and dispatch +- **WHEN** the modularization change is complete +- **THEN** `src/server.ts` MUST limit itself to gRPC package loading, `getServer` factory, service registration, authentication and telemetry wrappers, and delegation to handlers or helpers +- **AND** `src/server.ts` MUST NOT contain deposit matching, transfer network resolution, treasury discovery, or other domain business rules inline + +#### Scenario: New domain logic uses helpers or handlers +- **WHEN** a contributor adds new broker domain behavior after this change lands +- **THEN** the implementation MUST reside in `src/helpers/.ts` or `src/handlers//.ts` +- **AND** it MUST NOT be added as new top-level functions inside `src/server.ts` + +#### Scenario: No parallel utils tree +- **WHEN** shared primitives are introduced or consolidated +- **THEN** they MUST live under `src/helpers/shared/` or `src/helpers/grpc/` +- **AND** the repository MUST NOT introduce a separate `src/utils/` tree for the same purpose + +### Requirement: Enforced module dependency direction +Helper modules MUST NOT depend on `server.ts` or `handlers/`; handlers and `server.ts` MAY depend on helpers. + +#### Scenario: Helpers remain independent of server +- **WHEN** any file under `src/helpers/` is imported +- **THEN** that file MUST NOT import from `src/server.ts` or `src/handlers/**` + +#### Scenario: Handlers compose helpers +- **WHEN** an action handler under `src/handlers/execute-action/` needs domain logic +- **THEN** it MUST import from `src/helpers/**` or `src/schemas/**` +- **AND** it MUST NOT duplicate logic that already exists in a helper module + +### Requirement: Shared primitives are defined once +The broker SHALL provide a single implementation for record guards and error message extraction used across server, helpers, and subscribe paths. + +#### Scenario: isRecord is shared +- **WHEN** code needs to narrow `unknown` to `Record` +- **THEN** it MUST use the export from `src/helpers/shared/guards.ts` +- **AND** duplicate local `isRecord` or equivalent `asRecord` implementations MUST NOT remain in `server.ts`, `order-book.ts`, or `order-telemetry.ts` + +#### Scenario: getErrorMessage is shared +- **WHEN** an error is formatted for logging, gRPC details, or Subscribe stream JSON +- **THEN** it MUST use `getErrorMessage` from `src/helpers/shared/errors.ts` +- **AND** inline `error instanceof Error ? error.message : ...` ternaries MUST NOT remain in `Subscribe` handlers after Phase 0 + +### Requirement: gRPC transport helpers are centralized +Payload validation and error-to-status mapping SHALL live in `src/helpers/grpc/` and be reused by all `ExecuteAction` paths. + +#### Scenario: Payload parsing uses grpc helper +- **WHEN** an action validates a Zod schema against `Record` payload fields +- **THEN** it MUST use `parsePayload` from `src/helpers/grpc/payload.ts` + +#### Scenario: CCXT and stable errors map consistently +- **WHEN** an `ExecuteAction` handler surfaces an error to the client +- **THEN** it MUST resolve gRPC status via `stableGrpcErrorCode` and/or `mapCcxtErrorToGrpcStatus` from `src/helpers/grpc/status.ts` (or a composed `resolveGrpcError` helper defined there) +- **AND** the mapping MUST remain behavior-identical to pre-extraction behavior for the same error inputs + +### Requirement: Domain helpers extracted from server.ts +Treasury discovery, transfer network resolution, and deposit validation helpers currently in `server.ts` SHALL be moved to dedicated helper modules without changing RPC contracts. + +#### Scenario: Treasury discovery is modular +- **WHEN** `Action.Call` dispatches `fetchMarkets` or `fetchCurrencies` treasury paths +- **THEN** the logic MUST be implemented in `src/helpers/treasury-discovery.ts` +- **AND** existing `test/treasury-discovery-rpc.test.ts` scenarios MUST pass unchanged + +#### Scenario: Transfer network resolution is modular +- **WHEN** withdraw or deposit flows resolve operator network aliases +- **THEN** the logic MUST be implemented in `src/helpers/transfer-network.ts` +- **AND** `test/internal-transfer-rpc.test.ts` MUST pass unchanged where applicable + +#### Scenario: Deposit validation helpers are modular +- **WHEN** deposit observation compares amounts, addresses, or transaction hashes +- **THEN** the logic MUST use exports from `src/helpers/deposit.ts` + +### Requirement: Phased delivery with regression safety +Each extraction phase MUST ship independently with passing tests; handler extraction is optional and action-scoped. + +#### Scenario: Phase 0–1 does not require handlers +- **WHEN** Phases 0–1 (shared, grpc, domain helpers) are merged +- **THEN** `getServer` MUST remain exported from `src/server.ts` +- **AND** `bun test` MUST pass with no proto or public API changes + +#### Scenario: Handler extraction is incremental +- **WHEN** Phase 3 migrates an `ExecuteAction` case to `src/handlers/execute-action/` +- **THEN** at most one action cluster SHOULD move per PR +- **AND** RPC tests covering that action MUST pass before merge + +#### Scenario: Public gRPC contract unchanged +- **WHEN** modularization PRs merge +- **THEN** proto definitions and client-visible request/response JSON shapes MUST NOT change +- **AND** no requirement in this spec SHALL be interpreted as permitting breaking gRPC API changes + +### Requirement: Unit tests for extracted pure helpers +Pure functions moved out of `server.ts` SHALL have dedicated unit tests that do not require starting a gRPC server. + +#### Scenario: Shared and grpc helpers are unit tested +- **WHEN** `helpers/shared/guards.ts`, `helpers/shared/errors.ts`, `helpers/grpc/status.ts`, or `helpers/deposit.ts` export pure functions +- **THEN** corresponding tests MUST exist under `test/` and run via `bun test` + +#### Scenario: RPC integration tests remain the contract for handlers +- **WHEN** handler modules are introduced +- **THEN** existing RPC test files (`treasury-discovery-rpc`, `order-book-rpc`, `internal-transfer-rpc`, `order-telemetry`) MUST continue to pass without modifying their public import path from `src/server.ts` + +### Requirement: Concrete imports over barrel growth +Contributors SHALL import from specific helper modules rather than expanding `helpers/index.ts` with server or handler utilities. + +#### Scenario: Server helpers are not re-exported from index +- **WHEN** `parsePayload`, deposit helpers, or grpc status helpers are extracted +- **THEN** they MUST NOT be added to the public barrel in `src/helpers/index.ts` +- **AND** consumers MUST import from the concrete module path (for example `helpers/grpc/payload`) \ No newline at end of file diff --git a/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/tasks.md b/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/tasks.md new file mode 100644 index 0000000..86749bc --- /dev/null +++ b/openspec/changes/archive/2026-06-05-cex-broker-server-modularization/tasks.md @@ -0,0 +1,64 @@ +## 1. Layout And Documentation Precedent + +- [x] 1.1 Document canonical folder layout and dependency rules in `design.md` (done in OpenSpec) and add a short pointer in `README.md` or `AGENTS.md` for contributors +- [x] 1.2 Confirm no `src/utils/` tree is introduced; all new modules live under `src/helpers/` or `src/handlers/` + +## 2. Phase 0 — Shared Primitives + +- [x] 2.1 Create `src/helpers/shared/guards.ts` with `isRecord` and `asRecord` (or unified export) +- [x] 2.2 Create `src/helpers/shared/errors.ts` with `getErrorMessage` and `safeLogError` +- [x] 2.3 Replace duplicate `isRecord` in `src/server.ts` and `src/helpers/order-book.ts` with shared import +- [x] 2.4 Replace duplicate record guard in `src/helpers/order-telemetry.ts` with shared import +- [x] 2.5 Replace six inline error ternaries in `Subscribe` with `getErrorMessage` +- [x] 2.6 Add `test/shared-guards.test.ts` and `test/shared-errors.test.ts` + +## 3. Phase 1a — gRPC Helpers + +- [x] 3.1 Create `src/helpers/grpc/payload.ts` and move `parsePayload` from `server.ts` +- [x] 3.2 Create `src/helpers/grpc/status.ts` with `stableGrpcErrorCode` and `mapCcxtErrorToGrpcStatus` +- [x] 3.3 Wire `server.ts` imports; remove local copies +- [x] 3.4 Add `test/grpc-status.test.ts` covering stable prefix and CCXT error class mappings + +## 4. Phase 1b — Domain Helpers + +- [x] 4.1 Create `src/helpers/treasury-discovery.ts` (`ExchangeWithDiscovery`, `callArgs`, `handleTreasuryDiscoveryCall`, `fetchCurrencyMetadata`) +- [x] 4.2 Create `src/helpers/transfer-network.ts` (`resolveTransferNetwork`, evidence builders, `networkAliasSet`) +- [x] 4.3 Create `src/helpers/deposit.ts` (deposit field, status, matching, amount/address helpers) +- [x] 4.4 Move `emitOrderExecutionTelemetryInBackground` into `src/helpers/order-telemetry.ts` +- [x] 4.5 Wire `server.ts` imports; remove ~350 lines of local helpers +- [x] 4.6 Add `test/deposit-helper.test.ts` for pure deposit helpers +- [x] 4.7 Run `bun test` including `test/treasury-discovery-rpc.test.ts` and `test/internal-transfer-rpc.test.ts` + +## 5. Phase 2 — RPC Boilerplate Patterns + +- [x] 5.1 Add `resolveGrpcError` (or equivalent) in `helpers/grpc/status.ts` composing message + stable + CCXT mapping +- [x] 5.2 Add payload guard helper that maps validation failures to `INVALID_ARGUMENT` consistently +- [x] 5.3 Extract repeated broker resolution sequence into a small helper (explicit deps: brokers, metadata) +- [x] 5.4 Refactor `ExecuteAction` cases to use new patterns without behavior change +- [x] 5.5 Run full `bun test` and `bun run check` + +## 6. Phase 3 — ExecuteAction Handlers (incremental) + +- [x] 6.1 Create `src/handlers/execute-action/index.ts` with `ExecuteActionContext`, handler registry, and types +- [x] 6.2 Extract `Action.Deposit` to `handlers/execute-action/deposit.ts` as template; verify deposit/treasury RPC tests +- [x] 6.3 Extract `Action.Withdraw` to `handlers/execute-action/withdraw.ts` +- [x] 6.4 Extract order actions (CreateOrder, GetOrderDetails, CancelOrder) to `handlers/execute-action/orders.ts` +- [x] 6.5 Extract `Action.Call` treasury branch to `handlers/execute-action/treasury-call.ts` +- [x] 6.6 Extract pass-through actions (FetchBalances, FetchTicker, FetchCurrency, etc.) to `handlers/execute-action/pass-through.ts` +- [x] 6.7 Reduce `server.ts` `ExecuteAction` to context build + registry dispatch +- [x] 6.8 Confirm `getServer` export path unchanged for all RPC tests + +## 7. Phase 4 — Subscribe Modularization + +- [x] 7.1 Extract subscription type handlers to `src/handlers/subscribe/` or `src/helpers/subscribe/` per design +- [x] 7.2 Ensure all stream error paths use `getErrorMessage` +- [x] 7.3 Delegate ORDERBOOK loop to existing `order-book` helpers where possible +- [x] 7.4 Run order-book and subscribe-related RPC tests + +## 8. Guardrails And Validation + +- [x] 8.1 Measure `server.ts` line count; target under 400 lines after Phases 3–4 (or document remaining debt) — `server.ts` is 45 lines; action logic lives under `handlers/execute-action/` +- [x] 8.2 Optional: add CI script to fail if `server.ts` exceeds agreed line budget — `scripts/check-server-line-budget.sh`, `bun run check:server-lines` +- [x] 8.3 Run `bun test` +- [x] 8.4 Run `bun run check` +- [x] 8.5 Run `openspec validate cex-broker-server-modularization --strict` \ No newline at end of file diff --git a/openspec/changes/cex-broker-order-book-depth-sourcing/.openspec.yaml b/openspec/changes/cex-broker-order-book-depth-sourcing/.openspec.yaml new file mode 100644 index 0000000..0ba725f --- /dev/null +++ b/openspec/changes/cex-broker-order-book-depth-sourcing/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-03 diff --git a/openspec/changes/cex-broker-order-book-depth-sourcing/README.md b/openspec/changes/cex-broker-order-book-depth-sourcing/README.md new file mode 100644 index 0000000..6392a22 --- /dev/null +++ b/openspec/changes/cex-broker-order-book-depth-sourcing/README.md @@ -0,0 +1,3 @@ +# cex-broker-order-book-depth-sourcing + +Add HB-compatible broker order-book capability, snapshot, live stream metadata, and historical unsupported/result contracts. diff --git a/openspec/changes/cex-broker-order-book-depth-sourcing/design.md b/openspec/changes/cex-broker-order-book-depth-sourcing/design.md new file mode 100644 index 0000000..cfa0a3f --- /dev/null +++ b/openspec/changes/cex-broker-order-book-depth-sourcing/design.md @@ -0,0 +1,109 @@ +## Context + +Fiet Maker HB strategy compatibility now depends on a broker-mediated order-book access surface for current depth, live depth, capability discovery, and historical sampled top-N snapshots when available. The broker currently exposes `ExecuteAction(Action.Call)` as a generic CCXT method invocation using `functionName`, and `Subscribe(ORDERBOOK)` streams raw CCXT order books as JSON text. + +Maker-side clients already call `Action.Call` with order-book-specific `method` payload values: + +- `fetch_order_book_capability` +- `fetch_order_book_snapshot` +- `fetch_historical_order_book_snapshots` + +The broker therefore needs a small typed order-book dispatch layer on top of the existing gRPC service before any proto-native order-book RPCs are considered. Existing stream clients must continue to decode `bids` and `asks` from `SubscribeResponse.data`. + +## Goals / Non-Goals + +**Goals:** + +- Provide HB-compatible JSON-over-`Action.Call` order-book methods. +- Return normalized current top-N snapshots with exchange, symbol, source timestamp, received timestamp, sequence/update id when available, and requested depth limit. +- Preserve existing `Subscribe(ORDERBOOK)` semantics while enriching stream payloads with the same metadata. +- Return typed historical unsupported payloads when the broker cannot supply historical sampled top-N snapshots. +- Report truthful provider capabilities without advertising exact L2 reconstruction prematurely. +- Keep exchange and symbol strict so the broker never substitutes another venue. + +**Non-Goals:** + +- Implement exact L2 reconstruction in this change. +- Treat OHLCV or trade volume as order-book-backed depth evidence. +- Replace the existing gRPC service with new proto-native order-book RPCs. +- Add a native Hummingbot adapter to this TypeScript broker unless a real integration is introduced separately. +- Emit credentials, secrets, or private provider metadata in response payloads. + +## Decisions + +### 1. Add an order-book Call router before generic CCXT dispatch + +`Action.Call` should first inspect a normalized order-book method name from `payload.method` or `payload.functionName`. If the method is one of the HB-compatible order-book methods, the broker handles it with an internal typed router. Other calls continue through the existing generic CCXT dispatch. + +This keeps Maker compatibility without a breaking proto change and avoids exposing these broker-specific methods as fake CCXT methods. + +Alternative considered: require Maker to send CCXT `functionName` values such as `fetchOrderBook`. That would provide current snapshots only and would not express capability discovery or typed historical unsupported responses. + +### 2. Keep JSON response contracts stable and tolerant + +The order-book router should emit camelCase fields that Maker already decodes, while preserving existing raw `bids` and `asks` arrays: + +```json +{ + "bids": [[100.0, 1.0]], + "asks": [[101.0, 2.0]], + "timestamp": 1760000000000, + "receivedTimestamp": 1760000000100, + "exchange": "binance", + "symbol": "BTC/USDT", + "sequence": 123, + "depthLimit": 100 +} +``` + +The broker may preserve provider raw fields in the object, but it should avoid reshaping `bids` and `asks` in a way that breaks old clients. + +Alternative considered: introduce protobuf messages immediately. That is cleaner long-term, but it forces generated client updates before the broker can satisfy the existing Maker helper contract. + +### 3. Historical support is capability-driven and honest + +Historical sampled top-N snapshots should be advertised only when the broker can return order-book snapshots for the requested exchange, symbol, window, cadence, and depth. If historical support is absent, `fetch_historical_order_book_snapshots` returns: + +```json +{ + "exchange": "mexc", + "symbol": "ARB/USDT", + "unsupported": true, + "unsupportedReason": "historical_order_book_provider_unsupported" +} +``` + +The broker must not synthesize historical responses from the live book after the requested window. + +Alternative considered: fail the gRPC call with `UNIMPLEMENTED`. That makes callers infer capability from transport behavior and conflicts with Maker's typed fallback path. + +### 4. Exact L2 reconstruction remains false until proven + +`supportsExactL2Reconstruction` should remain false unless the broker implements a snapshot-plus-delta reconstruction path with sequence continuity tests, timestamp ordering checks, and invalid-book rejection. + +Alternative considered: set exact reconstruction true for exchanges that expose websocket order-book updates. Websocket updates alone are insufficient because exact reconstruction requires initial snapshots, ordered deltas, continuity markers, and replay validation. + +### 5. Public market-data access needs an explicit implementation choice + +Current broker creation requires API key and secret. Order-book current/live public market-data access may need public exchange instantiation without credentials, or the broker may continue to require registered credentials/metadata for all exchange access. The implementation should pick one behavior deliberately and test it. + +Recommended v1: allow public order-book operations to create an exchange instance without credentials when the exchange class supports the requested public method, while keeping private trading and account actions credential-gated. + +Alternative considered: keep all broker access credential-gated. That is simpler but weakens the broker-mediated backtest path because public market data does not normally require credentials. + +## Risks / Trade-offs + +- Provider historical support is exchange-specific -> Capability responses must be derived from real provider support and conservative defaults. +- `Action.Call` currently validates `functionName` only -> Payload validation needs to accept Maker's `method` field without weakening dangerous-name protections for generic calls. +- Existing clients may expect raw CCXT stream payloads -> Stream enrichment must add fields without removing raw `bids`, `asks`, or timestamp fields. +- Public unauthenticated exchange creation can broaden broker behavior -> Limit it to public market-data methods and keep private actions on the existing credential path. +- CCXT `has` flags can be booleans or capability strings -> Capability mapping should treat unknown/non-true historical support conservatively. + +## Migration Plan + +1. Add payload parsing for order-book Call methods while preserving generic `functionName` calls. +2. Implement normalized current snapshot and capability responses using CCXT public order-book methods. +3. Enrich `Subscribe(ORDERBOOK)` payload JSON without changing response message fields. +4. Add historical response handling that returns typed unsupported unless real historical sampled top-N support is implemented. +5. Add tests for Maker method names, malformed payloads, unsupported historical results, stream backward compatibility, old/default subscription type behavior, and exact reconstruction false. +6. Update README/examples to document the order-book Call methods and corrected subscription enum behavior. diff --git a/openspec/changes/cex-broker-order-book-depth-sourcing/proposal.md b/openspec/changes/cex-broker-order-book-depth-sourcing/proposal.md new file mode 100644 index 0000000..0d76a5a --- /dev/null +++ b/openspec/changes/cex-broker-order-book-depth-sourcing/proposal.md @@ -0,0 +1,41 @@ +## Why + +Fiet Maker HB strategy backtests now expect cex-broker to provide order-book depth through broker-compatible typed operations instead of relying only on direct provider fallbacks. The broker currently exposes live `Subscribe(ORDERBOOK)` and generic CCXT `Call`, but it does not define HB-compatible order-book capability, current snapshot, or historical snapshot result contracts. + +## What Changes + +- Add HB-compatible JSON-over-`Action.Call` order-book methods: + - `fetch_order_book_capability` + - `fetch_order_book_snapshot` + - `fetch_historical_order_book_snapshots` +- Normalize current and live order-book payloads with bids, asks, source timestamp, broker received timestamp, exchange, symbol, sequence/update id when available, and requested depth limit. +- Return typed unsupported historical responses for unavailable historical depth instead of transport failures or empty streams. +- Preserve backward compatibility for existing `Subscribe(ORDERBOOK)` clients, including old/default subscription-type behavior and existing JSON `bids`/`asks` payload shape. +- Report truthful provider capabilities for current snapshots, live streams, sampled top-N historical snapshots, and exact L2 reconstruction. +- Keep exact L2 reconstruction unsupported until the broker implements snapshot-plus-delta reconstruction with sequence-continuity validation. +- Keep venue selection strict: responses must reflect the requested exchange and symbol and must not silently substitute another exchange. + +## Capabilities + +### New Capabilities + +- `cex-broker-order-book-depth-sourcing`: Defines broker order-book capability discovery, current snapshot fetches, live stream metadata compatibility, historical snapshot success/unsupported responses, and provider truthfulness for Fiet Maker HB compatibility. + +### Modified Capabilities + +- None. + +## Impact + +- Affected broker API surfaces: + - `ExecuteAction(Action.Call)` payload parsing and dispatch + - `Subscribe(ORDERBOOK)` response normalization + - broker capability/result JSON contracts consumed by Maker Python helpers +- Affected files likely include: + - `src/server.ts` + - `src/schemas/action-payloads.ts` + - `src/helpers/constants.ts` + - `src/proto/node.proto` and generated descriptor artifacts if proto comments or typed additions change + - `README.md` and order-book examples + - broker tests for Call payload compatibility, typed unsupported responses, live stream backward compatibility, and capability truthfulness +- No raw credentials, API secrets, or provider authentication metadata should be emitted in order-book response payloads. diff --git a/openspec/changes/cex-broker-order-book-depth-sourcing/specs/cex-broker-order-book-depth-sourcing/spec.md b/openspec/changes/cex-broker-order-book-depth-sourcing/specs/cex-broker-order-book-depth-sourcing/spec.md new file mode 100644 index 0000000..deec430 --- /dev/null +++ b/openspec/changes/cex-broker-order-book-depth-sourcing/specs/cex-broker-order-book-depth-sourcing/spec.md @@ -0,0 +1,134 @@ +## ADDED Requirements + +### Requirement: HB-compatible order-book Call methods +The broker SHALL expose HB-compatible order-book operations through `ExecuteAction` with `Action.Call` without removing the existing generic CCXT Call behavior. + +#### Scenario: Maker method payload dispatches order-book capability +- **WHEN** a client sends `Action.Call` with payload field `method = "fetch_order_book_capability"` +- **THEN** the broker MUST return a JSON capability object for the requested `cex`, `symbol`, `depthLimit`, and `constructionMode` +- **AND** the broker MUST NOT attempt to invoke a CCXT method named `fetch_order_book_capability` + +#### Scenario: Maker method payload dispatches current snapshot +- **WHEN** a client sends `Action.Call` with payload field `method = "fetch_order_book_snapshot"` +- **THEN** the broker MUST fetch or construct a current top-N order-book snapshot for the requested exchange and symbol +- **AND** the response MUST be decodable by Maker's `fetch_order_book_snapshot` helper + +#### Scenario: Maker method payload dispatches historical snapshots +- **WHEN** a client sends `Action.Call` with payload field `method = "fetch_historical_order_book_snapshots"` +- **THEN** the broker MUST return either typed historical snapshots or a typed unsupported historical response +- **AND** the client MUST NOT need to infer historical support from a gRPC transport failure or an empty stream + +#### Scenario: Existing generic Call behavior remains available +- **WHEN** a client sends `Action.Call` for a non-order-book CCXT method using the existing generic call payload shape +- **THEN** the broker MUST continue to validate and invoke the generic CCXT call according to existing behavior + +#### Scenario: Malformed order-book Call payload is rejected +- **WHEN** an order-book Call payload omits required fields or provides an invalid `depthLimit`, time window, cadence, or construction mode +- **THEN** the broker MUST fail the request with a typed validation error +- **AND** the broker MUST NOT call the provider with partially validated input + +### Requirement: Current order-book snapshots are normalized top-N payloads +The broker SHALL return current order-book snapshots using a normalized JSON object that preserves raw `bids` and `asks` arrays and includes source metadata required by Maker HB strategy compatibility. + +#### Scenario: Current snapshot includes required fields +- **WHEN** `fetch_order_book_snapshot` succeeds for an exchange, symbol, and depth limit +- **THEN** the response MUST include `bids`, `asks`, `timestamp`, `receivedTimestamp`, `exchange`, `symbol`, and `depthLimit` +- **AND** each bid and ask level MUST be a two-item numeric `[price, amount]` array + +#### Scenario: Sequence metadata is preserved when available +- **WHEN** the provider order book includes `sequence`, `updateId`, `lastUpdateId`, or `nonce` +- **THEN** the broker MUST include an equivalent sequence/update identifier in the normalized snapshot + +#### Scenario: Snapshot depth honors requested depth limit +- **WHEN** a client requests `depthLimit = N` +- **THEN** the broker MUST request or truncate each side to at most N levels +- **AND** it MUST report `depthLimit = N` in the response + +#### Scenario: Exchange and symbol are not substituted +- **WHEN** a client requests an order-book snapshot for a specific `cex` and `symbol` +- **THEN** the response MUST identify the same exchange and symbol +- **AND** the broker MUST NOT silently substitute another exchange or trading pair + +### Requirement: Live ORDERBOOK stream remains backward compatible +The broker SHALL preserve existing `Subscribe(ORDERBOOK)` behavior while allowing enriched order-book metadata for Maker live envelope compatibility. + +#### Scenario: Existing raw order-book consumers still decode stream data +- **WHEN** a client subscribes to `Subscribe(ORDERBOOK)` +- **THEN** each successful frame's `data` JSON MUST continue to contain `bids` and `asks` arrays at the top level +- **AND** old clients that only read `bids` and `asks` MUST continue to work + +#### Scenario: Stream frames include metadata for new clients +- **WHEN** the provider returns an order-book update with timestamp or sequence metadata +- **THEN** the broker MUST include equivalent metadata in the frame `data` JSON without removing the existing top-level `bids` and `asks` +- **AND** the `SubscribeResponse.timestamp` MUST record the broker received timestamp + +#### Scenario: Omitted or legacy subscription type remains orderbook +- **WHEN** a client omits subscription type or sends `NO_ACTION` +- **THEN** the broker MUST continue resolving the request to `ORDERBOOK` +- **AND** this compatibility behavior MUST be covered by tests + +#### Scenario: Stream error frames remain explicit +- **WHEN** the broker cannot continue an order-book stream +- **THEN** it MUST emit an error frame or stream error that identifies the failure +- **AND** it MUST NOT emit an empty successful order-book frame as a failure substitute + +### Requirement: Historical order-book snapshots return typed success or typed unsupported +The broker SHALL define historical sampled top-N order-book behavior as a typed result contract even when no provider currently supports the requested historical data. + +#### Scenario: Historical snapshot success payload +- **WHEN** the broker supports sampled top-N historical snapshots for the requested exchange, symbol, start, end, cadence, construction mode, and depth limit +- **THEN** the response MUST include `exchange`, `symbol`, and a `snapshots` array +- **AND** each snapshot MUST satisfy the normalized current snapshot field requirements + +#### Scenario: Historical unsupported payload +- **WHEN** the broker does not support the requested historical snapshots +- **THEN** the response MUST include `exchange`, `symbol`, `unsupported = true`, and `unsupportedReason = "historical_order_book_provider_unsupported"` +- **AND** the broker MUST return this as a successful `ActionResponse.result` JSON payload rather than a transport failure + +#### Scenario: Live book is not used as historical evidence +- **WHEN** a client requests historical snapshots for a past time window +- **THEN** the broker MUST NOT satisfy the request by sampling the current live order book after the requested window +- **AND** unavailable historical support MUST be reported as typed unsupported + +#### Scenario: Exact reconstruction request is unsupported without continuity +- **WHEN** a client requests `constructionMode = "exact_l2_reconstruction"` and the broker lacks a validated snapshot-plus-delta reconstruction path +- **THEN** the broker MUST return typed unsupported or report exact reconstruction as unsupported in capability discovery +- **AND** it MUST NOT downgrade the request to sampled top-N without the client changing construction mode + +### Requirement: Capability discovery is truthful and provider-scoped +The broker SHALL report order-book capabilities conservatively from actual broker/provider behavior for the requested exchange, symbol, construction mode, and depth limit. + +#### Scenario: Current and live support reflect provider methods +- **WHEN** `fetch_order_book_capability` is called +- **THEN** `supportsCurrentSnapshot` MUST be true only when the broker can fetch a current order-book snapshot for the requested exchange and symbol +- **AND** `supportsLiveStream` MUST be true only when the broker can stream order-book updates for the requested exchange and symbol + +#### Scenario: Historical support is not inferred from current support +- **WHEN** the provider supports current order-book snapshots but does not support historical sampled top-N snapshots for the requested parameters +- **THEN** the broker MUST set `supportsHistoricalSnapshots = false` +- **AND** it MUST set `supportsSampledTopN = false` for that historical capability + +#### Scenario: Exact L2 reconstruction is not advertised prematurely +- **WHEN** the broker has not implemented snapshot-plus-delta reconstruction with sequence-continuity validation +- **THEN** `supportsExactL2Reconstruction` MUST be false + +#### Scenario: MEXC capability uses CCXT provider identity +- **WHEN** capability is requested for MEXC +- **THEN** the broker MUST report a CCXT-backed provider identity for current/live order-book support when using CCXT +- **AND** historical support MUST remain false unless real CCXT-backed historical top-N snapshots are implemented for the requested parameters + +#### Scenario: Binance native Hummingbot capability is not fabricated +- **WHEN** capability is requested for Binance in this TypeScript broker +- **THEN** the broker MUST NOT advertise native Hummingbot historical support unless a real native Hummingbot adapter is implemented +- **AND** it MUST report CCXT-backed broker support only for capabilities actually available through the broker + +### Requirement: Order-book responses do not leak secrets +The broker SHALL keep order-book response payloads free of raw credentials, API secrets, authorization metadata, and private provider configuration. + +#### Scenario: Snapshot response excludes credentials +- **WHEN** a current, live, historical, or capability order-book response is emitted +- **THEN** the response MUST NOT include API keys, API secrets, request signing material, authorization headers, or secret-backed metadata values + +#### Scenario: Unsupported response excludes credentials +- **WHEN** a historical request returns typed unsupported +- **THEN** the unsupported response MUST include only non-secret diagnostic fields such as exchange, symbol, provider identity, and unsupported reason diff --git a/openspec/changes/cex-broker-order-book-depth-sourcing/tasks.md b/openspec/changes/cex-broker-order-book-depth-sourcing/tasks.md new file mode 100644 index 0000000..675a868 --- /dev/null +++ b/openspec/changes/cex-broker-order-book-depth-sourcing/tasks.md @@ -0,0 +1,50 @@ +## 1. Contract And Parsing + +- [x] 1.1 Add an order-book Call payload parser that accepts Maker's `method` field and the existing generic `functionName` field without weakening generic Call validation. +- [x] 1.2 Define supported order-book method constants for `fetch_order_book_capability`, `fetch_order_book_snapshot`, and `fetch_historical_order_book_snapshots`. +- [x] 1.3 Validate order-book Call inputs for `cex`, `symbol`, positive `depthLimit`, supported `constructionMode`, and historical `start`, `end`, and `cadence` fields before provider access. +- [x] 1.4 Route recognized order-book methods before the existing generic CCXT Call dispatch, and preserve existing generic Call behavior for all non-order-book methods. + +## 2. Order-Book Provider Helpers + +- [x] 2.1 Add a helper to resolve an exchange instance for public order-book market-data operations, including the chosen credential or public-instantiation behavior. +- [x] 2.2 Add a normalizer that converts provider order books into top-level `bids`, `asks`, `timestamp`, `receivedTimestamp`, `exchange`, `symbol`, `sequence`, and `depthLimit` fields. +- [x] 2.3 Ensure snapshot normalization requests or truncates each side to the requested depth limit and preserves provider sequence/update id aliases when available. +- [x] 2.4 Ensure normalized order-book payloads never include API keys, secrets, authorization headers, or secret-backed metadata. + +## 3. Current Snapshot And Capability Methods + +- [x] 3.1 Implement `fetch_order_book_snapshot` as a typed order-book Call handler backed by current provider order-book fetch behavior. +- [x] 3.2 Implement `fetch_order_book_capability` with conservative current, live, historical sampled top-N, and exact reconstruction capability fields. +- [x] 3.3 Ensure MEXC capability reports CCXT-backed provider identity for available broker current/live order-book support. +- [x] 3.4 Ensure Binance capability does not advertise native Hummingbot historical support unless a real adapter exists. +- [x] 3.5 Ensure `supportsExactL2Reconstruction` remains false until a validated reconstruction path exists. + +## 4. Historical Snapshot Result Contract + +- [x] 4.1 Implement `fetch_historical_order_book_snapshots` handler validation for window, cadence, depth, and construction mode. +- [x] 4.2 Return typed unsupported JSON with `unsupported = true` and `unsupportedReason = "historical_order_book_provider_unsupported"` when historical sampled top-N support is unavailable. +- [x] 4.3 Prevent historical requests from being satisfied by sampling the current live order book after the requested historical window. +- [x] 4.4 Return typed unsupported for exact L2 reconstruction unless snapshot-plus-delta continuity validation is implemented. + +## 5. Live Stream Compatibility + +- [x] 5.1 Enrich `Subscribe(ORDERBOOK)` frame `data` JSON with exchange, symbol, source timestamp, received timestamp, sequence/update id when available, and depth limit when configured. +- [x] 5.2 Preserve top-level `bids` and `asks` arrays in every successful order-book stream frame for old clients. +- [x] 5.3 Preserve existing omitted, invalid, and `NO_ACTION` subscription type compatibility that resolves to `ORDERBOOK`. +- [x] 5.4 Keep stream failures explicit through error frames or stream errors, never empty successful order-book frames. + +## 6. Tests And Documentation + +- [x] 6.1 Add unit tests for Maker `method` payload dispatch for capability, current snapshot, and historical snapshot calls. +- [x] 6.2 Add unit tests that generic non-order-book `Action.Call` behavior still works and dangerous generic method names remain rejected. +- [x] 6.3 Add unit tests for normalized snapshot fields, depth limit truncation, sequence alias preservation, and secret exclusion. +- [x] 6.4 Add unit tests for typed historical unsupported responses and exact reconstruction unsupported behavior. +- [x] 6.5 Add stream compatibility tests for old `bids`/`asks` consumers, enriched metadata, and omitted or `NO_ACTION` subscription type resolution. +- [x] 6.6 Update README and examples to document order-book Call methods, historical unsupported responses, capability fields, and the current `ORDERBOOK` enum/default behavior. + +## 7. Validation + +- [x] 7.1 Run `bun test`. +- [x] 7.2 Run `bun run check` or the repository's equivalent lint/type check command. +- [x] 7.3 Run `openspec validate cex-broker-order-book-depth-sourcing --strict`. diff --git a/openspec/specs/cex-broker-server-modularization/spec.md b/openspec/specs/cex-broker-server-modularization/spec.md new file mode 100644 index 0000000..020ccbe --- /dev/null +++ b/openspec/specs/cex-broker-server-modularization/spec.md @@ -0,0 +1,116 @@ +# cex-broker-server-modularization + +## Purpose + +The cex-broker broker SHALL organize server and domain logic in a thin `src/server.ts` entry with domain modules under `src/helpers/`, optional RPC handlers under `src/handlers/`, shared primitives under `src/helpers/shared/`, and gRPC transport helpers under `src/helpers/grpc/`. This layout reduces duplication, enforces dependency direction, and preserves gRPC contracts while enabling phased extraction and regression-safe delivery. + +## Requirements + +### Requirement: Canonical source layout for broker code +The cex-broker repository SHALL organize server and domain logic according to a documented layout: thin `src/server.ts`, domain modules under `src/helpers/`, optional RPC handlers under `src/handlers/`, shared primitives under `src/helpers/shared/`, and gRPC transport helpers under `src/helpers/grpc/`. + +#### Scenario: server.ts contains only wiring and dispatch +- **WHEN** the modularization change is complete +- **THEN** `src/server.ts` MUST limit itself to gRPC package loading, `getServer` factory, service registration, authentication and telemetry wrappers, and delegation to handlers or helpers +- **AND** `src/server.ts` MUST NOT contain deposit matching, transfer network resolution, treasury discovery, or other domain business rules inline + +#### Scenario: New domain logic uses helpers or handlers +- **WHEN** a contributor adds new broker domain behavior after this change lands +- **THEN** the implementation MUST reside in `src/helpers/.ts` or `src/handlers//.ts` +- **AND** it MUST NOT be added as new top-level functions inside `src/server.ts` + +#### Scenario: No parallel utils tree +- **WHEN** shared primitives are introduced or consolidated +- **THEN** they MUST live under `src/helpers/shared/` or `src/helpers/grpc/` +- **AND** the repository MUST NOT introduce a separate `src/utils/` tree for the same purpose + +### Requirement: Enforced module dependency direction +Helper modules MUST NOT depend on `server.ts` or `handlers/`; handlers and `server.ts` MAY depend on helpers. + +#### Scenario: Helpers remain independent of server +- **WHEN** any file under `src/helpers/` is imported +- **THEN** that file MUST NOT import from `src/server.ts` or `src/handlers/**` + +#### Scenario: Handlers compose helpers +- **WHEN** an action handler under `src/handlers/execute-action/` needs domain logic +- **THEN** it MUST import from `src/helpers/**` or `src/schemas/**` +- **AND** it MUST NOT duplicate logic that already exists in a helper module + +### Requirement: Shared primitives are defined once +The broker SHALL provide a single implementation for record guards and error message extraction used across server, helpers, and subscribe paths. + +#### Scenario: isRecord is shared +- **WHEN** code needs to narrow `unknown` to `Record` +- **THEN** it MUST use the export from `src/helpers/shared/guards.ts` +- **AND** duplicate local `isRecord` or equivalent `asRecord` implementations MUST NOT remain in `server.ts`, `order-book.ts`, or `order-telemetry.ts` + +#### Scenario: getErrorMessage is shared +- **WHEN** an error is formatted for logging, gRPC details, or Subscribe stream JSON +- **THEN** it MUST use `getErrorMessage` from `src/helpers/shared/errors.ts` +- **AND** inline `error instanceof Error ? error.message : ...` ternaries MUST NOT remain in `Subscribe` handlers after Phase 0 + +### Requirement: gRPC transport helpers are centralized +Payload validation and error-to-status mapping SHALL live in `src/helpers/grpc/` and be reused by all `ExecuteAction` paths. + +#### Scenario: Payload parsing uses grpc helper +- **WHEN** an action validates a Zod schema against `Record` payload fields +- **THEN** it MUST use `parsePayload` from `src/helpers/grpc/payload.ts` + +#### Scenario: CCXT and stable errors map consistently +- **WHEN** an `ExecuteAction` handler surfaces an error to the client +- **THEN** it MUST resolve gRPC status via `stableGrpcErrorCode` and/or `mapCcxtErrorToGrpcStatus` from `src/helpers/grpc/status.ts` (or a composed `resolveGrpcError` helper defined there) +- **AND** the mapping MUST remain behavior-identical to pre-extraction behavior for the same error inputs + +### Requirement: Domain helpers extracted from server.ts +Treasury discovery, transfer network resolution, and deposit validation helpers currently in `server.ts` SHALL be moved to dedicated helper modules without changing RPC contracts. + +#### Scenario: Treasury discovery is modular +- **WHEN** `Action.Call` dispatches `fetchMarkets` or `fetchCurrencies` treasury paths +- **THEN** the logic MUST be implemented in `src/helpers/treasury-discovery.ts` +- **AND** existing `test/treasury-discovery-rpc.test.ts` scenarios MUST pass unchanged + +#### Scenario: Transfer network resolution is modular +- **WHEN** withdraw or deposit flows resolve operator network aliases +- **THEN** the logic MUST be implemented in `src/helpers/transfer-network.ts` +- **AND** `test/internal-transfer-rpc.test.ts` MUST pass unchanged where applicable + +#### Scenario: Deposit validation helpers are modular +- **WHEN** deposit observation compares amounts, addresses, or transaction hashes +- **THEN** the logic MUST use exports from `src/helpers/deposit.ts` + +### Requirement: Phased delivery with regression safety +Each extraction phase MUST ship independently with passing tests; handler extraction is optional and action-scoped. + +#### Scenario: Phase 0–1 does not require handlers +- **WHEN** Phases 0–1 (shared, grpc, domain helpers) are merged +- **THEN** `getServer` MUST remain exported from `src/server.ts` +- **AND** `bun test` MUST pass with no proto or public API changes + +#### Scenario: Handler extraction is incremental +- **WHEN** Phase 3 migrates an `ExecuteAction` case to `src/handlers/execute-action/` +- **THEN** at most one action cluster SHOULD move per PR +- **AND** RPC tests covering that action MUST pass before merge + +#### Scenario: Public gRPC contract unchanged +- **WHEN** modularization PRs merge +- **THEN** proto definitions and client-visible request/response JSON shapes MUST NOT change +- **AND** no requirement in this spec SHALL be interpreted as permitting breaking gRPC API changes + +### Requirement: Unit tests for extracted pure helpers +Pure functions moved out of `server.ts` SHALL have dedicated unit tests that do not require starting a gRPC server. + +#### Scenario: Shared and grpc helpers are unit tested +- **WHEN** `helpers/shared/guards.ts`, `helpers/shared/errors.ts`, `helpers/grpc/status.ts`, or `helpers/deposit.ts` export pure functions +- **THEN** corresponding tests MUST exist under `test/` and run via `bun test` + +#### Scenario: RPC integration tests remain the contract for handlers +- **WHEN** handler modules are introduced +- **THEN** existing RPC test files (`treasury-discovery-rpc`, `order-book-rpc`, `internal-transfer-rpc`, `order-telemetry`) MUST continue to pass without modifying their public import path from `src/server.ts` + +### Requirement: Concrete imports over barrel growth +Contributors SHALL import from specific helper modules rather than expanding `helpers/index.ts` with server or handler utilities. + +#### Scenario: Server helpers are not re-exported from index +- **WHEN** `parsePayload`, deposit helpers, or grpc status helpers are extracted +- **THEN** they MUST NOT be added to the public barrel in `src/helpers/index.ts` +- **AND** consumers MUST import from the concrete module path (for example `helpers/grpc/payload`) \ No newline at end of file diff --git a/package.json b/package.json index 2fe0fcb..a1cf6bf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@usherlabs/cex-broker", - "version": "0.2.11", + "version": "0.2.36", "description": "Unified gRPC API to CEXs by Usher Labs.", "repository": { "type": "git", @@ -14,8 +14,9 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "devDependencies": { - "@biomejs/biome": "2.0.6", + "@biomejs/biome": "^2.4.16", "@types/bun": "latest", + "@types/ws": "^8.18.1", "bun-plugin-dts": "latest", "bun-types": "latest", "cpx": "^1.5.0", @@ -31,9 +32,17 @@ "scripts": { "proto-gen": "./proto-gen.sh", "start": "bun run ./src/index.ts", - "build": "bun run proto-gen && bun run ./build.ts && bun run build:ts && bun run copy:dts && bun run copy:proto", + "build": "bun run proto-gen && bun run ./build.ts && bun run build:ts && bun run copy:dts && bun run copy:proto && node scripts/check-node-package.mjs", "prepack": "bun run build", "start-broker": "bun run ./src/cli.ts", + "start-archive-forwarder": "bun run services/archive-forwarder/index.ts", + "start-ohlcv-collector": "bun run services/ohlcv-collector/index.ts", + "start-archive-watch": "bun run examples/archive-watch-subscribe.ts", + "start-candle-viewer": "bun run research/candle-viewer/server.ts", + "dev:candle-viewer": "bunx nodemon --watch research/candle-viewer --ext ts,html --signal SIGTERM --exec \"bun run start-candle-viewer\"", + "dev:archive-forwarder": "bunx nodemon --watch services/archive-forwarder --watch schema/clickhouse --ext ts,sql --signal SIGTERM --exec \"bun run start-archive-forwarder\"", + "dev:archive-watch": "bunx nodemon --watch examples --watch src/helpers/market-data-archive --watch src/handlers/subscribe --ext ts --signal SIGTERM --exec \"bun run start-archive-watch\"", + "dev:research": "bun run dev:candle-viewer", "start-broker-server": "bunx nodemon --watch src --watch policy --ext ts,js,json --exec 'bun run start-broker --policy policy/policy.json --port 8086 --whitelistAll'", "start-broker-server-with-verity": "bunx nodemon --watch src --watch policy --ext ts,js,json --exec 'bun run start-broker --policy policy/policy.json --port 8086 --whitelistAll --verityProverUrl http://verity-prover:8080'", "build:ts": "bunx tsc", @@ -43,6 +52,7 @@ "lint:fix": "bunx biome lint --write", "check": "bunx biome check", "check:fix": "bunx biome check --write", + "check:server-lines": "bash scripts/check-server-line-budget.sh", "copy:dts": "cpx \"build/**/*.d.ts\" ./dist/", "copy:proto": "cpx \"src/proto/*.proto\" ./dist/proto", "prepare": "bunx husky" @@ -51,6 +61,7 @@ "typescript": "^5" }, "dependencies": { + "@clickhouse/client": "^1.22.0", "@grpc/grpc-js": "^1.13.4", "@grpc/proto-loader": "^0.7.15", "@loglayer/plugin-opentelemetry": "^3.0.2", @@ -71,6 +82,7 @@ "protobufjs": "^7.4.0", "serialize-error": "^13.0.1", "tslog": "^4.9.3", + "ws": "8.18.3", "zod": "^4.3.6" }, "publishConfig": { @@ -80,6 +92,7 @@ "Oki Ayobami (https://github.com/xlassix)" ], "patchedDependencies": { - "@protobufjs/inquire@1.1.0": "patches/@protobufjs%2Finquire@1.1.0.patch" + "@protobufjs/inquire@1.1.0": "patches/@protobufjs%2Finquire@1.1.0.patch", + "@usherlabs/ccxt@0.0.14": "patches/@usherlabs%2Fccxt@0.0.14.patch" } } diff --git a/patches/@usherlabs%2Fccxt@0.0.14.patch b/patches/@usherlabs%2Fccxt@0.0.14.patch new file mode 100644 index 0000000..b8efc58 --- /dev/null +++ b/patches/@usherlabs%2Fccxt@0.0.14.patch @@ -0,0 +1,32 @@ +diff --git a/node_modules/@usherlabs/ccxt/.bun-tag-a296b4edbb70d359 b/.bun-tag-a296b4edbb70d359 +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/node_modules/@usherlabs/ccxt/.bun-tag-e1eca9fb87d1075f b/.bun-tag-e1eca9fb87d1075f +new file mode 100644 +index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 +diff --git a/dist/cjs/src/binance.js b/dist/cjs/src/binance.js +index 40b4c4e8989fcbcdc693216cdb0d43bf0f553a88..6c7cda095c614de0527fa225ed321671488cbef9 100644 +--- a/dist/cjs/src/binance.js ++++ b/dist/cjs/src/binance.js +@@ -12045,7 +12045,7 @@ class binance extends binance$1 { + if ((api === 'sapi') && (path === 'asset/dust')) { + query = this.urlencodeWithArrayRepeat(extendedParams); + } +- else if ((path === 'batchOrders') || (path.indexOf('sub-account') >= 0) || (path === 'capital/withdraw/apply') || (path.indexOf('staking') >= 0) || (path.indexOf('simple-earn') >= 0)) { ++ else if ((path === 'batchOrders') || (path.indexOf('sub-account') >= 0) || (path === 'capital/withdraw/apply') || (path === 'localentity/withdraw/apply') || (path === 'localentity/deposit/provide-info') || (path.indexOf('staking') >= 0) || (path.indexOf('simple-earn') >= 0)) { + if ((method === 'DELETE') && (path === 'batchOrders')) { + const orderidlist = this.safeList(extendedParams, 'orderidlist', []); + const origclientorderidlist = this.safeList(extendedParams, 'origclientorderidlist', []); +diff --git a/js/src/binance.js b/js/src/binance.js +index 9a472db82ff35f67c16bfde11b34831f503d00f9..7e7696cac61e08ac469d4d7c2b9c6fb861c84b3c 100644 +--- a/js/src/binance.js ++++ b/js/src/binance.js +@@ -12048,7 +12048,7 @@ export default class binance extends Exchange { + if ((api === 'sapi') && (path === 'asset/dust')) { + query = this.urlencodeWithArrayRepeat(extendedParams); + } +- else if ((path === 'batchOrders') || (path.indexOf('sub-account') >= 0) || (path === 'capital/withdraw/apply') || (path.indexOf('staking') >= 0) || (path.indexOf('simple-earn') >= 0)) { ++ else if ((path === 'batchOrders') || (path.indexOf('sub-account') >= 0) || (path === 'capital/withdraw/apply') || (path === 'localentity/withdraw/apply') || (path === 'localentity/deposit/provide-info') || (path.indexOf('staking') >= 0) || (path.indexOf('simple-earn') >= 0)) { + if ((method === 'DELETE') && (path === 'batchOrders')) { + const orderidlist = this.safeList(extendedParams, 'orderidlist', []); + const origclientorderidlist = this.safeList(extendedParams, 'origclientorderidlist', []); diff --git a/policy/policy.binance-mexc-usdc-bep20.example.json b/policy/policy.binance-mexc-usdc-bep20.example.json new file mode 100644 index 0000000..8766127 --- /dev/null +++ b/policy/policy.binance-mexc-usdc-bep20.example.json @@ -0,0 +1,41 @@ +{ + "withdraw": { + "rule": [ + { + "exchange": "BINANCE", + "network": "BEP20", + "coins": ["USDC"], + "whitelist": ["0x1111111111111111111111111111111111111111"] + }, + { + "exchange": "MEXC", + "network": "BEP20", + "coins": ["USDC"], + "whitelist": ["0x2222222222222222222222222222222222222222"] + } + ] + }, + "deposit": { + "rule": [ + { + "exchange": "MEXC", + "network": "BEP20", + "coins": ["USDC"] + }, + { + "exchange": "BINANCE", + "network": "BEP20", + "coins": ["USDC"] + } + ] + }, + "order": { + "rule": { + "markets": ["BINANCE:ARB/USDC", "MEXC:ARB/USDC"], + "limits": [ + { "from": "USDC", "to": "ARB", "min": 1, "max": 10000 }, + { "from": "ARB", "to": "USDC", "min": 1, "max": 1000 } + ] + } + } +} diff --git a/policy/policy.example.json b/policy/policy.example.json index cee629d..5d6c5bd 100644 --- a/policy/policy.example.json +++ b/policy/policy.example.json @@ -44,5 +44,23 @@ { "from": "USDT", "to": "ARB", "min": 1, "max": 10000 } ] } + }, + "travelRule": { + "rule": [ + { + "exchange": "BINANCE", + "enabled": true, + "description": "Enable for jurisdictions that require travel-rule metadata on Binance withdrawals (e.g. Australia/AUSTRAC), where the standard withdraw endpoint returns error -4104. Each address needs a questionnaire; self-owned destinations use isAddressOwner=1, sendTo=1, declaration=true.", + "addresses": { + "0x9d467fa9062b6e9b1a46e26007ad82db116c67cb": { + "questionnaire": { + "isAddressOwner": 1, + "sendTo": 1, + "declaration": true + } + } + } + } + ] } } diff --git a/research/README.md b/research/README.md new file mode 100644 index 0000000..3124287 --- /dev/null +++ b/research/README.md @@ -0,0 +1,161 @@ +# Research + +Tools for working with **cex-broker archived market data** in ClickHouse: live ingest verification, browser charts, Python backtests, and optional Hummingbot candle feeds. + +Full walkthrough: [docs/research-backtest.md](../docs/research-backtest.md). + +## Layout + +| Path | Purpose | +|------|---------| +| [`candle-viewer/`](candle-viewer/) | Browser chart (Lightweight Charts) polling `market_data.candles` | +| [`python/`](python/) | `cex-broker-research` package — load candles, rollups, simple backtest, Hummingbot param export | +| [`hummingbot/`](hummingbot/) | Optional `MarketDataProvider` feed that polls the same ClickHouse candles | + +## Data flow + +```text +cex-broker Subscribe (ORDERBOOK, OHLCV, TRADES, TICKER, …) + → BrokerExecutionArchiver + → archive-forwarder POST /archive (port 8090) + → ClickHouse market_data.* + → candle-viewer / Python / Hummingbot +``` + +### ClickHouse tables (`schema/clickhouse/market_data.sql`) + +| Table | Source stream | Notes | +|-------|---------------|--------| +| `market_data.candles` | OHLCV | Forming + closed bars; use `candles_closed` view for backtests | +| `market_data.orderbook_snapshots` | ORDERBOOK | TOB scalars + L2 depth arrays in one row per sample | +| `market_data.cex_trades` | TRADES | Public trade prints | +| `market_data.cex_ticker_events` | TICKER | Ticker snapshots | +| `market_data.cex_stream_events` | BALANCE, ORDERS, … | Redacted JSON payloads | + +Example queries: [`schema/clickhouse/research_queries.sql`](../schema/clickhouse/research_queries.sql). + +## Quick start (local) + +### 1. ClickHouse + forwarder + +```bash +docker network create fiet-sandbox || true +docker compose -f docker/clickhouse-research.compose.yml up -d +curl http://localhost:8090/health +``` + +Or run the forwarder on the host (schema is applied on startup): + +```bash +CLICKHOUSE_PORT=8123 bun run start-archive-forwarder +``` + +### 2. Broker with archive enabled + +```bash +CEX_BROKER_ARCHIVE_ENABLED=true \ +CEX_BROKER_ARCHIVE_FORWARDER_URL=http://localhost:8090/archive \ +CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH=./archive-loss.jsonl \ +CEX_BROKER_DEPLOYMENT_ID=local-dev \ +CEX_BROKER_MARKET_ARCHIVE_ENABLED=true \ +bun run start-broker --policy policy/policy.json --port 8086 --whitelistAll +``` + +### 3. Ingest market data + +Multi-stream watch (ORDERBOOK, OHLCV, TRADES, TICKER) for default symbols: + +```bash +SYMBOLS=BTC/USDT,BNB/USDT,DOGE/USDT bun run start-archive-watch +``` + +OHLCV-only seeder: `bun run examples/archive-ohlcv-subscribe.ts`. + +### 4. Live candle chart + +```bash +CLICKHOUSE_PORT=8123 bun run start-candle-viewer +``` + +Open [http://localhost:8091](http://localhost:8091). The UI polls `/api/candles` every 500ms (configurable). Higher timeframes in the chart are rolled up from archived `1m` bars. + +See [`candle-viewer/README.md`](candle-viewer/README.md) for env vars. + +### 5. Python backtest + +```bash +cd research/python +python -m venv .venv && source .venv/bin/activate +pip install -e ".[dev]" + +export CLICKHOUSE_HOST=localhost CLICKHOUSE_PORT=8123 CLICKHOUSE_DATABASE=market_data +python examples/candle_backtest.py +``` + +See [`python/README.md`](python/README.md). + +## Dev watchers (auto-restart on change) + +From repo root: + +| Script | Watches | Restarts | +|--------|---------|----------| +| `bun run dev:candle-viewer` | `research/candle-viewer/**` | candle viewer | +| `bun run dev:archive-forwarder` | `services/archive-forwarder`, `schema/clickhouse` | forwarder (+ schema) | +| `bun run dev:archive-watch` | `examples`, `src/helpers/market-data-archive`, subscribe handler | archive watch client | + +Example — chart dev loop with a local ClickHouse on port `18123`: + +```bash +CLICKHOUSE_PORT=18123 bun run dev:candle-viewer +``` + +Broker hot reload (separate from research): `bun run start-broker-server`. + +## Key environment variables + +### Broker → forwarder + +| Variable | Default | Purpose | +|----------|---------|---------| +| `CEX_BROKER_ARCHIVE_ENABLED` | disabled | Exact `true` enables archive delivery | +| `CEX_BROKER_ARCHIVE_FORWARDER_URL` | — | Required HTTP(S) forwarder POST target | +| `CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH` | — | Required writable durable JSONL loss journal | +| `CEX_BROKER_MARKET_ARCHIVE_ENABLED` | `true` | Enable market_data archiving | +| `CEX_BROKER_DEPLOYMENT_ID` | — | Tag rows in ClickHouse | +| `CEX_BROKER_ORDERBOOK_INTERVAL_MS` | `1000` | Orderbook archive sample rate | +| `CEX_BROKER_ORDERBOOK_TOB_INTERVAL_MS` | — | Legacy alias for orderbook interval | + +Production deployments must place `CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH` on persistent writable storage or a mounted volume. A container-local ephemeral file is not a durable loss journal. + +### ClickHouse clients (viewer, Python, forwarder) + +| Variable | Default | +|----------|---------| +| `CLICKHOUSE_HOST` | `localhost` | +| `CLICKHOUSE_PORT` | `8123` (forwarder) / set per tool | +| `CLICKHOUSE_DATABASE` | `market_data` | + +### Candle viewer + +| Variable | Default | +|----------|---------| +| `CANDLE_VIEWER_PORT` | `8091` | +| `CANDLE_VIEWER_POLL_MS` | `500` | +| `CANDLE_VIEWER_SYMBOLS` | `BTC/USDT,BNB/USDT,DOGE/USDT` | + +## Tests + +```bash +# TypeScript (viewer + archive) +bun test test/candle-viewer.test.ts test/market-data-archive.test.ts + +# Python +cd research/python && pytest +``` + +## Related docs + +- [docs/research-backtest.md](../docs/research-backtest.md) — end-to-end Path B guide +- [research/hummingbot/README.md](hummingbot/README.md) — live HB candles from ClickHouse +- [README.md](../README.md) — main broker documentation diff --git a/research/candle-viewer/README.md b/research/candle-viewer/README.md new file mode 100644 index 0000000..3299a31 --- /dev/null +++ b/research/candle-viewer/README.md @@ -0,0 +1,66 @@ +# Live candle viewer + +Browser candlestick chart backed by ClickHouse `market_data.candles`. Includes the **forming** bar (not just closed candles). + +## Run + +```bash +CLICKHOUSE_PORT=8123 bun run start-candle-viewer +``` + +Open [http://localhost:8091](http://localhost:8091). + +Dev mode (restart on file changes): + +```bash +CLICKHOUSE_PORT=8123 bun run dev:candle-viewer +``` + +## How it works + +- Serves static UI from `public/index.html` (TradingView **Lightweight Charts**). +- Client polls `GET /api/candles` on an interval (default **500ms**). +- Server reads `market_data.candles` with `FINAL` for deduped OHLCV. +- Timeframes `5m`, `15m`, `1h` are **rolled up in-process** from archived `1m` bars — only `1m` needs to be ingested. + +## API + +| Route | Description | +|-------|-------------| +| `GET /health` | ClickHouse ping | +| `GET /api/config` | Defaults, symbols, poll interval | +| `GET /api/candles?exchange=&symbol=&timeframe=&limit=` | Candle JSON | +| `GET /` | Chart UI | + +## Environment + +| Variable | Default | Purpose | +|----------|---------|---------| +| `CANDLE_VIEWER_PORT` | `8091` | HTTP port | +| `CANDLE_VIEWER_POLL_MS` | `500` | Client poll interval | +| `CANDLE_VIEWER_SYMBOLS` | `BTC/USDT,BNB/USDT,DOGE/USDT` | Symbol dropdown | +| `CANDLE_VIEWER_SYMBOL` | `BTC/USDT` | Initial symbol | +| `CANDLE_VIEWER_TIMEFRAME` | `1m` | Initial timeframe | +| `CANDLE_VIEWER_LIMIT` | `300` | Bars returned | +| `CANDLE_VIEWER_EXCHANGE` | `binance` | Exchange filter | +| `CLICKHOUSE_HOST` | `localhost` | | +| `CLICKHOUSE_PORT` | `8123` | | +| `CLICKHOUSE_DATABASE` | `market_data` | | + +## Prerequisites + +Archive ingest must be running (broker + forwarder + subscribe watch). See [research/README.md](../README.md). + +Verify data: + +```sql +SELECT max(open_time_ms), count() +FROM market_data.candles +WHERE exchange = 'binance' AND symbol = 'DOGE/USDT' AND timeframe = '1m'; +``` + +## Tests + +```bash +bun test test/candle-viewer.test.ts +``` diff --git a/research/candle-viewer/candles.ts b/research/candle-viewer/candles.ts new file mode 100644 index 0000000..1ed1b3e --- /dev/null +++ b/research/candle-viewer/candles.ts @@ -0,0 +1,185 @@ +import type { ClickHouseClient } from "@clickhouse/client"; +import { + BASE_TIMEFRAME, + rollupCandles, + timeframeMultiplier, + timeframeToMs, +} from "./timeframes"; + +export type CandleRow = { + open_time_ms: number; + open: number; + high: number; + low: number; + close: number; + volume: number; + is_closed: number; + broker_version: number; +}; + +export type ChartCandle = { + time: number; + open: number; + high: number; + low: number; + close: number; + volume: number; + isClosed: boolean; + brokerVersion: number; +}; + +export type CandleQuery = { + exchange: string; + symbol: string; + timeframe: string; + limit: number; +}; + +function parseFiniteNumber(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const trimmed = value.trim(); + if ( + trimmed === "" || + !/^[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?$/.test(trimmed) + ) { + return null; + } + const parsed = Number.parseFloat(trimmed); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return null; +} + +function toUInt(value: unknown): number | null { + const parsed = parseFiniteNumber(value); + if (parsed === null || parsed < 0 || !Number.isInteger(parsed)) { + return null; + } + return parsed; +} + +export function toChartCandle(row: CandleRow): ChartCandle | null { + const open = parseFiniteNumber(row.open); + const high = parseFiniteNumber(row.high); + const low = parseFiniteNumber(row.low); + const close = parseFiniteNumber(row.close); + const volume = parseFiniteNumber(row.volume); + const openTimeMs = toUInt(row.open_time_ms); + const isClosed = toUInt(row.is_closed); + const brokerVersion = toUInt(row.broker_version); + if ( + open === null || + high === null || + low === null || + close === null || + volume === null || + openTimeMs === null || + isClosed === null || + brokerVersion === null + ) { + return null; + } + return { + time: Math.trunc(openTimeMs / 1000), + open, + high, + low, + close, + volume, + isClosed: isClosed === 1, + brokerVersion, + }; +} + +export function candleFingerprint(candles: ChartCandle[]): string { + if (candles.length === 0) { + return ""; + } + const last = candles[candles.length - 1]; + return candles + .map( + (c) => + `${c.time}:${c.open}:${c.high}:${c.low}:${c.close}:${c.volume}:${c.isClosed}:${c.brokerVersion}`, + ) + .join("|"); +} + +export async function fetchRawCandles( + client: ClickHouseClient, + query: CandleQuery, + timeframe: string, +): Promise { + const result = await client.query({ + query: ` + SELECT + open_time_ms, + open, + high, + low, + close, + volume, + is_closed, + broker_version + FROM candles FINAL + WHERE exchange = {exchange:String} + AND symbol = {symbol:String} + AND timeframe = {timeframe:String} + ORDER BY open_time_ms DESC + LIMIT {limit:UInt32} + `, + query_params: { + exchange: query.exchange.toLowerCase(), + symbol: query.symbol, + timeframe, + limit: query.limit, + }, + format: "JSONEachRow", + }); + + const rows = (await result.json()) as CandleRow[]; + return rows + .map((row) => + toChartCandle({ + open_time_ms: row.open_time_ms, + open: row.open, + high: row.high, + low: row.low, + close: row.close, + volume: row.volume, + is_closed: row.is_closed, + broker_version: row.broker_version, + }), + ) + .filter((candle): candle is ChartCandle => candle !== null) + .sort((a, b) => a.time - b.time); +} + +export async function fetchCandles( + client: ClickHouseClient, + query: CandleQuery, +): Promise { + const requestedTimeframe = query.timeframe; + if (!timeframeToMs(requestedTimeframe)) { + return []; + } + + const multiplier = timeframeMultiplier(requestedTimeframe); + const baseLimit = Math.trunc(query.limit * multiplier); + + const baseCandles = await fetchRawCandles( + client, + { ...query, limit: baseLimit }, + BASE_TIMEFRAME, + ); + + if (multiplier === 1) { + return baseCandles.slice(-query.limit); + } + + return rollupCandles(baseCandles, requestedTimeframe).slice(-query.limit); +} diff --git a/research/candle-viewer/chart-update.ts b/research/candle-viewer/chart-update.ts new file mode 100644 index 0000000..a76de90 --- /dev/null +++ b/research/candle-viewer/chart-update.ts @@ -0,0 +1,28 @@ +export type CandleTimePoint = { + time: number; +}; + +export type SeriesSnapshot = { + count: number; + firstTime: number | null; + lastTime: number | null; +}; + +export function shouldReplaceCandleSeries( + previous: SeriesSnapshot, + candles: CandleTimePoint[], +): boolean { + if (candles.length === 0) { + return true; + } + if (previous.firstTime === null || previous.count === 0) { + return true; + } + const first = candles[0].time; + const last = candles[candles.length - 1].time; + return ( + candles.length !== previous.count || + first !== previous.firstTime || + last !== previous.lastTime + ); +} diff --git a/research/candle-viewer/config.ts b/research/candle-viewer/config.ts new file mode 100644 index 0000000..ebd5f21 --- /dev/null +++ b/research/candle-viewer/config.ts @@ -0,0 +1,80 @@ +export type ViewerConfig = { + port: number; + pollIntervalMs: number; + clickhouse: { + host: string; + port: number; + username: string; + password: string; + database: string; + }; + defaults: { + exchange: string; + symbol: string; + symbols: string[]; + timeframe: string; + limit: number; + }; +}; + +const SUPPORTED_TIMEFRAMES = ["1m", "5m", "15m", "1h"] as const; + +function parseSymbols(value: string | undefined): string[] { + const raw = + value?.trim() || + process.env.CANDLE_VIEWER_SYMBOLS?.trim() || + "BTC/USDT,BNB/USDT,DOGE/USDT"; + return raw + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function parsePort(value: string | undefined, fallback: number): number { + if (!value) { + return fallback; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function parsePositiveInt(value: string | undefined, fallback: number): number { + if (!value) { + return fallback; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +function parseTimeframe(value: string | undefined): string { + const normalized = value?.trim() || "1m"; + return SUPPORTED_TIMEFRAMES.includes( + normalized as (typeof SUPPORTED_TIMEFRAMES)[number], + ) + ? normalized + : "1m"; +} + +export function loadViewerConfig(): ViewerConfig { + return { + port: parsePort(process.env.CANDLE_VIEWER_PORT, 8091), + pollIntervalMs: parsePositiveInt( + process.env.CANDLE_VIEWER_POLL_MS, + 500, + ), + clickhouse: { + host: process.env.CLICKHOUSE_HOST?.trim() || "localhost", + port: parsePort(process.env.CLICKHOUSE_PORT, 18123), + username: process.env.CLICKHOUSE_USER?.trim() || "default", + password: process.env.CLICKHOUSE_PASSWORD ?? "", + database: process.env.CLICKHOUSE_DATABASE?.trim() || "market_data", + }, + defaults: { + exchange: process.env.CANDLE_VIEWER_EXCHANGE?.trim() || "binance", + symbol: process.env.CANDLE_VIEWER_SYMBOL?.trim() || "BTC/USDT", + symbols: parseSymbols(process.env.CANDLE_VIEWER_SYMBOLS), + timeframe: parseTimeframe(process.env.CANDLE_VIEWER_TIMEFRAME), + limit: parsePositiveInt(process.env.CANDLE_VIEWER_LIMIT, 300), + }, + }; +} diff --git a/research/candle-viewer/format.ts b/research/candle-viewer/format.ts new file mode 100644 index 0000000..0817ac2 --- /dev/null +++ b/research/candle-viewer/format.ts @@ -0,0 +1,23 @@ +/** Standard OHLCV price display precision (DOGE and sub-$2 pairs need 6dp). */ +export const PRICE_DECIMAL_PLACES = 6; + +export const PRICE_MIN_MOVE = 10 ** -PRICE_DECIMAL_PLACES; + +export function formatPrice(value: number): string { + return value.toLocaleString(undefined, { + minimumFractionDigits: PRICE_DECIMAL_PLACES, + maximumFractionDigits: PRICE_DECIMAL_PLACES, + }); +} + +export function chartPriceFormat(): { + type: "price"; + precision: number; + minMove: number; +} { + return { + type: "price", + precision: PRICE_DECIMAL_PLACES, + minMove: PRICE_MIN_MOVE, + }; +} diff --git a/research/candle-viewer/public/index.html b/research/candle-viewer/public/index.html new file mode 100644 index 0000000..7008f82 --- /dev/null +++ b/research/candle-viewer/public/index.html @@ -0,0 +1,451 @@ + + + + + + CEX Broker — Live Candles + + + + +
+
CEX Broker / live candles
+
+ + + + +
+
+
+
connecting
+
+
+
+
+ + + + + diff --git a/research/candle-viewer/server.ts b/research/candle-viewer/server.ts new file mode 100644 index 0000000..f266212 --- /dev/null +++ b/research/candle-viewer/server.ts @@ -0,0 +1,278 @@ +import { createClient } from "@clickhouse/client"; +import path from "path"; +import { + type CandleQuery, + candleFingerprint, + fetchCandles, + type ChartCandle, +} from "./candles"; +import { loadViewerConfig } from "./config"; +import { PRICE_DECIMAL_PLACES } from "./format"; + +const config = loadViewerConfig(); +const publicDir = path.join(import.meta.dir, "public"); + +const clickhouse = createClient({ + url: `http://${config.clickhouse.host}:${config.clickhouse.port}`, + username: config.clickhouse.username, + password: config.clickhouse.password, + database: config.clickhouse.database, +}); + +type WsData = { + type: "candles"; + exchange: string; + symbol: string; + timeframe: string; + candles: ChartCandle[]; + updatedAt: string; + total: number; +}; + +type WsClientState = { + query: CandleQuery; + lastFingerprint: string; +}; + +type WsSocket = ServerWebSocket; + +const wsClients = new Set(); +let pollRunning = false; +let pollInFlight = false; +let lastPollFinishedAt = Date.now(); + +async function pollAllClients(): Promise { + await Promise.allSettled( + [...wsClients].map(async (ws) => { + try { + await loadAndMaybePush(ws); + } catch (error) { + console.error("Candle poll failed:", error); + } + }), + ); +} + +async function runPollTick(): Promise { + if (pollInFlight) { + return; + } + pollInFlight = true; + try { + await pollAllClients(); + } catch (error) { + console.error("Candle poll loop failed:", error); + } finally { + pollInFlight = false; + lastPollFinishedAt = Date.now(); + setTimeout(() => { + void runPollTick(); + }, config.pollIntervalMs); + } +} + +function schedulePollLoop(): void { + if (pollRunning) { + return; + } + pollRunning = true; + setTimeout(() => { + void runPollTick(); + }, config.pollIntervalMs); + setInterval(() => { + if ( + !pollInFlight && + Date.now() - lastPollFinishedAt > config.pollIntervalMs * 4 + ) { + console.warn("Candle poll loop stalled; restarting"); + void runPollTick(); + } + }, config.pollIntervalMs * 2); +} + +function parseQuery(url: URL): CandleQuery { + return { + exchange: + url.searchParams.get("exchange")?.trim() || + config.defaults.exchange, + symbol: url.searchParams.get("symbol")?.trim() || config.defaults.symbol, + timeframe: + url.searchParams.get("timeframe")?.trim() || + config.defaults.timeframe, + limit: Number.parseInt( + url.searchParams.get("limit") ?? String(config.defaults.limit), + 10, + ), + }; +} + +function normalizeLimit(limit: number): number { + if (!Number.isFinite(limit) || limit <= 0) { + return config.defaults.limit; + } + return Math.min(Math.trunc(limit), 2_000); +} + +function sendCandles(ws: WsSocket, candles: ChartCandle[]): void { + try { + ws.send( + JSON.stringify({ + type: "candles", + exchange: ws.data.query.exchange, + symbol: ws.data.query.symbol, + timeframe: ws.data.query.timeframe, + candles, + updatedAt: new Date().toISOString(), + total: candles.length, + } satisfies WsData), + ); + } catch (error) { + console.error("Candle push failed; dropping client:", error); + wsClients.delete(ws); + try { + ws.close(); + } catch { + // ignore close errors on dead sockets + } + } +} + +async function loadAndMaybePush(ws: WsSocket, force = false): Promise { + const candles = await fetchCandles(clickhouse, { + ...ws.data.query, + limit: normalizeLimit(ws.data.query.limit), + }); + const fingerprint = candleFingerprint(candles); + if (!force && fingerprint === ws.data.lastFingerprint) { + return; + } + ws.data.lastFingerprint = fingerprint; + sendCandles(ws, candles); +} + +const server = Bun.serve({ + port: config.port, + async fetch(request, bunServer) { + const url = new URL(request.url); + + if (url.pathname === "/health") { + try { + await clickhouse.ping(); + return Response.json({ status: "ok", clickhouse: true }); + } catch { + return Response.json( + { status: "degraded", clickhouse: false }, + { status: 503 }, + ); + } + } + + if (url.pathname === "/api/candles") { + const query = parseQuery(url); + try { + const candles = await fetchCandles(clickhouse, { + ...query, + limit: normalizeLimit(query.limit), + }); + return Response.json({ + exchange: query.exchange, + symbol: query.symbol, + timeframe: query.timeframe, + candles, + updatedAt: new Date().toISOString(), + total: candles.length, + }); + } catch (error) { + return Response.json( + { + error: "Failed to load candles", + detail: error instanceof Error ? error.message : String(error), + }, + { status: 500 }, + ); + } + } + + if (url.pathname === "/api/config") { + return Response.json({ + defaults: config.defaults, + symbols: config.defaults.symbols, + timeframes: ["1m", "5m", "15m", "1h"], + baseTimeframe: "1m", + pollIntervalMs: config.pollIntervalMs, + priceDecimalPlaces: PRICE_DECIMAL_PLACES, + }); + } + + if (url.pathname === "/ws") { + const query = parseQuery(url); + const upgraded = bunServer.upgrade(request, { + data: { + query: { ...query, limit: normalizeLimit(query.limit) }, + lastFingerprint: "", + }, + }); + if (!upgraded) { + return new Response("WebSocket upgrade failed", { status: 500 }); + } + return undefined; + } + + const filePath = + url.pathname === "/" + ? path.join(publicDir, "index.html") + : path.join(publicDir, url.pathname.replace(/^\//, "")); + const file = Bun.file(filePath); + if (!(await file.exists())) { + return new Response("Not found", { status: 404 }); + } + return new Response(file); + }, + websocket: { + open(ws) { + wsClients.add(ws); + void loadAndMaybePush(ws, true).catch((error) => { + console.error("Initial candle load failed:", error); + }); + }, + close(ws) { + wsClients.delete(ws); + }, + message(ws, message) { + try { + const body = JSON.parse(String(message)) as { + type?: string; + exchange?: string; + symbol?: string; + timeframe?: string; + limit?: number; + }; + if (body.type !== "subscribe") { + return; + } + ws.data.query = { + exchange: body.exchange?.trim() || config.defaults.exchange, + symbol: body.symbol?.trim() || config.defaults.symbol, + timeframe: + body.timeframe?.trim() || config.defaults.timeframe, + limit: normalizeLimit(body.limit ?? config.defaults.limit), + }; + ws.data.lastFingerprint = ""; + void loadAndMaybePush(ws, true).catch((error) => { + console.error("Resubscribe candle load failed:", error); + }); + } catch { + // ignore malformed client messages + } + }, + }, +}); + +schedulePollLoop(); + +console.log( + `Candle viewer: http://localhost:${server.port} (poll ${config.pollIntervalMs}ms)`, +); +console.log( + `ClickHouse: ${config.clickhouse.host}:${config.clickhouse.port}/${config.clickhouse.database}`, +); diff --git a/research/candle-viewer/timeframes.ts b/research/candle-viewer/timeframes.ts new file mode 100644 index 0000000..2591352 --- /dev/null +++ b/research/candle-viewer/timeframes.ts @@ -0,0 +1,75 @@ +export const BASE_TIMEFRAME = "1m"; + +const TIMEFRAME_MS: Record = { + "1m": 60_000, + "5m": 5 * 60_000, + "15m": 15 * 60_000, + "1h": 60 * 60_000, +}; + +export const SUPPORTED_TIMEFRAMES = Object.keys(TIMEFRAME_MS); + +export function timeframeToMs(timeframe: string): number | null { + return TIMEFRAME_MS[timeframe] ?? null; +} + +export function timeframeMultiplier(requested: string): number { + const baseMs = timeframeToMs(BASE_TIMEFRAME); + const targetMs = timeframeToMs(requested); + if (!baseMs || !targetMs || targetMs <= baseMs) { + return 1; + } + return targetMs / baseMs; +} + +export type RollupCandle = { + time: number; + open: number; + high: number; + low: number; + close: number; + volume: number; + isClosed: boolean; + brokerVersion: number; +}; + +export function rollupCandles( + candles: RollupCandle[], + targetTimeframe: string, +): RollupCandle[] { + const targetMs = timeframeToMs(targetTimeframe); + const baseMs = timeframeToMs(BASE_TIMEFRAME); + if (!targetMs || !baseMs || targetMs <= baseMs || candles.length === 0) { + return candles; + } + + const barsPerBucket = targetMs / baseMs; + const buckets = new Map(); + + for (const candle of candles) { + const bucketMs = + Math.floor((candle.time * 1000) / targetMs) * targetMs; + const bucketTime = bucketMs / 1000; + const bucket = buckets.get(bucketTime) ?? []; + bucket.push(candle); + buckets.set(bucketTime, bucket); + } + + return [...buckets.entries()] + .sort(([a], [b]) => a - b) + .map(([time, bars]) => { + bars.sort((a, b) => a.time - b.time); + const open = bars[0].open; + const close = bars[bars.length - 1].close; + const high = Math.max(...bars.map((b) => b.high)); + const low = Math.min(...bars.map((b) => b.low)); + const volume = bars.reduce((sum, b) => sum + b.volume, 0); + const isClosed = + bars.length >= barsPerBucket && bars.every((b) => b.isClosed); + const brokerVersion = Math.max( + ...bars.map((b) => b.brokerVersion ?? 0), + ); + + return { time, open, high, low, close, volume, isClosed, brokerVersion }; + }); +} diff --git a/research/hummingbot/README.md b/research/hummingbot/README.md new file mode 100644 index 0000000..71425be --- /dev/null +++ b/research/hummingbot/README.md @@ -0,0 +1,81 @@ +# Hummingbot ClickHouse Market Data Feed + +Live OHLCV from cex-broker's ClickHouse archive for Hummingbot `MarketDataProvider`. + +Parent docs: [research/README.md](../README.md) · [docs/research-backtest.md](../../docs/research-backtest.md) + +## Files + +| File | Purpose | +|------|---------| +| `clickhouse_candles_feed.py` | `CexBrokerClickHouseCandles` — `CandlesBase` that polls ClickHouse | +| `register_clickhouse_feed.py` | Registers connector `cex_broker_clickhouse` in `CandlesFactory` | +| `example_market_data_provider.py` | Usage snippet | +| `market_data_provider_contract.py` | Contract tests for HB integration | +| `verify_market_data_contract.py` | CLI to verify feed against live ClickHouse | + +Core query logic lives in [research/python/cex_broker_research/live_candles.py](../python/cex_broker_research/live_candles.py) (no Hummingbot dependency). + +## Setup + +1. **Archive ingest running** — broker, archive-forwarder, ClickHouse, and archive watch: + +```bash +SYMBOLS=BTC/USDT,BNB/USDT,DOGE/USDT bun run start-archive-watch +``` + +2. **ClickHouse env** (same as research toolkit): + +```env +CLICKHOUSE_HOST=localhost +CLICKHOUSE_PORT=8123 +CLICKHOUSE_DATABASE=market_data +``` + +3. **Register at Hummingbot startup** (from fietCexBroker repo root): + +```bash +python research/hummingbot/register_clickhouse_feed.py +``` + +Or copy `clickhouse_candles_feed.py` into your Hummingbot tree and import/register manually. + +4. **Use in strategy**: + +```python +register_clickhouse_candles_feed() + +df = self.market_data_provider.get_candles_df( + connector_name="cex_broker_clickhouse", + trading_pair="binance:BTC-USDT", + interval="1m", + max_records=500, +) +``` + +## Trading pair format + +- `exchange:BASE-QUOTE` — e.g. `binance:BTC-USDT`, `bybit:ETH-USDT` +- `BASE-QUOTE` only — set `CLICKHOUSE_CANDLES_EXCHANGE=binance` + +Exchange names match the `exchange` column in `market_data.candles` (lowercase CCXT id). + +Symbol helpers: [research/python/cex_broker_research/symbols.py](../python/cex_broker_research/symbols.py). + +## Polling + +The feed polls `market_data.candles` (includes the forming bar) on an interval derived from the candle timeframe. Override with: + +```env +CLICKHOUSE_CANDLES_POLL_SEC=5 +``` + +## When to use + +- Signal from archived cex-broker data while executing on another connector +- Shared candle history across multiple HB instances reading the same ClickHouse table +- Research/backtest parity with live strategy indicators on the same data lane + +## Related tables + +This feed reads **`market_data.candles`** only. Orderbook context lives in **`market_data.orderbook_snapshots`** (TOB + depth per sample) if you need execution/spread analysis outside Hummingbot's candle API. diff --git a/research/hummingbot/clickhouse_candles_feed.py b/research/hummingbot/clickhouse_candles_feed.py new file mode 100644 index 0000000..48b1867 --- /dev/null +++ b/research/hummingbot/clickhouse_candles_feed.py @@ -0,0 +1,243 @@ +""" +Hummingbot candles feed that reads live OHLCV from cex-broker ClickHouse archives. + +Install: copy this file into your Hummingbot tree or add fietCexBroker/research/python +to PYTHONPATH, then run register_clickhouse_feed.py once at startup. + +Usage with MarketDataProvider: + candles_df = market_data_provider.get_candles_df( + connector_name="cex_broker_clickhouse", + trading_pair="binance:BTC-USDT", + interval="1m", + max_records=500, + ) +""" + +from __future__ import annotations + +import asyncio +import logging +import sys +from pathlib import Path +from typing import List, Optional + +import numpy as np + +_REPO_ROOT = Path(__file__).resolve().parents[2] +_PYTHON_RESEARCH = _REPO_ROOT / "research" / "python" +if str(_PYTHON_RESEARCH) not in sys.path: + sys.path.insert(0, str(_PYTHON_RESEARCH)) + +from cex_broker_research.live_candles import ( # noqa: E402 + ClickHouseCandleQuery, + default_poll_interval_seconds, + fetch_candle_rows, + parse_clickhouse_trading_pair, +) +from cex_broker_research.client import get_client # noqa: E402 + +try: + from hummingbot.core.network_iterator import NetworkStatus + from hummingbot.core.utils.async_utils import safe_ensure_future + from hummingbot.data_feed.candles_feed.candles_base import CandlesBase + from hummingbot.logger import HummingbotLogger +except ImportError as exc: # pragma: no cover - exercised inside Hummingbot runtime + raise ImportError( + "hummingbot is required for clickhouse_candles_feed; " + "run inside a Hummingbot environment or install hummingbot", + ) from exc + +CONNECTOR_NAME = "cex_broker_clickhouse" + + +def _seconds_to_ms(timestamp_sec: Optional[int]) -> int | None: + if timestamp_sec is None: + return None + return int(timestamp_sec) * 1_000 + + +def _build_candle_query( + exchange: str, + symbol: str, + timeframe: str, + max_records: int, + start_time: Optional[int] = None, + end_time: Optional[int] = None, +) -> ClickHouseCandleQuery: + return ClickHouseCandleQuery( + exchange=exchange, + symbol=symbol, + timeframe=timeframe, + max_records=max_records, + include_forming_bar=True, + start_time_ms=_seconds_to_ms(start_time), + end_time_ms=_seconds_to_ms(end_time), + ) + + +class CexBrokerClickHouseCandles(CandlesBase): + """Poll ClickHouse market_data.candles instead of exchange websocket klines.""" + + _logger: Optional[HummingbotLogger] = None + _poll_task: Optional[asyncio.Task] = None + + def __init__(self, trading_pair: str, interval: str = "1m", max_records: int = 150): + super().__init__(trading_pair, interval, max_records) + self._exchange, self._ccxt_symbol = parse_clickhouse_trading_pair(trading_pair) + self._poll_interval_sec = default_poll_interval_seconds(interval) + + @classmethod + def logger(cls) -> HummingbotLogger: + if cls._logger is None: + cls._logger = logging.getLogger(__name__) + return cls._logger + + @property + def name(self) -> str: + return f"cex_broker_clickhouse_{self._exchange}_{self._trading_pair}" + + @property + def rest_url(self) -> str: + return "clickhouse://candles" + + @property + def wss_url(self) -> str: + return "" + + @property + def health_check_url(self) -> str: + return self.rest_url + + @property + def candles_url(self) -> str: + return self.rest_url + + @property + def candles_endpoint(self) -> str: + return "candles" + + @property + def candles_max_result_per_rest_request(self) -> int: + return self.max_records + + @property + def rate_limits(self) -> list: + return [] + + @property + def intervals(self) -> dict: + return dict(self.interval_to_seconds) + + def get_exchange_trading_pair(self, trading_pair: str) -> str: + _, symbol = parse_clickhouse_trading_pair(trading_pair) + return symbol.replace("/", "") + + async def check_network(self) -> NetworkStatus: + await asyncio.get_event_loop().run_in_executor(None, get_client) + return NetworkStatus.CONNECTED + + def _get_rest_candles_params( + self, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + limit: Optional[int] = None, + ) -> dict: + return { + "exchange": self._exchange, + "symbol": self._ccxt_symbol, + "timeframe": self.interval, + "limit": limit or self.max_records, + "start_time": start_time, + "end_time": end_time, + } + + def _parse_rest_candles(self, data: dict, end_time: Optional[int] = None) -> List[List[float]]: + start_time = data.get("start_time") + query_end_time = end_time if end_time is not None else data.get("end_time") + return fetch_candle_rows( + _build_candle_query( + exchange=data["exchange"], + symbol=data["symbol"], + timeframe=data["timeframe"], + max_records=int(data["limit"]), + start_time=start_time, + end_time=query_end_time, + ), + ) + + async def fetch_candles( + self, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + limit: Optional[int] = None, + ): + await self.initialize_exchange_data() + query_limit = min(limit or self.max_records, self.max_records) + rows = await asyncio.get_event_loop().run_in_executor( + None, + fetch_candle_rows, + _build_candle_query( + exchange=self._exchange, + symbol=self._ccxt_symbol, + timeframe=self.interval, + max_records=query_limit, + start_time=start_time, + end_time=end_time, + ), + ) + if not rows: + return np.array([]).reshape(0, 10) + return np.array(rows[-query_limit:]).astype(float) + + async def listen_for_subscriptions(self): + """Poll ClickHouse on an interval and refresh the in-memory candle deque.""" + while True: + try: + await self._poll_clickhouse_once() + await self._sleep(self._poll_interval_sec) + except asyncio.CancelledError: + raise + except Exception: + self.logger().exception( + "ClickHouse candle poll failed; retrying in 1s", + ) + await self._sleep(1.0) + + async def _poll_clickhouse_once(self) -> None: + rows = await asyncio.get_event_loop().run_in_executor( + None, + fetch_candle_rows, + ClickHouseCandleQuery( + exchange=self._exchange, + symbol=self._ccxt_symbol, + timeframe=self.interval, + max_records=self.max_records, + include_forming_bar=True, + ), + ) + if not rows: + return + + if len(self._candles) == 0: + for row in rows: + self._candles.append(row) + self._ws_candle_available.set() + if self._fill_candles_task is None: + self._fill_candles_task = safe_ensure_future(self.fill_historical_candles()) + return + + latest_existing = int(self._candles[-1][0]) + latest_incoming = int(rows[-1][0]) + if latest_incoming > latest_existing + self.interval_in_seconds: + self._candles.clear() + for row in rows: + self._candles.append(row) + return + + for row in rows: + timestamp = int(row[0]) + if timestamp > latest_existing: + self._candles.append(row) + latest_existing = timestamp + elif timestamp == latest_existing: + self._candles[-1] = row diff --git a/research/hummingbot/example_market_data_provider.py b/research/hummingbot/example_market_data_provider.py new file mode 100644 index 0000000..26b90c5 --- /dev/null +++ b/research/hummingbot/example_market_data_provider.py @@ -0,0 +1,29 @@ +""" +Example: use ClickHouse archived candles via Hummingbot MarketDataProvider. + +Prerequisites: + - cex-broker archiving OHLCV to ClickHouse (see docs/research-backtest.md) + - CLICKHOUSE_HOST / CLICKHOUSE_PORT env vars pointing at ClickHouse HTTP + - register_clickhouse_feed.py executed once at HB startup + +In a ScriptStrategyBase or v2 controller: + + from research.hummingbot.register_clickhouse_feed import register_clickhouse_candles_feed + + register_clickhouse_candles_feed() + + # Inside on_tick or update_processed_data: + candles_df = self.market_data_provider.get_candles_df( + connector_name="cex_broker_clickhouse", + trading_pair="binance:BTC-USDT", + interval="1m", + max_records=500, + ) + +Trading pair format: + - ``binance:BTC-USDT`` — explicit exchange + HB pair + - ``BTC-USDT`` — requires CLICKHOUSE_CANDLES_EXCHANGE=binance + +The feed polls ClickHouse (closed + forming bars) instead of exchange websockets. +Useful when execution happens on a different venue than the signal source. +""" diff --git a/research/hummingbot/market_data_provider_contract.py b/research/hummingbot/market_data_provider_contract.py new file mode 100644 index 0000000..5e8a49b --- /dev/null +++ b/research/hummingbot/market_data_provider_contract.py @@ -0,0 +1,126 @@ +""" +MarketDataProvider functional contract coverage for cex-broker + Hummingbot integration. + +The full MarketDataProvider class lives in Hummingbot (hummingbot/data_feed/market_data_provider.py). +cex-broker extends only the **Candles** path via connector ``cex_broker_clickhouse``. + +Run: python research/hummingbot/verify_market_data_contract.py +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class Coverage(str, Enum): + """Who implements this contract group.""" + + HUMMINGBOT_NATIVE = "hummingbot_native" + CLICKHOUSE_CANDLES = "clickhouse_candles_extension" + NOT_IN_CEX_BROKER = "not_in_cex_broker" + + +@dataclass(frozen=True) +class ContractGroup: + name: str + methods: tuple[str, ...] + coverage: Coverage + notes: str + + +MARKET_DATA_PROVIDER_CONTRACT: tuple[ContractGroup, ...] = ( + ContractGroup( + "Lifecycle", + ("stop", "ready", "time"), + Coverage.HUMMINGBOT_NATIVE, + "HB MarketDataProvider; ClickHouse feeds participate in ready via CandlesBase.ready", + ), + ContractGroup( + "Rate Sources", + ("initialize_rate_sources", "remove_rate_sources"), + Coverage.HUMMINGBOT_NATIVE, + "Requires exchange/gateway connectors; not ClickHouse", + ), + ContractGroup( + "Connectors", + ("get_connector", "get_connector_with_fallback"), + Coverage.HUMMINGBOT_NATIVE, + "cex_broker_clickhouse is a candle feed connector name, not a trading ConnectorBase", + ), + ContractGroup( + "Balances", + ("get_balance", "get_available_balance"), + Coverage.HUMMINGBOT_NATIVE, + "Exchange connector only", + ), + ContractGroup( + "Market Prices", + ("get_price_by_type", "get_rate"), + Coverage.HUMMINGBOT_NATIVE, + "Exchange/gateway connectors", + ), + ContractGroup( + "Funding", + ("get_funding_info",), + Coverage.HUMMINGBOT_NATIVE, + "Perpetual connectors only", + ), + ContractGroup( + "Trading Metadata", + ("get_trading_pairs", "get_trading_rules"), + Coverage.HUMMINGBOT_NATIVE, + "Exchange connector metadata", + ), + ContractGroup( + "Quantization", + ("quantize_order_price", "quantize_order_amount"), + Coverage.HUMMINGBOT_NATIVE, + "Exchange trading rules", + ), + ContractGroup( + "Order Books", + ( + "initialize_order_book", + "initialize_order_books", + "remove_order_book", + "remove_order_books", + "get_order_book", + "get_order_book_snapshot", + ), + Coverage.HUMMINGBOT_NATIVE, + "Live exchange order books; cex-broker archives OB to ClickHouse separately", + ), + ContractGroup( + "Order Book Analytics", + ( + "get_price_for_volume", + "get_price_for_quote_volume", + "get_volume_for_price", + "get_quote_volume_for_price", + "get_vwap_for_volume", + ), + Coverage.HUMMINGBOT_NATIVE, + "Requires live order book from exchange connector", + ), + ContractGroup( + "Candles (live window)", + ( + "initialize_candles_feed", + "initialize_candles_feed_list", + "get_candles_feed", + "stop_candle_feed", + "get_candles_df", + ), + Coverage.CLICKHOUSE_CANDLES, + "Supported when connector=cex_broker_clickhouse after register_clickhouse_feed.py", + ), + ContractGroup( + "Historical Candles", + ("get_historical_candles_df",), + Coverage.CLICKHOUSE_CANDLES, + "Uses CandlesBase.fetch_candles / get_historical_candles backed by ClickHouse poll", + ), +) + +CONNECTOR_NAME = "cex_broker_clickhouse" diff --git a/research/hummingbot/register_clickhouse_feed.py b/research/hummingbot/register_clickhouse_feed.py new file mode 100644 index 0000000..d0afd12 --- /dev/null +++ b/research/hummingbot/register_clickhouse_feed.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +""" +Register cex-broker ClickHouse candles with Hummingbot CandlesFactory / MarketDataProvider. + +Run once at Hummingbot startup (before strategies load candle feeds): + + python research/hummingbot/register_clickhouse_feed.py + +Or from inside Hummingbot: + + from register_clickhouse_feed import register_clickhouse_candles_feed + register_clickhouse_candles_feed() +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_DIR = Path(__file__).resolve().parent +_REPO_ROOT = _DIR.parents[1] +sys.path.insert(0, str(_REPO_ROOT / "research" / "python")) +sys.path.insert(0, str(_DIR)) + +from clickhouse_candles_feed import CONNECTOR_NAME, CexBrokerClickHouseCandles # noqa: E402 + + +def register_clickhouse_candles_feed() -> None: + from hummingbot.data_feed.candles_feed.candles_factory import CandlesFactory + + CandlesFactory._candles_map[CONNECTOR_NAME] = CexBrokerClickHouseCandles + + +if __name__ == "__main__": + register_clickhouse_candles_feed() + print(f"Registered Hummingbot candles connector: {CONNECTOR_NAME}") diff --git a/research/hummingbot/verify_market_data_contract.py b/research/hummingbot/verify_market_data_contract.py new file mode 100644 index 0000000..6906400 --- /dev/null +++ b/research/hummingbot/verify_market_data_contract.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Print MarketDataProvider contract coverage and run smoke checks.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_DIR = Path(__file__).resolve().parent +_REPO_ROOT = _DIR.parents[1] +sys.path.insert(0, str(_REPO_ROOT / "research" / "python")) +sys.path.insert(0, str(_DIR)) + +from market_data_provider_contract import ( # noqa: E402 + CONNECTOR_NAME, + Coverage, + MARKET_DATA_PROVIDER_CONTRACT, +) +from cex_broker_research.live_candles import ( # noqa: E402 + fetch_candle_rows, + parse_clickhouse_trading_pair, +) + + +def _require(condition: bool, message: str) -> None: + if not condition: + raise RuntimeError(message) + + +def _is_connection_error(error: Exception) -> bool: + message = str(error).lower() + connection_markers = ( + "connection refused", + "connection reset", + "failed to establish", + "name or service not known", + "nodename nor servname provided", + "timed out", + "timeout", + "network is unreachable", + "errno 111", + "errno 61", + "errno 110", + ) + return any(marker in message for marker in connection_markers) + + +def print_coverage_matrix() -> None: + print("MarketDataProvider contract coverage (cex-broker repo)\n") + print(f"{'Group':<24} {'Coverage':<28} Methods") + print("-" * 80) + for group in MARKET_DATA_PROVIDER_CONTRACT: + print(f"{group.name:<24} {group.coverage.value:<28} {len(group.methods)}") + for method in group.methods: + print(f" - {method}") + print(f" {group.notes}\n") + + +def smoke_test_clickhouse_candle_path() -> None: + exchange, symbol = parse_clickhouse_trading_pair("binance:BTC-USDT") + _require(exchange == "binance", f"expected exchange binance, got {exchange!r}") + _require(symbol == "BTC/USDT", f"expected symbol BTC/USDT, got {symbol!r}") + + # Feed class + registration (requires hummingbot) + try: + from clickhouse_candles_feed import CexBrokerClickHouseCandles + from register_clickhouse_feed import register_clickhouse_candles_feed + + _require( + CexBrokerClickHouseCandles.__name__ == "CexBrokerClickHouseCandles", + "unexpected ClickHouse candles feed class name", + ) + from hummingbot.data_feed.candles_feed.candles_factory import CandlesFactory + + original_map = dict(CandlesFactory._candles_map) + try: + register_clickhouse_candles_feed() + _require( + CandlesFactory._candles_map[CONNECTOR_NAME] is CexBrokerClickHouseCandles, + "CandlesFactory registration did not map connector to feed class", + ) + print("OK: CandlesFactory registration") + finally: + CandlesFactory._candles_map.clear() + CandlesFactory._candles_map.update(original_map) + except ImportError as error: + if "hummingbot" not in str(error).lower(): + raise + print("SKIP: CexBrokerClickHouseCandles / registration (hummingbot not installed)") + + # Optional live ClickHouse query + try: + from cex_broker_research.live_candles import ClickHouseCandleQuery + + rows = fetch_candle_rows( + ClickHouseCandleQuery( + exchange=exchange, + symbol=symbol, + timeframe="1m", + max_records=3, + ), + ) + print(f"OK: ClickHouse poll returned {len(rows)} candle row(s)") + except Exception as error: # noqa: BLE001 + if _is_connection_error(error): + print(f"SKIP: ClickHouse live poll ({error})") + return + raise RuntimeError(f"ClickHouse live poll failed: {error}") from error + + +def main() -> int: + try: + print_coverage_matrix() + clickhouse_groups = [ + g for g in MARKET_DATA_PROVIDER_CONTRACT if g.coverage is Coverage.CLICKHOUSE_CANDLES + ] + native_groups = [ + g for g in MARKET_DATA_PROVIDER_CONTRACT if g.coverage is Coverage.HUMMINGBOT_NATIVE + ] + print( + f"Summary: {len(clickhouse_groups)} group(s) extended by cex-broker ClickHouse feed, " + f"{len(native_groups)} group(s) are Hummingbot-native (not reimplemented here).\n", + ) + smoke_test_clickhouse_candle_path() + except Exception as error: # noqa: BLE001 + print(f"FAIL: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/research/python/README.md b/research/python/README.md new file mode 100644 index 0000000..42f4faa --- /dev/null +++ b/research/python/README.md @@ -0,0 +1,75 @@ +# Python research toolkit + +Installable package **`cex-broker-research`** for loading archived candles from ClickHouse, rolling up timeframes, running a simple SMA crossover backtest, and exporting Hummingbot-oriented params. + +## Install + +```bash +cd research/python +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ".[dev]" +``` + +Requires Python **3.11+**. + +## Configure ClickHouse + +```bash +export CLICKHOUSE_HOST=localhost +export CLICKHOUSE_PORT=8123 +export CLICKHOUSE_DATABASE=market_data +``` + +Use the same host/port as your ClickHouse instance (compose default: `8123`). + +## Example backtest + +```bash +python examples/candle_backtest.py +``` + +Writes `examples/output/hummingbot_params.yaml` with symbol/timeframe hints for manual Hummingbot tuning. + +## Package overview + +| Module | Purpose | +|--------|---------| +| `candles.py` | Load closed candles into pandas | +| `live_candles.py` | Poll forming + closed bars (used by Hummingbot feed) | +| `rollups.py` | Roll `1m` → higher timeframes | +| `backtest_simple.py` | SMA crossover backtest | +| `export_hummingbot.py` | YAML param export | +| `symbols.py` | CCXT ↔ Hummingbot pair mapping | + +```python +from cex_broker_research import load_closed_candles, ccxt_to_hb, run_sma_crossover + +df = load_closed_candles(exchange="binance", symbol="BTC/USDT", timeframe="1m") +ccxt_to_hb("BTC/USDT") # "BTC-USDT" +``` + +Closed bars only — query path uses `market_data.candles_closed` (or equivalent filter). + +## Symbol mapping + +| CCXT (broker) | Hummingbot | +|---------------|------------| +| `BTC/USDT` | `BTC-USDT` | +| `binance` | `binance` / `binance_perpetual` | + +```python +from cex_broker_research.symbols import ccxt_to_hb, hb_to_ccxt +``` + +## Tests + +```bash +pytest +``` + +## Related + +- [research/README.md](../README.md) — full stack setup +- [research/hummingbot/README.md](../hummingbot/README.md) — live candles in Hummingbot strategies +- [docs/research-backtest.md](../../docs/research-backtest.md) — Path B guide diff --git a/research/python/cex_broker_research/__init__.py b/research/python/cex_broker_research/__init__.py new file mode 100644 index 0000000..a86a0b1 --- /dev/null +++ b/research/python/cex_broker_research/__init__.py @@ -0,0 +1,25 @@ +"""Research toolkit for cex-broker archived ClickHouse candles.""" + +from cex_broker_research.backtest_simple import BacktestSummary, run_sma_crossover +from cex_broker_research.candles import load_closed_candles +from cex_broker_research.export_hummingbot import export_hummingbot_params +from cex_broker_research.live_candles import ( + ClickHouseCandleQuery, + fetch_candle_rows, + parse_clickhouse_trading_pair, +) +from cex_broker_research.rollups import rollup_candles +from cex_broker_research.symbols import ccxt_to_hb, hb_to_ccxt + +__all__ = [ + "BacktestSummary", + "ClickHouseCandleQuery", + "ccxt_to_hb", + "export_hummingbot_params", + "fetch_candle_rows", + "hb_to_ccxt", + "load_closed_candles", + "parse_clickhouse_trading_pair", + "rollup_candles", + "run_sma_crossover", +] diff --git a/research/python/cex_broker_research/backtest_simple.py b/research/python/cex_broker_research/backtest_simple.py new file mode 100644 index 0000000..d24ecea --- /dev/null +++ b/research/python/cex_broker_research/backtest_simple.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + +from cex_broker_research.candles import load_closed_candles +from cex_broker_research.symbols import ccxt_to_hb + + +@dataclass(frozen=True) +class BacktestSummary: + trades: int + total_return: float + win_rate: float + + +def run_sma_crossover( + frame: pd.DataFrame, + fast_window: int = 10, + slow_window: int = 30, +) -> BacktestSummary: + if frame.empty: + return BacktestSummary(trades=0, total_return=0.0, win_rate=0.0) + + data = frame.sort_values("open_time_ms").copy() + data["fast_sma"] = data["close"].rolling(fast_window).mean() + data["slow_sma"] = data["close"].rolling(slow_window).mean() + data["signal"] = (data["fast_sma"] > data["slow_sma"]).astype(int) + data["position"] = data["signal"].shift(1).fillna(0) + data["returns"] = data["close"].pct_change().fillna(0) + data["strategy_returns"] = data["position"] * data["returns"] + + trades = int(data["position"].diff().abs().fillna(0).sum() / 2) + total_return = float((1 + data["strategy_returns"]).prod() - 1) + + completed_pnls: list[float] = [] + entry_price: float | None = None + entry_side = 0 + for row in data.itertuples(index=False): + position = int(row.position) + close = float(row.close) + if entry_price is None and position != 0: + entry_price = close + entry_side = position + continue + if entry_price is not None and position != entry_side: + if entry_side > 0: + completed_pnls.append((close - entry_price) / entry_price) + else: + completed_pnls.append((entry_price - close) / entry_price) + entry_price = close if position != 0 else None + entry_side = position + + winning_trades = sum(1 for pnl in completed_pnls if pnl > 0) + win_rate = float(winning_trades / max(len(completed_pnls), 1)) + return BacktestSummary(trades=trades, total_return=total_return, win_rate=win_rate) + + +def main() -> None: + exchange = "binance" + symbol = "BTC/USDT" + timeframe = "1m" + frame = load_closed_candles(exchange, symbol, timeframe) + summary = run_sma_crossover(frame) + print(f"Loaded {len(frame)} closed candles for {exchange} {symbol} {timeframe}") + print(f"Hummingbot pair hint: {ccxt_to_hb(symbol)}") + print( + "SMA crossover summary:", + f"trades={summary.trades}", + f"total_return={summary.total_return:.4f}", + f"win_rate={summary.win_rate:.4f}", + ) + + +if __name__ == "__main__": + main() diff --git a/research/python/cex_broker_research/candles.py b/research/python/cex_broker_research/candles.py new file mode 100644 index 0000000..267d2a8 --- /dev/null +++ b/research/python/cex_broker_research/candles.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import pandas as pd + +from cex_broker_research.client import get_client + + +def load_closed_candles( + exchange: str, + symbol: str, + timeframe: str, + start_ms: int | None = None, + end_ms: int | None = None, +) -> pd.DataFrame: + filters = [ + "exchange = %(exchange)s", + "symbol = %(symbol)s", + "timeframe = %(timeframe)s", + ] + params: dict[str, object] = { + "exchange": exchange.lower(), + "symbol": symbol, + "timeframe": timeframe, + } + if start_ms is not None: + filters.append("open_time_ms >= %(start_ms)s") + params["start_ms"] = start_ms + if end_ms is not None: + filters.append("open_time_ms <= %(end_ms)s") + params["end_ms"] = end_ms + + query = f""" + SELECT + open_time_ms, + open, + high, + low, + close, + volume, + quote_volume, + broker_version + FROM candles_closed + WHERE {' AND '.join(filters)} + ORDER BY open_time_ms + """ + client = get_client() + frame = client.query_df(query, parameters=params) + if frame.empty: + return frame + for col in ("open", "high", "low", "close", "volume", "quote_volume"): + if col in frame.columns: + frame[col] = pd.to_numeric(frame[col], errors="coerce") + frame["timestamp"] = pd.to_datetime(frame["open_time_ms"], unit="ms", utc=True) + return frame diff --git a/research/python/cex_broker_research/client.py b/research/python/cex_broker_research/client.py new file mode 100644 index 0000000..aceb201 --- /dev/null +++ b/research/python/cex_broker_research/client.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +import clickhouse_connect + +from cex_broker_research.config import ClickHouseSettings, load_clickhouse_settings + + +def get_client(settings: ClickHouseSettings | None = None): + resolved = settings or load_clickhouse_settings() + return clickhouse_connect.get_client( + host=resolved.host, + port=resolved.port, + username=resolved.username, + password=resolved.password, + database=resolved.database, + ) diff --git a/research/python/cex_broker_research/config.py b/research/python/cex_broker_research/config.py new file mode 100644 index 0000000..684420a --- /dev/null +++ b/research/python/cex_broker_research/config.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ClickHouseSettings: + host: str + port: int + username: str + password: str + database: str + + +def load_clickhouse_settings() -> ClickHouseSettings: + return ClickHouseSettings( + host=os.environ.get("CLICKHOUSE_HOST", "localhost"), + port=int(os.environ.get("CLICKHOUSE_PORT", "8123")), + username=os.environ.get("CLICKHOUSE_USER", "default"), + password=os.environ.get("CLICKHOUSE_PASSWORD", ""), + database=os.environ.get("CLICKHOUSE_DATABASE", "market_data"), + ) diff --git a/research/python/cex_broker_research/export_hummingbot.py b/research/python/cex_broker_research/export_hummingbot.py new file mode 100644 index 0000000..2036d19 --- /dev/null +++ b/research/python/cex_broker_research/export_hummingbot.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +from cex_broker_research.backtest_simple import BacktestSummary +from cex_broker_research.symbols import ccxt_to_hb + + +def export_hummingbot_params( + output_path: str | Path, + *, + exchange: str, + symbol: str, + timeframe: str, + summary: BacktestSummary, + fast_window: int, + slow_window: int, +) -> Path: + destination = Path(output_path) + destination.parent.mkdir(parents=True, exist_ok=True) + + payload = { + "research_source": "cex-broker-clickhouse", + "hummingbot": { + "connector": exchange, + "trading_pair": ccxt_to_hb(symbol), + "candles_interval": timeframe, + "strategy_notes": "Copy these values into your Hummingbot strategy config manually.", + }, + "indicators": { + "fast_sma_window": fast_window, + "slow_sma_window": slow_window, + }, + "backtest_summary": { + "trades": summary.trades, + "total_return": round(summary.total_return, 6), + "win_rate": round(summary.win_rate, 6), + }, + } + + destination.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8") + return destination diff --git a/research/python/cex_broker_research/live_candles.py b/research/python/cex_broker_research/live_candles.py new file mode 100644 index 0000000..437cc53 --- /dev/null +++ b/research/python/cex_broker_research/live_candles.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +import os +import re +from dataclasses import dataclass + +import pandas as pd + +from cex_broker_research.client import get_client +from cex_broker_research.symbols import hb_to_ccxt + +# Hummingbot CandlesBase column layout (seconds timestamp + OHLCV + placeholders). +HB_CANDLE_COLUMNS = [ + "timestamp", + "open", + "high", + "low", + "close", + "volume", + "quote_asset_volume", + "n_trades", + "taker_buy_base_volume", + "taker_buy_quote_volume", +] + +_TRADING_PAIR_PATTERN = re.compile( + r"^(?:(?P[a-z0-9_]+):)?(?P[A-Za-z0-9]+-[A-Za-z0-9]+)$", +) + + +@dataclass(frozen=True) +class ClickHouseCandleQuery: + exchange: str + symbol: str + timeframe: str + max_records: int + include_forming_bar: bool = True + start_time_ms: int | None = None + end_time_ms: int | None = None + + +def parse_clickhouse_trading_pair( + trading_pair: str, + default_exchange: str | None = None, +) -> tuple[str, str]: + """ + Parse HB trading pair for ClickHouse archive feeds. + + Formats: + - ``binance:BTC-USDT`` (exchange + HB pair) + - ``BTC-USDT`` (requires default_exchange or CLICKHOUSE_CANDLES_EXCHANGE env) + """ + match = _TRADING_PAIR_PATTERN.match(trading_pair.strip()) + if not match: + raise ValueError( + f"Invalid ClickHouse trading pair {trading_pair!r}; " + "expected 'exchange:BASE-QUOTE' or 'BASE-QUOTE'", + ) + exchange = match.group("exchange") + if not exchange: + exchange = ( + default_exchange + or os.environ.get("CLICKHOUSE_CANDLES_EXCHANGE", "").strip() + or os.environ.get("CEX_BROKER_HB_CLICKHOUSE_EXCHANGE", "").strip() + ) + if not exchange: + raise ValueError( + f"Trading pair {trading_pair!r} has no exchange prefix; " + "set CLICKHOUSE_CANDLES_EXCHANGE or use 'binance:BTC-USDT'", + ) + return exchange.lower(), hb_to_ccxt(match.group("pair")) + + +def dataframe_to_hb_candle_rows(frame: pd.DataFrame) -> list[list[float]]: + if frame.empty: + return [] + rows: list[list[float]] = [] + for record in frame.itertuples(index=False): + timestamp_sec = int(record.open_time_ms // 1000) + raw_quote_volume = record.quote_volume + quote_volume = ( + float(raw_quote_volume) + if raw_quote_volume is not None and raw_quote_volume == raw_quote_volume + else 0.0 + ) + rows.append( + [ + float(timestamp_sec), + float(record.open), + float(record.high), + float(record.low), + float(record.close), + float(record.volume), + quote_volume, + 0.0, + 0.0, + 0.0, + ], + ) + return rows + + +def fetch_candle_dataframe(query: ClickHouseCandleQuery) -> pd.DataFrame: + """Load the latest OHLCV window from ClickHouse (closed + optional forming bar).""" + source_table = "candles" if query.include_forming_bar else "candles_closed" + time_filters: list[str] = [] + parameters: dict[str, object] = { + "exchange": query.exchange.lower(), + "symbol": query.symbol, + "timeframe": query.timeframe, + "limit": query.max_records, + } + if query.start_time_ms is not None: + time_filters.append("open_time_ms >= %(start_time_ms)s") + parameters["start_time_ms"] = query.start_time_ms + if query.end_time_ms is not None: + time_filters.append("open_time_ms <= %(end_time_ms)s") + parameters["end_time_ms"] = query.end_time_ms + time_clause = f"\n\t\t\tAND {' AND '.join(time_filters)}" if time_filters else "" + sql = f""" + SELECT + open_time_ms, + open, + high, + low, + close, + volume, + quote_volume, + is_closed, + broker_version + FROM {source_table} + WHERE exchange = %(exchange)s + AND symbol = %(symbol)s + AND timeframe = %(timeframe)s{time_clause} + ORDER BY open_time_ms DESC + LIMIT %(limit)s + """ + client = get_client() + frame = client.query_df( + sql, + parameters=parameters, + ) + if frame.empty: + return frame + for col in ("open", "high", "low", "close", "volume", "quote_volume"): + if col in frame.columns: + frame[col] = pd.to_numeric(frame[col], errors="coerce") + frame.sort_values("open_time_ms", inplace=True) + frame.reset_index(drop=True, inplace=True) + return frame + + +def fetch_candle_rows(query: ClickHouseCandleQuery) -> list[list[float]]: + return dataframe_to_hb_candle_rows(fetch_candle_dataframe(query)) + + +def default_poll_interval_seconds(interval: str) -> float: + """Poll interval derived from candle timeframe (override via CLICKHOUSE_CANDLES_POLL_SEC).""" + override = os.environ.get("CLICKHOUSE_CANDLES_POLL_SEC", "").strip() + if override: + return max(float(override), 0.5) + interval_seconds = { + "1s": 1, + "1m": 60, + "3m": 180, + "5m": 300, + "15m": 900, + "30m": 1800, + "1h": 3600, + "2h": 7200, + "4h": 14400, + "6h": 21600, + "8h": 28800, + "12h": 43200, + "1d": 86400, + "3d": 259200, + "1w": 604800, + "1M": 2592000, + }.get(interval, 60) + return max(min(interval_seconds / 2.0, 15.0), 1.0) diff --git a/research/python/cex_broker_research/rollups.py b/research/python/cex_broker_research/rollups.py new file mode 100644 index 0000000..07ddb07 --- /dev/null +++ b/research/python/cex_broker_research/rollups.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import pandas as pd + + +def rollup_candles(frame: pd.DataFrame, target_ms: int) -> pd.DataFrame: + if frame.empty: + return frame.copy() + + working = frame.sort_values("open_time_ms").copy() + working["bucket_ms"] = (working["open_time_ms"] // target_ms) * target_ms + + grouped = working.groupby("bucket_ms", as_index=False).agg( + open=("open", "first"), + high=("high", "max"), + low=("low", "min"), + close=("close", "last"), + volume=("volume", "sum"), + ) + grouped["open_time_ms"] = grouped["bucket_ms"] + grouped["timestamp"] = pd.to_datetime(grouped["bucket_ms"], unit="ms", utc=True) + return grouped diff --git a/research/python/cex_broker_research/symbols.py b/research/python/cex_broker_research/symbols.py new file mode 100644 index 0000000..f42432a --- /dev/null +++ b/research/python/cex_broker_research/symbols.py @@ -0,0 +1,14 @@ +from __future__ import annotations + + +def ccxt_to_hb(symbol: str) -> str: + return symbol.replace("/", "-") + + +def hb_to_ccxt(symbol: str) -> str: + if "/" in symbol: + return symbol + if "-" not in symbol: + return symbol + base, quote = symbol.split("-", 1) + return f"{base}/{quote}" diff --git a/research/python/examples/candle_backtest.py b/research/python/examples/candle_backtest.py new file mode 100644 index 0000000..9ea752d --- /dev/null +++ b/research/python/examples/candle_backtest.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +from pathlib import Path + +from cex_broker_research.backtest_simple import run_sma_crossover +from cex_broker_research.candles import load_closed_candles +from cex_broker_research.export_hummingbot import export_hummingbot_params + +EXCHANGE = "binance" +SYMBOL = "BTC/USDT" +TIMEFRAME = "1m" +FAST_WINDOW = 10 +SLOW_WINDOW = 30 +OUTPUT = Path(__file__).resolve().parents[0] / "output" / "hummingbot_params.yaml" + + +def main() -> None: + frame = load_closed_candles(EXCHANGE, SYMBOL, TIMEFRAME) + summary = run_sma_crossover(frame, FAST_WINDOW, SLOW_WINDOW) + output = export_hummingbot_params( + OUTPUT, + exchange=EXCHANGE, + symbol=SYMBOL, + timeframe=TIMEFRAME, + summary=summary, + fast_window=FAST_WINDOW, + slow_window=SLOW_WINDOW, + ) + print(f"Loaded {len(frame)} closed candles") + print(f"Exported Hummingbot tuning params to {output}") + + +if __name__ == "__main__": + main() diff --git a/research/python/pyproject.toml b/research/python/pyproject.toml new file mode 100644 index 0000000..bdadf5e --- /dev/null +++ b/research/python/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "cex-broker-research" +version = "0.1.0" +description = "ClickHouse research toolkit for cex-broker archived candles" +requires-python = ">=3.11" +dependencies = [ + "clickhouse-connect>=0.7.0", + "pandas>=2.0.0", + "pyyaml>=6.0", +] + +[project.optional-dependencies] +dev = ["pytest>=8.0"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/research/python/tests/test_live_candles.py b/research/python/tests/test_live_candles.py new file mode 100644 index 0000000..7edea89 --- /dev/null +++ b/research/python/tests/test_live_candles.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +import pytest + +from cex_broker_research.live_candles import ( + ClickHouseCandleQuery, + dataframe_to_hb_candle_rows, + default_poll_interval_seconds, + parse_clickhouse_trading_pair, +) + + +def test_parse_clickhouse_trading_pair_with_exchange_prefix(): + exchange, symbol = parse_clickhouse_trading_pair("binance:BTC-USDT") + assert exchange == "binance" + assert symbol == "BTC/USDT" + + +def test_parse_clickhouse_trading_pair_uses_env(monkeypatch): + monkeypatch.setenv("CLICKHOUSE_CANDLES_EXCHANGE", "bybit") + exchange, symbol = parse_clickhouse_trading_pair("ETH-USDT") + assert exchange == "bybit" + assert symbol == "ETH/USDT" + + +def test_parse_clickhouse_trading_pair_requires_exchange(monkeypatch): + monkeypatch.delenv("CLICKHOUSE_CANDLES_EXCHANGE", raising=False) + monkeypatch.delenv("CEX_BROKER_HB_CLICKHOUSE_EXCHANGE", raising=False) + with pytest.raises(ValueError, match="no exchange prefix"): + parse_clickhouse_trading_pair("BTC-USDT") + + +def test_dataframe_to_hb_candle_rows(): + class Row: + open_time_ms: int + open: float + high: float + low: float + close: float + volume: float + quote_volume: float + + def __init__(self, **values: float | int): + for key, value in values.items(): + setattr(self, key, value) + + class FakeFrame: + empty = False + + def itertuples(self, index: bool = False): + yield Row( + open_time_ms=1_700_000_000_000, + open=100, + high=110, + low=90, + close=105, + volume=12.5, + quote_volume=1300.0, + ) + + rows = dataframe_to_hb_candle_rows(FakeFrame()) # type: ignore[arg-type] + assert rows == [[1_700_000_000, 100, 110, 90, 105, 12.5, 1300.0, 0.0, 0.0, 0.0]] + + +def test_default_poll_interval_seconds_respects_override(monkeypatch): + monkeypatch.setenv("CLICKHOUSE_CANDLES_POLL_SEC", "2.5") + assert default_poll_interval_seconds("1m") == 2.5 + + +def test_clickhouse_candle_query_defaults(): + query = ClickHouseCandleQuery( + exchange="binance", + symbol="BTC/USDT", + timeframe="1m", + max_records=100, + ) + assert query.include_forming_bar is True + assert query.start_time_ms is None + assert query.end_time_ms is None + + +def test_fetch_candle_dataframe_applies_time_bounds_in_sql(monkeypatch): + captured: dict[str, object] = {} + + class FakeClient: + def query_df(self, sql, parameters=None): + captured["sql"] = sql + captured["parameters"] = parameters + + class EmptyFrame: + empty = True + + return EmptyFrame() + + monkeypatch.setattr( + "cex_broker_research.live_candles.get_client", + lambda: FakeClient(), + ) + from cex_broker_research.live_candles import fetch_candle_dataframe + + fetch_candle_dataframe( + ClickHouseCandleQuery( + exchange="binance", + symbol="BTC/USDT", + timeframe="1m", + max_records=10, + start_time_ms=1_000, + end_time_ms=2_000, + ), + ) + sql = str(captured["sql"]) + parameters = captured["parameters"] + assert "open_time_ms >= %(start_time_ms)s" in sql + assert "open_time_ms <= %(end_time_ms)s" in sql + assert parameters["start_time_ms"] == 1_000 # type: ignore[index] + assert parameters["end_time_ms"] == 2_000 # type: ignore[index] diff --git a/research/python/tests/test_market_data_contract.py b/research/python/tests/test_market_data_contract.py new file mode 100644 index 0000000..f116d9f --- /dev/null +++ b/research/python/tests/test_market_data_contract.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[3] +_HB_DIR = _REPO_ROOT / "research" / "hummingbot" + + +def _load_contract_module(): + sys.path.insert(0, str(_HB_DIR)) + return importlib.import_module("market_data_provider_contract") + + +def test_contract_lists_all_candles_methods(): + mod = _load_contract_module() + methods = { + method + for group in mod.MARKET_DATA_PROVIDER_CONTRACT + for method in group.methods + } + expected_candles = { + "initialize_candles_feed", + "initialize_candles_feed_list", + "get_candles_feed", + "stop_candle_feed", + "get_candles_df", + "get_historical_candles_df", + } + assert expected_candles.issubset(methods) + + +def test_only_candles_groups_use_clickhouse_extension(): + mod = _load_contract_module() + for group in mod.MARKET_DATA_PROVIDER_CONTRACT: + if group.coverage is mod.Coverage.CLICKHOUSE_CANDLES: + assert "candle" in group.name.lower() or "historical" in group.name.lower() + else: + assert group.coverage is mod.Coverage.HUMMINGBOT_NATIVE + + +def test_clickhouse_connector_name(): + mod = _load_contract_module() + assert mod.CONNECTOR_NAME == "cex_broker_clickhouse" + + +@pytest.mark.skipif( + not importlib.util.find_spec("hummingbot"), + reason="hummingbot not installed", +) +def test_register_clickhouse_feed_when_hummingbot_present(): + sys.path.insert(0, str(_HB_DIR)) + from register_clickhouse_feed import register_clickhouse_candles_feed + from clickhouse_candles_feed import CONNECTOR_NAME, CexBrokerClickHouseCandles + from hummingbot.data_feed.candles_feed.candles_factory import CandlesFactory + + original_map = dict(CandlesFactory._candles_map) + try: + register_clickhouse_candles_feed() + assert CandlesFactory._candles_map[CONNECTOR_NAME] is CexBrokerClickHouseCandles + finally: + CandlesFactory._candles_map.clear() + CandlesFactory._candles_map.update(original_map) diff --git a/research/python/tests/test_symbols.py b/research/python/tests/test_symbols.py new file mode 100644 index 0000000..6baeaaa --- /dev/null +++ b/research/python/tests/test_symbols.py @@ -0,0 +1,11 @@ +from cex_broker_research.symbols import ccxt_to_hb, hb_to_ccxt + + +def test_ccxt_to_hb() -> None: + assert ccxt_to_hb("BTC/USDT") == "BTC-USDT" + assert ccxt_to_hb("ETH/USDC") == "ETH-USDC" + + +def test_hb_to_ccxt() -> None: + assert hb_to_ccxt("BTC-USDT") == "BTC/USDT" + assert hb_to_ccxt("BTC/USDT") == "BTC/USDT" diff --git a/schema/clickhouse/broker_account.sql b/schema/clickhouse/broker_account.sql new file mode 100644 index 0000000..74a390b --- /dev/null +++ b/schema/clickhouse/broker_account.sql @@ -0,0 +1,38 @@ +-- Durable account-level CEX balance observations. Each row is one coherent +-- fetchBalance({ type: 'spot' }) response for one configured broker account. +-- Quantities are decimal strings produced from CCXT-normalized JavaScript numbers; +-- they are not venue-raw or atomic-unit precision. +-- NO TTL: these rows are replay evidence for later as-of diagnostics. + +CREATE DATABASE IF NOT EXISTS broker_account; + +CREATE TABLE IF NOT EXISTS broker_account.balance_snapshots +( + broker_observed_timestamp DateTime64(3, 'UTC'), + exchange_timestamp Nullable(DateTime64(3, 'UTC')), + source LowCardinality(String), + deployment_id LowCardinality(String), + schema_version LowCardinality(String), + exchange LowCardinality(String), + account_selector LowCardinality(String), + balance_scope LowCardinality(String), + observation_id String, + -- Union of aggregate-map keys and CCXT per-asset entries, including assets + -- whose quantity is missing or non-numeric. + reported_assets Array(String), + asset_entry_assets Array(String), + -- Only explicit finite CCXT-normalized quantities are stored. A missing key + -- is unknown, while a present "0" is an explicit zero. + free_balances Map(String, String), + used_balances Map(String, String), + total_balances Map(String, String), + -- Whether CCXT supplied each aggregate map. Quantities can still be present + -- from the coherent response's per-asset entries. + aggregate_free_map_present UInt8, + aggregate_used_map_present UInt8, + aggregate_total_map_present UInt8, + precision_basis LowCardinality(String) +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(broker_observed_timestamp) +ORDER BY (exchange, account_selector, balance_scope, broker_observed_timestamp, observation_id); diff --git a/schema/clickhouse/broker_execution.sql b/schema/clickhouse/broker_execution.sql new file mode 100644 index 0000000..e7f6d38 --- /dev/null +++ b/schema/clickhouse/broker_execution.sql @@ -0,0 +1,204 @@ +-- Broker execution audit tables (order lifecycle + pre-order market metadata). +-- +-- Columns are derived 1:1 from the row builders in +-- src/helpers/broker-execution-archive/rows.ts. Every field those builders emit +-- has a column here; the full untruncated telemetry is also kept in payload_json. +-- +-- These rows reach ClickHouse through the same archive forwarder as market_data.* +-- (HTTP POST /archive). Unlike market_data streams they carry no Int64-ms event +-- column, so partitioning uses the always-present ISO8601 broker_observed_timestamp +-- as the event-time source (parsed to DateTime). +-- +-- NO TTL: these are execution audit facts and must not expire (market_data +-- streams carry a 90-day TTL; execution history is retained indefinitely). + +CREATE DATABASE IF NOT EXISTS broker_execution; + +-- Order lifecycle events: execute-action results and user-stream order updates. +-- Plain MergeTree intentionally retains duplicates. They are expected from status +-- polling (every GetOrderDetails observation is archived, while the strategy polls +-- twice per cycle) and at-least-once WebSocket delivery. The canonical read-time +-- dedup key is (exchange, account_selector, symbol, order_id, status, +-- filled_amount), taking argMin(..., broker_observed_timestamp); there is no +-- sequence or updated_at column from which to infer a later authoritative row. +CREATE TABLE IF NOT EXISTS broker_execution.order_events +( + source LowCardinality(String), + deployment_id LowCardinality(String), + account_selector LowCardinality(String), + -- Caller-declared order origin; a primary read key, default-empty when absent. + order_author LowCardinality(String) DEFAULT '', + + exchange LowCardinality(String), + symbol LowCardinality(String), + broker_observed_timestamp String, + + event_kind LowCardinality(String), + action LowCardinality(String), + subscription_type LowCardinality(String), + + -- Optional join keys: the row builders omit absent identifiers, so these are + -- Nullable rather than plain String. A non-nullable String would default to + -- '' on omission, and two rows that both lack a value would spuriously match + -- on '' in the documented joins (maker_action_id / idempotency_id / + -- client_order_id / order_id / market_metadata_hash). None are ORDER BY keys. + order_id Nullable(String), + client_order_id Nullable(String), + idempotency_id Nullable(String), + maker_action_id Nullable(String), + market_metadata_hash Nullable(String), + + status LowCardinality(String), + side LowCardinality(String), + order_type LowCardinality(String), + + requested_quantity Nullable(Float64), + requested_notional Nullable(Float64), + executed_base_quantity Nullable(Float64), + executed_quote_quantity Nullable(Float64), + average_execution_price Nullable(Float64), + filled_amount Nullable(Float64), + remaining_amount Nullable(Float64), + fee_amount Nullable(Float64), + fee_currency LowCardinality(String), + fee_rate Nullable(Float64), + + exchange_timestamp String, + error_type LowCardinality(String), + error_message String, + + payload_json String +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(parseDateTimeBestEffortOrZero(broker_observed_timestamp)) +ORDER BY (exchange, symbol, broker_observed_timestamp); + +ALTER TABLE broker_execution.order_events +ADD COLUMN IF NOT EXISTS order_author LowCardinality(String) DEFAULT '' AFTER account_selector; + +-- CEX value movements: withdrawals, deposits, and sub<->master internal transfers. +-- +-- Column names/types/ORDER BY match the fiet-maker consumer contract +-- (docs/CEX_EXECUTION_ARCHIVE_CONTRACT.md): MergeTree, DateTime64(3,'UTC') +-- timestamps, string quantities, result_index UInt32, error_summary. The contract +-- does not constrain retention; like every table in this file these are execution +-- audit facts (the venue cash-flow ledger) and must not expire, so no TTL. Three +-- ADDITIVE columns not in the consumer contract: client_withdrawal_id (the +-- caller's request-side withdrawal identity) and fee_amount / fee_currency (the +-- ccxt withdrawal object exposes the fee, the dominant small-commit cost). +-- broker_observed_timestamp is emitted as an ISO-8601 UTC string and parsed on +-- insert via the forwarder's date_time_input_format=best_effort (see +-- services/archive-forwarder/index.ts). +-- +-- Engine is plain MergeTree (contract), so re-observed rows are NOT collapsed; +-- dedup, when needed, is at read time (GROUP BY / argMax over exchange, +-- account_selector, symbol, external_id, lifecycle_action). +CREATE TABLE IF NOT EXISTS broker_execution.transfer_events +( + broker_observed_timestamp DateTime64(3, 'UTC'), + source LowCardinality(String), + deployment_id LowCardinality(String), + schema_version LowCardinality(String), + account_selector LowCardinality(String), + exchange LowCardinality(String), + symbol LowCardinality(String), + event_kind LowCardinality(String), + lifecycle_action LowCardinality(String), + status LowCardinality(String) DEFAULT '', + asset_symbol LowCardinality(String) DEFAULT '', + amount String DEFAULT '', + address String DEFAULT '', + network LowCardinality(String) DEFAULT '', + external_id String DEFAULT '', + client_withdrawal_id String DEFAULT '', + txid String DEFAULT '', + result_index UInt32 DEFAULT 0, + fee_amount String DEFAULT '', + fee_currency LowCardinality(String) DEFAULT '', + exchange_timestamp Nullable(DateTime64(3, 'UTC')), + error_summary String DEFAULT '', + payload_json String DEFAULT '' +) +ENGINE = MergeTree +PARTITION BY toDate(broker_observed_timestamp) +ORDER BY (account_selector, broker_observed_timestamp, exchange, symbol, event_kind, lifecycle_action); + +-- CREATE TABLE IF NOT EXISTS is a no-op on an already-populated table, so an +-- added column reaches fresh deployments only. Inserts name every column, so a +-- broker emitting client_withdrawal_id against a table that lacks it fails the +-- insert and the batch is dropped with a counter — silent loss in the ledger +-- that proves where money went. This runs on forwarder startup ahead of serving +-- and fail-closes (services/archive-forwarder/index.ts), so the column cannot +-- be missing while rows are accepted. +ALTER TABLE broker_execution.transfer_events +ADD COLUMN IF NOT EXISTS client_withdrawal_id String DEFAULT '' AFTER external_id; + +-- Per-fill execution facts from the venue trade-history endpoint (fetchMyTrades), +-- captured by the broker-internal fill poller. GetOrderDetails/createOrder payloads +-- carry no per-trade breakdown and no fee on most venues, so per-fill truth +-- (incl. fee) requires this endpoint; hence event_kind is stamped +-- "trade_history_fill" rather than the contract fixture's "create_order_fill". +-- +-- Column names/types/ORDER BY match the fiet-maker consumer contract: MergeTree, +-- DateTime64 timestamps, string quantities, fill_index UInt32. The contract does +-- not constrain retention; fills are execution audit facts and carry no TTL. Plain +-- MergeTree (contract): the fill poller re-scans a 24-hour lookback window, so the +-- same trade can be re-inserted. The canonical read-time dedup key is (exchange, +-- account_selector, symbol, order_id, fill_id). fill_index is NOT a stable +-- identifier and must not be used as a dedup key. There is no sequence or +-- updated_at column from which to infer a later authoritative row. +CREATE TABLE IF NOT EXISTS broker_execution.fill_events +( + broker_observed_timestamp DateTime64(3, 'UTC'), + source LowCardinality(String), + deployment_id LowCardinality(String), + schema_version LowCardinality(String), + account_selector LowCardinality(String), + exchange LowCardinality(String), + symbol LowCardinality(String), + event_kind LowCardinality(String), + order_id String, + client_order_id String DEFAULT '', + fill_id String DEFAULT '', + fill_index UInt32 DEFAULT 0, + side LowCardinality(String) DEFAULT '', + order_type LowCardinality(String) DEFAULT '', + price String DEFAULT '', + base_quantity String DEFAULT '', + quote_quantity String DEFAULT '', + fee_amount String DEFAULT '', + fee_currency LowCardinality(String) DEFAULT '', + fee_rate String DEFAULT '', + exchange_timestamp Nullable(DateTime64(3, 'UTC')), + payload_json String DEFAULT '' +) +ENGINE = MergeTree +PARTITION BY toDate(broker_observed_timestamp) +ORDER BY (symbol, account_selector, broker_observed_timestamp, exchange, order_id, fill_index); + +-- Pre-order top-of-book snapshots captured immediately before an order action, +-- joinable to order_events via market_metadata_hash and the order identifiers. +CREATE TABLE IF NOT EXISTS broker_execution.market_metadata_snapshots +( + source LowCardinality(String), + deployment_id LowCardinality(String), + account_selector LowCardinality(String), + + exchange LowCardinality(String), + symbol LowCardinality(String), + broker_observed_timestamp String, + + -- Optional join keys (see order_events): Nullable so an omitted identifier is + -- NULL, not '', avoiding spurious ''-on-'' matches. market_metadata_hash is + -- always computed for a snapshot row, so it stays non-nullable. + client_order_id Nullable(String), + order_id Nullable(String), + maker_action_id Nullable(String), + idempotency_id Nullable(String), + market_metadata_hash String, + + snapshot_json String +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(parseDateTimeBestEffortOrZero(broker_observed_timestamp)) +ORDER BY (exchange, symbol, broker_observed_timestamp); diff --git a/schema/clickhouse/market_data.sql b/schema/clickhouse/market_data.sql new file mode 100644 index 0000000..71f8121 --- /dev/null +++ b/schema/clickhouse/market_data.sql @@ -0,0 +1,215 @@ +-- Market data tables for cex-broker subscribe watch streams +-- (ORDERBOOK, OHLCV, TRADES, TICKER, BALANCE, ORDERS). +-- +-- Forwarder contract (HTTP POST from BrokerExecutionArchiver): +-- Enabled only by CEX_BROKER_ARCHIVE_ENABLED=true. +-- URL: CEX_BROKER_ARCHIVE_FORWARDER_URL (required, no derived default). +-- Loss journal: CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH (required, writable). +-- { +-- "source": "broker_write", +-- "deployment_id": "", +-- "rows": [{ "table": "", "row": { ...columns } }] +-- } +-- +-- The forwarder is the single durable sink for every archive table: market_data.* +-- here, plus broker_execution.* (broker_execution.sql), broker_account.* +-- (broker_account.sql), and strategy_data.* (strategy_data.sql). Execution rows +-- may ALSO be mirrored to OTel logs for +-- observability when CEX_BROKER_ARCHIVE_OTEL_LOGS_ENABLED=true; that mirror is in +-- addition to the forwarder, not a replacement, and market_data.* is never +-- mirrored (no OTel schema exists for it). + +CREATE DATABASE IF NOT EXISTS market_data; + + +CREATE TABLE IF NOT EXISTS market_data.orderbook_snapshots +( + source LowCardinality(String), + deployment_id LowCardinality(String), + account_selector LowCardinality(String), + + exchange LowCardinality(String), + asset_type LowCardinality(String), + symbol LowCardinality(String), + + event_time_ms UInt64, + received_time_ms UInt64, + + best_bid Decimal(18, 8), + best_ask Decimal(18, 8), + bid_size Decimal(18, 8), + ask_size Decimal(18, 8), + mid Decimal(18, 8), + spread_bps Float32, + + depth_limit UInt16, + bid_levels UInt16, + ask_levels UInt16, + + bids_price Array(Decimal(18, 8)), + bids_size Array(Decimal(18, 8)), + asks_price Array(Decimal(18, 8)), + asks_size Array(Decimal(18, 8)), + + sequence Nullable(UInt64) +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(fromUnixTimestamp64Milli(event_time_ms)) +ORDER BY (exchange, asset_type, symbol, event_time_ms) +TTL toDateTime(fromUnixTimestamp64Milli(event_time_ms)) + INTERVAL 90 DAY; + +-- Backward-compatible views (query only; inserts use orderbook_snapshots). +CREATE VIEW IF NOT EXISTS market_data.orderbook_tob AS +SELECT + source, + deployment_id, + account_selector, + exchange, + asset_type, + symbol, + event_time_ms, + received_time_ms, + best_bid, + best_ask, + bid_size, + ask_size, + mid, + spread_bps, + sequence +FROM market_data.orderbook_snapshots; + +CREATE VIEW IF NOT EXISTS market_data.orderbook_depth AS +SELECT + source, + deployment_id, + account_selector, + exchange, + asset_type, + symbol, + event_time_ms, + received_time_ms, + depth_limit, + bid_levels, + ask_levels, + bids_price, + bids_size, + asks_price, + asks_size, + sequence +FROM market_data.orderbook_snapshots; + +-- OHLCV candles from fetchOHLCVWs (forming + closed via ReplacingMergeTree). +CREATE TABLE IF NOT EXISTS market_data.candles +( + source LowCardinality(String), + deployment_id LowCardinality(String), + account_selector LowCardinality(String), + + exchange LowCardinality(String), + asset_type LowCardinality(String), + symbol LowCardinality(String), + timeframe LowCardinality(String), + + open_time_ms UInt64, + + open Decimal(18, 8), + high Decimal(18, 8), + low Decimal(18, 8), + close Decimal(18, 8), + volume Decimal(18, 8), + quote_volume Nullable(Decimal(18, 8)), + + is_closed UInt8, + broker_version UInt64 +) +ENGINE = ReplacingMergeTree(broker_version) +PARTITION BY toYYYYMM(fromUnixTimestamp64Milli(open_time_ms)) +ORDER BY (exchange, asset_type, symbol, timeframe, open_time_ms); + +-- Deduped closed candles for research/backtest queries (ReplacingMergeTree FINAL). +CREATE VIEW IF NOT EXISTS market_data.candles_closed AS +SELECT * +FROM market_data.candles FINAL +WHERE is_closed = 1; + +-- Generic subscribe stream payloads (balance, orders, etc.). +CREATE TABLE IF NOT EXISTS market_data.cex_stream_events +( + source LowCardinality(String), + deployment_id LowCardinality(String), + account_selector LowCardinality(String), + + exchange LowCardinality(String), + asset_type LowCardinality(String), + symbol LowCardinality(String), + + stream_type LowCardinality(String), + + event_time_ms UInt64, + received_time_ms UInt64, + + payload_json String +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(fromUnixTimestamp64Milli(event_time_ms)) +ORDER BY (exchange, asset_type, symbol, stream_type, event_time_ms) +TTL toDateTime(fromUnixTimestamp64Milli(event_time_ms)) + INTERVAL 90 DAY; + +-- Ticker snapshots from watchTicker. +CREATE TABLE IF NOT EXISTS market_data.cex_ticker_events +( + source LowCardinality(String), + deployment_id LowCardinality(String), + account_selector LowCardinality(String), + + exchange LowCardinality(String), + asset_type LowCardinality(String), + symbol LowCardinality(String), + + event_time_ms UInt64, + received_time_ms UInt64, + + last Nullable(Decimal(18, 8)), + bid Nullable(Decimal(18, 8)), + ask Nullable(Decimal(18, 8)), + high Nullable(Decimal(18, 8)), + low Nullable(Decimal(18, 8)), + open Nullable(Decimal(18, 8)), + close Nullable(Decimal(18, 8)), + base_volume Nullable(Decimal(18, 8)), + quote_volume Nullable(Decimal(18, 8)), + change Nullable(Decimal(18, 8)), + percentage Nullable(Decimal(18, 8)), + + payload_json Nullable(String) +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(fromUnixTimestamp64Milli(event_time_ms)) +ORDER BY (exchange, asset_type, symbol, event_time_ms) +TTL toDateTime(fromUnixTimestamp64Milli(event_time_ms)) + INTERVAL 90 DAY; + +-- Public trade prints from watchTrades. +CREATE TABLE IF NOT EXISTS market_data.cex_trades +( + source LowCardinality(String), + deployment_id LowCardinality(String), + account_selector LowCardinality(String), + + exchange LowCardinality(String), + asset_type LowCardinality(String), + symbol LowCardinality(String), + + trade_id String, + event_time_ms UInt64, + received_time_ms UInt64, + + side LowCardinality(String), + price Decimal(18, 8), + amount Decimal(18, 8), + cost Nullable(Decimal(18, 8)), + taker_or_maker LowCardinality(Nullable(String)) +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(fromUnixTimestamp64Milli(event_time_ms)) +ORDER BY (exchange, asset_type, symbol, event_time_ms, trade_id) +TTL toDateTime(fromUnixTimestamp64Milli(event_time_ms)) + INTERVAL 90 DAY; diff --git a/schema/clickhouse/research_queries.sql b/schema/clickhouse/research_queries.sql new file mode 100644 index 0000000..221bebe --- /dev/null +++ b/schema/clickhouse/research_queries.sql @@ -0,0 +1,56 @@ +-- Example research queries for market_data.candles (not run automatically). +-- Use closed candles via market_data.candles_closed for backtests. + +-- Load closed 1m candles for a symbol over the last 7 days +-- SELECT +-- open_time_ms, +-- open, high, low, close, volume +-- FROM market_data.candles_closed +-- WHERE exchange = 'binance' +-- AND symbol = 'BTC/USDT' +-- AND timeframe = '1m' +-- AND open_time_ms >= toUnixTimestamp(now() - INTERVAL 7 DAY) * 1000 +-- ORDER BY open_time_ms; + +-- Roll up 1m closed candles to 5m buckets +-- SELECT +-- intDiv(open_time_ms, 300000) * 300000 AS bucket_ms, +-- argMin(open, open_time_ms) AS open, +-- max(high) AS high, +-- min(low) AS low, +-- argMax(close, broker_version) AS close, +-- sum(volume) AS volume +-- FROM market_data.candles_closed +-- WHERE exchange = 'binance' +-- AND symbol = 'BTC/USDT' +-- AND timeframe = '1m' +-- GROUP BY bucket_ms +-- ORDER BY bucket_ms; + +-- Freshness check per symbol +-- SELECT +-- exchange, +-- symbol, +-- timeframe, +-- max(open_time_ms) AS latest_open_time_ms, +-- count() AS closed_bars +-- FROM market_data.candles_closed +-- GROUP BY exchange, symbol, timeframe +-- ORDER BY latest_open_time_ms DESC; + +-- Nearest top-of-book snapshot before each candle open (execution context) +-- SELECT +-- c.open_time_ms, +-- c.close, +-- t.mid, +-- t.spread_bps +-- FROM market_data.candles_closed AS c +-- ASOF LEFT JOIN market_data.orderbook_snapshots AS t +-- ON c.exchange = t.exchange +-- AND c.symbol = t.symbol +-- AND t.event_time_ms <= c.open_time_ms +-- WHERE c.exchange = 'binance' +-- AND c.symbol = 'BTC/USDT' +-- AND c.timeframe = '1m' +-- ORDER BY c.open_time_ms +-- LIMIT 100; diff --git a/schema/clickhouse/strategy_data.sql b/schema/clickhouse/strategy_data.sql new file mode 100644 index 0000000..a9790d8 --- /dev/null +++ b/schema/clickhouse/strategy_data.sql @@ -0,0 +1,197 @@ +-- Strategy runtime archive tables (HB external-strategy bridge, FIET-924). +-- +-- Cross-repo contract between this forwarder (DDL + validation) and the +-- fiet-maker HB bridge (row producer). Column and table NAMES are the contract: +-- do not rename them here without changing the producer in lockstep. +-- Derived from Linear specs FIET-904 (policy clock), FIET-905 (policy/identity +-- snapshots), FIET-906 (inventory/settlement), FIET-909 (provenance). +-- +-- Rows arrive through the archive forwarder (HTTP POST /archive) with the batch +-- envelope source = "hb_runtime". +-- +-- NO TTL: replay-critical strategy history, retained indefinitely. +-- +-- Delivery contract: the producer is AT-MOST-ONCE over a bounded in-memory +-- queue. It drops the oldest row when the queue is full and discards a batch +-- after two failed POST attempts, so rows can be lost silently. To make loss +-- distinguishable from "the event never happened", every row carries `seq`: a +-- monotonic counter starting at 1, scoped to one (controller_id, run_id) and +-- shared across ALL tables in this database. Gap detection is therefore a UNION +-- of these tables filtered by run_id, looking for holes in `seq`; the producer's +-- heartbeat carries dropped_rows/failed_batches as the aggregate counterpart. +-- A hole proves an allocated row was lost. The converse does not hold: paths +-- that skip emission entirely (e.g. blocked Layer12 ticks bypassing the archive +-- tap) never allocate a `seq`, so their absence leaves no hole. +-- +-- Adding a column here is a POISON PILL if it ships after the producer. Inserts +-- use JSONEachRow, so a row carrying a column ClickHouse does not have fails the +-- whole per-table batch; the forwarder returns non-2xx and the producer drops it. +-- Deploy the forwarder (which applies this file at startup) BEFORE the producer. + +CREATE DATABASE IF NOT EXISTS strategy_data; + +-- One row per control-loop evaluation that produces a Layer12 decision (FIET-904). +CREATE TABLE IF NOT EXISTS strategy_data.policy_evaluation_events +( + event_time_ms Int64, + emitted_at_ms Int64, + source LowCardinality(String), + deployment_id LowCardinality(String), + schema_version LowCardinality(String), + controller_id String, + controller_type LowCardinality(String), + connector_name LowCardinality(String), + exchange LowCardinality(String), + trading_pair LowCardinality(String), + market_id String, + run_id String, + -- Per-run gap-detection counter; see the delivery contract at the top. + seq UInt64, + + policy_epoch String, + fidelity LowCardinality(String), + lag_ms Int64, + fallback_reason String, + -- Durable replay provenance cursor ("block::log:"), empty when not derived from a chain cursor. + source_cursor String, + decision_kind LowCardinality(String), + payload_json String +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(fromUnixTimestamp64Milli(event_time_ms)) +ORDER BY (controller_id, trading_pair, event_time_ms); + +ALTER TABLE strategy_data.policy_evaluation_events +ADD COLUMN IF NOT EXISTS source_cursor String AFTER fallback_reason; + +-- Append-only versioned snapshots of the effective controller/ladder config +-- on start and on config/policy change, hash-gated (FIET-905). +CREATE TABLE IF NOT EXISTS strategy_data.strategy_policy_snapshots +( + event_time_ms Int64, + emitted_at_ms Int64, + source LowCardinality(String), + deployment_id LowCardinality(String), + schema_version LowCardinality(String), + controller_id String, + controller_type LowCardinality(String), + connector_name LowCardinality(String), + exchange LowCardinality(String), + trading_pair LowCardinality(String), + market_id String, + run_id String, + -- Per-run gap-detection counter; see the delivery contract at the top. + seq UInt64, + + snapshot_reason LowCardinality(String), + policy_epoch String, + config_file_path String, + source_hash String, + payload_json String +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(fromUnixTimestamp64Milli(event_time_ms)) +ORDER BY (controller_id, trading_pair, event_time_ms); + +-- Append-only market identity snapshots keyed by canonical core pool id; the +-- latest row with event_time_ms <= t is the identity active at t (FIET-905). +CREATE TABLE IF NOT EXISTS strategy_data.market_identity +( + event_time_ms Int64, + emitted_at_ms Int64, + source LowCardinality(String), + deployment_id LowCardinality(String), + schema_version LowCardinality(String), + controller_id String, + controller_type LowCardinality(String), + connector_name LowCardinality(String), + exchange LowCardinality(String), + trading_pair LowCardinality(String), + market_id String, + run_id String, + -- Per-run gap-detection counter; see the delivery contract at the top. + seq UInt64, + + snapshot_reason LowCardinality(String), + source_hash String, + core_pool_id String, + canonical_core_pool_id String, + payload_json String +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(fromUnixTimestamp64Milli(event_time_ms)) +ORDER BY (canonical_core_pool_id, event_time_ms); + +-- Append-only CEX routeability snapshots keyed by exchange + trading_pair +-- (FIET-905). +CREATE TABLE IF NOT EXISTS strategy_data.symbol_mapping +( + event_time_ms Int64, + emitted_at_ms Int64, + source LowCardinality(String), + deployment_id LowCardinality(String), + schema_version LowCardinality(String), + controller_id String, + controller_type LowCardinality(String), + connector_name LowCardinality(String), + exchange LowCardinality(String), + trading_pair LowCardinality(String), + market_id String, + run_id String, + -- Per-run gap-detection counter; see the delivery contract at the top. + seq UInt64, + + snapshot_reason LowCardinality(String), + source_hash String, + payload_json String +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(fromUnixTimestamp64Milli(event_time_ms)) +ORDER BY (exchange, trading_pair, event_time_ms); + +-- Inventory snapshots (and, later, reservation/funding facts) on change with a +-- periodic heartbeat, hash-gated (FIET-906). +CREATE TABLE IF NOT EXISTS strategy_data.inventory_settlement_events +( + event_time_ms Int64, + emitted_at_ms Int64, + source LowCardinality(String), + deployment_id LowCardinality(String), + schema_version LowCardinality(String), + controller_id String, + controller_type LowCardinality(String), + connector_name LowCardinality(String), + exchange LowCardinality(String), + trading_pair LowCardinality(String), + market_id String, + run_id String, + -- Per-run gap-detection counter; see the delivery contract at the top. + seq UInt64, + + event_kind LowCardinality(String), + token LowCardinality(String), + account LowCardinality(String), + reservation_id String, + workflow_state LowCardinality(String), + payload_json String +) +ENGINE = MergeTree +PARTITION BY toYYYYMM(fromUnixTimestamp64Milli(event_time_ms)) +ORDER BY (controller_id, trading_pair, event_time_ms); + +-- Backfill the gap-detection counter onto already-created tables. Existing rows +-- keep seq = 0; only runs that start after the producer ships allocate real values. +ALTER TABLE strategy_data.policy_evaluation_events +ADD COLUMN IF NOT EXISTS seq UInt64 AFTER run_id; + +ALTER TABLE strategy_data.strategy_policy_snapshots +ADD COLUMN IF NOT EXISTS seq UInt64 AFTER run_id; + +ALTER TABLE strategy_data.market_identity +ADD COLUMN IF NOT EXISTS seq UInt64 AFTER run_id; + +ALTER TABLE strategy_data.symbol_mapping +ADD COLUMN IF NOT EXISTS seq UInt64 AFTER run_id; + +ALTER TABLE strategy_data.inventory_settlement_events +ADD COLUMN IF NOT EXISTS seq UInt64 AFTER run_id; diff --git a/scripts/check-node-package.mjs b/scripts/check-node-package.mjs new file mode 100644 index 0000000..1143b35 --- /dev/null +++ b/scripts/check-node-package.mjs @@ -0,0 +1,14 @@ +delete process.env.CEX_BROKER_ARCHIVE_ENABLED; + +const { default: CEXBroker } = await import("../dist/index.js"); +const broker = new CEXBroker( + {}, + { + withdraw: { rule: [] }, + deposit: {}, + order: { rule: { markets: [], limits: [] } }, + }, +); + +await broker.stop(); +console.log("Node package import and construction passed"); diff --git a/scripts/check-server-line-budget.sh b/scripts/check-server-line-budget.sh new file mode 100755 index 0000000..f6f1b1f --- /dev/null +++ b/scripts/check-server-line-budget.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +MAX_LINES="${SERVER_TS_MAX_LINES:-400}" +SERVER_FILE="${1:-src/server.ts}" +ACTUAL="$(wc -l < "$SERVER_FILE" | tr -d ' ')" + +if [ "$ACTUAL" -gt "$MAX_LINES" ]; then + echo "error: $SERVER_FILE has $ACTUAL lines (budget: $MAX_LINES)" >&2 + exit 1 +fi + +echo "ok: $SERVER_FILE within line budget ($ACTUAL <= $MAX_LINES)" \ No newline at end of file diff --git a/services/archive-forwarder/Dockerfile b/services/archive-forwarder/Dockerfile new file mode 100644 index 0000000..cce0994 --- /dev/null +++ b/services/archive-forwarder/Dockerfile @@ -0,0 +1,27 @@ +# Archive forwarder: receives cex-broker archive batches over HTTP (POST /archive) +# and writes them to ClickHouse (market_data, broker_execution, strategy_data). +# It self-initializes the schema on boot and fail-closes if that fails. +# +# Build from the repository ROOT so the schema/ directory is in the build context: +# docker build -f services/archive-forwarder/Dockerfile -t cex-broker-archive-forwarder . +FROM oven/bun:1.3 + +WORKDIR /app + +# Install dependencies from the frozen lockfile. patches/ is required because +# package.json pins patchedDependencies; the forwarder itself only imports +# @clickhouse/client plus Bun built-ins, but a frozen install needs the manifest. +COPY package.json bun.lock ./ +COPY patches ./patches +RUN bun install --frozen-lockfile + +# Forwarder source and the SQL files its schema initializer applies at startup +# (schema.ts resolves ../../schema/clickhouse relative to this directory). +COPY services/archive-forwarder ./services/archive-forwarder +COPY src/helpers/otel.ts src/helpers/logger.ts ./src/helpers/ +COPY schema/clickhouse ./schema/clickhouse + +ENV ARCHIVE_FORWARDER_PORT=8090 +EXPOSE 8090 + +CMD ["bun", "run", "services/archive-forwarder/index.ts"] diff --git a/services/archive-forwarder/auth.ts b/services/archive-forwarder/auth.ts new file mode 100644 index 0000000..cd3c6bf --- /dev/null +++ b/services/archive-forwarder/auth.ts @@ -0,0 +1,18 @@ +export function isArchiveAuthConfigured(token: string | undefined): boolean { + return Boolean(token?.trim()); +} + +export function isArchiveRequestAuthorized( + request: Request, + expectedToken: string | undefined, +): boolean { + if (!isArchiveAuthConfigured(expectedToken)) { + return true; + } + const header = request.headers.get("authorization")?.trim(); + if (!header?.startsWith("Bearer ")) { + return false; + } + const provided = header.slice("Bearer ".length).trim(); + return provided.length > 0 && provided === expectedToken?.trim(); +} diff --git a/services/archive-forwarder/config.ts b/services/archive-forwarder/config.ts new file mode 100644 index 0000000..3477de0 --- /dev/null +++ b/services/archive-forwarder/config.ts @@ -0,0 +1,36 @@ +export type ClickHouseConfig = { + host: string; + port: number; + username: string; + password: string; + database: string; +}; + +export type ForwarderConfig = { + port: number; + authToken?: string; + clickhouse: ClickHouseConfig; +}; + +function parsePort(value: string | undefined, fallback: number): number { + if (!value) { + return fallback; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export function loadForwarderConfig(): ForwarderConfig { + const authToken = process.env.ARCHIVE_FORWARDER_TOKEN?.trim(); + return { + port: parsePort(process.env.ARCHIVE_FORWARDER_PORT, 8090), + authToken: authToken || undefined, + clickhouse: { + host: process.env.CLICKHOUSE_HOST?.trim() || "localhost", + port: parsePort(process.env.CLICKHOUSE_PORT, 8123), + username: process.env.CLICKHOUSE_USER?.trim() || "default", + password: process.env.CLICKHOUSE_PASSWORD ?? "", + database: process.env.CLICKHOUSE_DATABASE?.trim() || "market_data", + }, + }; +} diff --git a/services/archive-forwarder/health.ts b/services/archive-forwarder/health.ts new file mode 100644 index 0000000..1d7895a --- /dev/null +++ b/services/archive-forwarder/health.ts @@ -0,0 +1,16 @@ +import type { ClickHouseClient } from "@clickhouse/client"; + +export async function pingClickHouse( + client: ClickHouseClient, +): Promise { + try { + const result = await client.query({ + query: "SELECT 1 AS ok", + format: "JSONEachRow", + }); + const rows = (await result.json()) as Array<{ ok: number }>; + return rows[0]?.ok === 1; + } catch { + return false; + } +} diff --git a/services/archive-forwarder/index.ts b/services/archive-forwarder/index.ts new file mode 100644 index 0000000..ef076f0 --- /dev/null +++ b/services/archive-forwarder/index.ts @@ -0,0 +1,64 @@ +import { createClient } from "@clickhouse/client"; +import { loadForwarderConfig } from "./config"; +import { createClickHouseInserter } from "./insert"; +import { pingClickHouse } from "./health"; +import { handleArchiveRequest } from "./request"; +import { ensureArchiveSchema } from "./schema"; +import { createArchiveForwarderTelemetry } from "./telemetry"; + +const config = loadForwarderConfig(); +const clickhouse = createClient({ + url: `http://${config.clickhouse.host}:${config.clickhouse.port}`, + username: config.clickhouse.username, + password: config.clickhouse.password, + database: config.clickhouse.database, + // Broker execution and account snapshot tables use DateTime64 columns; producers + // emit broker_observed_timestamp/exchange_timestamp as ISO-8601 UTC strings, so + // the inserter must best-effort-parse them (basic mode rejects the 'T'/'Z' form). + // Strictly more lenient than basic, so it never breaks the existing String/Int64 + // timestamp columns on the other archive tables. + clickhouse_settings: { date_time_input_format: "best_effort" }, +}); +const inserter = createClickHouseInserter(clickhouse); +const telemetry = createArchiveForwarderTelemetry(); + +try { + await ensureArchiveSchema(clickhouse); + console.log( + "ClickHouse archive schema ensured (market_data, broker_execution, broker_account, strategy_data)", + ); +} catch (error) { + console.error("Failed to ensure ClickHouse schema:", error); + process.exit(1); +} + +const server = Bun.serve({ + port: config.port, + async fetch(request) { + const url = new URL(request.url); + if (request.method === "GET" && url.pathname === "/health") { + const clickhouseOk = await pingClickHouse(clickhouse); + return Response.json( + { status: clickhouseOk ? "ok" : "degraded", clickhouse: clickhouseOk }, + { status: clickhouseOk ? 200 : 503 }, + ); + } + + if (request.method === "POST" && url.pathname === "/archive") { + return handleArchiveRequest(request, { + authToken: config.authToken, + inserter, + telemetry, + }); + } + + return Response.json({ error: "Not found" }, { status: 404 }); + }, +}); + +console.log( + `Archive forwarder listening on http://0.0.0.0:${server.port}/archive`, +); +console.log( + `ClickHouse target: ${config.clickhouse.host}:${config.clickhouse.port}/${config.clickhouse.database}`, +); diff --git a/services/archive-forwarder/insert.ts b/services/archive-forwarder/insert.ts new file mode 100644 index 0000000..196fa53 --- /dev/null +++ b/services/archive-forwarder/insert.ts @@ -0,0 +1,77 @@ +import type { ClickHouseClient } from "@clickhouse/client"; +import type { ArchiveForwarderTelemetry } from "./telemetry"; +import type { ArchiveRow, ArchiveBatchResult, SupportedTable } from "./types"; +import { isSupportedTable } from "./types"; + +export type RowInserter = ( + table: SupportedTable, + rows: Record[], +) => Promise; + +export function groupRowsByTable( + rows: ArchiveRow[], +): Map[]> { + const grouped = new Map[]>(); + for (const entry of rows) { + if (!isSupportedTable(entry.table)) { + continue; + } + const bucket = grouped.get(entry.table) ?? []; + bucket.push(entry.row); + grouped.set(entry.table, bucket); + } + return grouped; +} + +export function countSkippedRows(rows: ArchiveRow[]): number { + return rows.filter((entry) => !isSupportedTable(entry.table)).length; +} + +export async function insertArchiveRows( + inserter: RowInserter, + rows: ArchiveRow[], + telemetry?: ArchiveForwarderTelemetry, +): Promise { + const grouped = groupRowsByTable(rows); + const byTable: Record = {}; + const failedTables: string[] = []; + let inserted = 0; + let failed = 0; + + for (const [table, tableRows] of grouped.entries()) { + if (tableRows.length === 0) { + continue; + } + try { + await inserter(table, tableRows); + byTable[table] = tableRows.length; + inserted += tableRows.length; + telemetry?.recordRowsInserted(table, tableRows.length); + } catch (error) { + failedTables.push(table); + failed += tableRows.length; + telemetry?.recordInsertFailure(table, error); + console.error(`Archive insert failed for ${table}:`, error); + } + } + + return { + inserted, + skipped: countSkippedRows(rows), + failed, + byTable, + failedTables, + }; +} + +export function createClickHouseInserter( + client: ClickHouseClient, +): RowInserter { + return async (table, rows) => { + await client.insert({ + table, + values: rows, + format: "JSONEachRow", + }); + }; +} diff --git a/services/archive-forwarder/limits.ts b/services/archive-forwarder/limits.ts new file mode 100644 index 0000000..6bd9135 --- /dev/null +++ b/services/archive-forwarder/limits.ts @@ -0,0 +1,47 @@ +export const MAX_ARCHIVE_BODY_BYTES = 5 * 1024 * 1024; +export const MAX_ARCHIVE_ROWS = 1_000; + +export function isArchiveBodyTooLarge(contentLength: string | null): boolean { + if (!contentLength) { + return false; + } + const parsed = Number.parseInt(contentLength, 10); + return Number.isFinite(parsed) && parsed > MAX_ARCHIVE_BODY_BYTES; +} + +export type BoundedBodyReadResult = + | { ok: true; text: string } + | { ok: false; status: 413 | 400 }; + +export async function readBoundedArchiveBody( + request: Request, + maxBytes: number = MAX_ARCHIVE_BODY_BYTES, +): Promise { + if (!request.body) { + return { ok: true, text: "" }; + } + + const reader = request.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + await reader.cancel(); + return { ok: false, status: 413 }; + } + chunks.push(value); + } + } catch { + return { ok: false, status: 400 }; + } + + const bodyBytes = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))); + return { ok: true, text: new TextDecoder().decode(bodyBytes) }; +} diff --git a/services/archive-forwarder/request.ts b/services/archive-forwarder/request.ts new file mode 100644 index 0000000..c3dbd0f --- /dev/null +++ b/services/archive-forwarder/request.ts @@ -0,0 +1,115 @@ +import { isArchiveRequestAuthorized } from "./auth"; +import type { RowInserter } from "./insert"; +import { + MAX_ARCHIVE_ROWS, + isArchiveBodyTooLarge, + readBoundedArchiveBody, +} from "./limits"; +import { handleArchiveBatch, parseArchiveBatchRequest } from "./router"; +import type { ArchiveForwarderTelemetry } from "./telemetry"; + +export type ArchiveRequestDependencies = { + authToken?: string; + inserter: RowInserter; + telemetry: ArchiveForwarderTelemetry; +}; + +export async function handleArchiveRequest( + request: Request, + dependencies: ArchiveRequestDependencies, +): Promise { + if (!isArchiveRequestAuthorized(request, dependencies.authToken)) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + + if (isArchiveBodyTooLarge(request.headers.get("content-length"))) { + return Response.json({ error: "Request body too large" }, { status: 413 }); + } + + const bodyRead = await readBoundedArchiveBody(request); + if (!bodyRead.ok) { + return Response.json( + { + error: + bodyRead.status === 413 + ? "Request body too large" + : "Failed to read request body", + }, + { status: bodyRead.status }, + ); + } + + let body: unknown; + try { + body = JSON.parse(bodyRead.text); + } catch { + return Response.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const parsed = parseArchiveBatchRequest(body); + if (!parsed.ok) { + return Response.json( + { error: "Invalid archive batch payload" }, + { status: 400 }, + ); + } + + if (parsed.rejectedRowCount > 0) { + dependencies.telemetry.recordRejectedRows(parsed.rejectedRowsByTable); + // Name the offending tables: an unknown table (e.g. a forgotten + // SUPPORTED_TABLES entry for a new archive table) would otherwise reject + // the whole batch with only a count, hiding which table is at fault. + console.warn( + `Rejected ${parsed.rejectedRowCount}/${parsed.inputRowCount} archive row(s) from ${parsed.batch.source}; tables: ${parsed.rejectedTables.join(", ")}`, + ); + return Response.json( + { + error: "Malformed archive rows in batch", + rejected: parsed.rejectedRowCount, + inputRows: parsed.inputRowCount, + rejectedTables: parsed.rejectedTables, + }, + { status: 400 }, + ); + } + + if (parsed.batch.rows.length > MAX_ARCHIVE_ROWS) { + return Response.json( + { + error: "Too many archive rows in batch", + maxRows: MAX_ARCHIVE_ROWS, + received: parsed.batch.rows.length, + }, + { status: 413 }, + ); + } + + try { + const result = await handleArchiveBatch( + dependencies.inserter, + parsed.batch, + dependencies.telemetry, + ); + if (result.skipped > 0) { + console.warn( + `Skipped ${result.skipped} unsupported archive row(s) from ${parsed.batch.source}`, + ); + } + if (result.failed > 0) { + console.warn( + `Failed ${result.failed} archive row(s) from ${parsed.batch.source}: ${result.failedTables.join(", ")}`, + ); + return Response.json( + { error: "Archive insert failed", ...result }, + { status: 500 }, + ); + } + return Response.json({ ok: true, ...result }); + } catch (error) { + console.error("Archive insert failed:", error); + return Response.json( + { error: "Archive insert failed" }, + { status: 500 }, + ); + } +} diff --git a/services/archive-forwarder/router.ts b/services/archive-forwarder/router.ts new file mode 100644 index 0000000..1f4b273 --- /dev/null +++ b/services/archive-forwarder/router.ts @@ -0,0 +1,109 @@ +import type { ArchiveBatchRequest, ArchiveBatchResult } from "./types"; +import { isSupportedTable, MALFORMED_TABLE_LABEL } from "./types"; +import { insertArchiveRows, type RowInserter } from "./insert"; +import type { ArchiveForwarderTelemetry } from "./telemetry"; + +export type ParsedArchiveBatch = + | { + ok: true; + batch: ArchiveBatchRequest; + inputRowCount: number; + rejectedRowCount: number; + // Distinct table names among rejected rows (unknown/unsupported tables), + // so the caller can name them in a WARN instead of dropping silently. + // A rejected row with no string `table` contributes "(malformed)". + rejectedTables: string[]; + rejectedRowsByTable: Record; + } + | { ok: false }; + +function isValidArchiveRow(entry: unknown): entry is ArchiveBatchRequest["rows"][number] { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + return false; + } + const row = entry as ArchiveBatchRequest["rows"][number]; + if (typeof row.table !== "string") { + return false; + } + if ( + typeof row.row !== "object" || + row.row === null || + Array.isArray(row.row) + ) { + return false; + } + return isSupportedTable(row.table); +} + +export function parseArchiveBatchRequest(body: unknown): ParsedArchiveBatch { + if (!body || typeof body !== "object") { + return { ok: false }; + } + const record = body as Record; + if ( + typeof record.source !== "string" || + typeof record.deployment_id !== "string" + ) { + return { ok: false }; + } + if (!Array.isArray(record.rows)) { + return { ok: false }; + } + const inputRowCount = record.rows.length; + const rows = record.rows.filter(isValidArchiveRow); + const rejectedRowsByTable = countRejectedRowsByTable(record.rows); + return { + ok: true, + batch: { + source: record.source, + deployment_id: record.deployment_id, + rows, + }, + inputRowCount, + rejectedRowCount: inputRowCount - rows.length, + rejectedTables: Object.keys(rejectedRowsByTable), + rejectedRowsByTable, + }; +} + +function countRejectedRowsByTable(rows: unknown[]): Record { + const counts = new Map(); + for (const entry of rows) { + if (isValidArchiveRow(entry)) { + continue; + } + const table = (entry as { table?: unknown } | null)?.table; + const label = + typeof table === "string" ? table : MALFORMED_TABLE_LABEL; + counts.set(label, (counts.get(label) ?? 0) + 1); + } + return Object.fromEntries(counts); +} + +/** @deprecated Use parseArchiveBatchRequest returning ParsedArchiveBatch */ +export function parseArchiveBatchRequestLegacy( + body: unknown, +): ArchiveBatchRequest | null { + const parsed = parseArchiveBatchRequest(body); + if (!parsed.ok) { + return null; + } + return parsed.batch; +} + +export async function handleArchiveBatch( + inserter: RowInserter, + request: ArchiveBatchRequest, + telemetry?: ArchiveForwarderTelemetry, +): Promise { + const result = await insertArchiveRows(inserter, request.rows, telemetry); + // Requires rows to have actually landed. `failed === 0` is also true for an + // empty batch, and an empty POST advancing this gauge would keep a staleness + // alert green while nothing reaches ClickHouse — the precise failure this + // heartbeat exists to catch, since its whole job is separating "quiet" from + // "dead". + if (result.failed === 0 && result.inserted > 0) { + telemetry?.recordSuccessfulFlush(); + } + return result; +} diff --git a/services/archive-forwarder/schema.ts b/services/archive-forwarder/schema.ts new file mode 100644 index 0000000..ad05cf8 --- /dev/null +++ b/services/archive-forwarder/schema.ts @@ -0,0 +1,91 @@ +import type { ClickHouseClient } from "@clickhouse/client"; +import path from "path"; + +function stripSqlComments(sql: string): string { + return sql.replace(/--[^\n]*/g, ""); +} + +function splitSqlStatements(sql: string): string[] { + const cleaned = stripSqlComments(sql); + const statements: string[] = []; + let depth = 0; + let current = ""; + + for (const char of cleaned) { + current += char; + if (char === "(") { + depth += 1; + } else if (char === ")") { + depth = Math.max(0, depth - 1); + } else if (char === ";" && depth === 0) { + const statement = current.trim(); + if (statement.length > 1) { + statements.push(statement); + } + current = ""; + } + } + + const trailing = current.trim(); + if (trailing.length > 0) { + statements.push(trailing); + } + + return statements; +} + +function isIdempotentSchemaError(error: unknown): boolean { + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : String(error); + const normalized = message.toLowerCase(); + return ( + normalized.includes("already exists") || + normalized.includes("table already exists") || + normalized.includes("table_exists") || + normalized.includes("database already exists") + ); +} + +// Every archive database owned by the forwarder. Each file self-creates its +// database and tables idempotently; the forwarder fail-closes if any cannot be +// applied (see index.ts). +const ARCHIVE_SCHEMA_FILES = [ + "market_data.sql", + "broker_execution.sql", + "broker_account.sql", + "strategy_data.sql", +] as const; + +async function applySchemaFile( + client: ClickHouseClient, + fileName: string, +): Promise { + const schemaPath = path.resolve( + import.meta.dir, + "../../schema/clickhouse", + fileName, + ); + const sql = await Bun.file(schemaPath).text(); + for (const statement of splitSqlStatements(sql)) { + try { + await client.command({ query: statement }); + } catch (error) { + if (isIdempotentSchemaError(error)) { + continue; + } + throw error; + } + } +} + +export async function ensureArchiveSchema( + client: ClickHouseClient, +): Promise { + for (const fileName of ARCHIVE_SCHEMA_FILES) { + await applySchemaFile(client, fileName); + } +} diff --git a/services/archive-forwarder/telemetry.ts b/services/archive-forwarder/telemetry.ts new file mode 100644 index 0000000..68226ff --- /dev/null +++ b/services/archive-forwarder/telemetry.ts @@ -0,0 +1,132 @@ +import { + createOtelMetricsFromEnv, + type OtelMetrics, +} from "../../src/helpers/otel"; +import { + isSupportedTable, + MALFORMED_TABLE_LABEL, + UNSUPPORTED_TABLE_LABEL, +} from "./types"; + +export const ARCHIVE_FORWARDER_METRICS = { + rowsInserted: "archive_forwarder_rows_inserted_total", + rowsRejected: "archive_forwarder_rows_rejected_total", + insertFailures: "archive_forwarder_insert_failures_total", + lastSuccessfulFlush: + "archive_forwarder_last_successful_flush_timestamp_seconds", +} as const; + +export type ArchiveMetricsRecorder = Pick< + OtelMetrics, + "recordCounter" | "setObservableGauge" +>; + +export class ArchiveForwarderTelemetry { + constructor(private readonly metrics: ArchiveMetricsRecorder) {} + + public recordRowsInserted(table: string, count: number): void { + this.bestEffort(() => + this.metrics.recordCounter( + ARCHIVE_FORWARDER_METRICS.rowsInserted, + count, + { table }, + ), + ); + } + + /** + * Rejected rows are precisely the rows whose table is unknown, so their names + * come straight from the request payload. Every distinct label value becomes a + * permanent series in the metrics SDK, so emitting them verbatim lets any client + * grow our memory without bound — and a metrics pipeline that can be exhausted + * by traffic is worse than no metrics. + * + * Labels are therefore bounded to the supported-table set plus two fixed + * buckets. The bound lives here, at the metric boundary, rather than at the one + * current call site, so a future caller cannot reintroduce the problem. The raw + * names are still reported and logged per request, where they are bounded by the + * batch and are what an operator actually needs for diagnosis. + */ + public recordRejectedRows(rowsByTable: Readonly>): void { + const bounded = new Map(); + for (const [table, count] of Object.entries(rowsByTable)) { + const label = + table === MALFORMED_TABLE_LABEL || isSupportedTable(table) + ? table + : UNSUPPORTED_TABLE_LABEL; + bounded.set(label, (bounded.get(label) ?? 0) + count); + } + for (const [table, count] of bounded) { + this.bestEffort(() => + this.metrics.recordCounter( + ARCHIVE_FORWARDER_METRICS.rowsRejected, + count, + { table }, + ), + ); + } + } + + public recordInsertFailure(table: string, error: unknown): void { + this.bestEffort(() => + this.metrics.recordCounter( + ARCHIVE_FORWARDER_METRICS.insertFailures, + 1, + { table, error_class: classifyInsertError(error) }, + ), + ); + } + + public recordSuccessfulFlush(completedAt: Date = new Date()): void { + this.bestEffort(() => + this.metrics.setObservableGauge( + ARCHIVE_FORWARDER_METRICS.lastSuccessfulFlush, + Math.floor(completedAt.getTime() / 1_000), + {}, + ), + ); + } + + private bestEffort(action: () => void | Promise): void { + try { + const result = action(); + if (result instanceof Promise) { + void result.catch(() => {}); + } + } catch { + // Metrics must never affect archive request handling or insertion. + } + } +} + +export function createArchiveForwarderTelemetry(): ArchiveForwarderTelemetry { + return new ArchiveForwarderTelemetry( + createOtelMetricsFromEnv({ + defaultServiceName: "archive-forwarder", + allowLegacyBrokerConfig: false, + }), + ); +} + +export function classifyInsertError(error: unknown): string { + const message = + error instanceof Error + ? `${error.name} ${error.message}`.toLowerCase() + : String(error).toLowerCase(); + + if (/timeout|timed out|abort/.test(message)) return "timeout"; + if (/econn|connection|network|socket|fetch failed/.test(message)) { + return "connection"; + } + if (/unauthorized|forbidden|authentication|credential/.test(message)) { + return "authentication"; + } + if ( + /unknown (table|column)|does not exist|doesn't exist|table missing/.test( + message, + ) + ) { + return "schema"; + } + return "unknown"; +} diff --git a/services/archive-forwarder/types.ts b/services/archive-forwarder/types.ts new file mode 100644 index 0000000..454af63 --- /dev/null +++ b/services/archive-forwarder/types.ts @@ -0,0 +1,52 @@ +export type ArchiveRow = { + table: string; + row: Record; +}; + +export type ArchiveBatchRequest = { + source: string; + deployment_id: string; + rows: ArchiveRow[]; +}; + +export type ArchiveBatchResult = { + inserted: number; + skipped: number; + failed: number; + byTable: Record; + failedTables: string[]; +}; + +export const SUPPORTED_TABLES = [ + "market_data.candles", + "market_data.orderbook_snapshots", + "market_data.cex_stream_events", + "market_data.cex_ticker_events", + "market_data.cex_trades", + "broker_execution.order_events", + "broker_execution.market_metadata_snapshots", + "broker_execution.transfer_events", + "broker_execution.fill_events", + "broker_account.balance_snapshots", + "strategy_data.policy_evaluation_events", + "strategy_data.strategy_policy_snapshots", + "strategy_data.market_identity", + "strategy_data.symbol_mapping", + "strategy_data.inventory_settlement_events", +] as const; + +export type SupportedTable = (typeof SUPPORTED_TABLES)[number]; + +export function isSupportedTable(table: string): table is SupportedTable { + return (SUPPORTED_TABLES as readonly string[]).includes(table); +} + +/** Rejected row carrying no usable `table` field. */ +export const MALFORMED_TABLE_LABEL = "(malformed)"; + +/** + * Rejected row naming a table we do not support. The name is client-controlled, + * so it is collapsed to this fixed bucket before reaching a metric label; the raw + * name stays in the response and the log line, where it is bounded per request. + */ +export const UNSUPPORTED_TABLE_LABEL = "(unsupported)"; diff --git a/services/ohlcv-collector/Dockerfile b/services/ohlcv-collector/Dockerfile new file mode 100644 index 0000000..78db233 --- /dev/null +++ b/services/ohlcv-collector/Dockerfile @@ -0,0 +1,28 @@ +# OHLCV collector: runs a loopback-only, keyless public broker and keeps one +# Subscribe(OHLCV) stream alive for every configured pair. The broker forwards +# captured candles to the separately deployed archive-forwarder. +# +# Build from the repository root: +# docker build -f services/ohlcv-collector/Dockerfile -t cex-broker-ohlcv-collector . +# +# Mount a JSON array of {"exchange","symbol","timeframe"} entries and set +# CEX_BROKER_OHLCV_COLLECTOR_CONFIG to its container path. timeframe defaults to 1m. +FROM oven/bun:1.3 + +WORKDIR /app + +COPY package.json bun.lock ./ +COPY patches ./patches +RUN bun install --frozen-lockfile + +COPY src ./src +COPY services/ohlcv-collector ./services/ohlcv-collector + +# Every reconnect invokes the broker's OHLCV bootstrap. At 1m, 1000 bars provide +# roughly 16 hours of gap coverage without adding a collector-side write/backfill path. +ENV CEX_BROKER_OHLCV_ARCHIVE_BOOTSTRAP_LIMIT=1000 + +# The collector needs no elevated privileges; run as the base image's bun user. +USER bun + +CMD ["bun", "run", "services/ohlcv-collector/index.ts"] diff --git a/services/ohlcv-collector/collector.ts b/services/ohlcv-collector/collector.ts new file mode 100644 index 0000000..2287869 --- /dev/null +++ b/services/ohlcv-collector/collector.ts @@ -0,0 +1,272 @@ +import * as grpc from "@grpc/grpc-js"; +import { SubscriptionType } from "../../src/helpers/constants"; +import { log } from "../../src/helpers/logger"; +import type { OtelMetrics } from "../../src/helpers/otel"; +import { CEX_BROKER_PACKAGE_DEFINITION } from "../../src/proto-package-definition"; +import type { OhlcvSubscription } from "./config"; + +type SubscribeResponse = { + data: string; + timestamp: string; + symbol: string; + type: string; +}; + +type SubscribeClient = grpc.Client & { + Subscribe( + request: Record, + ): grpc.ClientReadableStream; +}; + +type CollectorMetrics = Pick; + +export type OhlcvCollectorOptions = { + brokerUrl: string; + subscriptions: OhlcvSubscription[]; + metrics?: CollectorMetrics; + retry?: Partial; +}; + +type RetryPolicy = { + initialDelayMs: number; + maxDelayMs: number; + jitterRatio: number; + random: () => number; +}; + +type StreamResult = + | { reason: "aborted" } + | { reason: "end" | "close" } + | { reason: "error"; error: grpc.ServiceError }; + +const DEFAULT_RETRY_POLICY: RetryPolicy = { + initialDelayMs: 1_000, + maxDelayMs: 60_000, + jitterRatio: 0.2, + random: Math.random, +}; +const BACKOFF_RESET_AFTER_MS = 60_000; + +const grpcObject = grpc.loadPackageDefinition( + CEX_BROKER_PACKAGE_DEFINITION, +) as unknown as { + cex_broker: { + cex_service: new ( + address: string, + credentials: grpc.ChannelCredentials, + ) => SubscribeClient; + }; +}; + +function pairLabels(subscription: OhlcvSubscription) { + return { + exchange: subscription.exchange, + symbol: subscription.symbol, + timeframe: subscription.timeframe, + }; +} + +function countOhlcvBars(data: string): number { + try { + const payload = JSON.parse(data) as unknown; + if (!Array.isArray(payload) || payload.length === 0) { + return 0; + } + if (Array.isArray(payload[0])) { + return payload.filter((bar) => Array.isArray(bar) && bar.length >= 6) + .length; + } + return payload.length >= 6 ? 1 : 0; + } catch { + return 0; + } +} + +function waitForDelay(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.resolve(false); + } + return new Promise((resolve) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(true); + }, delayMs); + const onAbort = () => { + clearTimeout(timer); + resolve(false); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +export class OhlcvCollector { + readonly #client: SubscribeClient; + readonly #subscriptions: OhlcvSubscription[]; + readonly #metrics?: CollectorMetrics; + readonly #retry: RetryPolicy; + #started = false; + + constructor(options: OhlcvCollectorOptions) { + this.#subscriptions = options.subscriptions; + this.#metrics = options.metrics; + this.#retry = { ...DEFAULT_RETRY_POLICY, ...options.retry }; + this.#client = new grpcObject.cex_broker.cex_service( + options.brokerUrl, + grpc.credentials.createInsecure(), + ); + } + + async run(signal: AbortSignal): Promise { + if (this.#started) { + throw new Error("OHLCV collector can only be started once"); + } + this.#started = true; + try { + await Promise.all( + this.#subscriptions.map((subscription) => + this.#keepSupervisorAlive(subscription, signal), + ), + ); + } finally { + this.#client.close(); + } + } + + async #keepSupervisorAlive( + subscription: OhlcvSubscription, + signal: AbortSignal, + ): Promise { + while (!signal.aborted) { + try { + await this.#supervise(subscription, signal); + return; + } catch (error) { + log.error("OHLCV collector pair supervisor failed; restarting", { + ...pairLabels(subscription), + error, + }); + if (!(await waitForDelay(this.#retry.initialDelayMs, signal))) { + return; + } + } + } + } + + async #supervise( + subscription: OhlcvSubscription, + signal: AbortSignal, + ): Promise { + let consecutiveFailures = 0; + let subscriptionCount = 0; + const labels = pairLabels(subscription); + + while (!signal.aborted) { + if (subscriptionCount > 0) { + void this.#metrics?.recordCounter( + "cex_ohlcv_collector_reconnects_total", + 1, + labels, + ); + } + subscriptionCount += 1; + log.info("OHLCV collector subscription opened", { + ...labels, + subscription_attempt: subscriptionCount, + }); + + const openedAt = Date.now(); + const result = await this.#openStream(subscription, signal); + if (result.reason === "aborted" || signal.aborted) { + break; + } + + if (Date.now() - openedAt >= BACKOFF_RESET_AFTER_MS) { + consecutiveFailures = 0; + } + consecutiveFailures += 1; + const delayMs = this.#retryDelay(consecutiveFailures); + const errorFields = + result.reason === "error" + ? { grpc_code: result.error.code, error: result.error.message } + : {}; + log.warn("OHLCV collector stream closed; reconnect scheduled", { + ...labels, + reason: result.reason, + delay_ms: delayMs, + ...errorFields, + }); + if (!(await waitForDelay(delayMs, signal))) { + break; + } + } + } + + #openStream( + subscription: OhlcvSubscription, + signal: AbortSignal, + ): Promise { + return new Promise((resolve) => { + let settled = false; + let stream: grpc.ClientReadableStream; + const settle = (result: StreamResult) => { + if (settled) { + return; + } + settled = true; + signal.removeEventListener("abort", onAbort); + resolve(result); + }; + const onAbort = () => { + stream.cancel(); + settle({ reason: "aborted" }); + }; + + try { + stream = this.#client.Subscribe({ + cex: subscription.exchange, + symbol: subscription.symbol, + type: SubscriptionType.OHLCV, + options: { timeframe: subscription.timeframe }, + }); + } catch (error) { + settle({ reason: "error", error: error as grpc.ServiceError }); + return; + } + + stream.on("data", (response) => { + const bars = countOhlcvBars(response.data); + if (bars === 0) { + return; + } + void this.#metrics?.recordCounter( + "cex_ohlcv_collector_bars_received_total", + bars, + pairLabels(subscription), + ); + log.debug("OHLCV collector bars received", { + ...pairLabels(subscription), + bars, + }); + }); + stream.on("error", (error: grpc.ServiceError) => { + settle({ reason: "error", error }); + }); + stream.on("end", () => settle({ reason: "end" })); + stream.on("close", () => settle({ reason: "close" })); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) { + onAbort(); + } + }); + } + + #retryDelay(consecutiveFailures: number): number { + const exponent = Math.min(consecutiveFailures - 1, 30); + const baseDelay = Math.min( + this.#retry.maxDelayMs, + this.#retry.initialDelayMs * 2 ** exponent, + ); + const jitter = 1 + (this.#retry.random() * 2 - 1) * this.#retry.jitterRatio; + return Math.max(0, Math.round(baseDelay * jitter)); + } +} diff --git a/services/ohlcv-collector/config.ts b/services/ohlcv-collector/config.ts new file mode 100644 index 0000000..0954ce6 --- /dev/null +++ b/services/ohlcv-collector/config.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; + +export const OHLCV_COLLECTOR_CONFIG_ENV = "CEX_BROKER_OHLCV_COLLECTOR_CONFIG"; + +const subscriptionSchema = z + .object({ + exchange: z + .string() + .trim() + .min(1) + .transform((value) => value.toLowerCase()), + symbol: z.string().trim().min(1), + timeframe: z.string().trim().min(1).default("1m"), + }) + .strict(); + +const configSchema = z + .array(subscriptionSchema) + .min(1, "at least one OHLCV subscription is required") + .superRefine((subscriptions, context) => { + const seen = new Set(); + for (const [index, subscription] of subscriptions.entries()) { + const key = `${subscription.exchange}\u0000${subscription.symbol}\u0000${subscription.timeframe}`; + if (seen.has(key)) { + context.addIssue({ + code: "custom", + message: "duplicate OHLCV subscription", + path: [index], + }); + } + seen.add(key); + } + }); + +export type OhlcvSubscription = z.infer; + +export function parseOhlcvCollectorConfig(input: unknown): OhlcvSubscription[] { + const result = configSchema.safeParse(input); + if (!result.success) { + const details = result.error.issues + .map((issue) => { + const path = issue.path.length > 0 ? issue.path.join(".") : "config"; + return `${path}: ${issue.message}`; + }) + .join("; "); + throw new Error(`Invalid OHLCV collector config: ${details}`); + } + return result.data; +} + +export async function loadOhlcvCollectorConfig( + configPath = process.env[OHLCV_COLLECTOR_CONFIG_ENV], +): Promise { + const path = configPath?.trim(); + if (!path) { + throw new Error(`${OHLCV_COLLECTOR_CONFIG_ENV} must point to a JSON file`); + } + + let contents: string; + try { + contents = await Bun.file(path).text(); + } catch (error) { + throw new Error(`Failed to read OHLCV collector config at ${path}`, { + cause: error, + }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(contents); + } catch (error) { + throw new Error(`OHLCV collector config at ${path} is not valid JSON`, { + cause: error, + }); + } + + return parseOhlcvCollectorConfig(parsed); +} diff --git a/services/ohlcv-collector/index.ts b/services/ohlcv-collector/index.ts new file mode 100644 index 0000000..db4de62 --- /dev/null +++ b/services/ohlcv-collector/index.ts @@ -0,0 +1,155 @@ +import * as grpc from "@grpc/grpc-js"; +import { SubscribeBrokerLifecycle } from "../../src/handlers/subscribe"; +import { createBrokerExecutionArchiverFromEnv } from "../../src/helpers/broker-execution-archive"; +import { log } from "../../src/helpers/logger"; +import { + createOtelLogsFromEnv, + createOtelMetricsFromEnv, +} from "../../src/helpers/otel"; +import { getServer } from "../../src/server"; +import type { PolicyConfig } from "../../src/types"; +import { OhlcvCollector } from "./collector"; +import { loadOhlcvCollectorConfig } from "./config"; + +const PUBLIC_ONLY_POLICY: PolicyConfig = { + withdraw: { rule: [] }, + deposit: {}, + order: { rule: { markets: [], limits: [] } }, +}; + +// CCXT can otherwise retain a connecting WebSocket until its own 10-second timer fires. +const SHUTDOWN_CLOSE_TIMEOUT_MS = 2_000; + +type CloseResult = "closed" | "failed" | "timed_out"; + +async function closeWithinDeadline( + path: string, + close: () => Promise, +): Promise { + let timeout: ReturnType | undefined; + try { + const result = await Promise.race([ + close().then(() => "closed" as const), + new Promise<"timed_out">((resolve) => { + timeout = setTimeout( + () => resolve("timed_out"), + SHUTDOWN_CLOSE_TIMEOUT_MS, + ); + }), + ]); + if (result === "timed_out") { + log.warn("OHLCV collector shutdown path timed out", { + path, + timeout_ms: SHUTDOWN_CLOSE_TIMEOUT_MS, + }); + } + return result; + } catch (error) { + log.warn("OHLCV collector shutdown path failed", { path, error }); + return "failed"; + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + +function bindPublicBroker(server: grpc.Server): Promise { + return new Promise((resolve, reject) => { + server.bindAsync( + "127.0.0.1:0", + grpc.ServerCredentials.createInsecure(), + (error, port) => { + if (error) { + reject(error); + return; + } + resolve(port); + }, + ); + }); +} + +async function run(): Promise { + const subscriptions = await loadOhlcvCollectorConfig(); + const metrics = createOtelMetricsFromEnv(); + const otelLogs = createOtelLogsFromEnv(); + const archiver = createBrokerExecutionArchiverFromEnv(otelLogs, metrics); + const subscribeBrokerLifecycle = new SubscribeBrokerLifecycle(); + const server = getServer( + PUBLIC_ONLY_POLICY, + {}, + ["127.0.0.1", "::1"], + false, + "", + metrics, + archiver, + undefined, + undefined, + subscribeBrokerLifecycle, + ); + const shutdown = new AbortController(); + const onSignal = (signal: NodeJS.Signals) => { + log.info("OHLCV collector shutdown requested", { signal }); + shutdown.abort(); + }; + process.once("SIGINT", onSignal); + process.once("SIGTERM", onSignal); + const incompletePaths: string[] = []; + + try { + await metrics.initialize(); + const port = await bindPublicBroker(server); + const brokerUrl = `127.0.0.1:${port}`; + log.info("OHLCV collector service started", { + broker_url: brokerUrl, + subscriptions: subscriptions.length, + bootstrap_limit: + process.env.CEX_BROKER_OHLCV_ARCHIVE_BOOTSTRAP_LIMIT ?? "100", + }); + const collector = new OhlcvCollector({ + brokerUrl, + subscriptions, + metrics, + }); + await collector.run(shutdown.signal); + } finally { + shutdown.abort(); + process.off("SIGINT", onSignal); + process.off("SIGTERM", onSignal); + server.forceShutdown(); + const closeAndRecord = async ( + path: string, + close: () => Promise, + ): Promise => { + if ((await closeWithinDeadline(path, close)) !== "closed") { + incompletePaths.push(path); + } + }; + const brokerClose = closeAndRecord("subscribe_brokers", () => + subscribeBrokerLifecycle.closeAll(), + ); + await closeAndRecord("archiver", () => archiver.close()); + await closeAndRecord("metrics", () => metrics.close()); + await closeAndRecord("otel_logs", () => otelLogs.close()); + await brokerClose; + log.info("OHLCV collector service stopped", { + incomplete_paths: incompletePaths, + }); + } + + return incompletePaths; +} + +try { + const incompletePaths = await run(); + if (incompletePaths.length > 0) { + log.warn("Forcing process exit after bounded OHLCV collector shutdown", { + incomplete_paths: incompletePaths, + }); + process.exit(0); + } +} catch (error) { + log.fatal("OHLCV collector service failed", { error }); + process.exitCode = 1; +} diff --git a/src/client.dev.ts b/src/client.dev.ts index c06b72b..5ae56ad 100644 --- a/src/client.dev.ts +++ b/src/client.dev.ts @@ -4,9 +4,9 @@ import { config } from "dotenv"; import path from "path"; import { fileURLToPath } from "url"; import CEXBroker from "."; +import { loadPolicy } from "./helpers"; // import CEXBroker from "../dist/index"; import { Action } from "./helpers/constants"; -import { loadPolicy } from "./helpers"; import { log } from "./helpers/logger"; const __filename = fileURLToPath(import.meta.url); diff --git a/src/handlers/execute-action/context.ts b/src/handlers/execute-action/context.ts new file mode 100644 index 0000000..fe0492d --- /dev/null +++ b/src/handlers/execute-action/context.ts @@ -0,0 +1,115 @@ +import type * as grpc from "@grpc/grpc-js"; +import type { Metadata } from "@grpc/grpc-js"; +import type { Exchange } from "@usherlabs/ccxt"; +import type { z } from "zod"; +import type { BrokerAccount, BrokerPoolEntry } from "../../helpers"; +import type { + BrokerExecutionArchiver, + WithdrawalObservationTracker, +} from "../../helpers/broker-execution-archive"; +import type { Action as ActionType } from "../../helpers/constants"; +import { + invalidArgumentError, + parseActionPayload, +} from "../../helpers/grpc/callbacks"; +import { + resolveGrpcError, + stableGrpcErrorCode, +} from "../../helpers/grpc/status"; +import type { OrderActivityTracker } from "../../helpers/order-activity-tracker"; +import type { OtelMetrics } from "../../helpers/otel"; +import { errorClassName, getErrorMessage } from "../../helpers/shared/errors"; +import type { PolicyConfig } from "../../types"; +import type { ActionRequest, ActionResponse } from "../types"; + +export type ActionHandler = (ctx: ExecuteActionContext) => Promise; + +export type ExecuteActionContext = { + call: grpc.ServerUnaryCall; + wrappedCallback: grpc.sendUnaryData; + action: ActionType; + policy: PolicyConfig; + brokers: Record; + metadata: Metadata; + normalizedCex: string; + cex: string; + symbol?: string; + selectedBrokerAccount?: BrokerAccount; + broker: Exchange; + verity: { proof: string }; + applyVerityToBroker: (target: Exchange) => void; + useVerity: boolean; + verityProverUrl: string; + otelMetrics?: OtelMetrics; + brokerArchiver?: BrokerExecutionArchiver; + orderActivityTracker?: OrderActivityTracker; + withdrawalObservationTracker: WithdrawalObservationTracker; +}; + +export function requireSymbol( + ctx: ExecuteActionContext, + message = "ValidationError: Symbol required", +): ctx is ExecuteActionContext & { symbol: string } { + if (!ctx.symbol) { + ctx.wrappedCallback(invalidArgumentError(message), null); + return false; + } + return true; +} + +export function parsePayloadForAction( + ctx: ExecuteActionContext, + schema: z.ZodType, +): T | null { + return parseActionPayload( + schema, + ctx.call.request.payload, + ctx.wrappedCallback, + ); +} + +export function rejectWithGrpcError( + ctx: ExecuteActionContext, + error: unknown, + options?: { + message?: string; + prefix?: string; + preferStableMessageOnly?: boolean; + /** Append the caught error's class name (e.g. InsufficientFunds) as a + * suffix. It goes last, not in front, because the gRPC status code is + * derived from the raw message via stableGrpcErrorCode; prepending the + * class name would break that prefix match. */ + appendClassName?: boolean; + }, +): void { + const resolvedMessage = options?.message ?? getErrorMessage(error); + const { code, message } = resolveGrpcError(error, resolvedMessage); + let finalMessage: string; + if ( + options?.preferStableMessageOnly && + stableGrpcErrorCode(resolvedMessage) !== undefined + ) { + finalMessage = resolvedMessage; + } else if (options?.prefix) { + finalMessage = `${options.prefix}${message}`; + } else { + finalMessage = message; + } + if (options?.appendClassName) { + const className = errorClassName(error); + if (className && !finalMessage.includes(className)) { + finalMessage = `${finalMessage} (${className})`; + } + } + ctx.wrappedCallback({ code, message: finalMessage }, null); +} + +export function successWithProof( + ctx: ExecuteActionContext, + result: unknown, +): void { + ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify(result), + }); +} diff --git a/src/handlers/execute-action/deposit.ts b/src/handlers/execute-action/deposit.ts new file mode 100644 index 0000000..57be571 --- /dev/null +++ b/src/handlers/execute-action/deposit.ts @@ -0,0 +1,248 @@ +import * as grpc from "@grpc/grpc-js"; +import ccxt from "@usherlabs/ccxt"; +import { + archiveTransferEventInBackground, + normalizeCcxtTransactionForArchive, +} from "../../helpers/broker-execution-archive"; +import { + depositField, + depositMatchesTransaction, + normalizeAddress, + normalizeDepositStatus, + stringAmountEquals, +} from "../../helpers/deposit"; +import { log } from "../../helpers/logger"; +import { getErrorMessage, safeLogError } from "../../helpers/shared/errors"; +import { + resolveTransferNetwork, + type TransferNetworkResolution, +} from "../../helpers/transfer-network"; +import type { ExchangeWithDiscovery } from "../../helpers/treasury-discovery"; +import { DepositPayloadSchema } from "../../schemas/action-payloads"; +import type { ExecuteActionContext } from "./context"; +import { parsePayloadForAction, rejectWithGrpcError } from "./context"; + +export async function handleDeposit(ctx: ExecuteActionContext): Promise { + const { + normalizedCex, + symbol, + selectedBrokerAccount, + broker, + brokerArchiver, + } = ctx; + + if (!symbol) { + return ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: "ValidationError: Symbol required", + }, + null, + ); + } + const value = parsePayloadForAction(ctx, DepositPayloadSchema); + if (value === null) return; + let depositNetwork: TransferNetworkResolution | undefined; + try { + const depositBroker = broker as ExchangeWithDiscovery; + const requestedNetwork = + typeof value.params.network === "string" + ? value.params.network + : typeof value.params.chain === "string" + ? value.params.chain + : undefined; + depositNetwork = requestedNetwork + ? await resolveTransferNetwork(broker, symbol, requestedNetwork) + : undefined; + const depositParams: Record = { + ...(value.params ?? {}), + }; + if (depositNetwork) { + depositParams.network = depositNetwork.exchangeNetworkId; + } + if ( + typeof depositBroker.fetchDeposits !== "function" || + depositBroker.has?.fetchDeposits === false + ) { + return ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify({ + status: "unsupported", + exchange: normalizedCex, + accountSelector: selectedBrokerAccount?.label, + asset: symbol, + operatorAlias: depositNetwork?.operatorAlias ?? null, + brokerNetworkId: depositNetwork?.brokerNetworkId ?? null, + exchangeNetworkId: depositNetwork?.exchangeNetworkId ?? null, + txid: value.transactionHash, + transactionId: value.transactionHash, + address: value.recipientAddress, + expectedAmount: value.amount, + raw: null, + }), + }); + } + const deposits = (await depositBroker.fetchDeposits( + symbol, + value.since, + 50, + depositParams, + )) as unknown as Array>; + const deposit = deposits.find((deposit) => + depositMatchesTransaction(deposit, value.transactionHash), + ); + if (deposit) { + const observedAmount = depositField(deposit, ["amount"]); + if ( + observedAmount !== undefined && + !stringAmountEquals(observedAmount, value.amount) + ) { + return ctx.wrappedCallback( + { + code: grpc.status.FAILED_PRECONDITION, + message: `deposit_amount_mismatch: expected ${value.amount}, observed ${observedAmount}`, + }, + null, + ); + } + const observedAddress = depositField(deposit, [ + "address", + "recipientAddress", + "to", + "destination", + ]); + if ( + normalizeAddress(observedAddress) !== undefined && + normalizeAddress(observedAddress) !== + normalizeAddress(value.recipientAddress) + ) { + return ctx.wrappedCallback( + { + code: grpc.status.FAILED_PRECONDITION, + message: `deposit_address_mismatch: expected address ${value.recipientAddress}, observed ${String(observedAddress)}`, + }, + null, + ); + } + const responseStatus = normalizeDepositStatus( + depositField(deposit, ["status", "state"]), + ); + // The RPC contract uses deposit lifecycle statuses such as "credited"; + // transfer_events retains ccxt's raw lowercased vocabulary such as "ok" + // because accounting consumers query that archive contract directly. + const archiveStatus = normalizeCcxtTransactionForArchive(deposit).status; + const depositTxid = String( + depositField(deposit, ["txid", "txId", "tx_hash", "txHash"]) ?? + value.transactionHash, + ); + const creditedAt = depositField(deposit, [ + "creditedAt", + "credited_at", + "updated", + "updatedAt", + "timestamp", + "datetime", + ]); + // Observed deposit lifecycle fact (contract lifecycle_action + // "observe_deposit"). MergeTree keeps all observations; dedup, if needed, + // is at read time over (exchange, account, symbol, external_id, status). + archiveTransferEventInBackground(brokerArchiver, { + exchange: normalizedCex, + accountSelector: selectedBrokerAccount?.label, + assetSymbol: symbol, + transfer: { + eventKind: "deposit", + lifecycleAction: "observe_deposit", + status: archiveStatus, + amount: + observedAmount !== undefined ? String(observedAmount) : undefined, + address: String(observedAddress ?? value.recipientAddress), + network: depositNetwork?.exchangeNetworkId, + externalId: depositTxid, + txid: depositTxid, + exchangeTimestamp: + typeof creditedAt === "string" ? creditedAt : undefined, + payload: deposit, + }, + }); + log.info( + `Amount ${value.amount} at ${value.transactionHash} . Paid to ${value.recipientAddress}`, + ); + return ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify({ + status: responseStatus, + exchange: normalizedCex, + accountSelector: selectedBrokerAccount?.label, + asset: symbol, + operatorAlias: depositNetwork?.operatorAlias ?? null, + brokerNetworkId: depositNetwork?.brokerNetworkId ?? null, + exchangeNetworkId: depositNetwork?.exchangeNetworkId ?? null, + txid: + depositField(deposit, ["txid", "txId", "tx_hash", "txHash"]) ?? + value.transactionHash, + transactionId: value.transactionHash, + amount: observedAmount, + observedAmount, + expectedAmount: value.amount, + address: value.recipientAddress, + observedAddress, + confirmations: depositField(deposit, ["confirmations"]), + creditedAt: depositField(deposit, [ + "creditedAt", + "credited_at", + "updated", + "updatedAt", + "timestamp", + "datetime", + ]), + raw: deposit, + }), + }); + } + return ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify({ + status: "not_found", + exchange: normalizedCex, + accountSelector: selectedBrokerAccount?.label, + asset: symbol, + operatorAlias: depositNetwork?.operatorAlias ?? null, + brokerNetworkId: depositNetwork?.brokerNetworkId ?? null, + exchangeNetworkId: depositNetwork?.exchangeNetworkId ?? null, + txid: value.transactionHash, + transactionId: value.transactionHash, + address: value.recipientAddress, + expectedAmount: value.amount, + raw: null, + }), + }); + } catch (error) { + safeLogError("Deposit confirmation failed", error); + const message = getErrorMessage(error); + if (error instanceof ccxt.NotSupported) { + return ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify({ + status: "unsupported", + exchange: normalizedCex, + accountSelector: selectedBrokerAccount?.label, + asset: symbol, + operatorAlias: depositNetwork?.operatorAlias ?? null, + brokerNetworkId: depositNetwork?.brokerNetworkId ?? null, + exchangeNetworkId: depositNetwork?.exchangeNetworkId ?? null, + txid: value.transactionHash, + transactionId: value.transactionHash, + address: value.recipientAddress, + expectedAmount: value.amount, + raw: { error: message }, + }), + }); + } + rejectWithGrpcError(ctx, error, { + message, + prefix: "deposit_observation_unavailable: ", + appendClassName: true, + }); + } +} diff --git a/src/handlers/execute-action/handler.ts b/src/handlers/execute-action/handler.ts new file mode 100644 index 0000000..96cc390 --- /dev/null +++ b/src/handlers/execute-action/handler.ts @@ -0,0 +1,201 @@ +import type { Metadata } from "@grpc/grpc-js"; +import * as grpc from "@grpc/grpc-js"; +import type { Exchange } from "@usherlabs/ccxt"; +import { authenticateRequest } from "../../helpers/auth"; +import { type BrokerPoolEntry, createBroker } from "../../helpers/broker"; +import { + type BrokerExecutionArchiver, + WithdrawalObservationTracker, +} from "../../helpers/broker-execution-archive"; +import { Action, getActionName, resolveAction } from "../../helpers/constants"; +import { selectBrokerAccountForCex } from "../../helpers/grpc/broker"; +import { log } from "../../helpers/logger"; +import type { OrderActivityTracker } from "../../helpers/order-activity-tracker"; +import type { OtelMetrics } from "../../helpers/otel"; +import { safeLogError } from "../../helpers/shared/errors"; +import { + buildHttpClientOverrideFromMetadata, + verityHttpClientOverridePredicate, +} from "../../helpers/verity"; +import type { PolicyConfig } from "../../types"; +import type { ActionRequest, ActionResponse } from "../types"; +import type { ExecuteActionContext } from "./context"; +import { handleOrderBookCall } from "./order-book-call"; +import { dispatchExecuteAction } from "./registry"; + +export type ExecuteActionDeps = { + policy: PolicyConfig; + brokers: Record; + whitelistIps: string[]; + useVerity: boolean; + verityProverUrl: string; + otelMetrics?: OtelMetrics; + brokerArchiver?: BrokerExecutionArchiver; + orderActivityTracker?: OrderActivityTracker; + withdrawalObservationTracker?: WithdrawalObservationTracker; +}; + +export function createExecuteActionHandler(deps: ExecuteActionDeps) { + const { + policy, + brokers, + whitelistIps, + useVerity, + verityProverUrl, + otelMetrics, + brokerArchiver, + orderActivityTracker, + } = deps; + const withdrawalObservationTracker = + deps.withdrawalObservationTracker ?? new WithdrawalObservationTracker(); + + return async ( + call: grpc.ServerUnaryCall, + callback: grpc.sendUnaryData, + ) => { + const startTime = Date.now(); + const { action: rawAction, cex, symbol } = call.request; + const action = resolveAction(rawAction); + let actionCompleted = false; + + const wrappedCallback: grpc.sendUnaryData = ( + error, + value, + ) => { + if (!actionCompleted) { + actionCompleted = true; + const latency = Date.now() - startTime; + const actionName = getActionName(action); + otelMetrics?.recordHistogram("execute_action_duration_ms", latency, { + action: actionName, + cex: cex || "unknown", + }); + if (error) { + otelMetrics?.recordCounter("execute_action_errors_total", 1, { + action: actionName, + cex: cex || "unknown", + error_type: error.code + ? grpc.status[error.code] || "unknown" + : "unknown", + }); + } else { + otelMetrics?.recordCounter("execute_action_success_total", 1, { + action: actionName, + cex: cex || "unknown", + }); + } + } + callback(error, value); + }; + + try { + log.info(`Request - ExecuteAction:`, { action, cex, symbol }); + otelMetrics?.recordCounter("execute_action_requests_total", 1, { + action: getActionName(action), + cex: cex || "unknown", + }); + + if (!authenticateRequest(call, whitelistIps)) { + return wrappedCallback( + { + code: grpc.status.PERMISSION_DENIED, + message: "Access denied: Unauthorized IP", + }, + null, + ); + } + + if (!action || !cex) { + return wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: "`action` AND `cex` fields are required", + }, + null, + ); + } + + const normalizedCex = cex.trim().toLowerCase(); + const metadata: Metadata = call.metadata; + const selectedBrokerAccount = selectBrokerAccountForCex( + normalizedCex, + brokers, + metadata, + ); + + const verity = { proof: "" }; + const applyVerityToBroker = (targetBroker: Exchange) => { + if (!useVerity) return; + const override = buildHttpClientOverrideFromMetadata( + metadata, + verityProverUrl, + (proof, notaryPubKey) => { + verity.proof = proof; + log.debug(`Verity proof:`, { proof, notaryPubKey }); + }, + ); + targetBroker.setHttpClientOverride( + override, + verityHttpClientOverridePredicate, + ); + }; + + const preludeCtx: ExecuteActionContext = { + call, + wrappedCallback, + action, + policy, + brokers, + metadata, + normalizedCex, + cex, + symbol, + selectedBrokerAccount, + broker: + selectedBrokerAccount?.exchange ?? + (createBroker(normalizedCex, metadata) as Exchange), + verity, + applyVerityToBroker, + useVerity, + verityProverUrl, + otelMetrics, + brokerArchiver, + orderActivityTracker, + withdrawalObservationTracker, + }; + + if (action === Action.Call) { + const handled = await handleOrderBookCall(preludeCtx); + if (handled) return; + } + + const broker = + selectedBrokerAccount?.exchange ?? + createBroker(normalizedCex, metadata); + + if (!broker) { + return wrappedCallback( + { + code: grpc.status.UNAUTHENTICATED, + message: `This Exchange is not registered and No API metadata was found`, + }, + null, + ); + } + + applyVerityToBroker(broker); + + const ctx: ExecuteActionContext = { ...preludeCtx, broker }; + await dispatchExecuteAction(ctx); + } catch (error) { + safeLogError("ExecuteAction unhandled error", error); + return wrappedCallback( + { + code: grpc.status.INTERNAL, + message: "ExecuteAction failed unexpectedly", + }, + null, + ); + } + }; +} diff --git a/src/handlers/execute-action/index.ts b/src/handlers/execute-action/index.ts new file mode 100644 index 0000000..56733d7 --- /dev/null +++ b/src/handlers/execute-action/index.ts @@ -0,0 +1,13 @@ +export type { ActionHandler, ExecuteActionContext } from "./context"; +export { handleDeposit } from "./deposit"; +export { + createExecuteActionHandler, + type ExecuteActionDeps, +} from "./handler"; +export { handleInternalTransfer } from "./internal-transfer"; +export { handleOrderBookCall } from "./order-book-call"; +export { handleOrders } from "./orders"; +export { handlePassThrough } from "./pass-through"; +export { ACTION_HANDLERS, dispatchExecuteAction } from "./registry"; +export { handleTreasuryCall } from "./treasury-call"; +export { handleWithdraw } from "./withdraw"; diff --git a/src/handlers/execute-action/internal-transfer.ts b/src/handlers/execute-action/internal-transfer.ts new file mode 100644 index 0000000..848eb1a --- /dev/null +++ b/src/handlers/execute-action/internal-transfer.ts @@ -0,0 +1,162 @@ +import * as grpc from "@grpc/grpc-js"; +import { + BrokerAccountPreconditionError, + buildHttpClientOverrideFromMetadata, + getCurrentBrokerSelector, + resolveBrokerAccount, + transferBinanceInternal, + verityHttpClientOverridePredicate, +} from "../../helpers"; +import { + archiveTransferEventInBackground, + extractBinanceInternalTransferId, +} from "../../helpers/broker-execution-archive"; +import { mapCcxtErrorToGrpcStatus } from "../../helpers/grpc/status"; +import { log } from "../../helpers/logger"; +import { + getErrorMessage, + safeLogError, + sanitizeErrorDetail, +} from "../../helpers/shared/errors"; +import { InternalTransferPayloadSchema } from "../../schemas/action-payloads"; +import type { ExecuteActionContext } from "./context"; +import { parsePayloadForAction } from "./context"; + +export async function handleInternalTransfer( + ctx: ExecuteActionContext, +): Promise { + const { + brokers, + metadata, + normalizedCex, + symbol, + verity, + useVerity, + verityProverUrl, + brokerArchiver, + } = ctx; + + if (!symbol) { + return ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `ValidationError: Symbol required`, + }, + null, + ); + } + const transferPayload = parsePayloadForAction( + ctx, + InternalTransferPayloadSchema, + ); + if (transferPayload === null) return; + if (normalizedCex !== "binance") { + return ctx.wrappedCallback( + { + code: grpc.status.UNIMPLEMENTED, + message: `InternalTransfer is only supported for Binance`, + }, + null, + ); + } + const pool = brokers[normalizedCex as keyof typeof brokers]; + if (!pool) { + return ctx.wrappedCallback( + { + code: grpc.status.FAILED_PRECONDITION, + message: `No broker accounts configured for ${normalizedCex}`, + }, + null, + ); + } + const fromSelector = + transferPayload.fromAccount ?? getCurrentBrokerSelector(metadata); + const toSelector = transferPayload.toAccount ?? "primary"; + const sourceAccount = resolveBrokerAccount(pool, fromSelector); + if (!sourceAccount) { + return ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `Source account "${fromSelector}" is not configured`, + }, + null, + ); + } + const destAccount = resolveBrokerAccount(pool, toSelector); + if (!destAccount) { + return ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `Destination account "${toSelector}" is not configured`, + }, + null, + ); + } + try { + if (useVerity) { + sourceAccount.exchange.setHttpClientOverride( + buildHttpClientOverrideFromMetadata( + metadata, + verityProverUrl, + (proof, notaryPubKey) => { + verity.proof = proof; + log.debug(`Verity proof:`, { proof, notaryPubKey }); + }, + ), + verityHttpClientOverridePredicate, + ); + } + const result = await transferBinanceInternal( + sourceAccount, + destAccount, + symbol, + transferPayload.amount, + ); + // account_selector is the source; the destination is kept in payload_json. + archiveTransferEventInBackground(brokerArchiver, { + exchange: normalizedCex, + accountSelector: fromSelector, + assetSymbol: symbol, + transfer: { + eventKind: "internal_transfer", + lifecycleAction: "submit_internal_transfer", + status: "ok", + amount: String(transferPayload.amount), + network: "internal", + externalId: extractBinanceInternalTransferId(result), + payload: { from: fromSelector, to: toSelector, result }, + }, + }); + ctx.wrappedCallback(null, { + proof: verity.proof, + result: JSON.stringify(result), + }); + } catch (error) { + safeLogError("InternalTransfer failed", error); + if (error instanceof BrokerAccountPreconditionError) { + return ctx.wrappedCallback( + { + code: grpc.status.FAILED_PRECONDITION, + message: getErrorMessage(error), + }, + null, + ); + } + const msg = getErrorMessage(error); + let code: grpc.status; + if (msg.includes("Unsupported transfer direction")) { + code = grpc.status.INVALID_ARGUMENT; + } else if (msg.includes("unavailable in this CCXT build")) { + code = grpc.status.UNIMPLEMENTED; + } else { + code = mapCcxtErrorToGrpcStatus(error) ?? grpc.status.INTERNAL; + } + ctx.wrappedCallback( + { + code, + message: `InternalTransfer failed: ${sanitizeErrorDetail(error)}`, + }, + null, + ); + } +} diff --git a/src/handlers/execute-action/order-book-call.ts b/src/handlers/execute-action/order-book-call.ts new file mode 100644 index 0000000..4c47f18 --- /dev/null +++ b/src/handlers/execute-action/order-book-call.ts @@ -0,0 +1,128 @@ +import * as grpc from "@grpc/grpc-js"; +import { createBroker, createPublicBroker } from "../../helpers"; +import { mapCcxtErrorToGrpcStatus } from "../../helpers/grpc/status"; +import { + buildHistoricalOrderBookUnsupported, + buildOrderBookCapability, + normalizeOrderBookSnapshot, + ORDER_BOOK_CALL_METHODS, + parseOrderBookCallPayload, +} from "../../helpers/order-book"; +import { safeLogError, sanitizeErrorDetail } from "../../helpers/shared/errors"; +import type { ExecuteActionContext } from "./context"; + +/** Handles Action.Call order-book methods before generic broker dispatch. Returns true when fully handled. */ +export async function handleOrderBookCall( + ctx: ExecuteActionContext, +): Promise { + const parsedOrderBookCall = parseOrderBookCallPayload( + ctx.call.request.payload, + { + exchange: ctx.normalizedCex, + symbol: ctx.symbol, + }, + ); + if (parsedOrderBookCall.kind === "error") { + ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: parsedOrderBookCall.message, + }, + null, + ); + return true; + } + if (parsedOrderBookCall.kind !== "order_book") { + return false; + } + + const orderBookBroker = + ctx.selectedBrokerAccount?.exchange ?? + createBroker(ctx.normalizedCex, ctx.metadata) ?? + createPublicBroker(ctx.normalizedCex); + if (!orderBookBroker) { + ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `Unsupported exchange for order-book market data: ${ctx.normalizedCex}`, + }, + null, + ); + return true; + } + ctx.applyVerityToBroker(orderBookBroker); + + try { + const orderBookPayload = parsedOrderBookCall.payload; + if (orderBookPayload.method === ORDER_BOOK_CALL_METHODS.FETCH_CAPABILITY) { + ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify( + buildOrderBookCapability(orderBookBroker, orderBookPayload), + ), + }); + return true; + } + + if ( + orderBookPayload.method === + ORDER_BOOK_CALL_METHODS.FETCH_HISTORICAL_SNAPSHOTS + ) { + ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify( + buildHistoricalOrderBookUnsupported(orderBookPayload), + ), + }); + return true; + } + + const fetchOrderBook = ( + orderBookBroker as unknown as Record + ).fetchOrderBook; + const canFetchOrderBook = + typeof fetchOrderBook === "function" && + (orderBookBroker.has as Record | undefined) + ?.fetchOrderBook !== false; + if (!canFetchOrderBook) { + ctx.wrappedCallback( + { + code: grpc.status.UNIMPLEMENTED, + message: `Order-book snapshot unsupported for ${ctx.normalizedCex}`, + }, + null, + ); + return true; + } + + const receivedTimestamp = Date.now(); + const rawOrderBook = await ( + fetchOrderBook as (symbol: string, limit?: number) => Promise + ).call( + orderBookBroker, + orderBookPayload.symbol, + orderBookPayload.depthLimit, + ); + ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify( + normalizeOrderBookSnapshot(rawOrderBook, { + exchange: orderBookPayload.exchange, + symbol: orderBookPayload.symbol, + depthLimit: orderBookPayload.depthLimit, + receivedTimestamp, + }), + ), + }); + } catch (error: unknown) { + safeLogError("Order-book Call failed", error); + ctx.wrappedCallback( + { + code: mapCcxtErrorToGrpcStatus(error) ?? grpc.status.INTERNAL, + message: `Order-book Call failed: ${sanitizeErrorDetail(error)}`, + }, + null, + ); + } + return true; +} diff --git a/src/handlers/execute-action/orders.ts b/src/handlers/execute-action/orders.ts new file mode 100644 index 0000000..443dfe7 --- /dev/null +++ b/src/handlers/execute-action/orders.ts @@ -0,0 +1,431 @@ +import * as grpc from "@grpc/grpc-js"; +import { resolveOrderExecution } from "../../helpers"; +import { + archiveOrderExecutionInBackground, + captureMarketMetadataSnapshot, + rethrowArchiveDurabilityError, +} from "../../helpers/broker-execution-archive"; +import { Action } from "../../helpers/constants"; +import { + emitOrderExecutionTelemetryInBackground, + extractOrderTelemetryIds, +} from "../../helpers/order-telemetry"; +import { classifyPassiveOrderError } from "../../helpers/passive-order"; +import { + safeLogError, + safeLogRedactedError, + sanitizeErrorDetail, +} from "../../helpers/shared/errors"; +import { + CancelOrderPayloadSchema, + CreateOrderPayloadSchema, + GetOrderDetailsPayloadSchema, +} from "../../schemas/action-payloads"; +import type { ExecuteActionContext } from "./context"; +import { parsePayloadForAction, rejectWithGrpcError } from "./context"; + +async function handleCreateOrder(ctx: ExecuteActionContext): Promise { + const { + call, + wrappedCallback, + policy, + brokers, + metadata, + normalizedCex, + cex, + symbol, + selectedBrokerAccount, + broker, + verity, + applyVerityToBroker, + useVerity, + verityProverUrl, + otelMetrics, + brokerArchiver, + orderActivityTracker, + } = ctx; + const verityProof = verity.proof; + + const orderValue = parsePayloadForAction(ctx, CreateOrderPayloadSchema); + if (orderValue === null) return; + const isPassiveOrder = orderValue.orderIntent === "passive_only"; + if (isPassiveOrder && orderValue.orderType !== "limit") { + return ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: + "ValidationError: passive_only order intent requires a limit order", + }, + null, + ); + } + const createOrderParams = { + ...orderValue.params, + ...(orderValue.clientOrderId !== undefined && { + clientOrderId: orderValue.clientOrderId, + }), + ...(isPassiveOrder && { postOnly: true }), + }; + let resolvedOrderTelemetry: { + symbol?: string; + side?: string; + requestedQuantity?: number; + } = {}; + let marketMetadataHash: string | undefined; + // A passive error code is a statement about what the VENUE did with our + // submission. Failures before the call (policy resolution, metadata capture) + // never reached the venue, and failures after it leave a real order resting — + // reporting either as a passive rejection would tell the client its rung was + // never placed and invite a duplicate repost. + let submission: "not_attempted" | "in_flight" | "placed" = "not_attempted"; + try { + if (!broker) { + return ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `Invalid CEX key: ${cex}. Supported keys: ${Object.keys(brokers).join(", ")}`, + }, + null, + ); + } + const resolution = await resolveOrderExecution( + policy, + broker, + cex, + orderValue.fromToken, + orderValue.toToken, + orderValue.amount, + orderValue.price, + orderValue.marketType, + ); + if (!resolution.valid || !resolution.symbol || !resolution.side) { + return ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: + resolution.error ?? + "Order rejected by policy: market or limits not satisfied", + }, + null, + ); + } + resolvedOrderTelemetry = { + symbol: resolution.symbol, + side: resolution.side, + requestedQuantity: resolution.amountBase ?? orderValue.amount, + }; + // Mark this (account, symbol) so the fill poller scans it for trade history. + if (selectedBrokerAccount?.label) { + orderActivityTracker?.record( + cex, + selectedBrokerAccount.label, + resolution.symbol, + ); + } + const telemetryIds = extractOrderTelemetryIds(createOrderParams); + const submissionTimestamp = new Date().toISOString(); + marketMetadataHash = await captureMarketMetadataSnapshot( + brokerArchiver, + broker, + { + exchange: cex, + accountSelector: selectedBrokerAccount?.label, + symbol: resolution.symbol, + action: "CreateOrder", + brokerObservedTimestamp: submissionTimestamp, + ...telemetryIds, + }, + ); + submission = "in_flight"; + const order = await broker.createOrder( + resolution.symbol, + orderValue.orderType, + resolution.side, + resolution.amountBase ?? orderValue.amount, + orderValue.price, + createOrderParams, + ); + submission = "placed"; + const createOrderContext = { + action: "CreateOrder" as const, + cex, + accountLabel: selectedBrokerAccount?.label, + symbol: resolvedOrderTelemetry.symbol, + side: resolvedOrderTelemetry.side, + orderType: orderValue.orderType, + requestedQuantity: resolvedOrderTelemetry.requestedQuantity, + requestedNotional: orderValue.amount * orderValue.price, + orderAuthor: orderValue.orderAuthor, + brokerObservedTimestamp: submissionTimestamp, + ...telemetryIds, + }; + emitOrderExecutionTelemetryInBackground( + otelMetrics, + createOrderContext, + order, + ); + archiveOrderExecutionInBackground( + brokerArchiver, + createOrderContext, + order, + undefined, + { marketMetadataHash }, + ); + ctx.wrappedCallback(null, { + result: JSON.stringify({ + ...order, + ...(isPassiveOrder && { + passivePlacementOutcome: "accepted_passive", + }), + }), + }); + } catch (error) { + rethrowArchiveDurabilityError(error); + safeLogRedactedError("Order Creation failed", error); + const failedCreateContext = { + action: "CreateOrder" as const, + cex, + accountLabel: selectedBrokerAccount?.label, + symbol: resolvedOrderTelemetry.symbol ?? symbol, + side: resolvedOrderTelemetry.side, + orderType: orderValue.orderType, + requestedQuantity: + resolvedOrderTelemetry.requestedQuantity ?? orderValue.amount, + requestedNotional: orderValue.amount * orderValue.price, + orderAuthor: orderValue.orderAuthor, + ...extractOrderTelemetryIds(createOrderParams), + }; + emitOrderExecutionTelemetryInBackground( + otelMetrics, + failedCreateContext, + undefined, + error, + ); + archiveOrderExecutionInBackground( + brokerArchiver, + failedCreateContext, + undefined, + error, + { marketMetadataHash }, + ); + if (isPassiveOrder && submission === "in_flight") { + const stableErrorCode = classifyPassiveOrderError(error); + return rejectWithGrpcError(ctx, error, { + message: `${stableErrorCode}: ${sanitizeErrorDetail(error)}`, + preferStableMessageOnly: true, + }); + } + ctx.wrappedCallback( + { + code: grpc.status.INTERNAL, + message: `Order Creation failed: ${sanitizeErrorDetail(error)}`, + }, + null, + ); + } +} + +async function handleGetOrderDetails(ctx: ExecuteActionContext): Promise { + const { + call, + wrappedCallback, + policy, + brokers, + metadata, + normalizedCex, + cex, + symbol, + selectedBrokerAccount, + broker, + verity, + applyVerityToBroker, + useVerity, + verityProverUrl, + otelMetrics, + brokerArchiver, + orderActivityTracker, + } = ctx; + const verityProof = verity.proof; + + const getOrderValue = parsePayloadForAction( + ctx, + GetOrderDetailsPayloadSchema, + ); + if (getOrderValue === null) return; + try { + // Validate CEX key + if (!broker) { + return ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `Invalid CEX key: ${cex}. Supported keys: ${Object.keys(brokers).join(", ")}`, + }, + null, + ); + } + const orderDetails = await broker.fetchOrder( + getOrderValue.orderId, + symbol, + { ...getOrderValue.params }, + ); + if (selectedBrokerAccount?.label && symbol) { + orderActivityTracker?.record(cex, selectedBrokerAccount.label, symbol); + } + const getOrderContext = { + action: "GetOrderDetails" as const, + cex, + accountLabel: selectedBrokerAccount?.label, + symbol, + ...extractOrderTelemetryIds(getOrderValue.params), + }; + emitOrderExecutionTelemetryInBackground( + otelMetrics, + getOrderContext, + orderDetails, + ); + archiveOrderExecutionInBackground( + brokerArchiver, + getOrderContext, + orderDetails, + ); + ctx.wrappedCallback(null, { + result: JSON.stringify({ + orderId: orderDetails.id, + status: orderDetails.status, + amount: orderDetails.amount, + filled: orderDetails.filled, + remaining: orderDetails.remaining, + symbol: orderDetails.symbol, + side: orderDetails.side, + price: orderDetails.price, + }), + }); + } catch (error) { + safeLogError(`Error fetching order details from ${cex}`, error); + const failedGetOrderContext = { + action: "GetOrderDetails" as const, + cex, + accountLabel: selectedBrokerAccount?.label, + symbol, + ...extractOrderTelemetryIds(getOrderValue.params), + }; + emitOrderExecutionTelemetryInBackground( + otelMetrics, + failedGetOrderContext, + undefined, + error, + ); + archiveOrderExecutionInBackground( + brokerArchiver, + failedGetOrderContext, + undefined, + error, + ); + ctx.wrappedCallback( + { + code: grpc.status.INTERNAL, + message: `Failed to fetch order details from ${cex}: ${sanitizeErrorDetail(error)}`, + }, + null, + ); + } +} + +async function handleCancelOrder(ctx: ExecuteActionContext): Promise { + const { + call, + wrappedCallback, + policy, + brokers, + metadata, + normalizedCex, + cex, + symbol, + selectedBrokerAccount, + broker, + verity, + applyVerityToBroker, + useVerity, + verityProverUrl, + otelMetrics, + brokerArchiver, + orderActivityTracker, + } = ctx; + const verityProof = verity.proof; + + const cancelOrderValue = parsePayloadForAction(ctx, CancelOrderPayloadSchema); + if (cancelOrderValue === null) return; + try { + if (!broker) { + return ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `Invalid CEX key: ${cex}. Supported keys: ${Object.keys(brokers).join(", ")}`, + }, + null, + ); + } + const cancelOrderContext = { + action: "CancelOrder" as const, + cex, + accountLabel: selectedBrokerAccount?.label, + symbol, + ...extractOrderTelemetryIds(cancelOrderValue.params), + }; + const cancelledOrder = await broker.cancelOrder( + cancelOrderValue.orderId, + symbol, + cancelOrderValue.params ?? {}, + ); + if (selectedBrokerAccount?.label && symbol) { + orderActivityTracker?.record(cex, selectedBrokerAccount.label, symbol); + } + emitOrderExecutionTelemetryInBackground( + otelMetrics, + cancelOrderContext, + cancelledOrder, + ); + archiveOrderExecutionInBackground( + brokerArchiver, + cancelOrderContext, + cancelledOrder, + ); + ctx.wrappedCallback(null, { + result: JSON.stringify({ ...cancelledOrder }), + }); + } catch (error) { + safeLogError(`Error cancelling order from ${cex}`, error); + const failedCancelContext = { + action: "CancelOrder" as const, + cex, + accountLabel: selectedBrokerAccount?.label, + symbol, + ...extractOrderTelemetryIds(cancelOrderValue.params), + }; + emitOrderExecutionTelemetryInBackground( + otelMetrics, + failedCancelContext, + undefined, + error, + ); + archiveOrderExecutionInBackground( + brokerArchiver, + failedCancelContext, + undefined, + error, + ); + ctx.wrappedCallback( + { + code: grpc.status.INTERNAL, + message: `Failed to cancel order from ${cex}: ${sanitizeErrorDetail(error)}`, + }, + null, + ); + } +} + +export async function handleOrders(ctx: ExecuteActionContext): Promise { + if (ctx.action === Action.CreateOrder) return handleCreateOrder(ctx); + if (ctx.action === Action.GetOrderDetails) return handleGetOrderDetails(ctx); + if (ctx.action === Action.CancelOrder) return handleCancelOrder(ctx); +} diff --git a/src/handlers/execute-action/pass-through.ts b/src/handlers/execute-action/pass-through.ts new file mode 100644 index 0000000..e50a100 --- /dev/null +++ b/src/handlers/execute-action/pass-through.ts @@ -0,0 +1,574 @@ +import * as grpc from "@grpc/grpc-js"; +import { validateDeposit } from "../../helpers"; +import { Action } from "../../helpers/constants"; +import { + mapCcxtErrorToGrpcStatus, + stableGrpcErrorCode, +} from "../../helpers/grpc/status"; +import { log } from "../../helpers/logger"; +import { + marketTypeToCcxtType, + parseMarketType, +} from "../../helpers/market-type"; +import { getErrorMessage, safeLogError } from "../../helpers/shared/errors"; +import { + buildTransferNetworkEvidence, + resolveTransferNetwork, + type TransferNetworkResolution, +} from "../../helpers/transfer-network"; +import { + type ExchangeWithDiscovery, + fetchCurrencyMetadata, +} from "../../helpers/treasury-discovery"; +import { + FetchDepositAddressesPayloadSchema, + FetchFeesPayloadSchema, +} from "../../schemas/action-payloads"; +import type { ExecuteActionContext } from "./context"; +import { parsePayloadForAction, rejectWithGrpcError } from "./context"; + +async function handleFetchCurrency(ctx: ExecuteActionContext): Promise { + const { + call, + wrappedCallback, + policy, + brokers, + metadata, + normalizedCex, + cex, + symbol, + selectedBrokerAccount, + broker, + verity, + applyVerityToBroker, + useVerity, + verityProverUrl, + otelMetrics, + } = ctx; + const verityProof = verity.proof; + + if (!symbol) { + return ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `ValidationError: Symbol required`, + }, + null, + ); + } + try { + const assetCode = symbol.trim().toUpperCase(); + const currencyInfo = await fetchCurrencyMetadata(broker, assetCode); + if (!currencyInfo) { + return ctx.wrappedCallback( + { + code: grpc.status.NOT_FOUND, + message: `venue_discovery_unavailable: currency not found for ${assetCode}`, + }, + null, + ); + } + const networkEvidence = buildTransferNetworkEvidence(currencyInfo); + ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify({ + ...currencyInfo, + exchange: normalizedCex, + asset: assetCode, + code: currencyInfo.code ?? assetCode, + id: currencyInfo.id ?? null, + networks: networkEvidence.networks, + networkAliases: networkEvidence.aliases, + raw: currencyInfo, + }), + }); + } catch (error) { + safeLogError(`Error fetching currency ${symbol} from ${cex}`, error); + const message = getErrorMessage(error); + ctx.wrappedCallback( + { + code: + stableGrpcErrorCode(message) ?? + mapCcxtErrorToGrpcStatus(error) ?? + grpc.status.INTERNAL, + message: message.startsWith("venue_discovery_unavailable:") + ? message + : `venue_discovery_unavailable: ${message}`, + }, + null, + ); + } +} + +async function handleFetchAccountId(ctx: ExecuteActionContext): Promise { + const { + call, + wrappedCallback, + policy, + brokers, + metadata, + normalizedCex, + cex, + symbol, + selectedBrokerAccount, + broker, + verity, + applyVerityToBroker, + useVerity, + verityProverUrl, + otelMetrics, + } = ctx; + const verityProof = verity.proof; + + try { + const accountId = await broker.fetchAccountId(); + // Return normalized response + return ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify({ accountId }), + }); + } catch (error) { + safeLogError(`Error fetching account ID ${cex}`, error); + ctx.wrappedCallback( + { + code: grpc.status.INTERNAL, + message: `Error fetching account ID from ${cex}`, + }, + null, + ); + } +} + +async function handleFetchFees(ctx: ExecuteActionContext): Promise { + const { + call, + wrappedCallback, + policy, + brokers, + metadata, + normalizedCex, + cex, + symbol, + selectedBrokerAccount, + broker, + verity, + applyVerityToBroker, + useVerity, + verityProverUrl, + otelMetrics, + } = ctx; + const verityProof = verity.proof; + + if (!symbol) { + return ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `ValidationError: Symbol required`, + }, + null, + ); + } + const feesPayload = parsePayloadForAction(ctx, FetchFeesPayloadSchema); + if (feesPayload === null) return; + const includeAllFees = + feesPayload.includeAllFees || feesPayload.includeFundingFees === true; + try { + await broker.loadMarkets(); + const fetchFundingFees = async (currencyCodes: string[]) => { + let fundingFeeSource: + | "fetchDepositWithdrawFees" + | "currencies" + | "unavailable" = "unavailable"; + const fundingFeesByCurrency: Record = {}; + if (broker.has.fetchDepositWithdrawFees) { + try { + const feeMap = (await broker.fetchDepositWithdrawFees( + currencyCodes, + )) as unknown as Record< + string, + { + deposit?: unknown; + withdraw?: unknown; + networks?: unknown; + fee?: number; + percentage?: boolean; + } + >; + for (const code of currencyCodes) { + const feeInfo = feeMap[code]; + if (!feeInfo) { + continue; + } + const fallbackFee = + feeInfo.fee !== undefined || feeInfo.percentage !== undefined + ? { + fee: feeInfo.fee ?? null, + percentage: feeInfo.percentage ?? null, + } + : null; + fundingFeesByCurrency[code] = { + deposit: feeInfo.deposit ?? fallbackFee, + withdraw: feeInfo.withdraw ?? fallbackFee, + networks: feeInfo.networks ?? {}, + }; + } + if (Object.keys(fundingFeesByCurrency).length > 0) { + fundingFeeSource = "fetchDepositWithdrawFees"; + } + } catch (error) { + safeLogError( + `Error fetching deposit/withdraw fee map for ${symbol} from ${cex}`, + error, + ); + } + } + if (fundingFeeSource === "unavailable") { + try { + const currencies = await broker.fetchCurrencies(); + for (const code of currencyCodes) { + const currency = currencies[code]; + if (!currency) { + continue; + } + fundingFeesByCurrency[code] = { + deposit: { + enabled: currency.deposit ?? null, + }, + withdraw: { + enabled: currency.withdraw ?? null, + fee: currency.fee ?? null, + limits: currency.limits?.withdraw ?? null, + }, + networks: currency.networks ?? {}, + }; + } + if (Object.keys(fundingFeesByCurrency).length > 0) { + fundingFeeSource = "currencies"; + } + } catch (error) { + safeLogError( + `Error fetching currency metadata for fees for ${symbol} from ${cex}`, + error, + ); + } + } + return { fundingFeeSource, fundingFeesByCurrency }; + }; + const isMarketSymbol = symbol.includes("/"); + if (isMarketSymbol) { + const market = await broker.market(symbol); + const generalFee = broker.fees ?? null; + const feeStatus = broker.fees ? "available" : "unknown"; + if (!broker.fees) { + log.warn(`Fee metadata unavailable for ${cex}`, { symbol }); + } + if (!includeAllFees) { + return ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify({ + feeScope: "market", + generalFee, + feeStatus, + market, + }), + }); + } + const currencyCodes = Array.from(new Set([market.base, market.quote])); + const { fundingFeeSource, fundingFeesByCurrency } = + await fetchFundingFees(currencyCodes); + return ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify({ + feeScope: "market+funding", + generalFee, + feeStatus, + market, + fundingFeeSource, + fundingFeesByCurrency, + }), + }); + } + const tokenCode = symbol.toUpperCase(); + const { fundingFeeSource, fundingFeesByCurrency } = await fetchFundingFees([ + tokenCode, + ]); + return ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify({ + feeScope: "token", + symbol: tokenCode, + fundingFeeSource, + fundingFeesByCurrency, + }), + }); + } catch (error) { + safeLogError(`Error fetching fees for ${symbol} from ${cex}`, error); + ctx.wrappedCallback( + { + code: grpc.status.INTERNAL, + message: `Error fetching fees from ${cex}`, + }, + null, + ); + } +} + +async function handleFetchDepositAddresses( + ctx: ExecuteActionContext, +): Promise { + const { + call, + wrappedCallback, + policy, + brokers, + metadata, + normalizedCex, + cex, + symbol, + selectedBrokerAccount, + broker, + verity, + applyVerityToBroker, + useVerity, + verityProverUrl, + otelMetrics, + } = ctx; + const verityProof = verity.proof; + + if (!symbol) { + return ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `ValidationError: Symbol required`, + }, + null, + ); + } + const fetchDepositAddresses = parsePayloadForAction( + ctx, + FetchDepositAddressesPayloadSchema, + ); + if (fetchDepositAddresses === null) return; + let depositNetwork: TransferNetworkResolution; + try { + depositNetwork = await resolveTransferNetwork( + broker, + symbol, + fetchDepositAddresses.chain, + ); + } catch (error) { + const message = getErrorMessage(error); + return ctx.wrappedCallback( + { + code: stableGrpcErrorCode(message) ?? grpc.status.INVALID_ARGUMENT, + message, + }, + null, + ); + } + const depositValidation = validateDeposit( + policy, + cex, + depositNetwork.brokerNetworkId, + symbol, + ); + if (!depositValidation.valid) { + return ctx.wrappedCallback( + { + code: grpc.status.PERMISSION_DENIED, + message: `policy_deposit_denied: ${depositValidation.error}`, + }, + null, + ); + } + try { + const depositAddresses = + broker.has.fetchDepositAddress === true + ? [ + await broker.fetchDepositAddress(symbol, { + network: depositNetwork.exchangeNetworkId, + ...(fetchDepositAddresses.params ?? {}), + }), + ] + : await broker.fetchDepositAddressesByNetwork(symbol, { + network: depositNetwork.exchangeNetworkId, + ...(fetchDepositAddresses.params ?? {}), + }); + if (depositAddresses.length > 0) { + return ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify( + depositAddresses.map((depositAddress) => ({ + ...depositAddress, + operatorAlias: depositNetwork.operatorAlias, + brokerNetworkId: depositNetwork.brokerNetworkId, + exchangeNetworkId: depositNetwork.exchangeNetworkId, + })), + ), + }); + } + ctx.wrappedCallback( + { + code: grpc.status.INTERNAL, + message: "Deposit confirmation failed", + }, + null, + ); + } catch (error: unknown) { + safeLogError("Fetch Deposit Addresses confirmation failed", error); + const message = getErrorMessage(error); + ctx.wrappedCallback( + { + code: grpc.status.INTERNAL, + message: "Fetch Deposit Addresses confirmation failed: " + message, + }, + null, + ); + } +} + +async function handleFetchBalances(ctx: ExecuteActionContext): Promise { + const { + call, + wrappedCallback, + policy, + brokers, + metadata, + normalizedCex, + cex, + symbol, + selectedBrokerAccount, + broker, + verity, + applyVerityToBroker, + useVerity, + verityProverUrl, + otelMetrics, + } = ctx; + const verityProof = verity.proof; + + try { + // Determine balance type: free | used | total (default: total) + const payload = (call.request.payload as Record) || {}; + const providedBalanceType = payload.balanceType as string | undefined; + const balanceType = (providedBalanceType ?? "total").toString(); + const validBalanceTypes = new Set(["free", "used", "total"]); + if (!validBalanceTypes.has(balanceType)) { + return ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `ValidationError: invalid balanceType '${providedBalanceType}'. Expected one of: free | used | total`, + }, + null, + ); + } + const params = { ...payload } as Record; + delete (params as Record).balanceType; // Remove balanceType from params before passing to CCXT + const marketType = parseMarketType(params.marketType); + delete params.marketType; + // Default market type to spot unless explicitly provided + if (params.type === undefined) { + params.type = marketTypeToCcxtType(marketType); + } + // Always return the same schema with empty objects when not requested + let responseBalances: Record = {}; + if (balanceType === "free") { + // biome-ignore lint/suspicious/noExplicitAny: ccxt typing quirk for partial balances + const partial = (await broker.fetchFreeBalance(params)) as any; + responseBalances = partial ?? {}; + } else if (balanceType === "used") { + // biome-ignore lint/suspicious/noExplicitAny: ccxt typing quirk for partial balances + const partial = (await broker.fetchUsedBalance(params)) as any; + responseBalances = partial ?? {}; + } else if (balanceType === "total") { + // biome-ignore lint/suspicious/noExplicitAny: ccxt typing quirk for partial balances + const partial = (await broker.fetchTotalBalance(params)) as any; + responseBalances = partial ?? {}; + } + // Extract and isolate the symbol if it exists. + if (symbol) { + if (typeof responseBalances[symbol] === "number") { + responseBalances = { + [symbol]: responseBalances[symbol] ?? 0, + }; + } else { + responseBalances = {}; + } + } + ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify({ + balances: responseBalances, + balanceType, + }), + }); + } catch (error) { + safeLogError(`Error fetching balance from ${cex}`, error); + ctx.wrappedCallback( + { + code: grpc.status.INTERNAL, + message: `Failed to fetch balance from ${cex}`, + }, + null, + ); + } +} + +async function handleFetchTicker(ctx: ExecuteActionContext): Promise { + const { + call, + wrappedCallback, + policy, + brokers, + metadata, + normalizedCex, + cex, + symbol, + selectedBrokerAccount, + broker, + verity, + applyVerityToBroker, + useVerity, + verityProverUrl, + otelMetrics, + } = ctx; + const verityProof = verity.proof; + + if (!symbol) { + return ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `ValidationError: Symbol required`, + }, + null, + ); + } + try { + const ticker = await broker.fetchTicker(symbol); + ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify(ticker), + }); + } catch (error) { + safeLogError(`Error fetching ticker from ${cex}`, error); + ctx.wrappedCallback( + { + code: grpc.status.INTERNAL, + message: `Failed to fetch ticker from ${cex}`, + }, + null, + ); + } +} + +export async function handlePassThrough( + ctx: ExecuteActionContext, +): Promise { + if (ctx.action === Action.FetchCurrency) return handleFetchCurrency(ctx); + if (ctx.action === Action.FetchAccountId) return handleFetchAccountId(ctx); + if (ctx.action === Action.FetchFees) return handleFetchFees(ctx); + if (ctx.action === Action.FetchDepositAddresses) + return handleFetchDepositAddresses(ctx); + if (ctx.action === Action.FetchBalances) return handleFetchBalances(ctx); + if (ctx.action === Action.FetchTicker) return handleFetchTicker(ctx); +} diff --git a/src/handlers/execute-action/perp-config.ts b/src/handlers/execute-action/perp-config.ts new file mode 100644 index 0000000..f17cc6f --- /dev/null +++ b/src/handlers/execute-action/perp-config.ts @@ -0,0 +1,170 @@ +import * as grpc from "@grpc/grpc-js"; +import type { Exchange } from "@usherlabs/ccxt"; +import { Action } from "../../helpers/constants"; +import { safeLogError, sanitizeErrorDetail } from "../../helpers/shared/errors"; +import { + GetPerpConfigStatePayloadSchema, + SetPerpConfigStatePayloadSchema, +} from "../../schemas/action-payloads"; +import type { ExecuteActionContext } from "./context"; +import { parsePayloadForAction } from "./context"; + +type ExchangeWithPerpCapabilities = Exchange & { + has?: Record; + fetchPositions?: ( + symbols?: string[], + params?: Record, + ) => Promise[]>; + setLeverage?: ( + leverage: number, + symbol: string, + params?: Record, + ) => Promise; +}; + +function exchangeSupports( + broker: ExchangeWithPerpCapabilities, + capability: string, +): boolean { + return broker.has?.[capability] === true; +} + +function extractPerpConfigs(positions: Record[]): Array<{ + symbol?: string; + leverage?: number; + marginMode?: string; +}> { + return positions.map((position) => ({ + symbol: typeof position.symbol === "string" ? position.symbol : undefined, + leverage: + typeof position.leverage === "number" ? position.leverage : undefined, + marginMode: + typeof position.marginMode === "string" ? position.marginMode : undefined, + })); +} + +async function handleGetPerpConfigState( + ctx: ExecuteActionContext, +): Promise { + const { wrappedCallback, cex, normalizedCex, broker } = ctx; + const payload = parsePayloadForAction(ctx, GetPerpConfigStatePayloadSchema); + if (payload === null) { + return; + } + + if (!broker) { + return wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `Invalid CEX key: ${cex}`, + }, + null, + ); + } + + const exchange = broker as ExchangeWithPerpCapabilities; + if (!exchangeSupports(exchange, "fetchPositions")) { + return wrappedCallback( + { + code: grpc.status.UNIMPLEMENTED, + message: `${normalizedCex} does not support fetchPositions`, + }, + null, + ); + } + + try { + const symbols = payload.symbol ? [payload.symbol] : undefined; + const positions = (await exchange.fetchPositions?.( + symbols, + payload.params, + )) as unknown as Record[]; + ctx.wrappedCallback(null, { + result: JSON.stringify({ + exchange: normalizedCex, + configs: extractPerpConfigs(positions ?? []), + positions: positions ?? [], + }), + }); + } catch (error) { + safeLogError(`GetPerpConfigState failed for ${cex}`, error); + ctx.wrappedCallback( + { + code: grpc.status.INTERNAL, + message: `GetPerpConfigState failed: ${sanitizeErrorDetail(error)}`, + }, + null, + ); + } +} + +async function handleSetPerpConfigState( + ctx: ExecuteActionContext, +): Promise { + const { wrappedCallback, cex, normalizedCex, broker } = ctx; + const payload = parsePayloadForAction(ctx, SetPerpConfigStatePayloadSchema); + if (payload === null) { + return; + } + + if (!broker) { + return wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `Invalid CEX key: ${cex}`, + }, + null, + ); + } + + const exchange = broker as ExchangeWithPerpCapabilities; + if (!exchangeSupports(exchange, "setLeverage")) { + return wrappedCallback( + { + code: grpc.status.UNIMPLEMENTED, + message: `${normalizedCex} does not support setLeverage`, + }, + null, + ); + } + + try { + const response = await exchange.setLeverage?.( + payload.leverage, + payload.symbol, + { + marginMode: payload.marginMode ?? "cross", + ...payload.params, + }, + ); + ctx.wrappedCallback(null, { + result: JSON.stringify({ + exchange: normalizedCex, + symbol: payload.symbol, + leverage: payload.leverage, + marginMode: payload.marginMode ?? "cross", + response, + }), + }); + } catch (error) { + safeLogError(`SetPerpConfigState failed for ${cex}`, error); + ctx.wrappedCallback( + { + code: grpc.status.INTERNAL, + message: `SetPerpConfigState failed: ${sanitizeErrorDetail(error)}`, + }, + null, + ); + } +} + +export async function handlePerpConfig( + ctx: ExecuteActionContext, +): Promise { + if (ctx.action === Action.GetPerpConfigState) { + return handleGetPerpConfigState(ctx); + } + if (ctx.action === Action.SetPerpConfigState) { + return handleSetPerpConfigState(ctx); + } +} diff --git a/src/handlers/execute-action/registry.ts b/src/handlers/execute-action/registry.ts new file mode 100644 index 0000000..4fbdf0d --- /dev/null +++ b/src/handlers/execute-action/registry.ts @@ -0,0 +1,46 @@ +import * as grpc from "@grpc/grpc-js"; +import { Action, type Action as ActionType } from "../../helpers/constants"; +import type { ActionHandler, ExecuteActionContext } from "./context"; +import { handleDeposit } from "./deposit"; +import { handleInternalTransfer } from "./internal-transfer"; +import { handleOrders } from "./orders"; +import { handlePassThrough } from "./pass-through"; +import { handlePerpConfig } from "./perp-config"; +import { handleTreasuryCall } from "./treasury-call"; +import { handleWithdraw } from "./withdraw"; + +/** Maps each ExecuteAction to its handler module. Cluster routers (orders, pass-through) are registered per action. */ +export const ACTION_HANDLERS: Partial> = { + [Action.Deposit]: handleDeposit, + [Action.Withdraw]: handleWithdraw, + [Action.Call]: handleTreasuryCall, + [Action.InternalTransfer]: handleInternalTransfer, + [Action.CreateOrder]: handleOrders, + [Action.GetOrderDetails]: handleOrders, + [Action.CancelOrder]: handleOrders, + [Action.FetchCurrency]: handlePassThrough, + [Action.FetchAccountId]: handlePassThrough, + [Action.FetchFees]: handlePassThrough, + [Action.FetchDepositAddresses]: handlePassThrough, + [Action.FetchBalances]: handlePassThrough, + [Action.FetchTicker]: handlePassThrough, + [Action.GetPerpConfigState]: handlePerpConfig, + [Action.SetPerpConfigState]: handlePerpConfig, +}; + +export async function dispatchExecuteAction( + ctx: ExecuteActionContext, +): Promise { + const handler = ACTION_HANDLERS[ctx.action]; + if (!handler) { + ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: "Invalid Action", + }, + null, + ); + return; + } + await handler(ctx); +} diff --git a/src/handlers/execute-action/treasury-call.ts b/src/handlers/execute-action/treasury-call.ts new file mode 100644 index 0000000..832f27e --- /dev/null +++ b/src/handlers/execute-action/treasury-call.ts @@ -0,0 +1,183 @@ +import * as grpc from "@grpc/grpc-js"; +import { + archiveOrderExecutionInBackground, + archiveWithdrawalObservationsInBackground, + captureMarketMetadataSnapshot, + rethrowArchiveDurabilityError, +} from "../../helpers/broker-execution-archive"; +import { + emitOrderExecutionTelemetryInBackground, + extractOrderTelemetryIds, + type OrderTelemetryContext, +} from "../../helpers/order-telemetry"; +import { getErrorMessage, safeLogError } from "../../helpers/shared/errors"; +import { + callArgs, + handleTreasuryDiscoveryCall, +} from "../../helpers/treasury-discovery"; +import { CallPayloadSchema } from "../../schemas/action-payloads"; +import type { ExecuteActionContext } from "./context"; +import { parsePayloadForAction, rejectWithGrpcError } from "./context"; + +export async function handleTreasuryCall( + ctx: ExecuteActionContext, +): Promise { + const { broker } = ctx; + const callValue = parsePayloadForAction(ctx, CallPayloadSchema); + if (callValue === null) return; + let createOrderContext: OrderTelemetryContext | undefined; + let marketMetadataHash: string | undefined; + try { + // Prevent access to dangerous names + if ( + callValue.functionName.startsWith("_") || + callValue.functionName.includes("constructor") || + callValue.functionName.includes("prototype") + ) { + return ctx.wrappedCallback( + { + code: grpc.status.PERMISSION_DENIED, + message: "Access to the requested function is denied", + }, + null, + ); + } + // Prepare arguments + const argsArray = callArgs(callValue.args, callValue.params ?? {}); + const treasuryDiscovery = await handleTreasuryDiscoveryCall( + broker, + callValue.functionName, + callValue.args, + callValue.params ?? {}, + ); + if (treasuryDiscovery.handled) { + return ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify(treasuryDiscovery.result), + }); + } + // Ensure function exists and is callable on the broker. + const fn = (broker as unknown as Record)[ + callValue.functionName + ]; + if ( + typeof fn !== "function" || + broker.has?.[callValue.functionName] === false + ) { + return ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `Function not found on broker: ${callValue.functionName}`, + }, + null, + ); + } + if (callValue.functionName === "createOrder") { + const [symbol, orderType, side, quantity, price] = callValue.args; + const requestedQuantity = asFiniteNumber(quantity); + const requestedPrice = asFiniteNumber(price); + const requestedNotional = + requestedQuantity !== undefined && requestedPrice !== undefined + ? asFiniteNumber(requestedQuantity * requestedPrice) + : undefined; + const telemetryIds = extractOrderTelemetryIds(callValue.params); + const submissionTimestamp = new Date().toISOString(); + createOrderContext = { + action: "CreateOrder", + cex: ctx.cex, + accountLabel: ctx.selectedBrokerAccount?.label, + symbol: asNonEmptyString(symbol), + orderType: asNonEmptyString(orderType), + side: asNonEmptyString(side), + requestedQuantity, + requestedNotional, + orderAuthor: callValue.orderAuthor, + brokerObservedTimestamp: submissionTimestamp, + ...telemetryIds, + }; + if (createOrderContext.symbol !== undefined) { + marketMetadataHash = await captureMarketMetadataSnapshot( + ctx.brokerArchiver, + broker, + { + exchange: ctx.cex, + accountSelector: ctx.selectedBrokerAccount?.label, + symbol: createOrderContext.symbol, + action: "CreateOrder", + brokerObservedTimestamp: submissionTimestamp, + ...telemetryIds, + }, + ); + } + } + // Invoke + // biome-ignore lint/suspicious/noExplicitAny: dynamic call required for generic broker methods + const result = await (fn as any).apply(broker, argsArray); + if (createOrderContext !== undefined) { + emitOrderExecutionTelemetryInBackground( + ctx.otelMetrics, + createOrderContext, + result, + ); + archiveOrderExecutionInBackground( + ctx.brokerArchiver, + createOrderContext, + result, + undefined, + { marketMetadataHash }, + ); + } else if (callValue.functionName === "fetchWithdrawals") { + archiveWithdrawalObservationsInBackground( + ctx.brokerArchiver, + ctx.withdrawalObservationTracker, + { + exchange: ctx.normalizedCex, + accountSelector: ctx.selectedBrokerAccount?.label, + transactions: result, + }, + ); + } + ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify(result), + }); + } catch (error: unknown) { + if (createOrderContext !== undefined) { + rethrowArchiveDurabilityError(error); + emitOrderExecutionTelemetryInBackground( + ctx.otelMetrics, + createOrderContext, + undefined, + error, + ); + archiveOrderExecutionInBackground( + ctx.brokerArchiver, + createOrderContext, + undefined, + error, + { marketMetadataHash }, + ); + } + safeLogError("Call failed", error); + rejectWithGrpcError(ctx, error, { + message: getErrorMessage(error), + preferStableMessageOnly: true, + appendClassName: true, + }); + } +} + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value : undefined; +} + +function asFiniteNumber(value: unknown): number | undefined { + if (typeof value === "number") { + return Number.isFinite(value) ? value : undefined; + } + if (typeof value !== "string" || !value.trim()) { + return undefined; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} diff --git a/src/handlers/execute-action/withdraw.ts b/src/handlers/execute-action/withdraw.ts new file mode 100644 index 0000000..4fa9d69 --- /dev/null +++ b/src/handlers/execute-action/withdraw.ts @@ -0,0 +1,200 @@ +import * as grpc from "@grpc/grpc-js"; +import { + resolveTravelRuleDecision, + validateWithdraw, + withdrawViaLocalEntity, +} from "../../helpers"; +import { + archiveTransferEventInBackground, + normalizeCcxtTransactionForArchive, +} from "../../helpers/broker-execution-archive"; +import { + mapCcxtErrorToGrpcStatus, + stableGrpcErrorCode, +} from "../../helpers/grpc/status"; +import { log } from "../../helpers/logger"; +import { + getErrorMessage, + safeLogError, + sanitizeErrorDetail, +} from "../../helpers/shared/errors"; +import { + resolveTransferNetwork, + type TransferNetworkResolution, +} from "../../helpers/transfer-network"; +import { WithdrawPayloadSchema } from "../../schemas/action-payloads"; +import type { ExecuteActionContext } from "./context"; +import { parsePayloadForAction, rejectWithGrpcError } from "./context"; + +export async function handleWithdraw(ctx: ExecuteActionContext): Promise { + const { + call, + wrappedCallback, + policy, + brokers, + metadata, + normalizedCex, + cex, + symbol, + selectedBrokerAccount, + broker, + verity, + applyVerityToBroker, + useVerity, + verityProverUrl, + otelMetrics, + brokerArchiver, + } = ctx; + const verityProof = verity.proof; + + if (!symbol) { + return ctx.wrappedCallback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `ValidationError: Symbol required`, + }, + null, + ); + } + const transferValue = parsePayloadForAction(ctx, WithdrawPayloadSchema); + if (transferValue === null) return; + let withdrawNetwork: TransferNetworkResolution; + try { + withdrawNetwork = await resolveTransferNetwork( + broker, + symbol, + transferValue.chain, + ); + } catch (error) { + const message = getErrorMessage(error); + return ctx.wrappedCallback( + { + code: stableGrpcErrorCode(message) ?? grpc.status.INVALID_ARGUMENT, + message, + }, + null, + ); + } + const transferValidation = validateWithdraw( + policy, + cex, + withdrawNetwork.brokerNetworkId, + transferValue.recipientAddress, + transferValue.amount, + symbol, + ); + if (!transferValidation.valid) { + return ctx.wrappedCallback( + { + code: grpc.status.PERMISSION_DENIED, + message: `policy_withdrawal_denied: ${transferValidation.error}`, + }, + null, + ); + } + + const travelRule = resolveTravelRuleDecision( + policy, + cex, + transferValue.recipientAddress, + ); + if (travelRule.mode === "denied") { + return ctx.wrappedCallback( + { + code: grpc.status.FAILED_PRECONDITION, + message: `travel_rule_denied: ${travelRule.error}`, + }, + null, + ); + } + + const withdrawOrderId = transferValue.params.withdrawOrderId; + const clientWithdrawalId = + typeof withdrawOrderId === "string" && withdrawOrderId.length > 0 + ? withdrawOrderId + : undefined; + + try { + const transaction = + travelRule.mode === "localentity" + ? await withdrawViaLocalEntity(broker, { + code: symbol, + amount: transferValue.amount, + address: transferValue.recipientAddress, + network: withdrawNetwork.exchangeNetworkId, + questionnaire: travelRule.questionnaire, + params: transferValue.params, + }) + : await broker.withdraw( + symbol, + transferValue.amount, + transferValue.recipientAddress, + undefined, + { + ...(transferValue.params ?? {}), + network: withdrawNetwork.exchangeNetworkId, + }, + ); + log.info(`Withdraw Result: ${JSON.stringify(transaction)}`); + // The ccxt transaction carries the venue-normalized withdrawal fee (the + // dominant cost at small commits) which the gRPC response drops; archive it. + const normalized = normalizeCcxtTransactionForArchive(transaction); + archiveTransferEventInBackground(brokerArchiver, { + exchange: cex, + accountSelector: selectedBrokerAccount?.label, + assetSymbol: normalized.assetSymbol ?? symbol, + transfer: { + eventKind: "withdrawal", + lifecycleAction: "submit_withdrawal", + status: normalized.status, + amount: normalized.amount ?? String(transferValue.amount), + address: normalized.address ?? transferValue.recipientAddress, + network: normalized.network ?? withdrawNetwork.exchangeNetworkId, + externalId: normalized.externalId, + clientWithdrawalId, + txid: normalized.txid, + feeAmount: normalized.feeAmount, + feeCurrency: normalized.feeCurrency, + exchangeTimestamp: normalized.exchangeTimestamp, + payload: transaction, + }, + }); + ctx.wrappedCallback(null, { + proof: ctx.verity.proof, + result: JSON.stringify({ + ...transaction, + operatorAlias: withdrawNetwork.operatorAlias, + brokerNetworkId: withdrawNetwork.brokerNetworkId, + exchangeNetworkId: withdrawNetwork.exchangeNetworkId, + }), + }); + } catch (error) { + safeLogError("Withdraw failed", error); + // Record the failed submission as a movement-lifecycle fact (error_summary + // set). error_summary is the redacted grpc message, not the raw error. + archiveTransferEventInBackground(brokerArchiver, { + exchange: cex, + accountSelector: selectedBrokerAccount?.label, + assetSymbol: symbol, + transfer: { + eventKind: "withdrawal", + lifecycleAction: "submit_withdrawal", + status: "failed", + amount: String(transferValue.amount), + address: transferValue.recipientAddress, + network: withdrawNetwork.exchangeNetworkId, + clientWithdrawalId, + errorSummary: getErrorMessage(error), + payload: { recipientAddress: transferValue.recipientAddress }, + }, + }); + const code = mapCcxtErrorToGrpcStatus(error) ?? grpc.status.INTERNAL; + ctx.wrappedCallback( + { + code, + message: `Withdraw failed: ${sanitizeErrorDetail(error)}`, + }, + null, + ); + } +} diff --git a/src/handlers/subscribe/broker-lifecycle.ts b/src/handlers/subscribe/broker-lifecycle.ts new file mode 100644 index 0000000..67b9bbb --- /dev/null +++ b/src/handlers/subscribe/broker-lifecycle.ts @@ -0,0 +1,76 @@ +import type { Exchange } from "@usherlabs/ccxt"; +import { log } from "../../helpers/logger"; + +type BrokerContext = { + cex: string; + symbol: string; +}; + +export type BrokerCloseOutcome = "closed" | "failed"; + +export class SubscribeBrokerLifecycle { + readonly #brokers = new Map(); + readonly #closing = new Map>(); + #shuttingDown = false; + + register(broker: Exchange, context: BrokerContext): void { + this.#brokers.set(broker, context); + if (this.#shuttingDown) { + void this.close(broker); + } + } + + // Never rejects: per-broker close failures are logged and reported through + // the outcome so fire-and-forget callers (stream end/cancel paths) stay safe. + close(broker: Exchange): Promise { + const existing = this.#closing.get(broker); + if (existing) { + return existing; + } + + const context = this.#brokers.get(broker) ?? { + cex: "unknown", + symbol: "unknown", + }; + this.#brokers.delete(broker); + const closing = (async (): Promise => { + try { + await broker.close(); + log.debug("Request-scoped Subscribe broker closed", context); + return "closed"; + } catch (error) { + log.warn("Failed to close request-scoped Subscribe broker", { + ...context, + error, + }); + return "failed"; + } finally { + this.#closing.delete(broker); + } + })(); + this.#closing.set(broker, closing); + return closing; + } + + // Drains in a loop so brokers registered while shutdown is already in + // progress are still awaited, and rejects when any close failed so callers + // treat shutdown as incomplete (the collector then force-exits within its + // bounded deadline instead of hanging on leaked exchange handles). + async closeAll(): Promise { + this.#shuttingDown = true; + let failed = 0; + while (this.#brokers.size > 0 || this.#closing.size > 0) { + const inFlight = [...this.#closing.values()]; + const fresh = [...this.#brokers.keys()].map((broker) => + this.close(broker), + ); + const outcomes = await Promise.all([...fresh, ...inFlight]); + failed += outcomes.filter((outcome) => outcome === "failed").length; + } + if (failed > 0) { + throw new Error( + `${failed} request-scoped Subscribe broker(s) failed to close`, + ); + } + } +} diff --git a/src/handlers/subscribe/handler.ts b/src/handlers/subscribe/handler.ts new file mode 100644 index 0000000..14e3912 --- /dev/null +++ b/src/handlers/subscribe/handler.ts @@ -0,0 +1,807 @@ +import * as grpc from "@grpc/grpc-js"; +import type { Exchange } from "@usherlabs/ccxt"; +import { authenticateRequest } from "../../helpers/auth"; +import { + BinanceSpotUserDataStream, + type BinanceUserDataEvent, + isBinanceBalanceUserDataEvent, + isBinanceOrderUserDataEvent, +} from "../../helpers/binance-user-data-stream"; +import { + type BrokerPoolEntry, + createBroker, + createPublicBroker, + selectBrokerAccount, +} from "../../helpers/broker"; +import type { BrokerExecutionArchiver } from "../../helpers/broker-execution-archive"; +import { archiveSubscribeStreamInBackground } from "../../helpers/broker-execution-archive"; +import { + getSubscriptionTypeName, + resolveSubscriptionType, + SubscriptionType, + type SubscriptionType as SubscriptionTypeValue, +} from "../../helpers/constants"; +import { log } from "../../helpers/logger"; +import { + archiveCexStreamEventInBackground, + archiveOhlcvInBackground, + archiveOrderbookInBackground, + archiveTickerInBackground, + archiveTradesInBackground, + bootstrapOhlcvHistory, + createOhlcvBarTracker, + createOrderbookSampler, +} from "../../helpers/market-data-archive"; +import { + type BrokerMarketType, + parseMarketType, + resolveSubscriptionSymbol, +} from "../../helpers/market-type"; +import { + normalizeOrderBookSnapshot, + parseOptionalDepthLimit, +} from "../../helpers/order-book"; +import type { OtelMetrics } from "../../helpers/otel"; +import { getErrorMessage } from "../../helpers/shared/errors"; +import type { SubscribeRequest, SubscribeResponse } from "../types"; +import { SubscribeBrokerLifecycle } from "./broker-lifecycle"; + +export type SubscribeDeps = { + brokers: Record; + whitelistIps: string[]; + otelMetrics?: OtelMetrics; + brokerArchiver?: BrokerExecutionArchiver; + brokerLifecycle?: SubscribeBrokerLifecycle; +}; + +type SubscribeCall = grpc.ServerWritableStream< + SubscribeRequest, + SubscribeResponse +>; + +function isBinanceSpotAccountSubscription( + cex: string, + subscriptionType: SubscriptionTypeValue, + marketTypeInput: unknown, +): boolean { + return ( + cex === "binance" && + parseMarketType(marketTypeInput) === "spot" && + (subscriptionType === SubscriptionType.BALANCE || + subscriptionType === SubscriptionType.ORDERS) + ); +} + +function waitForSubscribeDrain( + call: SubscribeCall, + isClosed: () => boolean, +): Promise { + if (isClosed() || call.destroyed) { + return Promise.resolve(false); + } + + return new Promise((resolve) => { + let settled = false; + const cleanup = () => { + call.off("drain", onDrain); + call.off("close", onClosed); + call.off("cancelled", onClosed); + call.off("end", onClosed); + call.off("error", onClosed); + }; + const settle = (drained: boolean) => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(drained && !isClosed() && !call.destroyed); + }; + const onDrain = () => settle(true); + const onClosed = () => settle(false); + + call.once("drain", onDrain); + call.once("close", onClosed); + call.once("cancelled", onClosed); + call.once("end", onClosed); + call.once("error", onClosed); + }); +} + +async function writeSubscribeFrame( + call: SubscribeCall, + isClosed: () => boolean, + frame: SubscribeResponse, +): Promise { + if (isClosed() || call.destroyed) { + return false; + } + const canContinue = call.write(frame); + if (canContinue) { + return true; + } + return waitForSubscribeDrain(call, isClosed); +} + +async function writeSubscribeError( + call: SubscribeCall, + isClosed: () => boolean, + frame: SubscribeResponse, +): Promise { + const frameWritten = await writeSubscribeFrame(call, isClosed, frame); + if (frameWritten && !isClosed() && !call.destroyed) { + call.end(); + } +} + +function getBinanceEventMarketId( + event: Record, +): string | null { + const value = event.s; + return typeof value === "string" ? value : null; +} + +async function getBinanceMarketId( + broker: Exchange, + symbol: string, +): Promise { + const loadMarkets = (broker as unknown as { loadMarkets?: unknown }) + .loadMarkets; + if (typeof loadMarkets === "function") { + await loadMarkets.call(broker); + } + const market = (broker as unknown as { market?: unknown }).market; + if (typeof market === "function") { + const resolvedMarket = market.call(broker, symbol) as + | { id?: unknown } + | undefined; + if (typeof resolvedMarket?.id === "string") { + return resolvedMarket.id; + } + } + return symbol.replace("/", "").toUpperCase(); +} + +async function streamBinanceUserData( + call: SubscribeCall, + broker: Exchange, + symbol: string, + subscriptionType: SubscriptionTypeValue, + isClosed: () => boolean, + archiveContext?: { + archiver?: BrokerExecutionArchiver; + otelMetrics?: OtelMetrics; + exchange: string; + accountSelector?: string; + deploymentId: string; + assetType: BrokerMarketType; + }, +): Promise { + const userDataStream = new BinanceSpotUserDataStream(broker); + call.once("close", () => userDataStream.close()); + call.once("cancelled", () => userDataStream.close()); + call.once("error", () => userDataStream.close()); + + const marketId = + subscriptionType === SubscriptionType.ORDERS + ? await getBinanceMarketId(broker, symbol) + : null; + + try { + for await (const message of userDataStream) { + if (isClosed()) { + break; + } + const event = message.event; + if (subscriptionType === SubscriptionType.BALANCE) { + if (!isBinanceBalanceUserDataEvent(event)) { + continue; + } + } else { + if (!isBinanceOrderUserDataEvent(event)) { + continue; + } + const eventMarketId = getBinanceEventMarketId(event); + if (eventMarketId && marketId && eventMarketId !== marketId) { + continue; + } + } + + const receivedTimestamp = Date.now(); + if ( + !(await writeSubscribeFrame(call, isClosed, { + data: JSON.stringify({ + subscriptionId: message.subscriptionId, + event, + } satisfies BinanceUserDataEvent), + timestamp: receivedTimestamp, + symbol, + type: subscriptionType, + })) + ) { + break; + } + const archiveSubscriptionType = + subscriptionType === SubscriptionType.BALANCE ? "BALANCE" : "ORDERS"; + archiveSubscribeStreamInBackground(archiveContext?.archiver, { + exchange: archiveContext?.exchange ?? "binance", + accountSelector: archiveContext?.accountSelector, + symbol, + subscriptionType: archiveSubscriptionType, + streamPayload: event, + }); + if (archiveContext) { + archiveCexStreamEventInBackground( + archiveContext.archiver, + archiveContext.otelMetrics, + { + deploymentId: archiveContext.deploymentId, + exchange: archiveContext.exchange, + symbol, + assetType: archiveContext.assetType, + accountSelector: archiveContext.accountSelector, + streamType: archiveSubscriptionType, + payload: event, + receivedTimestamp, + }, + ); + } + } + } finally { + userDataStream.close(); + } +} + +async function runCcxtSubscribeLoop( + call: SubscribeCall, + isClosed: () => boolean, + symbol: string, + subscriptionType: SubscriptionTypeValue, + watch: () => Promise, + archiveContext?: { + archiver?: BrokerExecutionArchiver; + otelMetrics?: OtelMetrics; + exchange: string; + accountSelector?: string; + deploymentId: string; + assetType: BrokerMarketType; + archiveSubscriptionType?: "ORDERS" | "BALANCE"; + }, +): Promise { + while (!isClosed()) { + const data = await watch(); + const receivedTimestamp = Date.now(); + if ( + !(await writeSubscribeFrame(call, isClosed, { + data: JSON.stringify(data), + timestamp: receivedTimestamp, + symbol, + type: subscriptionType, + })) + ) { + break; + } + if (archiveContext?.archiveSubscriptionType) { + archiveSubscribeStreamInBackground(archiveContext.archiver, { + exchange: archiveContext.exchange, + accountSelector: archiveContext.accountSelector, + symbol, + subscriptionType: archiveContext.archiveSubscriptionType, + streamPayload: data, + }); + archiveCexStreamEventInBackground( + archiveContext.archiver, + archiveContext.otelMetrics, + { + deploymentId: archiveContext.deploymentId, + exchange: archiveContext.exchange, + symbol, + assetType: archiveContext.assetType, + accountSelector: archiveContext.accountSelector, + streamType: archiveContext.archiveSubscriptionType, + payload: data, + receivedTimestamp, + }, + ); + } + } +} + +export function createSubscribeHandler(deps: SubscribeDeps) { + const { brokers, whitelistIps, otelMetrics, brokerArchiver } = deps; + const brokerLifecycle = + deps.brokerLifecycle ?? new SubscribeBrokerLifecycle(); + + return async (call: SubscribeCall) => { + const subscribeStartTime = Date.now(); + let streamClosed = false; + let ownedBroker: Exchange | null = null; + let ownedBrokerClosePromise: Promise | undefined; + const markStreamClosed = () => { + streamClosed = true; + }; + const isStreamClosed = () => + streamClosed || call.cancelled || call.writableEnded; + const closeOwnedBroker = (): Promise => { + if (ownedBrokerClosePromise) { + return ownedBrokerClosePromise; + } + if (!ownedBroker) { + return Promise.resolve(); + } + const broker = ownedBroker; + ownedBroker = null; + ownedBrokerClosePromise = brokerLifecycle.close(broker); + return ownedBrokerClosePromise; + }; + const closeOwnedBrokerOnCallEnd = () => { + void closeOwnedBroker(); + }; + + call.once("cancelled", markStreamClosed); + call.once("cancelled", closeOwnedBrokerOnCallEnd); + call.once("error", closeOwnedBrokerOnCallEnd); + call.once("end", () => { + markStreamClosed(); + log.info("Subscribe stream ended"); + const duration = Date.now() - subscribeStartTime; + otelMetrics?.recordHistogram("subscribe_duration_ms", duration, { + cex: call.request?.cex || "unknown", + symbol: call.request?.symbol || "unknown", + }); + }); + call.once("error", (error) => { + markStreamClosed(); + log.error("Subscribe stream error:", error); + otelMetrics?.recordCounter("subscribe_errors_total", 1, { + error_type: error instanceof Error ? error.message : "unknown", + }); + }); + + if (!authenticateRequest(call, whitelistIps)) { + otelMetrics?.recordCounter("subscribe_errors_total", 1, { + error_type: "permission_denied", + }); + call.emit( + "error", + { + code: grpc.status.PERMISSION_DENIED, + message: "Access denied: Unauthorized IP", + }, + null, + ); + call.destroy(new Error("Access denied: Unauthorized IP")); + return; + } + + const metadata = call.metadata; + let subscriptionType: SubscriptionTypeValue = SubscriptionType.ORDERBOOK; + + try { + const request = call.request as SubscribeRequest; + const { cex, symbol, type, options } = request; + + // proto-loader with defaults:true materializes omitted enums as NO_ACTION. + subscriptionType = resolveSubscriptionType(type); + + log.info(`Request - Subscribe:`, { + cex: request.cex, + symbol: request.symbol, + type: subscriptionType, + }); + + const subscriptionTypeName = getSubscriptionTypeName(subscriptionType); + otelMetrics?.recordCounter("subscribe_requests_total", 1, { + cex: cex || "unknown", + symbol: symbol || "unknown", + type: subscriptionTypeName, + }); + + if (!cex || !symbol) { + await writeSubscribeError(call, isStreamClosed, { + data: JSON.stringify({ + error: "cex, symbol, and type are required", + }), + timestamp: Date.now(), + symbol: symbol || "", + type: subscriptionType, + }); + return; + } + + const normalizedCex = cex.trim().toLowerCase(); + const brokerPool = brokers[normalizedCex as keyof typeof brokers]; + const selectedBrokerAccount = selectBrokerAccount(brokerPool, metadata); + const selectedBroker = + selectedBrokerAccount?.exchange ?? + createBroker(normalizedCex, metadata); + const broker = selectedBroker ?? createPublicBroker(normalizedCex); + + if (!broker) { + await writeSubscribeError(call, isStreamClosed, { + data: JSON.stringify({ + error: "Exchange not registered and no API metadata found", + }), + timestamp: Date.now(), + symbol, + type: subscriptionType, + }); + return; + } + if (!selectedBrokerAccount) { + ownedBroker = broker; + brokerLifecycle.register(broker, { + cex: normalizedCex, + symbol, + }); + if (isStreamClosed()) { + await closeOwnedBroker(); + return; + } + } + + const resolvedSymbol = await resolveSubscriptionSymbol( + broker, + symbol, + options?.marketType, + ); + const assetType = parseMarketType(options?.marketType); + const deploymentId = brokerArchiver?.getDeploymentId() ?? "unknown"; + const streamArchiveContext = { + archiver: brokerArchiver, + otelMetrics, + exchange: normalizedCex, + accountSelector: selectedBrokerAccount?.label, + deploymentId, + assetType, + }; + + if ( + isBinanceSpotAccountSubscription( + normalizedCex, + subscriptionType, + options?.marketType, + ) + ) { + const accountBroker = selectedBrokerAccount?.exchange ?? selectedBroker; + if (!accountBroker) { + await writeSubscribeError(call, isStreamClosed, { + data: JSON.stringify({ + error: "Binance account subscriptions require API credentials", + }), + timestamp: Date.now(), + symbol: resolvedSymbol, + type: subscriptionType, + }); + return; + } + await streamBinanceUserData( + call, + accountBroker, + resolvedSymbol, + subscriptionType, + isStreamClosed, + streamArchiveContext, + ); + return; + } + + switch (subscriptionType) { + case SubscriptionType.ORDERBOOK: + try { + const orderbookSampler = createOrderbookSampler(); + while (!isStreamClosed()) { + const depthLimit = parseOptionalDepthLimit( + options?.depthLimit ?? options?.limit, + ); + const orderbook = + depthLimit === undefined + ? await broker.watchOrderBook(resolvedSymbol) + : await broker.watchOrderBook(resolvedSymbol, depthLimit); + const receivedTimestamp = Date.now(); + const normalizedSnapshot = normalizeOrderBookSnapshot(orderbook, { + exchange: normalizedCex, + symbol: resolvedSymbol, + depthLimit: + depthLimit ?? + Math.max( + Array.isArray(orderbook?.bids) ? orderbook.bids.length : 0, + Array.isArray(orderbook?.asks) ? orderbook.asks.length : 0, + ), + receivedTimestamp, + }); + if ( + !(await writeSubscribeFrame(call, isStreamClosed, { + data: JSON.stringify(normalizedSnapshot), + timestamp: receivedTimestamp, + symbol: resolvedSymbol, + type: subscriptionType, + })) + ) { + break; + } + const shouldArchive = + orderbookSampler.shouldEmit(receivedTimestamp); + archiveOrderbookInBackground( + brokerArchiver, + otelMetrics, + { + deploymentId, + exchange: normalizedCex, + symbol: resolvedSymbol, + assetType, + accountSelector: selectedBrokerAccount?.label, + snapshot: normalizedSnapshot, + }, + { + sampledOut: !shouldArchive, + }, + ); + } + } catch (error: unknown) { + log.error( + `Error fetching orderbook for ${resolvedSymbol} on ${cex}:`, + error, + ); + const message = getErrorMessage(error); + await writeSubscribeError(call, isStreamClosed, { + data: JSON.stringify({ + error: `Failed to fetch orderbook: ${message}`, + }), + timestamp: Date.now(), + symbol: resolvedSymbol, + type: subscriptionType, + }); + } + break; + + case SubscriptionType.TRADES: + try { + while (!isStreamClosed()) { + const data = await broker.watchTrades(resolvedSymbol); + const receivedTimestamp = Date.now(); + if ( + !(await writeSubscribeFrame(call, isStreamClosed, { + data: JSON.stringify(data), + timestamp: receivedTimestamp, + symbol: resolvedSymbol, + type: subscriptionType, + })) + ) { + break; + } + archiveTradesInBackground(brokerArchiver, otelMetrics, { + deploymentId, + exchange: normalizedCex, + symbol: resolvedSymbol, + assetType, + accountSelector: selectedBrokerAccount?.label, + payload: data, + receivedTimestamp, + }); + } + } catch (error: unknown) { + const message = getErrorMessage(error); + log.error( + `Error fetching trades for ${resolvedSymbol} on ${cex}:`, + error, + ); + await writeSubscribeError(call, isStreamClosed, { + data: JSON.stringify({ + error: `Failed to fetch trades: ${message}`, + }), + timestamp: Date.now(), + symbol: resolvedSymbol, + type: subscriptionType, + }); + } + break; + + case SubscriptionType.TICKER: + try { + while (!isStreamClosed()) { + const data = await broker.watchTicker(resolvedSymbol); + const receivedTimestamp = Date.now(); + if ( + !(await writeSubscribeFrame(call, isStreamClosed, { + data: JSON.stringify(data), + timestamp: receivedTimestamp, + symbol: resolvedSymbol, + type: subscriptionType, + })) + ) { + break; + } + archiveTickerInBackground(brokerArchiver, otelMetrics, { + deploymentId, + exchange: normalizedCex, + symbol: resolvedSymbol, + assetType, + accountSelector: selectedBrokerAccount?.label, + payload: data, + receivedTimestamp, + }); + } + } catch (error: unknown) { + const message = getErrorMessage(error); + log.error( + `Error fetching ticker for ${resolvedSymbol} on ${cex}:`, + error, + ); + await writeSubscribeError(call, isStreamClosed, { + data: JSON.stringify({ + error: `Failed to fetch ticker: ${message}`, + }), + timestamp: Date.now(), + symbol: resolvedSymbol, + type: subscriptionType, + }); + } + break; + + case SubscriptionType.OHLCV: + try { + const timeframe = options?.timeframe || "1m"; + const ohlcvBarTracker = createOhlcvBarTracker(); + const ohlcvArchiveInput = { + deploymentId, + exchange: normalizedCex, + symbol: resolvedSymbol, + assetType, + timeframe, + accountSelector: selectedBrokerAccount?.label, + receivedTimestamp: Date.now(), + payload: [], + }; + const bootstrapPayload = await bootstrapOhlcvHistory( + broker, + brokerArchiver, + otelMetrics, + ohlcvBarTracker, + ohlcvArchiveInput, + { bootstrapLimit: options?.bootstrapLimit }, + ); + let ohlcvStreamActive = true; + if (bootstrapPayload) { + const bootstrapTimestamp = Date.now(); + const bootstrapSent = await writeSubscribeFrame( + call, + isStreamClosed, + { + data: JSON.stringify(bootstrapPayload), + timestamp: bootstrapTimestamp, + symbol: resolvedSymbol, + type: subscriptionType, + }, + ); + if (!bootstrapSent) { + ohlcvStreamActive = false; + } + } + while (ohlcvStreamActive && !isStreamClosed()) { + const data = await broker.watchOHLCV(resolvedSymbol, timeframe); + const receivedTimestamp = Date.now(); + if ( + !(await writeSubscribeFrame(call, isStreamClosed, { + data: JSON.stringify(data), + timestamp: receivedTimestamp, + symbol: resolvedSymbol, + type: subscriptionType, + })) + ) { + break; + } + archiveOhlcvInBackground( + brokerArchiver, + otelMetrics, + ohlcvBarTracker, + { + deploymentId, + exchange: normalizedCex, + symbol: resolvedSymbol, + assetType, + timeframe, + accountSelector: selectedBrokerAccount?.label, + payload: data, + receivedTimestamp, + }, + ); + } + } catch (error: unknown) { + log.error( + `Error fetching OHLCV for ${resolvedSymbol} on ${cex}:`, + error, + ); + const message = getErrorMessage(error); + await writeSubscribeError(call, isStreamClosed, { + data: JSON.stringify({ + error: `Failed to fetch OHLCV: ${message}`, + }), + timestamp: Date.now(), + symbol: resolvedSymbol, + type: subscriptionType, + }); + } + break; + + case SubscriptionType.BALANCE: + try { + await runCcxtSubscribeLoop( + call, + isStreamClosed, + symbol, + subscriptionType, + () => broker.watchBalance(), + { + ...streamArchiveContext, + archiveSubscriptionType: "BALANCE", + }, + ); + } catch (error: unknown) { + const message = getErrorMessage(error); + log.error(`Error fetching balance for ${cex}:`, error); + await writeSubscribeError(call, isStreamClosed, { + data: JSON.stringify({ + error: `Failed to fetch balance: ${message}`, + }), + timestamp: Date.now(), + symbol, + type: subscriptionType, + }); + } + break; + + case SubscriptionType.ORDERS: + try { + await runCcxtSubscribeLoop( + call, + isStreamClosed, + resolvedSymbol, + subscriptionType, + () => broker.watchOrders(resolvedSymbol), + { + ...streamArchiveContext, + archiveSubscriptionType: "ORDERS", + }, + ); + } catch (error: unknown) { + log.error( + `Error fetching orders for ${resolvedSymbol} on ${cex}:`, + error, + ); + const message = getErrorMessage(error); + await writeSubscribeError(call, isStreamClosed, { + data: JSON.stringify({ + error: `Failed to fetch orders: ${message}`, + }), + timestamp: Date.now(), + symbol: resolvedSymbol, + type: subscriptionType, + }); + } + break; + + default: + await writeSubscribeError(call, isStreamClosed, { + data: JSON.stringify({ error: "Invalid subscription type" }), + timestamp: Date.now(), + symbol, + type: subscriptionType, + }); + } + } catch (error) { + log.error("Error in Subscribe stream:", error); + const message = getErrorMessage(error); + await writeSubscribeError(call, isStreamClosed, { + data: JSON.stringify({ error: `Internal server error: ${message}` }), + timestamp: Date.now(), + symbol: "", + type: subscriptionType, + }); + } finally { + call.off("cancelled", closeOwnedBrokerOnCallEnd); + call.off("error", closeOwnedBrokerOnCallEnd); + await closeOwnedBroker(); + } + }; +} diff --git a/src/handlers/subscribe/index.ts b/src/handlers/subscribe/index.ts new file mode 100644 index 0000000..cb683c9 --- /dev/null +++ b/src/handlers/subscribe/index.ts @@ -0,0 +1,2 @@ +export { SubscribeBrokerLifecycle } from "./broker-lifecycle"; +export { createSubscribeHandler, type SubscribeDeps } from "./handler"; diff --git a/src/handlers/types.ts b/src/handlers/types.ts new file mode 100644 index 0000000..8c986aa --- /dev/null +++ b/src/handlers/types.ts @@ -0,0 +1,32 @@ +import type { + ActionName, + Action as ActionType, + SubscriptionTypeName, + SubscriptionType as SubscriptionTypeValue, +} from "../helpers/constants"; + +export type ActionRequest = { + action?: ActionType | ActionName; + payload?: Record; + cex?: string; + symbol?: string; +}; + +export type ActionResponse = { + result: string; + proof?: string; +}; + +export type SubscribeRequest = { + cex?: string; + symbol?: string; + type?: SubscriptionTypeValue | SubscriptionTypeName; + options?: Record; +}; + +export type SubscribeResponse = { + data: string; + timestamp: number; + symbol: string; + type: SubscriptionTypeValue; +}; diff --git a/src/helpers/account-balance-archive-poller.ts b/src/helpers/account-balance-archive-poller.ts new file mode 100644 index 0000000..8709e3a --- /dev/null +++ b/src/helpers/account-balance-archive-poller.ts @@ -0,0 +1,208 @@ +import type { BrokerAccount, BrokerPoolEntry } from "./broker"; +import { + ACCOUNT_BALANCE_SCOPE, + type BrokerExecutionArchiver, + buildAccountBalanceSnapshotRow, + buildCommonArchiveTags, + normalizeCcxtBalanceForArchive, + rethrowArchiveDurabilityError, +} from "./broker-execution-archive"; +import { log } from "./logger"; +import type { OtelMetrics } from "./otel"; + +type ExchangeWithBalance = { + fetchBalance: (params: { type: "spot" }) => Promise; +}; + +export type AccountBalanceArchivePollerConfig = { + pollIntervalMs: number; +}; + +const DEFAULT_CONFIG: AccountBalanceArchivePollerConfig = { + pollIntervalMs: 60_000, +}; + +type BalancePollTarget = { + exchangeId: string; + account: BrokerAccount; +}; + +const metricLabels = (target: BalancePollTarget) => ({ + exchange: target.exchangeId, + account_selector: target.account.label, + balance_scope: ACCOUNT_BALANCE_SCOPE, +}); + +export class AccountBalanceArchivePoller { + #timer: ReturnType | null = null; + #stopped = false; + #running: Promise | null = null; + readonly #lastSuccessMs = new Map(); + readonly #config: AccountBalanceArchivePollerConfig; + + constructor( + private readonly params: { + brokers: Record; + archiver: BrokerExecutionArchiver; + metrics?: OtelMetrics; + config?: Partial; + }, + ) { + this.#config = { ...DEFAULT_CONFIG, ...params.config }; + } + + start(): void { + if ( + this.#timer || + this.#stopped || + !this.params.archiver.canPersistAccountBalanceSnapshots() + ) { + return; + } + log.info("💰 Account balance archive poller started", { + balanceScope: ACCOUNT_BALANCE_SCOPE, + }); + this.#schedule(0); + } + + async stop(): Promise { + this.#stopped = true; + if (this.#timer) { + clearTimeout(this.#timer); + this.#timer = null; + } + await this.#running; + } + + async pollAllOnce(): Promise { + if ( + this.#stopped || + this.#running || + !this.params.archiver.canPersistAccountBalanceSnapshots() + ) { + return false; + } + this.#running = this.#pollAllSequentially(); + try { + return await this.#running; + } finally { + this.#running = null; + } + } + + #targets(): BalancePollTarget[] { + const targets: BalancePollTarget[] = []; + for (const [exchangeId, pool] of Object.entries(this.params.brokers)) { + for (const account of [pool.primary, ...pool.secondaryBrokers]) { + targets.push({ exchangeId, account }); + } + } + return targets; + } + + async #pollAllSequentially(): Promise { + for (const target of this.#targets()) { + if (this.#stopped) { + break; + } + await this.#pollOne(target); + } + return true; + } + + async #pollOne(target: BalancePollTarget): Promise { + const labels = metricLabels(target); + void this.params.metrics?.recordCounter( + "cex_account_balance_poll_attempts_total", + 1, + labels, + ); + try { + const exchange = target.account + .exchange as unknown as ExchangeWithBalance; + const balance = await exchange.fetchBalance({ + type: ACCOUNT_BALANCE_SCOPE, + }); + const observedAt = new Date(); + const normalized = normalizeCcxtBalanceForArchive(balance); + this.params.archiver.enqueue( + buildAccountBalanceSnapshotRow({ + tags: buildCommonArchiveTags({ + deploymentId: this.params.archiver.getDeploymentId(), + accountSelector: target.account.label, + exchange: target.exchangeId, + brokerObservedTimestamp: observedAt.toISOString(), + }), + balance: normalized, + }), + ); + + const successMs = Date.now(); + this.#lastSuccessMs.set(this.#targetKey(target), successMs); + void this.params.metrics?.recordCounter( + "cex_account_balance_poll_successes_total", + 1, + labels, + ); + void this.params.metrics?.recordGauge( + "cex_account_balance_poll_last_success_timestamp_seconds", + Math.floor(successMs / 1000), + labels, + ); + this.#recordFreshness(labels, successMs, successMs); + } catch (error) { + rethrowArchiveDurabilityError(error); + void this.params.metrics?.recordCounter( + "cex_account_balance_poll_failures_total", + 1, + labels, + ); + const now = Date.now(); + const lastSuccess = this.#lastSuccessMs.get(this.#targetKey(target)); + if (lastSuccess !== undefined) { + this.#recordFreshness(labels, lastSuccess, now); + } + log.warn("Account balance archive poll failed", { + exchange: target.exchangeId, + account: target.account.label, + balanceScope: ACCOUNT_BALANCE_SCOPE, + errorType: error instanceof Error ? error.name : "unknown", + }); + } + } + + #recordFreshness( + labels: ReturnType, + lastSuccessMs: number, + nowMs: number, + ): void { + void this.params.metrics?.recordGauge( + "cex_account_balance_poll_freshness_seconds", + Math.max(0, (nowMs - lastSuccessMs) / 1000), + labels, + ); + } + + #targetKey(target: BalancePollTarget): string { + return `${target.exchangeId}|${target.account.label}|${ACCOUNT_BALANCE_SCOPE}`; + } + + #schedule(delayMs: number): void { + this.#timer = setTimeout(() => void this.#tick(), delayMs); + this.#timer.unref?.(); + } + + async #tick(): Promise { + this.#timer = null; + try { + await this.pollAllOnce(); + } catch (error) { + rethrowArchiveDurabilityError(error); + log.error("Account balance archive poller tick failed", error); + } finally { + if (!this.#stopped) { + this.#schedule(this.#config.pollIntervalMs); + } + } + } +} diff --git a/src/helpers/auth.ts b/src/helpers/auth.ts new file mode 100644 index 0000000..547d07e --- /dev/null +++ b/src/helpers/auth.ts @@ -0,0 +1,99 @@ +import { isIP } from "node:net"; +import { log } from "./logger"; + +type IpVersion = 4 | 6; + +type GrpcPeerCall = { + getPeer(): string; +}; + +function parsePeerHost(peer: string): string | undefined { + const value = peer.trim(); + if (!value) return undefined; + + if (value.startsWith("ipv4:")) { + return parsePeerEndpoint(value.slice("ipv4:".length), 4); + } + + if (value.startsWith("ipv6:")) { + return parsePeerEndpoint(value.slice("ipv6:".length), 6); + } + + return parsePeerEndpoint(value); +} + +function parsePeerEndpoint( + endpoint: string, + expectedIpVersion?: IpVersion, +): string | undefined { + if (!endpoint) return undefined; + + const bracketedIpv6 = endpoint.match(/^\[([^\]]+)\](?::(\d+))?$/); + if (bracketedIpv6) { + const host = bracketedIpv6[1]; + const port = bracketedIpv6[2]; + if (!host || !isValidHost(host, expectedIpVersion, 6)) { + return undefined; + } + return port === undefined || isValidPort(port) ? host : undefined; + } + + if (endpoint.includes("[") || endpoint.includes("]")) { + return undefined; + } + + if (isValidHost(endpoint, expectedIpVersion)) { + return endpoint; + } + + const hostWithPort = endpoint.match(/^([^:]+):(\d+)$/); + if (!hostWithPort) { + return undefined; + } + + const host = hostWithPort[1]; + const port = hostWithPort[2]; + if ( + !host || + !port || + !isValidHost(host, expectedIpVersion) || + !isValidPort(port) + ) { + return undefined; + } + + return host; +} + +function isValidHost( + host: string, + expectedIpVersion?: IpVersion, + requiredIpVersion?: IpVersion, +): boolean { + const actualIpVersion = isIP(host); + const requiredVersion = expectedIpVersion ?? requiredIpVersion; + if (requiredVersion !== undefined) { + return actualIpVersion === requiredVersion; + } + + return actualIpVersion > 0 || /^[A-Za-z0-9.-]+$/.test(host); +} + +function isValidPort(port: string): boolean { + const value = Number(port); + return Number.isInteger(value) && value >= 0 && value <= 65535; +} + +export function authenticateRequest( + call: GrpcPeerCall, + whitelistIps: string[], +): boolean { + const clientIp = parsePeerHost(call.getPeer()); + if (whitelistIps.includes("*")) { + return true; + } else if (!clientIp || !whitelistIps.includes(clientIp)) { + log.warn(`Blocked access from unauthorized IP: ${clientIp || "unknown"}`); + return false; + } + return true; +} diff --git a/src/helpers/binance-user-data-stream.ts b/src/helpers/binance-user-data-stream.ts new file mode 100644 index 0000000..d738880 --- /dev/null +++ b/src/helpers/binance-user-data-stream.ts @@ -0,0 +1,464 @@ +import { Buffer } from "node:buffer"; +import { createHmac } from "node:crypto"; +import type { Exchange } from "@usherlabs/ccxt"; +import WebSocket from "ws"; + +export const BINANCE_SPOT_WS_API_URL = "wss://ws-api.binance.com:443/ws-api/v3"; +export const DEFAULT_BINANCE_USER_DATA_MAX_BUFFERED_EVENTS = 16; + +export type BinanceUserDataEvent = { + subscriptionId: number; + event: Record; +}; + +type BinanceUserDataMessage = + | { + id?: string | null; + status?: number; + error?: { code?: number; msg?: string; message?: string }; + result?: { subscriptionId?: number }; + } + | { + subscriptionId?: number; + event?: Record; + }; + +type WebSocketLike = { + on(event: "open", listener: () => void): unknown; + on(event: "message", listener: (data: unknown) => void): unknown; + on(event: "error", listener: (error: unknown) => void): unknown; + on( + event: "close", + listener: (code: unknown, reason: unknown) => void, + ): unknown; + send(data: string): void; + close(code?: number, reason?: string): void; +}; + +type WebSocketFactory = (url: string) => WebSocketLike; + +type BinanceSpotUserDataStreamOptions = { + maxBufferedEvents?: number; +}; + +let createWebSocket: WebSocketFactory = (url) => + new WebSocket(url) as WebSocketLike; +let userDataRequestCounter = 0; + +export function setBinanceUserDataWebSocketFactoryForTests( + factory: WebSocketFactory, +): () => void { + const previous = createWebSocket; + createWebSocket = factory; + return () => { + createWebSocket = previous; + }; +} + +function getExchangeString( + exchange: Exchange, + key: "apiKey" | "secret", +): string { + const value = exchange[key]; + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Binance user-data stream requires exchange.${key}`); + } + return value; +} + +function sortedQuery(params: Record): string { + return Object.entries(params) + .sort(([left], [right]) => left.localeCompare(right)) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`, + ) + .join("&"); +} + +function signUserDataStreamParams( + exchange: Exchange, + params: Record, +): Record { + const signParams = (exchange as unknown as { signParams?: unknown }) + .signParams; + if (typeof signParams === "function") { + return signParams.call(exchange, params) as Record; + } + + const secret = getExchangeString(exchange, "secret"); + return { + ...params, + signature: createHmac("sha256", secret) + .update(sortedQuery(params)) + .digest("hex"), + }; +} + +export function getBinanceSpotWsApiUrl(exchange: Exchange): string { + const urls = ( + exchange as unknown as { + urls?: { + api?: { ws?: { "ws-api"?: { spot?: string } } }; + }; + } + ).urls; + return urls?.api?.ws?.["ws-api"]?.spot ?? BINANCE_SPOT_WS_API_URL; +} + +function getRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null + ? (value as Record) + : null; +} + +function getMessage(value: unknown): string | null { + if (value instanceof Error) { + return value.message; + } + if (typeof value === "string" && value.length > 0) { + return value; + } + const record = getRecord(value); + const message = record?.message; + return typeof message === "string" && message.length > 0 ? message : null; +} + +function getOptionalExchangeString( + exchange: Exchange, + key: "apiKey" | "secret", +): string | null { + const value = exchange[key]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +function redactDiagnosticMessage( + message: string, + secretValues: readonly string[], +): string { + let redacted = message; + for (const value of secretValues) { + if (value.length > 0) { + redacted = redacted.split(value).join("[redacted]"); + } + } + return redacted + .replace( + /(\b(?:apiKey|secret|signature)\b\s*=\s*)[^\s&,;)]+/gi, + "$1[redacted]", + ) + .replace( + /("(?:apiKey|secret|signature)"\s*:\s*")[^"]*(")/gi, + "$1[redacted]$2", + ); +} + +function formatBinanceUserDataWebSocketError( + event: unknown, + secretValues: readonly string[], +): Error { + const record = getRecord(event); + const message = + getMessage(record?.error) ?? + getMessage(record?.message) ?? + getMessage(event); + const safeMessage = + message === null ? null : redactDiagnosticMessage(message, secretValues); + return new Error( + safeMessage + ? `Binance user-data WebSocket error: ${safeMessage}` + : "Binance user-data WebSocket error", + ); +} + +function getCloseReason(value: unknown): string | null { + if (typeof value === "string") { + return value.length > 0 ? value : null; + } + if (Buffer.isBuffer(value)) { + const reason = value.toString("utf8"); + return reason.length > 0 ? reason : null; + } + if (value instanceof Uint8Array) { + const reason = Buffer.from(value).toString("utf8"); + return reason.length > 0 ? reason : null; + } + return null; +} + +function formatBinanceUserDataWebSocketClose( + codeOrEvent: unknown, + reasonOrUndefined: unknown, + secretValues: readonly string[], +): Error { + const record = getRecord(codeOrEvent); + const code = record ? record.code : codeOrEvent; + const reason = getCloseReason(record ? record.reason : reasonOrUndefined); + const safeReason = + reason === null ? null : redactDiagnosticMessage(reason, secretValues); + const details = [ + typeof code === "number" || typeof code === "string" + ? `code=${code}` + : null, + safeReason ? `reason=${safeReason}` : null, + ].filter((detail): detail is string => detail !== null); + return new Error( + details.length > 0 + ? `Binance user-data WebSocket closed unexpectedly (${details.join(", ")})` + : "Binance user-data WebSocket closed unexpectedly", + ); +} + +function decodeMessageData(data: unknown): unknown { + if (typeof data === "string") { + return data; + } + if (Buffer.isBuffer(data)) { + return data.toString("utf8"); + } + if (data instanceof ArrayBuffer) { + return Buffer.from(data).toString("utf8"); + } + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString( + "utf8", + ); + } + if (Array.isArray(data) && data.every((item) => Buffer.isBuffer(item))) { + return Buffer.concat(data).toString("utf8"); + } + return data; +} + +export class BinanceSpotUserDataStream + implements AsyncIterable +{ + private readonly ws: WebSocketLike; + private readonly secretValues: string[]; + // Binance rejects request ids not matching ^[a-zA-Z0-9-_]{1,36}$ with + // error -1135 and then closes the socket (1008 "disconnected"). + private readonly requestId = + `user-data-${Date.now()}-${userDataRequestCounter++}`; + private readonly maxBufferedEvents: number; + private readonly queue: BinanceUserDataEvent[] = []; + private readonly waiters: Array<{ + resolve: (event: BinanceUserDataEvent | null) => void; + reject: (error: Error) => void; + }> = []; + private closed = false; + private closeError: Error | null = null; + private subscriptionId: number | null = null; + + constructor( + private readonly exchange: Exchange, + options: BinanceSpotUserDataStreamOptions = {}, + ) { + this.maxBufferedEvents = + options.maxBufferedEvents ?? + DEFAULT_BINANCE_USER_DATA_MAX_BUFFERED_EVENTS; + this.secretValues = [ + getOptionalExchangeString(exchange, "apiKey"), + getOptionalExchangeString(exchange, "secret"), + ].filter((value): value is string => value !== null); + this.ws = createWebSocket(getBinanceSpotWsApiUrl(exchange)); + this.ws.on("open", () => this.subscribe()); + this.ws.on("message", (data) => this.handleMessage(data)); + this.ws.on("error", (error) => + this.fail(formatBinanceUserDataWebSocketError(error, this.secretValues)), + ); + this.ws.on("close", (code, reason) => this.handleClose(code, reason)); + } + + async *[Symbol.asyncIterator](): AsyncIterator { + while (true) { + const event = await this.nextEvent(); + if (!event) { + break; + } + yield event; + } + } + + close(): void { + if (this.closed) { + return; + } + this.closed = true; + this.queue.length = 0; + try { + this.ws.close(); + } catch { + // Closing is best-effort; stream cancellation should still unblock waiters. + } + this.flushWaiters(); + } + + private handleClose(code: unknown, reason: unknown): void { + if (this.closed) { + return; + } + this.fail( + formatBinanceUserDataWebSocketClose(code, reason, this.secretValues), + ); + } + + private subscribe(): void { + const apiKey = getExchangeString(this.exchange, "apiKey"); + const signedParams = signUserDataStreamParams(this.exchange, { + apiKey, + timestamp: Date.now(), + }); + this.ws.send( + JSON.stringify({ + id: this.requestId, + method: "userDataStream.subscribe.signature", + params: signedParams, + }), + ); + } + + private handleMessage(data: unknown): void { + if (this.closed) { + return; + } + + let message: BinanceUserDataMessage; + try { + const decodedData = decodeMessageData(data); + message = + typeof decodedData === "string" + ? (JSON.parse(decodedData) as BinanceUserDataMessage) + : (decodedData as BinanceUserDataMessage); + } catch (error) { + this.fail( + error instanceof Error + ? error + : new Error("Invalid Binance user-data message"), + ); + return; + } + + if ("id" in message && message.id === this.requestId) { + if (message.status !== 200) { + this.fail( + new Error( + message.error?.msg ?? + message.error?.message ?? + `Binance user-data subscription failed with status ${message.status}`, + ), + ); + return; + } + this.subscriptionId = message.result?.subscriptionId ?? null; + return; + } + + if ( + "status" in message && + typeof message.status === "number" && + message.status !== 200 + ) { + const errorMessage = + message.error?.msg ?? + message.error?.message ?? + `Binance user-data request failed with status ${message.status}`; + const errorCode = message.error?.code; + this.fail( + new Error( + typeof errorCode === "number" + ? `${errorMessage} (code ${errorCode})` + : errorMessage, + ), + ); + return; + } + + if (!("event" in message) || !message.event) { + return; + } + const subscriptionId = message.subscriptionId ?? this.subscriptionId; + if (subscriptionId === null || subscriptionId === undefined) { + return; + } + this.push({ subscriptionId, event: message.event }); + } + + private push(event: BinanceUserDataEvent): void { + if (this.closed) { + return; + } + + const waiter = this.waiters.shift(); + if (waiter) { + waiter.resolve(event); + return; + } + if (this.queue.length >= this.maxBufferedEvents) { + this.fail( + new Error( + `Binance user-data stream buffered event limit exceeded (${this.maxBufferedEvents}); downstream consumer is not keeping up`, + ), + ); + return; + } + this.queue.push(event); + } + + private nextEvent(): Promise { + const event = this.queue.shift(); + if (event) { + return Promise.resolve(event); + } + if (this.closeError) { + return Promise.reject(this.closeError); + } + if (this.closed) { + return Promise.resolve(null); + } + return new Promise((resolve, reject) => { + this.waiters.push({ resolve, reject }); + }); + } + + private fail(error: Error): void { + if (this.closeError) { + return; + } + this.closeError = error; + this.closed = true; + this.queue.length = 0; + this.flushWaiters(); + try { + this.ws.close(); + } catch { + // Closing is best-effort after an already surfaced failure. + } + } + + private flushWaiters(): void { + const error = this.closeError; + for (const waiter of this.waiters.splice(0)) { + if (error) { + waiter.reject(error); + } else { + waiter.resolve(null); + } + } + } +} + +export function isBinanceBalanceUserDataEvent( + event: Record, +): boolean { + return ( + event.e === "outboundAccountPosition" || + event.e === "balanceUpdate" || + event.e === "externalLockUpdate" + ); +} + +export function isBinanceOrderUserDataEvent( + event: Record, +): boolean { + return event.e === "executionReport" || event.e === "listStatus"; +} diff --git a/src/helpers/broker-execution-archive/capture.ts b/src/helpers/broker-execution-archive/capture.ts new file mode 100644 index 0000000..d6c1d89 --- /dev/null +++ b/src/helpers/broker-execution-archive/capture.ts @@ -0,0 +1,268 @@ +import type { Exchange } from "@usherlabs/ccxt"; +import { log } from "../logger"; +import { + buildOrderExecutionTelemetry, + type OrderTelemetryAction, + type OrderTelemetryContext, +} from "../order-telemetry"; +import { sanitizeErrorDetail } from "../shared/errors"; +import { asRecord } from "../shared/guards"; +import { + buildCommonArchiveTags, + buildMarketMetadataSnapshotRow, + buildOrderEventArchiveRow, + buildSubscribeStreamArchiveRow, + buildTransferEventArchiveRow, + normalizeCcxtTransactionForArchive, + type TransferArchiveFields, +} from "./rows"; +import type { SubscribeArchiveType } from "./types"; +import type { WithdrawalObservationTracker } from "./withdrawal-observation-tracker"; +import { + type BrokerExecutionArchiver, + rethrowArchiveDurabilityError, +} from "./writer"; + +export function archiveOrderExecutionInBackground( + archiver: BrokerExecutionArchiver | undefined, + context: OrderTelemetryContext, + order: unknown, + error?: unknown, + options?: { + marketMetadataHash?: string; + }, +): void { + if (!archiver?.isEnabled()) { + return; + } + queueMicrotask(() => { + try { + const telemetry = buildOrderExecutionTelemetry(context, order, error); + const tags = buildCommonArchiveTags({ + deploymentId: archiver.getDeploymentId(), + accountSelector: context.accountLabel, + exchange: context.cex, + symbol: telemetry.symbol, + brokerObservedTimestamp: telemetry.brokerObservedTimestamp, + }); + archiver.enqueue( + buildOrderEventArchiveRow({ + tags, + action: context.action, + telemetry, + errorDetail: + context.action === "CreateOrder" && error !== undefined + ? sanitizeErrorDetail(error, { includeCode: true }) + : undefined, + marketMetadataHash: options?.marketMetadataHash, + }), + ); + } catch (archiveError) { + rethrowArchiveDurabilityError(archiveError); + log.warn("Failed to archive order execution", { error: archiveError }); + } + }); +} + +export function archiveSubscribeStreamInBackground( + archiver: BrokerExecutionArchiver | undefined, + input: { + exchange: string; + accountSelector?: string; + symbol: string; + subscriptionType: SubscribeArchiveType; + streamPayload: unknown; + secretLiterals?: readonly string[]; + }, +): void { + if (!archiver?.isEnabled()) { + return; + } + queueMicrotask(() => { + try { + const tags = buildCommonArchiveTags({ + deploymentId: archiver.getDeploymentId(), + accountSelector: input.accountSelector, + exchange: input.exchange, + symbol: input.symbol, + }); + archiver.enqueue( + buildSubscribeStreamArchiveRow({ + tags, + subscriptionType: input.subscriptionType, + streamPayload: input.streamPayload, + secretLiterals: input.secretLiterals, + }), + ); + } catch (archiveError) { + rethrowArchiveDurabilityError(archiveError); + log.warn("Failed to archive subscribe stream event", { + error: archiveError, + }); + } + }); +} + +export function archiveTransferEventInBackground( + archiver: BrokerExecutionArchiver | undefined, + input: { + exchange: string; + accountSelector?: string; + assetSymbol?: string; + brokerObservedTimestamp?: string; + transfer: TransferArchiveFields; + }, +): void { + if (!archiver?.isEnabled()) { + return; + } + queueMicrotask(() => { + try { + const tags = buildCommonArchiveTags({ + deploymentId: archiver.getDeploymentId(), + accountSelector: input.accountSelector, + exchange: input.exchange, + symbol: input.assetSymbol, + brokerObservedTimestamp: input.brokerObservedTimestamp, + }); + archiver.enqueue( + buildTransferEventArchiveRow({ tags, transfer: input.transfer }), + ); + } catch (archiveError) { + rethrowArchiveDurabilityError(archiveError); + log.warn("Failed to archive transfer event", { error: archiveError }); + } + }); +} + +export function archiveWithdrawalObservationsInBackground( + archiver: BrokerExecutionArchiver | undefined, + tracker: WithdrawalObservationTracker, + input: { + exchange: string; + accountSelector?: string; + transactions: unknown; + }, +): void { + try { + if (!archiver?.isEnabled() || !Array.isArray(input.transactions)) { + return; + } + const brokerObservedTimestamp = new Date().toISOString(); + for (const [resultIndex, transaction] of input.transactions.entries()) { + const normalized = normalizeCcxtTransactionForArchive(transaction); + const assetSymbol = normalized.assetSymbol; + if ( + !tracker.shouldArchive({ + exchange: input.exchange, + accountSelector: input.accountSelector, + assetSymbol, + transaction, + normalized, + }) + ) { + continue; + } + archiveTransferEventInBackground(archiver, { + exchange: input.exchange, + accountSelector: input.accountSelector, + assetSymbol, + brokerObservedTimestamp, + transfer: { + eventKind: "withdrawal", + lifecycleAction: "observe_withdrawal", + status: normalized.status, + amount: normalized.amount, + address: normalized.address, + network: normalized.network, + externalId: normalized.externalId, + clientWithdrawalId: normalized.clientWithdrawalId, + txid: normalized.txid, + resultIndex, + feeAmount: normalized.feeAmount, + feeCurrency: normalized.feeCurrency, + exchangeTimestamp: normalized.exchangeTimestamp, + payload: transaction, + }, + }); + } + } catch (archiveError) { + rethrowArchiveDurabilityError(archiveError); + log.warn("Failed to archive withdrawal observations", { + error: archiveError, + }); + } +} + +export async function captureMarketMetadataSnapshot( + archiver: BrokerExecutionArchiver | undefined, + broker: Exchange, + input: { + exchange: string; + accountSelector?: string; + symbol: string; + action: OrderTelemetryAction; + clientOrderId?: string; + orderId?: string; + makerActionId?: string; + idempotencyId?: string; + brokerObservedTimestamp?: string; + }, +): Promise { + if (!archiver?.canPersistMarketMetadataSnapshot()) { + return undefined; + } + try { + const fetchOrderBook = ( + broker as unknown as { + fetchOrderBook?: (symbol: string, limit?: number) => Promise; + } + ).fetchOrderBook; + if (typeof fetchOrderBook !== "function") { + return undefined; + } + const orderBook = await fetchOrderBook.call(broker, input.symbol, 5); + const record = asRecord(orderBook); + const snapshot = { + action: input.action, + symbol: input.symbol, + bids: record?.bids, + asks: record?.asks, + timestamp: record?.timestamp ?? Date.now(), + datetime: record?.datetime, + nonce: record?.nonce, + }; + const tags = buildCommonArchiveTags({ + deploymentId: archiver.getDeploymentId(), + accountSelector: input.accountSelector, + exchange: input.exchange, + symbol: input.symbol, + brokerObservedTimestamp: input.brokerObservedTimestamp, + }); + const row = buildMarketMetadataSnapshotRow({ + tags, + clientOrderId: input.clientOrderId, + orderId: input.orderId, + makerActionId: input.makerActionId, + idempotencyId: input.idempotencyId, + marketSnapshot: snapshot, + }); + archiver.enqueue(row); + const hash = row.row.market_metadata_hash; + return typeof hash === "string" ? hash : undefined; + } catch (archiveError) { + rethrowArchiveDurabilityError(archiveError); + log.warn("Failed to capture market metadata snapshot", { + error: archiveError, + }); + return undefined; + } +} + +export function captureMarketMetadataSnapshotInBackground( + archiver: BrokerExecutionArchiver | undefined, + broker: Exchange, + input: Parameters[2], +): void { + void captureMarketMetadataSnapshot(archiver, broker, input); +} diff --git a/src/helpers/broker-execution-archive/index.ts b/src/helpers/broker-execution-archive/index.ts new file mode 100644 index 0000000..42deedb --- /dev/null +++ b/src/helpers/broker-execution-archive/index.ts @@ -0,0 +1,58 @@ +export { + archiveOrderExecutionInBackground, + archiveSubscribeStreamInBackground, + archiveTransferEventInBackground, + archiveWithdrawalObservationsInBackground, + captureMarketMetadataSnapshot, + captureMarketMetadataSnapshotInBackground, +} from "./capture"; +export { + hashMarketMetadata, + redactErrorForArchive, + redactSecretLiterals, + redactStreamPayload, +} from "./redact"; +export { + buildAccountBalanceSnapshotRow, + buildCommonArchiveTags, + buildFillEventArchiveRow, + buildMarketMetadataSnapshotRow, + buildOrderEventArchiveRow, + buildSubscribeStreamArchiveRow, + buildTransferEventArchiveRow, + extractBinanceInternalTransferId, + type FillArchiveFields, + type NormalizedCcxtBalance, + type NormalizedCcxtTransfer, + normalizeCcxtBalanceForArchive, + normalizeCcxtTradeForArchive, + normalizeCcxtTransactionForArchive, + type TransferArchiveFields, +} from "./rows"; +export { + ACCOUNT_BALANCE_PRECISION_BASIS, + ACCOUNT_BALANCE_SCOPE, + ARCHIVE_SCHEMA_VERSION, + BROKER_WRITE_SOURCE, + type BrokerArchiveCommonTags, + type BrokerArchiveRow, + type BrokerArchiveTable, + type OrderArchiveAction, + type SubscribeArchiveType, + type TransferEventKind, + type TransferLifecycleAction, +} from "./types"; +export { + DEFAULT_WITHDRAWAL_OBSERVATION_TRACKER_MAX_ENTRIES, + WithdrawalObservationTracker, +} from "./withdrawal-observation-tracker"; +export { + BrokerExecutionArchiveDurabilityError, + BrokerExecutionArchiver, + type BrokerExecutionArchiverOptions, + createBrokerExecutionArchiverFromEnv, + isArchiveOtelLogsEnabled, + isBrokerExecutionArchiveTable, + resolveArchiveForwarderUrlFromEnv, + rethrowArchiveDurabilityError, +} from "./writer"; diff --git a/src/helpers/broker-execution-archive/redact.ts b/src/helpers/broker-execution-archive/redact.ts new file mode 100644 index 0000000..866658e --- /dev/null +++ b/src/helpers/broker-execution-archive/redact.ts @@ -0,0 +1,100 @@ +import { createHash } from "node:crypto"; +import { REDACTED_ERROR_MESSAGE } from "../shared/errors"; +import { asRecord } from "../shared/guards"; + +const SECRET_KEY_PATTERN = + /\b(api[_-]?key|api[_-]?secret|secret|signature|passphrase|password|token|credential)\b/i; +const SECRET_VALUE_PATTERN = + /(\b(?:apiKey|api_key|apiSecret|api_secret|secret|signature|passphrase|password|token)\b\s*[=:]\s*)[^\s&,;)}\]]+/gi; +const SECRET_JSON_PATTERN = + /("(?:apiKey|api_key|apiSecret|api_secret|secret|signature|passphrase|password|token)"\s*:\s*")[^"]*(")/gi; + +export function redactErrorForArchive(error: unknown): { + error_type?: string; + error_message?: string; +} { + if (!(error instanceof Error)) { + return {}; + } + return { + error_type: error.name, + error_message: REDACTED_ERROR_MESSAGE, + }; +} + +export function redactSecretLiterals( + value: string, + secretLiterals: readonly string[] = [], +): string { + let redacted = value; + for (const literal of secretLiterals) { + if (literal.length > 0) { + redacted = redacted.split(literal).join("[redacted]"); + } + } + return redacted + .replace(SECRET_VALUE_PATTERN, "$1[redacted]") + .replace(SECRET_JSON_PATTERN, "$1[redacted]$2"); +} + +function redactUnknownValue( + value: unknown, + secretLiterals: readonly string[], +): unknown { + if (value === null || value === undefined) { + return value; + } + if (typeof value === "string") { + return redactSecretLiterals(value, secretLiterals); + } + if (typeof value === "number" || typeof value === "boolean") { + return value; + } + if (Array.isArray(value)) { + return value.map((entry) => redactUnknownValue(entry, secretLiterals)); + } + if (typeof value === "object") { + const record = value as Record; + const redacted: Record = {}; + for (const [key, entry] of Object.entries(record)) { + if (SECRET_KEY_PATTERN.test(key)) { + redacted[key] = "[redacted]"; + continue; + } + redacted[key] = redactUnknownValue(entry, secretLiterals); + } + return redacted; + } + return String(value); +} + +export function redactStreamPayload( + payload: unknown, + secretLiterals: readonly string[] = [], +): Record { + if (Array.isArray(payload)) { + return { + items: payload.map((entry) => redactUnknownValue(entry, secretLiterals)), + }; + } + const record = asRecord(payload); + if (!record) { + return {}; + } + return redactUnknownValue(record, secretLiterals) as Record; +} + +export function hashMarketMetadata(payload: unknown): string | undefined { + if (payload === undefined || payload === null) { + return undefined; + } + try { + const normalized = JSON.stringify(payload); + if (!normalized || normalized === "{}") { + return undefined; + } + return createHash("sha256").update(normalized).digest("hex"); + } catch { + return undefined; + } +} diff --git a/src/helpers/broker-execution-archive/rows.ts b/src/helpers/broker-execution-archive/rows.ts new file mode 100644 index 0000000..9f4dd47 --- /dev/null +++ b/src/helpers/broker-execution-archive/rows.ts @@ -0,0 +1,591 @@ +import { createHash } from "node:crypto"; +import type { OrderExecutionTelemetry } from "../order-telemetry"; +import { asRecord } from "../shared/guards"; +import { hashMarketMetadata, redactStreamPayload } from "./redact"; +import { + ACCOUNT_BALANCE_PRECISION_BASIS, + ACCOUNT_BALANCE_SCOPE, + ARCHIVE_SCHEMA_VERSION, + BROKER_WRITE_SOURCE, + type BrokerArchiveCommonTags, + type BrokerArchiveRow, + FILL_EVENT_KIND, + type OrderArchiveAction, + type SubscribeArchiveType, + type TransferEventKind, + type TransferLifecycleAction, +} from "./types"; + +type BalanceQuantityMap = Record; + +export type NormalizedCcxtBalance = { + exchangeTimestamp?: string; + reportedAssets: string[]; + assetEntryAssets: string[]; + freeBalances: BalanceQuantityMap; + usedBalances: BalanceQuantityMap; + totalBalances: BalanceQuantityMap; + freeMapPresent: boolean; + usedMapPresent: boolean; + totalMapPresent: boolean; +}; + +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + } + return undefined; +} + +function firstNumber(...values: unknown[]): number | undefined { + for (const value of values) { + const numeric = + typeof value === "number" + ? value + : typeof value === "string" && value.trim() + ? Number(value) + : Number.NaN; + if (Number.isFinite(numeric)) { + return numeric; + } + } + return undefined; +} + +function normalizeTimestamp(value: unknown): string | undefined { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + const ms = firstNumber(value); + if (ms === undefined) { + return undefined; + } + const date = new Date(ms); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +} + +// CCXT has already normalized the venue value to a JavaScript number. Expanding +// exponent notation makes storage stable but cannot recover venue-raw precision. +function decimalString(value: unknown): string | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + if (Object.is(value, -0)) { + return "0"; + } + const rendered = String(value).toLowerCase(); + if (!rendered.includes("e")) { + return rendered; + } + const [coefficient = "", rawExponent = "0"] = rendered.split("e"); + const exponent = Number.parseInt(rawExponent, 10); + const negative = coefficient.startsWith("-"); + const unsigned = negative ? coefficient.slice(1) : coefficient; + const [integer = "0", fraction = ""] = unsigned.split("."); + const digits = `${integer}${fraction}`; + const decimalIndex = integer.length + exponent; + let expanded: string; + if (decimalIndex <= 0) { + expanded = `0.${"0".repeat(-decimalIndex)}${digits}`; + } else if (decimalIndex >= digits.length) { + expanded = `${digits}${"0".repeat(decimalIndex - digits.length)}`; + } else { + expanded = `${digits.slice(0, decimalIndex)}.${digits.slice(decimalIndex)}`; + } + return negative ? `-${expanded}` : expanded; +} + +function normalizedBalanceMap(value: unknown): BalanceQuantityMap { + const record = asRecord(value); + if (!record) { + return {}; + } + const entries: Array<[string, string]> = []; + for (const [rawAsset, quantity] of Object.entries(record)) { + const asset = rawAsset.trim(); + const normalized = decimalString(quantity); + if (asset && normalized !== undefined) { + entries.push([asset, normalized]); + } + } + return Object.fromEntries( + entries.sort(([left], [right]) => left.localeCompare(right)), + ); +} + +function sortedBalanceMap(map: BalanceQuantityMap): BalanceQuantityMap { + return Object.fromEntries( + Object.entries(map).sort(([left], [right]) => left.localeCompare(right)), + ); +} + +const BALANCE_METADATA_KEYS = new Set([ + "info", + "timestamp", + "datetime", + "free", + "used", + "total", + "debt", +]); + +export function normalizeCcxtBalanceForArchive( + balance: unknown, +): NormalizedCcxtBalance { + const record = asRecord(balance) ?? {}; + const aggregateRecords = { + free: asRecord(record.free), + used: asRecord(record.used), + total: asRecord(record.total), + }; + const maps = { + free: normalizedBalanceMap(record.free), + used: normalizedBalanceMap(record.used), + total: normalizedBalanceMap(record.total), + }; + const assetEntryAssets = new Set(); + + for (const [rawAsset, value] of Object.entries(record)) { + if (BALANCE_METADATA_KEYS.has(rawAsset)) { + continue; + } + const asset = rawAsset.trim(); + const assetEntry = asRecord(value); + if ( + !asset || + !assetEntry || + !("free" in assetEntry || "used" in assetEntry || "total" in assetEntry) + ) { + continue; + } + assetEntryAssets.add(asset); + for (const field of ["free", "used", "total"] as const) { + const normalized = decimalString(assetEntry[field]); + if (normalized !== undefined && maps[field][asset] === undefined) { + maps[field][asset] = normalized; + } + } + } + + const reportedAssets = new Set(assetEntryAssets); + for (const aggregateRecord of Object.values(aggregateRecords)) { + for (const rawAsset of Object.keys(aggregateRecord ?? {})) { + const asset = rawAsset.trim(); + if (asset) { + reportedAssets.add(asset); + } + } + } + for (const map of Object.values(maps)) { + for (const asset of Object.keys(map)) { + reportedAssets.add(asset); + } + } + + return { + exchangeTimestamp: normalizeTimestamp(record.timestamp ?? record.datetime), + reportedAssets: [...reportedAssets].sort(), + assetEntryAssets: [...assetEntryAssets].sort(), + freeBalances: sortedBalanceMap(maps.free), + usedBalances: sortedBalanceMap(maps.used), + totalBalances: sortedBalanceMap(maps.total), + freeMapPresent: aggregateRecords.free !== undefined, + usedMapPresent: aggregateRecords.used !== undefined, + totalMapPresent: aggregateRecords.total !== undefined, + }; +} + +export function buildAccountBalanceSnapshotRow(input: { + tags: BrokerArchiveCommonTags; + balance: NormalizedCcxtBalance; +}): BrokerArchiveRow { + const { tags, balance } = input; + const observationId = createHash("sha256") + .update( + JSON.stringify({ + deployment_id: tags.deployment_id, + exchange: tags.exchange, + account_selector: tags.account_selector, + balance_scope: ACCOUNT_BALANCE_SCOPE, + broker_observed_timestamp: tags.broker_observed_timestamp, + balance, + }), + ) + .digest("hex"); + + return { + table: "broker_account.balance_snapshots", + row: compactUndefined({ + broker_observed_timestamp: tags.broker_observed_timestamp, + exchange_timestamp: balance.exchangeTimestamp, + source: tags.source, + deployment_id: tags.deployment_id, + schema_version: ARCHIVE_SCHEMA_VERSION, + exchange: tags.exchange, + account_selector: tags.account_selector, + balance_scope: ACCOUNT_BALANCE_SCOPE, + observation_id: observationId, + reported_assets: balance.reportedAssets, + asset_entry_assets: balance.assetEntryAssets, + free_balances: balance.freeBalances, + used_balances: balance.usedBalances, + total_balances: balance.totalBalances, + aggregate_free_map_present: Number(balance.freeMapPresent), + aggregate_used_map_present: Number(balance.usedMapPresent), + aggregate_total_map_present: Number(balance.totalMapPresent), + precision_basis: ACCOUNT_BALANCE_PRECISION_BASIS, + }), + }; +} + +function compactUndefined( + record: Record, +): Record { + return Object.fromEntries( + Object.entries(record).filter(([, value]) => value !== undefined), + ); +} + +export function buildCommonArchiveTags(input: { + deploymentId: string; + accountSelector?: string; + exchange: string; + symbol?: string; + brokerObservedTimestamp?: string; +}): BrokerArchiveCommonTags { + return { + source: BROKER_WRITE_SOURCE, + deployment_id: input.deploymentId, + account_selector: input.accountSelector ?? "unknown", + exchange: input.exchange.trim().toLowerCase() || "unknown", + symbol: input.symbol?.trim() || "unknown", + broker_observed_timestamp: + input.brokerObservedTimestamp ?? new Date().toISOString(), + }; +} + +export function buildOrderEventArchiveRow(input: { + tags: BrokerArchiveCommonTags; + action: OrderArchiveAction; + telemetry: OrderExecutionTelemetry; + errorDetail?: string; + eventKind?: "execute_action" | "subscribe_stream"; + subscriptionType?: SubscribeArchiveType; + marketMetadataHash?: string; +}): BrokerArchiveRow { + const { tags, telemetry, action } = input; + return { + table: "broker_execution.order_events", + row: compactUndefined({ + ...tags, + event_kind: input.eventKind ?? "execute_action", + action, + subscription_type: input.subscriptionType, + order_id: telemetry.orderId, + order_author: telemetry.orderAuthor ?? "", + client_order_id: telemetry.clientOrderId, + idempotency_id: telemetry.idempotencyId, + maker_action_id: telemetry.makerActionId, + market_metadata_hash: input.marketMetadataHash, + status: telemetry.status, + side: telemetry.side, + order_type: telemetry.orderType, + requested_quantity: telemetry.requestedQuantity, + requested_notional: telemetry.requestedNotional, + executed_base_quantity: telemetry.executedBaseQuantity, + executed_quote_quantity: telemetry.executedQuoteQuantity, + average_execution_price: telemetry.averageExecutionPrice, + filled_amount: telemetry.filledAmount, + remaining_amount: telemetry.remainingAmount, + fee_amount: telemetry.feeAmount, + fee_currency: telemetry.feeCurrency, + fee_rate: telemetry.feeRate, + exchange_timestamp: telemetry.exchangeTimestamp, + error_type: telemetry.errorType, + error_message: input.errorDetail ?? telemetry.errorMessage, + payload_json: JSON.stringify(telemetry), + }), + }; +} + +export function buildSubscribeStreamArchiveRow(input: { + tags: BrokerArchiveCommonTags; + subscriptionType: SubscribeArchiveType; + streamPayload: unknown; + secretLiterals?: readonly string[]; +}): BrokerArchiveRow { + const redactedPayload = redactStreamPayload( + input.streamPayload, + input.secretLiterals, + ); + const record = asRecord(input.streamPayload); + const info = asRecord(record?.info); + return { + table: "broker_execution.order_events", + row: compactUndefined({ + ...input.tags, + event_kind: "subscribe_stream", + subscription_type: input.subscriptionType, + order_id: firstString( + record?.id, + record?.orderId, + record?.i, + info?.orderId, + info?.i, + ), + client_order_id: firstString( + record?.clientOrderId, + record?.clientOrderID, + record?.c, + info?.clientOrderId, + info?.c, + ), + status: firstString( + record?.status, + record?.X, + info?.status, + info?.X, + )?.toLowerCase(), + payload_json: JSON.stringify(redactedPayload), + }), + }; +} + +// Column shapes below follow the fiet-maker CEX_EXECUTION_ARCHIVE_CONTRACT: shared +// tags + contract columns, all quantities/prices as strings (venue precision +// varies by asset), fill_index/result_index as numbers (UInt32 columns). Three +// deliberate ADDITIVE divergences the consumer contract doesn't yet list: +// order_events carries order_author as a caller-declared primary read key; +// transfer_events carries client_withdrawal_id plus fee_amount/fee_currency (the +// ccxt withdrawal object exposes the fee, which is the dominant small-commit +// cost); and fill_events.event_kind is stamped with the true trade-history-poller +// source rather than "create_order_fill". + +// Preserve venue precision: prefer the raw string the venue returned (usually in +// `info`) over ccxt's parsed number, stringifying a number only as a fallback. +function quantityString(...values: unknown[]): string | undefined { + return firstString(...values); +} + +// Binance's implicit internal-transfer methods return raw SAPI responses, not +// ccxt unified transactions, and use a different id key for universal transfers. +export function extractBinanceInternalTransferId( + response: unknown, +): string | undefined { + const record = asRecord(response); + return firstString(record?.txnId, record?.tranId); +} + +export type TransferArchiveFields = { + eventKind: TransferEventKind; + lifecycleAction: TransferLifecycleAction; + status?: string; + amount?: string; + address?: string; + network?: string; + externalId?: string; + clientWithdrawalId?: string; + txid?: string; + resultIndex?: number; + feeAmount?: string; + feeCurrency?: string; + exchangeTimestamp?: string; + errorSummary?: string; + payload: unknown; +}; + +// asset_symbol mirrors the shared `symbol` tag for transfers (the moved asset, +// e.g. "USDC"), per the contract. event_kind/lifecycle_action and the two +// withdrawal identities are primary read keys; they are always emitted +// (external_id/client_withdrawal_id/status default to ""). +export function buildTransferEventArchiveRow(input: { + tags: BrokerArchiveCommonTags; + transfer: TransferArchiveFields; +}): BrokerArchiveRow { + const { tags, transfer } = input; + return { + table: "broker_execution.transfer_events", + row: compactUndefined({ + ...tags, + schema_version: ARCHIVE_SCHEMA_VERSION, + event_kind: transfer.eventKind, + lifecycle_action: transfer.lifecycleAction, + status: transfer.status ?? "", + asset_symbol: tags.symbol, + amount: transfer.amount, + address: transfer.address, + network: transfer.network, + external_id: transfer.externalId ?? "", + client_withdrawal_id: transfer.clientWithdrawalId ?? "", + txid: transfer.txid, + result_index: transfer.resultIndex ?? 0, + // Additive (not in the consumer contract's transfer_events column set). + fee_amount: transfer.feeAmount, + fee_currency: transfer.feeCurrency, + exchange_timestamp: transfer.exchangeTimestamp, + error_summary: transfer.errorSummary, + payload_json: JSON.stringify(transfer.payload), + }), + }; +} + +// Normalized fields pulled from a ccxt unified transaction (withdraw/deposit) so +// the withdraw/deposit handlers stay declarative. The full raw object is kept in +// payload_json regardless, so this only needs the columns we index/filter on. +export type NormalizedCcxtTransfer = { + externalId?: string; + clientWithdrawalId?: string; + txid?: string; + address?: string; + network?: string; + amount?: string; + assetSymbol?: string; + status?: string; + feeAmount?: string; + feeCurrency?: string; + exchangeTimestamp?: string; +}; + +export function normalizeCcxtTransactionForArchive( + transaction: unknown, +): NormalizedCcxtTransfer { + const record = asRecord(transaction); + const info = asRecord(record?.info); + const fee = asRecord(record?.fee); + return compactUndefined({ + externalId: firstString(record?.id, info?.id, record?.txid, info?.txId), + // The caller's withdrawal id, echoed back by the venue on withdrawal + // history records. Without it an observation carries only the venue id and + // a submission only the client id, so the two halves of one movement have + // no shared key and the lifecycle cannot be joined (amounts differ by the + // withdrawal fee, so they are not a fallback). + clientWithdrawalId: firstString(info?.withdrawOrderId), + txid: firstString(record?.txid, info?.txId, info?.txid, info?.tx_hash), + address: firstString(record?.address, record?.addressTo, info?.address), + network: firstString(record?.network, info?.network), + amount: quantityString(info?.amount, record?.amount), + assetSymbol: firstString(record?.currency, info?.coin, info?.asset), + status: firstString(record?.status, info?.status)?.toLowerCase(), + feeAmount: quantityString(fee?.cost, record?.feeCost), + feeCurrency: firstString(fee?.currency, record?.feeCurrency), + exchangeTimestamp: normalizeTimestamp( + firstValueForTransfer( + record?.timestamp, + record?.datetime, + info?.applyTime, + ), + ), + }) as NormalizedCcxtTransfer; +} + +function firstValueForTransfer(...values: unknown[]): unknown { + return values.find((value) => value !== undefined && value !== null); +} + +export type FillArchiveFields = { + orderId?: string; + clientOrderId?: string; + fillId?: string; + fillIndex?: number; + side?: string; + orderType?: string; + price?: string; + baseQuantity?: string; + quoteQuantity?: string; + feeAmount?: string; + feeCurrency?: string; + feeRate?: string; + exchangeTimestamp?: string; + payload: unknown; +}; + +// Contract read keys are (symbol, account_selector, broker_observed_timestamp, +// exchange, order_id, fill_index). order_id/fill_index are always present. +export function buildFillEventArchiveRow(input: { + tags: BrokerArchiveCommonTags; + fill: FillArchiveFields; +}): BrokerArchiveRow { + const { tags, fill } = input; + return { + table: "broker_execution.fill_events", + row: compactUndefined({ + ...tags, + schema_version: ARCHIVE_SCHEMA_VERSION, + event_kind: FILL_EVENT_KIND, + order_id: fill.orderId ?? "", + client_order_id: fill.clientOrderId, + fill_id: fill.fillId, + fill_index: fill.fillIndex ?? 0, + side: fill.side, + order_type: fill.orderType, + price: fill.price, + base_quantity: fill.baseQuantity, + quote_quantity: fill.quoteQuantity, + fee_amount: fill.feeAmount, + fee_currency: fill.feeCurrency, + fee_rate: fill.feeRate, + exchange_timestamp: fill.exchangeTimestamp, + payload_json: JSON.stringify(fill.payload), + }), + }; +} + +// Maps one ccxt unified trade (fetchMyTrades element) to fill archive fields. +// fillIndex is assigned by the poller (batch position); left undefined here. +export function normalizeCcxtTradeForArchive( + trade: unknown, +): FillArchiveFields { + const record = asRecord(trade); + const info = asRecord(record?.info); + const fee = asRecord(record?.fee); + return { + orderId: firstString(record?.order, info?.orderId, info?.orderID), + clientOrderId: firstString( + record?.clientOrderId, + info?.clientOrderId, + info?.origClientOrderId, + ), + fillId: firstString(record?.id, info?.id, info?.tradeId), + side: firstString(record?.side, info?.side)?.toLowerCase(), + orderType: firstString(record?.type, info?.type)?.toLowerCase(), + price: quantityString(info?.price, record?.price), + baseQuantity: quantityString(info?.qty, record?.amount), + quoteQuantity: quantityString(info?.quoteQty, record?.cost), + feeAmount: quantityString(fee?.cost, info?.commission), + feeCurrency: firstString(fee?.currency, info?.commissionAsset), + feeRate: quantityString(fee?.rate), + exchangeTimestamp: normalizeTimestamp( + firstValueForTransfer(record?.timestamp, record?.datetime, info?.time), + ), + payload: trade, + }; +} + +export function buildMarketMetadataSnapshotRow(input: { + tags: BrokerArchiveCommonTags; + clientOrderId?: string; + orderId?: string; + makerActionId?: string; + idempotencyId?: string; + marketSnapshot: unknown; +}): BrokerArchiveRow { + const redactedSnapshot = redactStreamPayload(input.marketSnapshot); + const metadataHash = hashMarketMetadata(redactedSnapshot); + return { + table: "broker_execution.market_metadata_snapshots", + row: compactUndefined({ + ...input.tags, + client_order_id: input.clientOrderId, + order_id: input.orderId, + maker_action_id: input.makerActionId, + idempotency_id: input.idempotencyId, + market_metadata_hash: metadataHash, + snapshot_json: JSON.stringify(redactedSnapshot), + }), + }; +} diff --git a/src/helpers/broker-execution-archive/types.ts b/src/helpers/broker-execution-archive/types.ts new file mode 100644 index 0000000..54f09aa --- /dev/null +++ b/src/helpers/broker-execution-archive/types.ts @@ -0,0 +1,59 @@ +export const BROKER_WRITE_SOURCE = "broker_write" as const; + +// Bumped only on a breaking broker archive column-shape change. Stamped onto +// transfer, fill, and account-balance rows so a reader can identify their layout. +export const ARCHIVE_SCHEMA_VERSION = "1" as const; + +export type BrokerArchiveTable = + | "broker_execution.order_events" + | "broker_execution.market_metadata_snapshots" + | "broker_execution.transfer_events" + | "broker_execution.fill_events" + | "broker_account.balance_snapshots" + | "market_data.orderbook_snapshots" + | "market_data.candles" + | "market_data.cex_stream_events" + | "market_data.cex_ticker_events" + | "market_data.cex_trades"; + +export type BrokerArchiveRow = { + table: BrokerArchiveTable; + row: Record; +}; + +export type BrokerArchiveCommonTags = { + source: typeof BROKER_WRITE_SOURCE; + deployment_id: string; + account_selector: string; + exchange: string; + symbol: string; + broker_observed_timestamp: string; +}; + +export type OrderArchiveAction = + | "CreateOrder" + | "CancelOrder" + | "GetOrderDetails"; + +export type SubscribeArchiveType = "ORDERS" | "BALANCE"; + +// A CEX value movement between accounts/wallets (as opposed to an order fill). +// Values match the fiet-maker CEX_EXECUTION_ARCHIVE_CONTRACT transfer_events grain. +export type TransferEventKind = "withdrawal" | "deposit" | "internal_transfer"; + +// Which movement lifecycle step produced the row (contract `lifecycle_action`). +export type TransferLifecycleAction = + | "submit_withdrawal" + | "observe_withdrawal" + | "observe_deposit" + | "submit_internal_transfer"; + +// fill_events.event_kind. The contract fixture uses "create_order_fill" for fills +// exploded from a createOrder result; our producer is the trade-history poller +// (createOrder results carry no trades[] on the venues in use — see WS2.2), so we +// stamp the true source here. Same column, honest provenance value. +export const FILL_EVENT_KIND = "trade_history_fill" as const; + +export const ACCOUNT_BALANCE_SCOPE = "spot" as const; +export const ACCOUNT_BALANCE_PRECISION_BASIS = + "ccxt_normalized_number" as const; diff --git a/src/helpers/broker-execution-archive/withdrawal-observation-tracker.ts b/src/helpers/broker-execution-archive/withdrawal-observation-tracker.ts new file mode 100644 index 0000000..88bf5c5 --- /dev/null +++ b/src/helpers/broker-execution-archive/withdrawal-observation-tracker.ts @@ -0,0 +1,133 @@ +import { asRecord } from "../shared/guards"; +import type { NormalizedCcxtTransfer } from "./rows"; + +export const DEFAULT_WITHDRAWAL_OBSERVATION_TRACKER_MAX_ENTRIES = 10_000; + +const VENUE_LIFECYCLE_EVIDENCE_FIELDS = [ + "updated", + "updatedAt", + "updated_at", + "updateTime", + "update_time", + "updateTimestamp", + "lastUpdateTimestamp", + "completed", + "completedAt", + "completed_at", + "completeTime", + "complete_time", + "completionTime", + "successTime", +] as const; + +type WithdrawalObservation = { + exchange: string; + accountSelector?: string; + assetSymbol?: string; + transaction: unknown; + normalized: NormalizedCcxtTransfer; +}; + +function fingerprintEvidenceValue(value: unknown): unknown { + if (value === undefined || value === null) { + return value; + } + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + if (typeof value === "bigint") { + return value.toString(); + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +function venueLifecycleEvidence(transaction: unknown): unknown[] { + const record = asRecord(transaction); + const info = asRecord(record?.info); + const evidence: unknown[] = []; + for (const [scope, source] of [ + ["transaction", record], + ["info", info], + ] as const) { + for (const field of VENUE_LIFECYCLE_EVIDENCE_FIELDS) { + const value = source?.[field]; + if (value !== undefined && value !== null) { + evidence.push([scope, field, fingerprintEvidenceValue(value)]); + } + } + } + return evidence; +} + +function observationFingerprint(observation: WithdrawalObservation): string { + const { normalized, transaction } = observation; + return JSON.stringify({ + status: normalized.status ?? null, + txid: normalized.txid ?? null, + amount: normalized.amount ?? null, + feeAmount: normalized.feeAmount ?? null, + feeCurrency: normalized.feeCurrency ?? null, + address: normalized.address ?? null, + network: normalized.network ?? null, + exchangeTimestamp: normalized.exchangeTimestamp ?? null, + venueLifecycleEvidence: venueLifecycleEvidence(transaction), + }); +} + +/** + * Suppresses unchanged withdrawal-history observations within one broker process. + * The tracker intentionally persists no state: restart replay is absorbed by + * archive consumers, while the in-process bound prevents unbounded venue history. + */ +export class WithdrawalObservationTracker { + readonly #fingerprints = new Map(); + readonly #maxEntries: number; + #missingIdSequence = 0n; + + constructor(options?: { maxEntries?: number }) { + const maxEntries = + options?.maxEntries ?? DEFAULT_WITHDRAWAL_OBSERVATION_TRACKER_MAX_ENTRIES; + this.#maxEntries = Number.isFinite(maxEntries) + ? Math.max(1, Math.floor(maxEntries)) + : DEFAULT_WITHDRAWAL_OBSERVATION_TRACKER_MAX_ENTRIES; + } + + shouldArchive(observation: WithdrawalObservation): boolean { + const externalId = observation.normalized.externalId?.trim(); + // Without a venue identity, a one-use process sequence is safer than + // suppressing two distinct withdrawals that happen to share the same fields. + const identity = JSON.stringify([ + observation.exchange.trim().toLowerCase() || "unknown", + observation.accountSelector?.trim() || "unknown", + observation.assetSymbol?.trim().toUpperCase() || "unknown", + externalId || `missing:${++this.#missingIdSequence}`, + ]); + const fingerprint = observationFingerprint(observation); + if (this.#fingerprints.get(identity) === fingerprint) { + return false; + } + + // Refresh changed entries so deterministic oldest-first eviction reflects the + // latest meaningful observation, not the first time an id was encountered. + this.#fingerprints.delete(identity); + this.#fingerprints.set(identity, fingerprint); + while (this.#fingerprints.size > this.#maxEntries) { + const oldest = this.#fingerprints.keys().next().value; + if (oldest === undefined) break; + this.#fingerprints.delete(oldest); + } + return true; + } + + getSize(): number { + return this.#fingerprints.size; + } +} diff --git a/src/helpers/broker-execution-archive/writer.ts b/src/helpers/broker-execution-archive/writer.ts new file mode 100644 index 0000000..a6c19b8 --- /dev/null +++ b/src/helpers/broker-execution-archive/writer.ts @@ -0,0 +1,633 @@ +import { closeSync, fsyncSync, openSync, writeSync } from "node:fs"; +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { SeverityNumber } from "@opentelemetry/api-logs"; +import { log } from "../logger"; +import type { OtelLogs, OtelMetrics } from "../otel"; +import { REDACTED_ERROR_MESSAGE } from "../shared/errors"; +import type { BrokerArchiveRow, BrokerArchiveTable } from "./types"; + +const BROKER_EXECUTION_ARCHIVE_TABLES = new Set([ + "broker_execution.order_events", + "broker_execution.market_metadata_snapshots", + "broker_execution.transfer_events", + "broker_execution.fill_events", +]); + +export function isBrokerExecutionArchiveTable( + table: BrokerArchiveTable, +): boolean { + return BROKER_EXECUTION_ARCHIVE_TABLES.has(table); +} + +export type BrokerExecutionArchiverOptions = { + deploymentId?: string; + otelLogs?: OtelLogs; + otelMetrics?: OtelMetrics; + forwarderUrl: string; + deadLetterPath: string; + maxQueueSize?: number; + batchSize?: number; + flushIntervalMs?: number; + forwarderTimeoutMs?: number; +}; + +export class BrokerExecutionArchiveDurabilityError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = "BrokerExecutionArchiveDurabilityError"; + } +} + +export function rethrowArchiveDurabilityError(error: unknown): void { + if (error instanceof BrokerExecutionArchiveDurabilityError) { + throw error; + } +} + +type ArchiverStats = { + enqueued: number; + shed: number; + flushed: number; + forwarderFailures: number; +}; + +const DEFAULT_MAX_QUEUE_SIZE = 10_000; +const DEFAULT_BATCH_SIZE = 10; +const DEFAULT_FLUSH_INTERVAL_MS = 1_000; +const DEFAULT_FORWARDER_TIMEOUT_MS = 3_000; +const SHED_WARN_INTERVAL_MS = 60_000; + +type ArchiveLossReason = "queue_shed" | "shutdown_forwarder_failure"; + +type ArchiveLossRecord = { + timestamp: string; + deployment_id: string; + reason: ArchiveLossReason; + payload: BrokerArchiveRow; +}; + +export function isArchiveOtelLogsEnabled(): boolean { + return process.env.CEX_BROKER_ARCHIVE_OTEL_LOGS_ENABLED === "true"; +} + +export function resolveArchiveForwarderUrlFromEnv(): string | undefined { + return process.env.CEX_BROKER_ARCHIVE_FORWARDER_URL?.trim() || undefined; +} + +export class BrokerExecutionArchiver { + private readonly deploymentId: string; + private readonly otelLogs?: OtelLogs; + private readonly otelMetrics?: OtelMetrics; + private readonly forwarderUrl?: string; + private readonly deadLetterPath?: string; + private deadLetterFd?: number; + private readonly maxQueueSize: number; + private readonly batchSize: number; + private readonly flushIntervalMs: number; + private readonly forwarderTimeoutMs: number; + private readonly queue: BrokerArchiveRow[] = []; + private readonly stats: ArchiverStats = { + enqueued: 0, + shed: 0, + flushed: 0, + forwarderFailures: 0, + }; + private flushTimer: ReturnType | null = null; + private flushInFlight: Promise | null = null; + private lastShedWarnAtMs = 0; + private closed = false; + private readonly enabled: boolean; + private readonly forwarderAuthToken?: string; + + private constructor(options: { + enabled: boolean; + deploymentId?: string; + otelLogs?: OtelLogs; + otelMetrics?: OtelMetrics; + forwarderUrl?: string; + deadLetterPath?: string; + maxQueueSize?: number; + batchSize?: number; + flushIntervalMs?: number; + forwarderTimeoutMs?: number; + }) { + this.deploymentId = + options.deploymentId?.trim() || + process.env.CEX_BROKER_DEPLOYMENT_ID?.trim() || + "unknown"; + this.otelLogs = options.otelLogs; + this.otelMetrics = options.otelMetrics; + this.forwarderUrl = options.forwarderUrl?.trim(); + this.deadLetterPath = options.deadLetterPath?.trim(); + this.maxQueueSize = options.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE; + this.batchSize = options.batchSize ?? DEFAULT_BATCH_SIZE; + this.flushIntervalMs = options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS; + this.forwarderTimeoutMs = + options.forwarderTimeoutMs ?? DEFAULT_FORWARDER_TIMEOUT_MS; + this.enabled = options.enabled; + this.forwarderAuthToken = + process.env.CEX_BROKER_ARCHIVE_FORWARDER_TOKEN?.trim() || undefined; + + if (!this.enabled) { + log.info("Broker execution archive disabled", { enabled: false }); + return; + } + + validateForwarderUrl(this.forwarderUrl); + if (!this.deadLetterPath) { + throw new Error( + "Broker execution archive is enabled but CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH is missing", + ); + } + try { + // The mode applies only when creating the file; existing operator-owned + // files retain their configured permissions. + this.deadLetterFd = openSync(this.deadLetterPath, "a", 0o600); + } catch { + throw new Error( + "Broker execution archive cannot open CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH for append", + ); + } + + try { + this.flushTimer = setInterval(() => { + void this.flush(); + }, this.flushIntervalMs); + this.flushTimer.unref?.(); + log.info("Broker execution archive enabled", { + enabled: true, + otel_mirror_enabled: Boolean(this.otelLogs?.isOtelEnabled()), + }); + } catch (error) { + this.closeLossJournal(); + throw error; + } + } + + static disabled(): BrokerExecutionArchiver { + return new BrokerExecutionArchiver({ enabled: false }); + } + + static create( + options: BrokerExecutionArchiverOptions, + ): BrokerExecutionArchiver { + return new BrokerExecutionArchiver({ ...options, enabled: true }); + } + + getDeploymentId(): string { + return this.deploymentId; + } + + isEnabled(): boolean { + return this.enabled && !this.closed; + } + + canPersistMarketMetadataSnapshot(): boolean { + return this.isEnabled(); + } + + canPersistAccountBalanceSnapshots(): boolean { + return this.isEnabled() && Boolean(this.forwarderUrl); + } + + enqueue(row: BrokerArchiveRow): void { + if (!this.enabled || this.closed) { + return; + } + if (this.queue.length >= this.maxQueueSize) { + const shedRow = this.queue[0]; + if (shedRow) { + this.appendLossRecords([shedRow], "queue_shed"); + this.queue.shift(); + } + this.stats.shed += 1; + void this.recordArchiveMetric("cex_archive_rows_shed_total", { + table: shedRow?.table ?? "unknown", + }); + // The durable journal is authoritative; this rate-limited warning makes + // sustained queue pressure visible without becoming another loss sink. + const now = Date.now(); + if (now - this.lastShedWarnAtMs >= SHED_WARN_INTERVAL_MS) { + log.warn("Archive queue full: shedding oldest rows", { + shed_total: this.stats.shed, + queue_max: this.maxQueueSize, + table: shedRow?.table ?? "unknown", + }); + this.lastShedWarnAtMs = now; + } + } + this.queue.push(row); + this.stats.enqueued += 1; + void this.recordArchiveMetric("cex_archive_rows_enqueued_total", { + table: row.table, + }); + if (this.queue.length >= this.batchSize) { + void this.flush(); + } + } + + enqueueInBackground(row: BrokerArchiveRow): void { + queueMicrotask(() => this.enqueue(row)); + } + + async flush(): Promise { + if (!this.enabled || this.closed || this.queue.length === 0) { + return; + } + if (this.flushInFlight) { + return this.flushInFlight; + } + // flushBatch resolves a boolean the callers read directly; the in-flight + // handle only needs completion, so discard it to keep this a Promise. + const inFlight = this.flushBatch() + .then(() => undefined) + .finally(() => { + this.flushInFlight = null; + }); + this.flushInFlight = inFlight; + return inFlight; + } + + async close(): Promise { + if (this.flushTimer) { + clearInterval(this.flushTimer); + this.flushTimer = null; + } + let closeError: unknown; + try { + while (this.queue.length > 0 || this.flushInFlight) { + if (this.flushInFlight) { + await this.flushInFlight; + continue; + } + const depthBefore = this.queue.length; + const flushed = await this.flushBatch(); + if (!flushed && this.queue.length >= depthBefore && depthBefore > 0) { + const undelivered = [...this.queue]; + this.appendLossRecords(undelivered, "shutdown_forwarder_failure"); + this.queue.length = 0; + break; + } + } + } catch (error) { + closeError = error; + } + this.closed = true; + if (this.deadLetterFd !== undefined) { + try { + this.closeLossJournal(); + } catch (error) { + closeError ??= error; + } + } + if (closeError) { + throw closeError; + } + } + + getStats(): Readonly { + return { ...this.stats }; + } + + getQueueDepth(): number { + return this.queue.length; + } + + private closeLossJournal(): void { + if (this.deadLetterFd === undefined) { + return; + } + const fd = this.deadLetterFd; + try { + closeSync(fd); + } catch (error) { + throw new BrokerExecutionArchiveDurabilityError( + "Broker execution archive failed to close the configured CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH loss journal", + { cause: error }, + ); + } finally { + this.deadLetterFd = undefined; + } + } + + // Drop oldest rows until the queue is within maxQueueSize, counting each into + // the shed stat/metric. Mirrors the enqueue-time shed policy for the requeue + // path, which can otherwise push the queue past the bound during an outage. + private enforceQueueBound(): void { + while (this.queue.length > this.maxQueueSize) { + const dropped = this.queue[0]; + if (!dropped) { + return; + } + this.appendLossRecords([dropped], "queue_shed"); + this.queue.shift(); + this.stats.shed += 1; + void this.recordArchiveMetric("cex_archive_rows_shed_total", { + table: dropped?.table ?? "unknown", + }); + } + } + + private appendLossRecords( + rows: readonly BrokerArchiveRow[], + reason: ArchiveLossReason, + ): void { + if (rows.length === 0) { + return; + } + if (this.deadLetterFd === undefined) { + throw new BrokerExecutionArchiveDurabilityError( + `Broker execution archive cannot record ${reason}: dead-letter file is not open`, + ); + } + const timestamp = new Date().toISOString(); + const records: ArchiveLossRecord[] = rows.map((payload) => ({ + timestamp, + deployment_id: this.deploymentId, + reason, + payload, + })); + try { + const bytes = Buffer.from( + records.map((record) => JSON.stringify(record)).join("\n") + "\n", + ); + const written = writeSync(this.deadLetterFd, bytes); + if (written !== bytes.length) { + throw new Error(`wrote ${written} of ${bytes.length} bytes`); + } + fsyncSync(this.deadLetterFd); + } catch (error) { + throw new BrokerExecutionArchiveDurabilityError( + `Broker execution archive failed to durably record ${reason}; affected row(s) were retained`, + { cause: error }, + ); + } + } + + private async flushBatch(): Promise { + const batch = this.queue.splice(0, this.batchSize); + if (batch.length === 0) { + return true; + } + + // Secondary observability mirror: execution rows (not market_data.*, which + // has no OTel schema) are echoed to OTel logs when + // CEX_BROKER_ARCHIVE_OTEL_LOGS_ENABLED gated an otelLogs sink in. This is in + // addition to the forwarder, never instead of it, so a durable record exists + // even while a forwarder is unreachable. A requeued batch re-emits here on + // the retry flush; OTel logs are observability, not the audit source. + for (const entry of batch) { + if (isBrokerExecutionArchiveTable(entry.table)) { + this.emitOtelLog(entry); + } + } + + // The forwarder is the durable sink for every archive table it supports + // (market_data.*, broker_execution.*, broker_account.*, strategy_data.*). + // Requeue the whole batch on failure so nothing is silently dropped while a + // forwarder is set. + if (this.forwarderUrl) { + try { + await this.postToForwarder(batch); + } catch (error) { + this.stats.forwarderFailures += 1; + // Re-apply the oldest-shed bound: the batch was spliced out before the + // post, so new rows may have refilled the queue while it was in flight. + // Pushing it back can exceed maxQueueSize by up to batchSize, so trim. + this.queue.push(...batch); + this.enforceQueueBound(); + void this.recordArchiveMetric("cex_archive_forwarder_failures_total", { + count: batch.length, + }); + log.warn("Broker execution archive forwarder failed", { error }); + return false; + } + } + + this.stats.flushed += batch.length; + this.recordFlushHealth(batch); + return true; + } + + // Self-health emitted only on a successful forwarder post: + // a per-table rows-flushed counter to compare against enqueued, and a + // last-flush-success gauge (unix seconds) whose staleness is the "archive plane + // stuck" signal. Fire-and-forget so metrics never gate flushing. + private recordFlushHealth(batch: BrokerArchiveRow[]): void { + const countByTable = new Map(); + for (const entry of batch) { + countByTable.set(entry.table, (countByTable.get(entry.table) ?? 0) + 1); + } + for (const [table, count] of countByTable) { + void this.recordArchiveMetric( + "cex_archive_rows_flushed_total", + { table }, + count, + ); + } + void this.recordArchiveGauge( + "cex_archive_last_flush_success", + Math.floor(Date.now() / 1000), + ); + } + + private async recordArchiveMetric( + metricName: string, + labels: Record, + value = 1, + ): Promise { + try { + await this.otelMetrics?.recordCounter(metricName, value, labels); + } catch { + // Archive metrics must not affect flushing. + } + } + + private async recordArchiveGauge( + metricName: string, + value: number, + ): Promise { + try { + await this.otelMetrics?.recordGauge(metricName, value, {}); + } catch { + // Archive metrics must not affect flushing. + } + } + + private emitOtelLog(entry: BrokerArchiveRow): void { + if (!this.otelLogs?.isOtelEnabled()) { + return; + } + try { + this.otelLogs.emit({ + body: entry.table, + severityNumber: SeverityNumber.INFO, + severityText: "INFO", + attributes: flattenArchiveAttributes(redactArchiveErrorForOtel(entry)), + }); + } catch (error) { + log.warn("Broker execution archive OTLP emit failed", { error }); + } + } + + // Uses node:http/node:https rather than the global fetch: inside the Gramine + // SGX enclave undici (which backs fetch) fails on every request when it lazily + // instantiates its llhttp WASM parser — `WebAssembly.Instance(): Out of memory` + // under the enclave's constrained memory — which silently kills the whole + // archive plane. node's request stays on the transport proven to work in the + // enclave. Same failure class and mitigation as resolveOnChainSender in + // travel-rule-deposit-reconciler.ts. + private postToForwarder(batch: BrokerArchiveRow[]): Promise { + if (!this.forwarderUrl || batch.length === 0) { + return Promise.resolve(); + } + const body = JSON.stringify({ + source: "broker_write", + deployment_id: this.deploymentId, + rows: batch, + }); + const url = new URL(this.forwarderUrl); + const doRequest = url.protocol === "http:" ? httpRequest : httpsRequest; + const headers: Record = { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + }; + if (this.forwarderAuthToken) { + headers.authorization = `Bearer ${this.forwarderAuthToken}`; + } + return new Promise((resolve, reject) => { + const req = doRequest( + url, + { + method: "POST", + headers, + // Bound the request: a hung forwarder would otherwise stall the flush + // loop (flushes are serialized behind flushInFlight) indefinitely. + timeout: this.forwarderTimeoutMs, + }, + (res) => { + // Drain the body so the socket can be released/reused. + res.on("data", () => {}); + res.on("end", () => { + const status = res.statusCode ?? 0; + if (status < 200 || status >= 300) { + reject( + new Error( + `Archive forwarder returned ${status} ${res.statusMessage ?? ""}`, + ), + ); + return; + } + resolve(); + }); + }, + ); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(new Error("Archive forwarder request timed out")); + }); + req.write(body); + req.end(); + }); + } +} + +function redactArchiveErrorForOtel(entry: BrokerArchiveRow): BrokerArchiveRow { + if ( + entry.table !== "broker_execution.order_events" || + typeof entry.row.error_message !== "string" || + entry.row.error_message.length === 0 + ) { + return entry; + } + return { + ...entry, + row: { ...entry.row, error_message: REDACTED_ERROR_MESSAGE }, + }; +} + +function flattenArchiveAttributes( + entry: BrokerArchiveRow, +): Record { + const attributes: Record = { + ch_table: entry.table, + }; + for (const [key, value] of Object.entries(entry.row)) { + if (value === undefined || value === null) { + continue; + } + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + attributes[key] = value; + } else { + attributes[key] = JSON.stringify(value); + } + } + return attributes; +} + +export function createBrokerExecutionArchiverFromEnv( + otelLogs?: OtelLogs, + otelMetrics?: OtelMetrics, +): BrokerExecutionArchiver { + if (process.env.CEX_BROKER_ARCHIVE_ENABLED !== "true") { + return BrokerExecutionArchiver.disabled(); + } + const forwarderUrl = resolveArchiveForwarderUrlFromEnv(); + const archiveOtelLogs = isArchiveOtelLogsEnabled() ? otelLogs : undefined; + return BrokerExecutionArchiver.create({ + otelLogs: archiveOtelLogs, + otelMetrics, + forwarderUrl: forwarderUrl ?? "", + deadLetterPath: + process.env.CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH?.trim() ?? "", + deploymentId: process.env.CEX_BROKER_DEPLOYMENT_ID, + maxQueueSize: parsePositiveInt( + process.env.CEX_BROKER_ARCHIVE_QUEUE_MAX, + DEFAULT_MAX_QUEUE_SIZE, + ), + batchSize: parsePositiveInt( + process.env.CEX_BROKER_ARCHIVE_BATCH_SIZE, + DEFAULT_BATCH_SIZE, + ), + flushIntervalMs: parsePositiveInt( + process.env.CEX_BROKER_ARCHIVE_FLUSH_INTERVAL_MS, + DEFAULT_FLUSH_INTERVAL_MS, + ), + }); +} + +function validateForwarderUrl(value: string | undefined): URL { + if (!value) { + throw new Error( + "Broker execution archive is enabled but CEX_BROKER_ARCHIVE_FORWARDER_URL is missing", + ); + } + let url: URL; + try { + url = new URL(value); + } catch (error) { + throw new Error( + "Broker execution archive requires a valid CEX_BROKER_ARCHIVE_FORWARDER_URL", + { cause: error }, + ); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error( + "Broker execution archive forwarder URL must use http or https", + ); + } + return url; +} + +function parsePositiveInt(value: string | undefined, fallback: number): number { + if (!value) { + return fallback; + } + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} diff --git a/src/helpers/broker.ts b/src/helpers/broker.ts new file mode 100644 index 0000000..8a5f76f --- /dev/null +++ b/src/helpers/broker.ts @@ -0,0 +1,316 @@ +import type { Metadata } from "@grpc/grpc-js"; +import type { Exchange } from "@usherlabs/ccxt"; +import ccxt from "@usherlabs/ccxt"; +import type { BrokerAccountRole, BrokerCredentials } from "../types"; +import { buildCcxtConfig } from "./exchange-credentials"; +import { log } from "./logger"; +import { + registerBinanceTravelRuleDepositEndpoints, + registerBinanceTravelRuleWithdrawEndpoint, +} from "./travel-rule"; + +export type BrokerAccount = { + exchange: Exchange; + label: "primary" | `secondary:${number}`; + index?: number; + role?: BrokerAccountRole; + email?: string; + subAccountId?: string; + uid?: string; +}; + +export type BrokerPoolEntry = { + primary: BrokerAccount; + secondaryBrokers: BrokerAccount[]; +}; + +export class BrokerAccountPreconditionError extends Error { + constructor(message: string) { + super(message); + this.name = "BrokerAccountPreconditionError"; + } +} + +export function requireDestinationEmail( + dest: BrokerAccount, + transferType: "sub-to-sub" | "primary-to-sub", +) { + const email = dest.email?.trim(); + if (!email) { + throw new BrokerAccountPreconditionError( + `Destination account '${dest.label}' requires an email configured for ${transferType} transfers`, + ); + } + return email; +} + +export function applyCommonExchangeConfig(exchange: Exchange) { + if (process.env.CEX_BROKER_SANDBOX_MODE === "true") { + exchange.setSandboxMode(true); + } + // Ensure consistent defaults + exchange.enableRateLimit = true; + exchange.timeout = 150 * 1000; + exchange.extendExchangeOptions({ + recvWindow: 60000, + adjustForTimeDifference: true, + }); + // Register Binance's travel-rule endpoints (no-op for other exchanges): the + // withdraw apply endpoint and the deposit reconciler's read/provide-info + // endpoints. Registered here so every account instance carries them before the + // reconciler's first tick. + registerBinanceTravelRuleWithdrawEndpoint(exchange); + registerBinanceTravelRuleDepositEndpoints(exchange); +} + +export function createBroker( + cex: string, + credsOrMetadata: { apiKey: string; apiSecret: string } | Metadata, +): Exchange | null { + let apiKey: string | undefined; + let apiSecret: string | undefined; + + // Duck-typing check for gRPC Metadata (has get/remove functions) + if ( + credsOrMetadata && + typeof (credsOrMetadata as unknown as { get: unknown }).get === + "function" && + typeof (credsOrMetadata as unknown as { remove: unknown }).remove === + "function" + ) { + const metadata = credsOrMetadata as Metadata; + apiKey = metadata.get("api-key")?.[0]?.toString(); + apiSecret = metadata.get("api-secret")?.[0]?.toString(); + metadata.remove("api-key"); + metadata.remove("api-secret"); + } else { + const creds = credsOrMetadata as { apiKey: string; apiSecret: string }; + apiKey = creds.apiKey; + apiSecret = creds.apiSecret; + } + + const ExchangeClass = (ccxt.pro as Record)[cex]; + if (!ExchangeClass || !apiKey || !apiSecret) { + return null; + } + + const config = buildCcxtConfig(cex, { apiKey, apiSecret }); + if (!config) { + return null; + } + + const exchange = new ExchangeClass(config); + applyCommonExchangeConfig(exchange); + return exchange; +} + +export function createPublicBroker(cex: string): Exchange | null { + const ExchangeClass = (ccxt.pro as Record)[cex]; + if (!ExchangeClass) { + return null; + } + + const exchange = new ExchangeClass({}); + applyCommonExchangeConfig(exchange); + return exchange; +} + +type EnvConfigMap = Record< + string, + Partial & { + _secondaryMap?: Record>; + } +>; + +type ValidatedCredentialsMap = Record< + string, + BrokerCredentials & { secondaryKeys: BrokerCredentials[] } +>; + +function createBrokerAccount( + brokerName: string, + label: BrokerAccount["label"], + creds: BrokerCredentials, + index?: number, +): BrokerAccount | null { + const exchange = createBroker(brokerName, { + apiKey: creds.apiKey, + apiSecret: creds.apiSecret, + }); + if (!exchange) { + return null; + } + return { + exchange, + label, + index, + role: creds.role, + email: creds.email, + subAccountId: creds.subAccountId, + uid: creds.uid, + }; +} + +export function createBrokerPool( + cfg: EnvConfigMap | ValidatedCredentialsMap, +): Record { + const pool: Record = {}; + + for (const [brokerName, creds] of Object.entries(cfg)) { + const ExchangeClass = (ccxt.pro as Record)[ + brokerName + ]; + if (!ExchangeClass) { + log.warn(`❌ Invalid Broker: ${brokerName}`); + continue; + } + + const credsRecord = creds as Record; + const primaryApiKey = + typeof credsRecord.apiKey === "string" + ? (credsRecord.apiKey as string) + : undefined; + const primaryApiSecret = + typeof credsRecord.apiSecret === "string" + ? (credsRecord.apiSecret as string) + : undefined; + if (!primaryApiKey || !primaryApiSecret) { + log.warn(`❌ Missing API_KEY and/or API_SECRET for "${brokerName}"`); + continue; + } + + const primary = createBrokerAccount(brokerName, "primary", { + apiKey: primaryApiKey, + apiSecret: primaryApiSecret, + role: + typeof credsRecord.role === "string" + ? (credsRecord.role as BrokerAccountRole) + : undefined, + email: + typeof credsRecord.email === "string" + ? (credsRecord.email as string) + : undefined, + subAccountId: + typeof credsRecord.subAccountId === "string" + ? (credsRecord.subAccountId as string) + : undefined, + uid: + typeof credsRecord.uid === "string" + ? (credsRecord.uid as string) + : undefined, + }); + if (!primary) { + log.warn(`❌ Failed to create primary for "${brokerName}"`); + continue; + } + + const secondaryBrokers: BrokerAccount[] = []; + const secondaryKeysFromValidated = Array.isArray(credsRecord.secondaryKeys) + ? (credsRecord.secondaryKeys as BrokerCredentials[]) + : undefined; + const secondaryEntriesFromValidated = secondaryKeysFromValidated?.map( + (sec, idx) => [idx + 1, sec] as const, + ); + const secondaryEntriesFromMap = + credsRecord._secondaryMap && typeof credsRecord._secondaryMap === "object" + ? Object.entries( + credsRecord._secondaryMap as Record< + number, + Partial + >, + ) + .filter( + ([, sec]) => + typeof sec.apiKey === "string" && + typeof sec.apiSecret === "string", + ) + .map( + ([rawIndex, sec]) => + [ + Number(rawIndex), + { + apiKey: sec.apiKey as string, + apiSecret: sec.apiSecret as string, + role: sec.role, + email: sec.email, + subAccountId: sec.subAccountId, + uid: sec.uid, + }, + ] as const, + ) + : []; + const secondaryEntries = + secondaryEntriesFromValidated ?? secondaryEntriesFromMap; + + secondaryEntries + .filter(([index]) => Number.isInteger(index) && index > 0) + .sort(([leftIndex], [rightIndex]) => leftIndex - rightIndex) + .forEach(([index, sec]) => { + const secEx = createBrokerAccount( + brokerName, + `secondary:${index}`, + sec, + index, + ); + if (secEx) secondaryBrokers.push(secEx); + else + log.warn( + `⚠️ Failed to create secondary #${index} for "${brokerName}"`, + ); + }); + + pool[brokerName] = { primary, secondaryBrokers }; + log.info( + `✅ Loaded "${brokerName}" with ${secondaryBrokers.length} secondaries`, + ); + } + + return pool; +} + +export function selectBroker( + brokers: BrokerPoolEntry | undefined, + metadata: Metadata, +): Exchange | null { + return selectBrokerAccount(brokers, metadata)?.exchange ?? null; +} + +export function getCurrentBrokerSelector(metadata: Metadata): string { + const use_secondary_key = metadata.get("use-secondary-key"); + if (!use_secondary_key || use_secondary_key.length === 0) { + return "primary"; + } + const rawIndex = use_secondary_key[use_secondary_key.length - 1]?.toString(); + const index = rawIndex ? Number.parseInt(rawIndex, 10) : Number.NaN; + return Number.isInteger(index) && index > 0 + ? `secondary:${index}` + : "primary"; +} + +export function resolveBrokerAccount( + brokers: BrokerPoolEntry | undefined, + selector: string, +): BrokerAccount | null { + if (!brokers) { + return null; + } + if (selector === "primary") { + return brokers.primary; + } + const match = selector.match(/^secondary:(\d+)$/); + if (!match) { + return null; + } + const index = Number.parseInt(match[1] ?? "", 10); + return Number.isInteger(index) && index > 0 + ? (brokers.secondaryBrokers.find((account) => account.index === index) ?? + null) + : null; +} + +export function selectBrokerAccount( + brokers: BrokerPoolEntry | undefined, + metadata: Metadata, +): BrokerAccount | null { + return resolveBrokerAccount(brokers, getCurrentBrokerSelector(metadata)); +} diff --git a/src/helpers/constants.ts b/src/helpers/constants.ts index 9c3e813..c31e151 100644 --- a/src/helpers/constants.ts +++ b/src/helpers/constants.ts @@ -28,6 +28,8 @@ export const Action = { FetchAccountId: 11, FetchFees: 12, InternalTransfer: 13, + GetPerpConfigState: 14, + SetPerpConfigState: 15, } as const; export const SubscriptionType = { @@ -41,8 +43,25 @@ export const SubscriptionType = { } as const; export type Action = (typeof Action)[keyof typeof Action]; +export type ActionName = keyof typeof Action; export type SubscriptionType = (typeof SubscriptionType)[keyof typeof SubscriptionType]; +export type SubscriptionTypeName = keyof typeof SubscriptionType; + +function resolveEnumValue>( + enumValues: T, + value: T[keyof T] | keyof T | undefined, +): T[keyof T] | undefined { + if (typeof value === "number") { + return Object.values(enumValues).includes(value) + ? (value as T[keyof T]) + : undefined; + } + if (typeof value === "string" && Object.hasOwn(enumValues, value)) { + return enumValues[value] as T[keyof T]; + } + return undefined; +} function createEnumNameMap>( enumValues: T, @@ -56,21 +75,38 @@ const actionNames = createEnumNameMap(Action); const subscriptionTypeNames = createEnumNameMap(SubscriptionType); export function getActionName(action: unknown): string { + if (typeof action === "string" && Object.hasOwn(Action, action)) { + return action; + } return typeof action === "number" ? (actionNames[action] ?? `unknown_${action}`) : `unknown_${action ?? "undefined"}`; } -export function getSubscriptionTypeName(subscriptionType: number): string { - return ( - subscriptionTypeNames[subscriptionType] ?? `unknown_${subscriptionType}` - ); +export function getSubscriptionTypeName(subscriptionType: unknown): string { + if ( + typeof subscriptionType === "string" && + Object.hasOwn(SubscriptionType, subscriptionType) + ) { + return subscriptionType; + } + return typeof subscriptionType === "number" + ? (subscriptionTypeNames[subscriptionType] ?? `unknown_${subscriptionType}`) + : `unknown_${subscriptionType ?? "undefined"}`; +} + +export function resolveAction( + action: Action | ActionName | undefined, +): Action | undefined { + return resolveEnumValue(Action, action); } export function resolveSubscriptionType( - type: SubscriptionType | undefined, + type: SubscriptionType | SubscriptionTypeName | undefined, ): SubscriptionType { - return type === undefined || type === SubscriptionType.NO_ACTION + const resolvedType = resolveEnumValue(SubscriptionType, type); + return resolvedType === undefined || + resolvedType === SubscriptionType.NO_ACTION ? SubscriptionType.ORDERBOOK - : type; + : resolvedType; } diff --git a/src/helpers/deposit-archive-poller.ts b/src/helpers/deposit-archive-poller.ts new file mode 100644 index 0000000..76a1d71 --- /dev/null +++ b/src/helpers/deposit-archive-poller.ts @@ -0,0 +1,386 @@ +import type { BrokerAccount, BrokerPoolEntry } from "./broker"; +import { + type BrokerExecutionArchiver, + buildCommonArchiveTags, + buildTransferEventArchiveRow, + normalizeCcxtTransactionForArchive, + rethrowArchiveDurabilityError, +} from "./broker-execution-archive"; +import { depositField, normalizeDepositStatus } from "./deposit"; +import { log } from "./logger"; +import type { OtelMetrics } from "./otel"; +import { asRecord } from "./shared/guards"; + +// ccxt method surface used by the poller (typed defensively — not every exchange +// build exposes fetchDeposits). +type ExchangeWithDeposits = { + fetchDeposits?: ( + code?: string, + since?: number, + limit?: number, + params?: Record, + ) => Promise; + has?: Record; +}; + +export type DepositArchivePollerConfig = { + // Constant defaults (no env vars): every broker env var must be allowlisted in a + // Gramine manifest in another repo, so the poller intentionally introduces none. + pollIntervalMs: number; + // How far back the first poll of an account reaches. A restart loses the + // in-memory cursor and re-scans this window; duplicate rows are acceptable + // because transfer_events is plain MergeTree and consumers deduplicate at read + // time over (exchange, account, symbol, external_id, status). A Binance deposit + // unlocking intentionally produces distinct credited_not_withdrawable and ok rows. + lookbackMs: number; + depositsLimit: number; +}; + +const DEFAULT_CONFIG: DepositArchivePollerConfig = { + pollIntervalMs: 60_000, + lookbackMs: 24 * 60 * 60 * 1000, + depositsLimit: 50, +}; + +const ALL_CURRENCIES_CODE = "*"; + +type DepositPollTarget = { + exchangeId: string; + account: BrokerAccount; + code: typeof ALL_CURRENCIES_CODE; +}; + +type LastArchivedDeposit = { + status: string | undefined; + timestamp: number | undefined; +}; + +function depositTimestamp(record: Record): number | undefined { + const observedAt = depositField(record, [ + "timestamp", + "creditedAt", + "credited_at", + "updated", + "updatedAt", + "datetime", + ]); + const timestamp = + typeof observedAt === "number" + ? observedAt + : typeof observedAt === "string" + ? Date.parse(observedAt) + : Number.NaN; + return Number.isFinite(timestamp) ? timestamp : undefined; +} + +function archivedDepositStatus( + exchangeId: string | undefined, + record: Record, +): string | undefined { + const rawStatus = asRecord(record.info)?.status; + if ( + exchangeId?.toLowerCase() === "binance" && + String(rawStatus ?? "").trim() === "6" + ) { + return "credited_not_withdrawable"; + } + return normalizeCcxtTransactionForArchive(record).status; +} + +/** + * Advances the inclusive since-watermark only across a fully observed, + * terminal prefix of deposit history. + */ +export function nextDepositCursor( + deposits: unknown[], + currentSince: number, + depositsLimit: number, + exchangeId?: string, +): number { + // A full batch may be either end of a truncated window. Without a portable + // ccxt pagination boundary, moving the watermark could permanently skip the + // unseen side of a newest-first response. + if (deposits.length >= depositsLimit) { + return currentSince; + } + + let next = currentSince; + let oldestPending = Number.POSITIVE_INFINITY; + for (const deposit of deposits) { + const record = asRecord(deposit); + if (!record) { + return currentSince; + } + const timestamp = depositTimestamp(record); + const archiveStatus = archivedDepositStatus(exchangeId, record); + const status = normalizeDepositStatus( + depositField(record, ["status", "state"]), + ); + const isPending = + archiveStatus === "credited_not_withdrawable" || status === "pending"; + if (timestamp === undefined) { + if (isPending) { + return currentSince; + } + continue; + } + if (isPending) { + oldestPending = Math.min(oldestPending, timestamp); + } else { + next = Math.max(next, timestamp + 1); + } + } + return Number.isFinite(oldestPending) + ? Math.max(currentSince, oldestPending) + : next; +} + +/** + * Broker-internal periodic capture of venue deposit history. It polls every + * configured account sequentially, using ccxt's unfiltered deposit-history + * surface so newly funded currencies do not depend on balance or market + * discovery. + */ +export class DepositArchivePoller { + #timer: ReturnType | null = null; + #stopped = false; + #running: Promise | null = null; + readonly #cursors = new Map(); + readonly #lastArchivedByTarget = new Map< + string, + Map + >(); + readonly #unsupportedLogged = new Set(); + readonly #config: DepositArchivePollerConfig; + + constructor( + private readonly params: { + brokers: Record; + archiver: BrokerExecutionArchiver; + metrics?: OtelMetrics; + config?: Partial; + }, + ) { + this.#config = { ...DEFAULT_CONFIG, ...params.config }; + } + + start(): void { + if (this.#timer || this.#stopped || !this.params.archiver.isEnabled()) { + return; + } + log.info("📥 Deposit archive poller started"); + this.#schedule(0); + } + + async stop(): Promise { + this.#stopped = true; + if (this.#timer) { + clearTimeout(this.#timer); + this.#timer = null; + } + await this.#running; + } + + async pollAllOnce(): Promise { + if (this.#stopped || this.#running || !this.params.archiver.isEnabled()) { + return false; + } + this.#running = this.#pollAllSequentially(); + try { + return await this.#running; + } finally { + this.#running = null; + } + } + + #targets(): DepositPollTarget[] { + const targets: DepositPollTarget[] = []; + for (const [exchangeId, pool] of Object.entries(this.params.brokers)) { + for (const account of [pool.primary, ...pool.secondaryBrokers]) { + targets.push({ + exchangeId, + account, + code: ALL_CURRENCIES_CODE, + }); + } + } + return targets; + } + + async #pollAllSequentially(): Promise { + for (const target of this.#targets()) { + if (this.#stopped) { + break; + } + await this.#pollOne(target); + } + return true; + } + + async #pollOne(target: DepositPollTarget): Promise { + const exchange = target.account.exchange as unknown as ExchangeWithDeposits; + const key = this.#targetKey(target); + if ( + typeof exchange.fetchDeposits !== "function" || + exchange.has?.fetchDeposits === false + ) { + if (!this.#unsupportedLogged.has(key)) { + this.#unsupportedLogged.add(key); + log.info("Deposit archive poll skipped: fetchDeposits unsupported", { + exchange: target.exchangeId, + account: target.account.label, + }); + } + return; + } + + const since = + this.#cursors.get(key) ?? Date.now() - this.#config.lookbackMs; + let deposits: unknown[]; + try { + deposits = await exchange.fetchDeposits( + undefined, + since, + this.#config.depositsLimit, + ); + } catch (error) { + void this.params.metrics?.recordCounter( + "cex_deposit_poller_errors_total", + 1, + { exchange: target.exchangeId }, + ); + log.warn("Deposit archive poll failed", { + exchange: target.exchangeId, + account: target.account.label, + error, + }); + return; + } + if (!Array.isArray(deposits) || deposits.length === 0) { + return; + } + + let archived = 0; + for (const deposit of deposits) { + const record = asRecord(deposit); + if (!record) { + continue; + } + const assetSymbol = depositField(record, ["currency", "code", "asset"]); + const amount = depositField(record, ["amount"]); + const address = depositField(record, [ + "address", + "recipientAddress", + "to", + "destination", + ]); + const txid = depositField(record, ["txid", "txId", "tx_hash", "txHash"]); + const network = depositField(record, ["network", "chain"]); + const archiveStatus = archivedDepositStatus(target.exchangeId, record); + const creditedAt = depositField(record, [ + "creditedAt", + "credited_at", + "updated", + "updatedAt", + "timestamp", + "datetime", + ]); + const depositTxid = txid === undefined ? undefined : String(txid); + const lastArchived = + depositTxid === undefined + ? undefined + : this.#lastArchivedByTarget.get(key)?.get(depositTxid); + if (lastArchived && lastArchived.status === archiveStatus) { + continue; + } + + this.params.archiver.enqueue( + buildTransferEventArchiveRow({ + tags: buildCommonArchiveTags({ + deploymentId: this.params.archiver.getDeploymentId(), + accountSelector: target.account.label, + exchange: target.exchangeId, + symbol: assetSymbol === undefined ? undefined : String(assetSymbol), + }), + transfer: { + eventKind: "deposit", + lifecycleAction: "observe_deposit", + status: archiveStatus, + amount: amount === undefined ? undefined : String(amount), + address: address === undefined ? undefined : String(address), + network: network === undefined ? undefined : String(network), + externalId: depositTxid, + txid: depositTxid, + exchangeTimestamp: + typeof creditedAt === "string" ? creditedAt : undefined, + payload: record, + }, + }), + ); + if (depositTxid !== undefined) { + let targetDeposits = this.#lastArchivedByTarget.get(key); + if (!targetDeposits) { + targetDeposits = new Map(); + this.#lastArchivedByTarget.set(key, targetDeposits); + } + targetDeposits.set(depositTxid, { + status: archiveStatus, + timestamp: depositTimestamp(record), + }); + } + archived += 1; + } + + if (archived > 0) { + void this.params.metrics?.recordCounter( + "cex_deposit_poller_deposits_archived_total", + archived, + { exchange: target.exchangeId }, + ); + } + const nextCursor = nextDepositCursor( + deposits, + since, + this.#config.depositsLimit, + target.exchangeId, + ); + this.#cursors.set(key, nextCursor); + const targetDeposits = this.#lastArchivedByTarget.get(key); + if (targetDeposits) { + for (const [externalId, lastArchived] of targetDeposits) { + if ( + lastArchived.timestamp !== undefined && + lastArchived.timestamp < nextCursor + ) { + targetDeposits.delete(externalId); + } + } + if (targetDeposits.size === 0) { + this.#lastArchivedByTarget.delete(key); + } + } + } + + #targetKey(target: DepositPollTarget): string { + return `${target.exchangeId}|${target.account.label}|${target.code}`; + } + + #schedule(delayMs: number): void { + this.#timer = setTimeout(() => void this.#tick(), delayMs); + this.#timer.unref?.(); + } + + async #tick(): Promise { + this.#timer = null; + try { + await this.pollAllOnce(); + } catch (error) { + rethrowArchiveDurabilityError(error); + log.error("Deposit archive poller tick failed", error); + } finally { + if (!this.#stopped) { + this.#schedule(this.#config.pollIntervalMs); + } + } + } +} diff --git a/src/helpers/deposit.ts b/src/helpers/deposit.ts new file mode 100644 index 0000000..85cbdac --- /dev/null +++ b/src/helpers/deposit.ts @@ -0,0 +1,78 @@ +export function depositField( + deposit: Record, + fields: string[], +) { + for (const field of fields) { + const value = deposit[field]; + if (value !== undefined && value !== null && String(value).length > 0) { + return value; + } + } + return undefined; +} + +export function normalizeDepositStatus( + status: unknown, +): + | "unsupported" + | "not_found" + | "pending" + | "credited" + | "failed" + | "timed_out" { + const normalized = String(status ?? "") + .trim() + .toLowerCase(); + if ( + ["ok", "credited", "complete", "completed", "success"].includes(normalized) + ) { + return "credited"; + } + if ( + ["failed", "failure", "canceled", "cancelled", "rejected"].includes( + normalized, + ) + ) { + return "failed"; + } + if (["timeout", "timed_out", "timedout", "expired"].includes(normalized)) { + return "timed_out"; + } + if (["pending", "processing", "confirming", "waiting"].includes(normalized)) { + return "pending"; + } + return "pending"; +} + +export function depositMatchesTransaction( + deposit: Record, + transactionHash: string, +): boolean { + const candidates = [ + depositField(deposit, [ + "txid", + "txId", + "tx_hash", + "txHash", + "transactionHash", + ]), + depositField(deposit, ["id"]), + ]; + return candidates.some((candidate) => String(candidate) === transactionHash); +} + +export function stringAmountEquals(left: unknown, right: unknown): boolean { + const leftNum = Number(left); + const rightNum = Number(right); + if (Number.isFinite(leftNum) && Number.isFinite(rightNum)) { + return Math.abs(leftNum - rightNum) < 1e-12; + } + return String(left) === String(right); +} + +export function normalizeAddress(value: unknown): string | undefined { + if (value === undefined || value === null) { + return undefined; + } + return String(value).trim().toLowerCase(); +} diff --git a/src/helpers/exchange-credentials.ts b/src/helpers/exchange-credentials.ts new file mode 100644 index 0000000..9ac39a2 --- /dev/null +++ b/src/helpers/exchange-credentials.ts @@ -0,0 +1,81 @@ +import type { Exchange } from "@usherlabs/ccxt"; +import ccxt from "@usherlabs/ccxt"; + +export type BrokerKeyPair = { + apiKey: string; + apiSecret: string; +}; + +type RequiredCredentials = Exchange["requiredCredentials"]; + +const walletBasedCache = new Map(); + +function resolveExchangeClass(cex: string): typeof Exchange | null { + const ExchangeClass = (ccxt.pro as Record)[cex]; + return ExchangeClass ?? null; +} + +export function getExchangeRequiredCredentials( + cex: string, +): RequiredCredentials | null { + const ExchangeClass = resolveExchangeClass(cex); + if (!ExchangeClass) { + return null; + } + + const probe = new ExchangeClass({}); + return probe.requiredCredentials; +} + +export function isWalletBasedExchange(cex: string): boolean { + const cached = walletBasedCache.get(cex); + if (cached !== undefined) { + return cached; + } + + const required = getExchangeRequiredCredentials(cex); + const walletBased = + required !== null && + required.walletAddress === true && + required.privateKey === true && + required.apiKey !== true && + required.secret !== true; + + walletBasedCache.set(cex, walletBased); + return walletBased; +} + +function normalizeHexCredential(value: string): string { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return trimmed; + } + if (trimmed.startsWith("0x") || trimmed.startsWith("0X")) { + return `0x${trimmed.slice(2)}`; + } + if (/^[0-9a-fA-F]{64}$/.test(trimmed)) { + return `0x${trimmed}`; + } + return trimmed; +} + +export function buildCcxtConfig( + cex: string, + creds: BrokerKeyPair, +): Record | null { + if (!creds.apiKey || !creds.apiSecret) { + return null; + } + + if (isWalletBasedExchange(cex)) { + return { + walletAddress: normalizeHexCredential(creds.apiKey), + privateKey: normalizeHexCredential(creds.apiSecret), + }; + } + + return { + apiKey: creds.apiKey, + secret: creds.apiSecret, + }; +} diff --git a/src/helpers/fill-archive-poller.ts b/src/helpers/fill-archive-poller.ts new file mode 100644 index 0000000..aa71e60 --- /dev/null +++ b/src/helpers/fill-archive-poller.ts @@ -0,0 +1,219 @@ +import type { BrokerPoolEntry } from "./broker"; +import { resolveBrokerAccount } from "./broker"; +import { + type BrokerExecutionArchiver, + buildCommonArchiveTags, + buildFillEventArchiveRow, + normalizeCcxtTradeForArchive, + rethrowArchiveDurabilityError, +} from "./broker-execution-archive"; +import { log } from "./logger"; +import type { OrderActivityTracker } from "./order-activity-tracker"; +import type { OtelMetrics } from "./otel"; +import { asRecord } from "./shared/guards"; + +// ccxt method surface used by the poller (typed defensively — not every exchange +// build exposes fetchMyTrades). +type ExchangeWithTrades = { + fetchMyTrades?: ( + symbol: string, + since?: number, + limit?: number, + params?: Record, + ) => Promise; + has?: Record; +}; + +export type FillArchivePollerConfig = { + // Constant defaults (no env vars): every broker env var must be allowlisted in a + // Gramine manifest in another repo, so the poller intentionally introduces none. + pollIntervalMs: number; + // How far back the first poll of a (account, symbol) reaches. A restart loses the + // in-memory cursor and re-scans this window; the resulting duplicate rows are + // resolved by read-time dedup (fill_events is plain MergeTree, per contract). + lookbackMs: number; + tradesLimit: number; +}; + +const DEFAULT_CONFIG: FillArchivePollerConfig = { + pollIntervalMs: 60_000, + lookbackMs: 24 * 60 * 60 * 1000, + tradesLimit: 500, +}; + +/** + * Advances a since-cursor past the newest trade in a batch. The next poll asks for + * trades strictly after the last one seen (+1ms) so the same trade is not refetched + * every tick, while still tolerating out-of-order timestamps within a batch. + */ +export function nextFillCursor( + trades: unknown[], + currentSince: number, +): number { + let next = currentSince; + for (const trade of trades) { + const ts = asRecord(trade)?.timestamp; + if (typeof ts === "number" && Number.isFinite(ts) && ts + 1 > next) { + next = ts + 1; + } + } + return next; +} + +/** + * Broker-internal periodic capture of per-fill facts. For each (account, symbol) + * that saw recent order activity it calls the venue trade-history endpoint + * (fetchMyTrades) with a since-cursor and archives each trade to + * broker_execution.fill_events. Started at bootstrap only when archiving is + * enabled; symbols are staggered by sequential awaits so one tick never bursts the + * venue rate limit. Mirrors the TravelRuleDepositReconciler lifecycle. + */ +export class FillArchivePoller { + #timer: ReturnType | null = null; + #stopped = false; + #running = false; + // Per (exchange|account|symbol) last since-cursor (epoch ms). In-memory only. + readonly #cursors = new Map(); + readonly #config: FillArchivePollerConfig; + + constructor( + private readonly params: { + brokers: Record; + archiver: BrokerExecutionArchiver; + tracker: OrderActivityTracker; + metrics?: OtelMetrics; + config?: Partial; + }, + ) { + this.#config = { ...DEFAULT_CONFIG, ...params.config }; + } + + start(): void { + if (this.#timer || this.#stopped) { + return; + } + if (!this.params.archiver.isEnabled()) { + return; + } + log.info("🧾 Fill archive poller started"); + this.#timer = setTimeout(() => void this.#tick(), 0); + this.#timer.unref?.(); + } + + stop(): void { + this.#stopped = true; + if (this.#timer) { + clearTimeout(this.#timer); + this.#timer = null; + } + } + + async #tick(): Promise { + if (this.#stopped || this.#running) { + return; + } + this.#running = true; + try { + await this.pollTrackedOnce(); + } catch (error) { + rethrowArchiveDurabilityError(error); + log.error("Fill archive poller tick failed", error); + } finally { + this.#running = false; + if (!this.#stopped) { + this.#timer = setTimeout( + () => void this.#tick(), + this.#config.pollIntervalMs, + ); + this.#timer.unref?.(); + } + } + } + + /** + * Runs one poll pass over every tracked (account, symbol), sequentially so the + * per-symbol awaits stagger venue calls. Exposed for tests (the timer loop calls + * it every tick). + */ + async pollTrackedOnce(): Promise { + for (const entry of this.params.tracker.list()) { + if (this.#stopped) { + break; + } + await this.#pollOne(entry.exchangeId, entry.accountLabel, entry.symbol); + } + } + + async #pollOne( + exchangeId: string, + accountLabel: string, + symbol: string, + ): Promise { + const account = resolveBrokerAccount( + this.params.brokers[exchangeId], + accountLabel, + ); + if (!account) { + return; + } + const exchange = account.exchange as unknown as ExchangeWithTrades; + if ( + typeof exchange.fetchMyTrades !== "function" || + exchange.has?.fetchMyTrades === false + ) { + return; + } + + const key = `${exchangeId}|${accountLabel}|${symbol}`; + const since = + this.#cursors.get(key) ?? Date.now() - this.#config.lookbackMs; + + let trades: unknown[]; + try { + trades = await exchange.fetchMyTrades( + symbol, + since, + this.#config.tradesLimit, + ); + } catch (error) { + void this.params.metrics?.recordCounter( + "cex_fill_poller_errors_total", + 1, + { + exchange: exchangeId, + }, + ); + log.warn("Fill archive poll failed", { + exchange: exchangeId, + account: accountLabel, + symbol, + error, + }); + return; + } + if (!Array.isArray(trades) || trades.length === 0) { + return; + } + + trades.forEach((trade, fillIndex) => { + const tags = buildCommonArchiveTags({ + deploymentId: this.params.archiver.getDeploymentId(), + accountSelector: accountLabel, + exchange: exchangeId, + symbol, + }); + this.params.archiver.enqueue( + buildFillEventArchiveRow({ + tags, + fill: { ...normalizeCcxtTradeForArchive(trade), fillIndex }, + }), + ); + }); + void this.params.metrics?.recordCounter( + "cex_fill_poller_trades_archived_total", + trades.length, + { exchange: exchangeId }, + ); + this.#cursors.set(key, nextFillCursor(trades, since)); + } +} diff --git a/src/helpers/grpc/broker.ts b/src/helpers/grpc/broker.ts new file mode 100644 index 0000000..3a17637 --- /dev/null +++ b/src/helpers/grpc/broker.ts @@ -0,0 +1,35 @@ +import type { Metadata } from "@grpc/grpc-js"; +import type { Exchange } from "@usherlabs/ccxt"; +import { + type BrokerAccount, + type BrokerPoolEntry, + createBroker, + createPublicBroker, + selectBrokerAccount, +} from "../broker"; + +export function resolveActionBroker( + normalizedCex: string, + brokers: Record, + metadata: Metadata, + selectedBrokerAccount?: BrokerAccount, +): Exchange | null { + return ( + selectedBrokerAccount?.exchange ?? + createBroker(normalizedCex, metadata) ?? + createPublicBroker(normalizedCex) + ); +} + +export function selectBrokerAccountForCex( + normalizedCex: string, + brokers: Record, + metadata: Metadata, +): BrokerAccount | undefined { + return ( + selectBrokerAccount( + brokers[normalizedCex as keyof typeof brokers], + metadata, + ) ?? undefined + ); +} diff --git a/src/helpers/grpc/callbacks.ts b/src/helpers/grpc/callbacks.ts new file mode 100644 index 0000000..50ec8d7 --- /dev/null +++ b/src/helpers/grpc/callbacks.ts @@ -0,0 +1,35 @@ +import * as grpc from "@grpc/grpc-js"; +import type { z } from "zod"; +import { type ParsePayloadResult, parsePayload } from "./payload"; + +export function invalidArgumentError(message: string): grpc.ServiceError { + return { + code: grpc.status.INVALID_ARGUMENT, + message, + details: message, + metadata: new grpc.Metadata(), + } as grpc.ServiceError; +} + +export function rejectInvalidPayload( + parsed: ParsePayloadResult, + callback: (error: grpc.ServiceError | null, value: null) => void, +): parsed is { success: true; data: T } { + if (!parsed.success) { + callback(invalidArgumentError(parsed.message), null); + return false; + } + return true; +} + +export function parseActionPayload( + schema: z.ZodType, + rawPayload: Record | undefined, + callback: (error: grpc.ServiceError | null, value: null) => void, +): T | null { + const parsed = parsePayload(schema, rawPayload); + if (!rejectInvalidPayload(parsed, callback)) { + return null; + } + return parsed.data; +} diff --git a/src/helpers/grpc/payload.ts b/src/helpers/grpc/payload.ts new file mode 100644 index 0000000..7805122 --- /dev/null +++ b/src/helpers/grpc/payload.ts @@ -0,0 +1,24 @@ +import type { z } from "zod"; + +export type ParsePayloadResult = + | { success: true; data: T } + | { success: false; message: string }; + +export function parsePayload( + schema: z.ZodType, + rawPayload: Record | undefined, +): ParsePayloadResult { + const parsed = schema.safeParse(rawPayload ?? {}); + if (parsed.success) { + return { success: true, data: parsed.data }; + } + const firstIssue = parsed.error.issues[0]; + const path = + firstIssue && firstIssue.path.length > 0 + ? `${firstIssue.path.join(".")}: ` + : ""; + return { + success: false, + message: `ValidationError: ${path}${firstIssue?.message ?? "Invalid payload"}`, + }; +} diff --git a/src/helpers/grpc/status.ts b/src/helpers/grpc/status.ts new file mode 100644 index 0000000..c1d7bc3 --- /dev/null +++ b/src/helpers/grpc/status.ts @@ -0,0 +1,80 @@ +import * as grpc from "@grpc/grpc-js"; +import ccxt from "@usherlabs/ccxt"; +import { getErrorMessage } from "../shared/errors"; + +export function stableGrpcErrorCode(message: string): grpc.status | undefined { + if (message.startsWith("AuthenticationError:")) { + return grpc.status.UNAUTHENTICATED; + } + if (message.startsWith("InsufficientFunds:")) { + return grpc.status.FAILED_PRECONDITION; + } + if (message.startsWith("venue_discovery_unavailable:")) { + return grpc.status.UNIMPLEMENTED; + } + if (message.startsWith("network_alias_unresolved:")) { + return grpc.status.INVALID_ARGUMENT; + } + if ( + message.startsWith("deposit_observation_unavailable:") || + message.startsWith("deposit_not_found:") + ) { + return grpc.status.UNIMPLEMENTED; + } + if (message.startsWith("deposit_amount_mismatch:")) { + return grpc.status.FAILED_PRECONDITION; + } + if (message.startsWith("passive_order_unsupported:")) { + return grpc.status.UNIMPLEMENTED; + } + if ( + message.startsWith("passive_order_rejected:") || + message.startsWith("passive_order_would_cross:") + ) { + return grpc.status.FAILED_PRECONDITION; + } + if ( + message.startsWith("policy_withdrawal_denied:") || + message.startsWith("policy_deposit_denied:") + ) { + return grpc.status.PERMISSION_DENIED; + } + return undefined; +} + +/** Maps CCXT typed errors to appropriate gRPC status codes. Returns undefined for unrecognized errors. */ +export function mapCcxtErrorToGrpcStatus( + error: unknown, +): grpc.status | undefined { + if (error instanceof ccxt.AuthenticationError) + return grpc.status.UNAUTHENTICATED; + if (error instanceof ccxt.PermissionDenied) + return grpc.status.PERMISSION_DENIED; + if (error instanceof ccxt.InsufficientFunds) + return grpc.status.FAILED_PRECONDITION; + if (error instanceof ccxt.InvalidAddress) return grpc.status.INVALID_ARGUMENT; + if (error instanceof ccxt.BadSymbol) return grpc.status.NOT_FOUND; + if (error instanceof ccxt.BadRequest) return grpc.status.INVALID_ARGUMENT; + if (error instanceof ccxt.NotSupported) return grpc.status.UNIMPLEMENTED; + if (error instanceof ccxt.RateLimitExceeded) + return grpc.status.RESOURCE_EXHAUSTED; + if (error instanceof ccxt.OnMaintenance) return grpc.status.UNAVAILABLE; + if (error instanceof ccxt.ExchangeNotAvailable) + return grpc.status.UNAVAILABLE; + if (error instanceof ccxt.NetworkError) return grpc.status.UNAVAILABLE; + return undefined; +} + +export function resolveGrpcError( + error: unknown, + message?: string, +): { code: grpc.status; message: string } { + const resolvedMessage = message ?? getErrorMessage(error); + return { + code: + stableGrpcErrorCode(resolvedMessage) ?? + mapCcxtErrorToGrpcStatus(error) ?? + grpc.status.INTERNAL, + message: resolvedMessage, + }; +} diff --git a/src/helpers/index.ts b/src/helpers/index.ts index 8b631dc..4607d47 100644 --- a/src/helpers/index.ts +++ b/src/helpers/index.ts @@ -1,359 +1,59 @@ -import type { Metadata, ServerUnaryCall } from "@grpc/grpc-js"; -import type { - Exchange, - HttpClientOverride, - HttpOverridePredicate, -} from "@usherlabs/ccxt"; -import ccxt from "@usherlabs/ccxt"; -import { VerityClient } from "@usherlabs/verity-client"; +import type { Exchange } from "@usherlabs/ccxt"; import fs from "fs"; import Joi from "joi"; import type { - BrokerAccountRole, - BrokerCredentials, DepositRuleEntry, PolicyConfig, WithdrawRuleEntry, } from "../types"; -import { CCXT_METHODS_WITH_VERITY } from "./constants"; +import { type BrokerAccount, requireDestinationEmail } from "./broker"; import { log } from "./logger"; - -export type BrokerAccount = { - exchange: Exchange; - label: "primary" | `secondary:${number}`; - index?: number; - role?: BrokerAccountRole; - email?: string; - subAccountId?: string; - uid?: string; -}; - -export type BrokerPoolEntry = { - primary: BrokerAccount; - secondaryBrokers: BrokerAccount[]; -}; - -export class BrokerAccountPreconditionError extends Error { - constructor(message: string) { - super(message); - this.name = "BrokerAccountPreconditionError"; - } -} - -function requireDestinationEmail( - dest: BrokerAccount, - transferType: "sub-to-sub" | "primary-to-sub", -) { - const email = dest.email?.trim(); - if (!email) { - throw new BrokerAccountPreconditionError( - `Destination account '${dest.label}' requires an email configured for ${transferType} transfers`, - ); - } - return email; -} - -export function authenticateRequest( - call: ServerUnaryCall, - whitelistIps: string[], -): boolean { - const clientIp = call.getPeer().split(":")[0]; - if (whitelistIps.includes("*")) { - return true; - } else if (!clientIp || !whitelistIps.includes(clientIp)) { - log.warn(`Blocked access from unauthorized IP: ${clientIp || "unknown"}`); - return false; - } - return true; -} - -export function createVerityHttpClientOverride( - verityProverUrl: string, - onProofCallback: (proof: string, notaryPubKey?: string) => void, -) { - const client = new VerityClient({ proverUrl: verityProverUrl }); - return (redact: string, proofTimeout: number): HttpClientOverride => - async ({ url, config }) => { - // { method, url, config, data, meta } - let pending = client.get(url, config, { proofTimeout }); - if (redact) { - pending = pending.redact(redact || ""); - } - const response = await pending; - if (response.proof) { - onProofCallback(response.proof, response.notary_pub_key); - } - return response; - }; -} - -export function applyCommonExchangeConfig(exchange: Exchange) { - if (process.env.CEX_BROKER_SANDBOX_MODE === "true") { - exchange.setSandboxMode(true); - } - // Ensure consistent defaults - exchange.enableRateLimit = true; - exchange.timeout = 150 * 1000; - exchange.extendExchangeOptions({ - recvWindow: 60000, - adjustForTimeDifference: true, - }); -} - -export function buildHttpClientOverrideFromMetadata( - metadata: Metadata, - verityProverUrl: string, - onProofCallback: (proof: string, notaryPubKey?: string) => void, -): HttpClientOverride { - const redact = metadata.get("verity-t-redacted")?.[0]?.toString() || ""; - const rawTimeout = metadata.get("verity-proof-timeout")?.[0]?.toString(); - const proofTimeout = rawTimeout ? parseInt(rawTimeout, 10) : 5 * 60 * 1000; // default 5 minutes - const factory = createVerityHttpClientOverride( - verityProverUrl, - onProofCallback, - ); - return factory(redact, proofTimeout); -} - -export const verityHttpClientOverridePredicate: HttpOverridePredicate = ({ - method, - methodCalled, -}) => { - return ( - ["get", "post"].includes(method.toLowerCase()) && - CCXT_METHODS_WITH_VERITY.includes(methodCalled) - ); -}; - -export function createBroker( - cex: string, - credsOrMetadata: { apiKey: string; apiSecret: string } | Metadata, -): Exchange | null { - let apiKey: string | undefined; - let apiSecret: string | undefined; - - // Duck-typing check for gRPC Metadata (has get/remove functions) - if ( - credsOrMetadata && - typeof (credsOrMetadata as unknown as { get: unknown }).get === - "function" && - typeof (credsOrMetadata as unknown as { remove: unknown }).remove === - "function" - ) { - const metadata = credsOrMetadata as Metadata; - apiKey = metadata.get("api-key")?.[0]?.toString(); - apiSecret = metadata.get("api-secret")?.[0]?.toString(); - metadata.remove("api-key"); - metadata.remove("api-secret"); - } else { - const creds = credsOrMetadata as { apiKey: string; apiSecret: string }; - apiKey = creds.apiKey; - apiSecret = creds.apiSecret; - } - - const ExchangeClass = (ccxt.pro as Record)[cex]; - if (!ExchangeClass || !apiKey || !apiSecret) { - return null; - } - - const exchange = new ExchangeClass({ apiKey, secret: apiSecret }); - applyCommonExchangeConfig(exchange); - return exchange; -} - -type EnvConfigMap = Record< - string, - Partial & { - _secondaryMap?: Record>; - } ->; - -type ValidatedCredentialsMap = Record< - string, - BrokerCredentials & { secondaryKeys: BrokerCredentials[] } ->; - -function createBrokerAccount( - brokerName: string, - label: BrokerAccount["label"], - creds: BrokerCredentials, - index?: number, -): BrokerAccount | null { - const exchange = createBroker(brokerName, { - apiKey: creds.apiKey, - apiSecret: creds.apiSecret, - }); - if (!exchange) { - return null; - } - return { - exchange, - label, - index, - role: creds.role, - email: creds.email, - subAccountId: creds.subAccountId, - uid: creds.uid, - }; -} - -export function createBrokerPool( - cfg: EnvConfigMap | ValidatedCredentialsMap, -): Record { - const pool: Record = {}; - - for (const [brokerName, creds] of Object.entries(cfg)) { - const ExchangeClass = (ccxt.pro as Record)[ - brokerName - ]; - if (!ExchangeClass) { - log.warn(`❌ Invalid Broker: ${brokerName}`); - continue; - } - - const credsRecord = creds as Record; - const primaryApiKey = - typeof credsRecord.apiKey === "string" - ? (credsRecord.apiKey as string) - : undefined; - const primaryApiSecret = - typeof credsRecord.apiSecret === "string" - ? (credsRecord.apiSecret as string) - : undefined; - if (!primaryApiKey || !primaryApiSecret) { - log.warn(`❌ Missing API_KEY and/or API_SECRET for "${brokerName}"`); - continue; - } - - const primary = createBrokerAccount(brokerName, "primary", { - apiKey: primaryApiKey, - apiSecret: primaryApiSecret, - role: - typeof credsRecord.role === "string" - ? (credsRecord.role as BrokerAccountRole) - : undefined, - email: - typeof credsRecord.email === "string" - ? (credsRecord.email as string) - : undefined, - subAccountId: - typeof credsRecord.subAccountId === "string" - ? (credsRecord.subAccountId as string) - : undefined, - uid: - typeof credsRecord.uid === "string" - ? (credsRecord.uid as string) - : undefined, - }); - if (!primary) { - log.warn(`❌ Failed to create primary for "${brokerName}"`); - continue; - } - - const secondaryBrokers: BrokerAccount[] = []; - const secondaryKeysFromValidated = Array.isArray(credsRecord.secondaryKeys) - ? (credsRecord.secondaryKeys as BrokerCredentials[]) - : undefined; - const secondaryEntriesFromValidated = secondaryKeysFromValidated?.map( - (sec, idx) => [idx + 1, sec] as const, - ); - const secondaryEntriesFromMap = - credsRecord._secondaryMap && typeof credsRecord._secondaryMap === "object" - ? Object.entries( - credsRecord._secondaryMap as Record< - number, - Partial - >, - ) - .filter( - ([, sec]) => - typeof sec.apiKey === "string" && - typeof sec.apiSecret === "string", - ) - .map( - ([rawIndex, sec]) => - [ - Number(rawIndex), - { - apiKey: sec.apiKey as string, - apiSecret: sec.apiSecret as string, - role: sec.role, - email: sec.email, - subAccountId: sec.subAccountId, - uid: sec.uid, - }, - ] as const, - ) - : []; - const secondaryEntries = - secondaryEntriesFromValidated ?? secondaryEntriesFromMap; - - secondaryEntries.forEach(([index, sec]) => { - const secEx = createBrokerAccount( - brokerName, - `secondary:${index}`, - sec, - index, - ); - if (secEx) secondaryBrokers[index - 1] = secEx; - else - log.warn(`⚠️ Failed to create secondary #${index} for "${brokerName}"`); - }); - - pool[brokerName] = { primary, secondaryBrokers }; - log.info( - `✅ Loaded "${brokerName}" with ${secondaryBrokers.length} secondaries`, - ); - } - - return pool; -} - -export function selectBroker( - brokers: BrokerPoolEntry | undefined, - metadata: Metadata, -): Exchange | null { - return selectBrokerAccount(brokers, metadata)?.exchange ?? null; -} - -export function getCurrentBrokerSelector(metadata: Metadata): string { - const use_secondary_key = metadata.get("use-secondary-key"); - if (!use_secondary_key || use_secondary_key.length === 0) { - return "primary"; - } - const rawIndex = use_secondary_key[use_secondary_key.length - 1]?.toString(); - const index = rawIndex ? Number.parseInt(rawIndex, 10) : Number.NaN; - return Number.isInteger(index) && index > 0 - ? `secondary:${index}` - : "primary"; -} - -export function resolveBrokerAccount( - brokers: BrokerPoolEntry | undefined, - selector: string, -): BrokerAccount | null { - if (!brokers) { - return null; - } - if (selector === "primary") { - return brokers.primary; - } - const match = selector.match(/^secondary:(\d+)$/); - if (!match) { - return null; - } - const index = Number.parseInt(match[1] ?? "", 10); - return Number.isInteger(index) && index > 0 - ? (brokers.secondaryBrokers[index - 1] ?? null) - : null; -} - -export function selectBrokerAccount( - brokers: BrokerPoolEntry | undefined, - metadata: Metadata, -): BrokerAccount | null { - return resolveBrokerAccount(brokers, getCurrentBrokerSelector(metadata)); -} +import { + type BrokerMarketType, + findTradableSymbol, + parseMarketPattern, + parseMarketType, +} from "./market-type"; +import { + australiaDepositQuestionnaireSchema, + australiaQuestionnaireSchema, +} from "./travel-rule"; + +export { authenticateRequest } from "./auth"; +export { + applyCommonExchangeConfig, + type BrokerAccount, + BrokerAccountPreconditionError, + type BrokerPoolEntry, + createBroker, + createBrokerPool, + createPublicBroker, + getCurrentBrokerSelector, + resolveBrokerAccount, + selectBroker, + selectBrokerAccount, +} from "./broker"; +export { + australiaDepositQuestionnaireSchema, + australiaQuestionnaireSchema, + getEnabledTravelRuleDepositConfig, + registerBinanceTravelRuleDepositEndpoints, + registerBinanceTravelRuleWithdrawEndpoint, + resolveDepositOriginatorQuestionnaire, + resolveTravelRuleDecision, + type TravelRuleDecision, + withdrawViaLocalEntity, +} from "./travel-rule"; +export { + loadTravelRuleDepositReconcilerConfigFromEnv, + resolveOnChainSender, + TravelRuleDepositReconciler, +} from "./travel-rule-deposit-reconciler"; +export { + buildHttpClientOverrideFromMetadata, + createVerityHttpClientOverride, + verityHttpClientOverridePredicate, +} from "./verity"; /** * Loads and validates policy configuration @@ -391,6 +91,41 @@ export function loadPolicy(policyPath: string): PolicyConfig { .default([]), }); + // Travel-rule config: per-exchange opt-in flag plus static questionnaire + // answers keyed by destination address, validated against the AU schema at + // load time so a malformed questionnaire fails startup, not a live withdraw. + const travelRuleEntrySchema = Joi.object({ + // Only Binance implements the travel-rule (localentity) withdraw endpoint, + // so reject other exchanges at load time rather than failing at withdraw + // time with a cryptic "endpoint not registered" error. + exchange: Joi.string().uppercase().valid("BINANCE").required(), + enabled: Joi.boolean().required(), + description: Joi.string().optional(), + addresses: Joi.object() + .pattern( + Joi.string(), + Joi.object({ + questionnaire: australiaQuestionnaireSchema.required(), + }), + ) + .required(), + // Deposit auto-clear config, keyed by on-chain sender (originator). Uses + // the deposit questionnaire schema, which is deliberately distinct from + // the withdraw one so a copy-paste of the wrong shape fails at load time. + deposits: Joi.object({ + enabled: Joi.boolean().required(), + description: Joi.string().optional(), + originators: Joi.object() + .pattern( + Joi.string(), + Joi.object({ + questionnaire: australiaDepositQuestionnaireSchema.required(), + }), + ) + .required(), + }).optional(), + }); + // Full PolicyConfig schema const policyConfigSchema = Joi.object({ withdraw: Joi.object({ @@ -404,6 +139,10 @@ export function loadPolicy(policyPath: string): PolicyConfig { order: Joi.object({ rule: orderRuleSchema.required(), }).required(), + + travelRule: Joi.object({ + rule: Joi.array().items(travelRuleEntrySchema).required(), + }).optional(), }); const { error, value } = policyConfigSchema.validate( @@ -431,7 +170,7 @@ export function normalizePolicyConfig(policy: PolicyConfig): PolicyConfig { rule: policy.withdraw.rule.map((rule) => ({ ...rule, exchange: rule.exchange.trim().toUpperCase(), - network: rule.network.trim().toUpperCase(), + network: normalizeBrokerNetworkId(rule.network), whitelist: rule.whitelist.map((address) => address.trim().toLowerCase(), ), @@ -446,7 +185,7 @@ export function normalizePolicyConfig(policy: PolicyConfig): PolicyConfig { rule: policy.deposit.rule.map((rule) => ({ ...rule, exchange: rule.exchange.trim().toUpperCase(), - network: rule.network.trim().toUpperCase(), + network: normalizeBrokerNetworkId(rule.network), ...(rule.coins && { coins: rule.coins.map((c) => c.trim().toUpperCase()), }), @@ -463,6 +202,22 @@ export function normalizePolicyConfig(policy: PolicyConfig): PolicyConfig { }; } +const BROKER_NETWORK_ALIASES: Record = { + ARB: "ARBITRUM", + ARBITRUM: "ARBITRUM", + ETH: "ETHEREUM", + ERC20: "ETHEREUM", + ETHEREUM: "ETHEREUM", + BNB: "BNB", + BSC: "BNB", + BEP20: "BNB", +}; + +export function normalizeBrokerNetworkId(network: string): string { + const normalized = network.trim().toUpperCase(); + return BROKER_NETWORK_ALIASES[normalized] ?? normalized; +} + /** * Validates withdraw request against policy rules */ @@ -520,7 +275,7 @@ export function validateWithdraw( ): { valid: boolean; error?: string } { const normalizedPolicy = normalizePolicyConfig(policy); const exchangeNorm = exchange.trim().toUpperCase(); - const networkNorm = network.trim().toUpperCase(); + const networkNorm = normalizeBrokerNetworkId(network); const matchingRules = normalizedPolicy.withdraw.rule .map((rule) => ({ rule, @@ -708,6 +463,7 @@ function isMarketPatternMatch( broker: string, fromToken: string, toToken: string, + marketType: BrokerMarketType = "spot", ): boolean { const normalizedPattern = pattern.toUpperCase().trim(); const directPair = `${fromToken}/${toToken}`; @@ -717,8 +473,8 @@ function isMarketPatternMatch( return true; } - const [exchangePattern, symbolPattern] = normalizedPattern.split(":"); - if (!exchangePattern || !symbolPattern) { + const [exchangePattern, rawSymbolPattern] = normalizedPattern.split(":"); + if (!exchangePattern || !rawSymbolPattern) { return false; } @@ -727,11 +483,25 @@ function isMarketPatternMatch( return false; } + const parsedPattern = parseMarketPattern(rawSymbolPattern); + if ( + parsedPattern.requiredMarketType !== undefined && + parsedPattern.requiredMarketType !== marketType + ) { + return false; + } + + const symbolPattern = parsedPattern.symbolPattern.toUpperCase(); if (symbolPattern === "*") { return true; } - return symbolPattern === directPair || symbolPattern === reversePair; + return ( + symbolPattern === directPair || + symbolPattern === reversePair || + symbolPattern === `${directPair}:${toToken}` || + symbolPattern === `${reversePair}:${fromToken}` + ); } function getMatchedMarketPatterns( @@ -739,9 +509,10 @@ function getMatchedMarketPatterns( broker: string, fromToken: string, toToken: string, + marketType: BrokerMarketType = "spot", ): string[] { return markets.filter((pattern) => - isMarketPatternMatch(pattern, broker, fromToken, toToken), + isMarketPatternMatch(pattern, broker, fromToken, toToken, marketType), ); } @@ -788,35 +559,37 @@ export async function resolveOrderExecution( toToken: string, amount: number, price: number, + marketTypeInput?: unknown, ): Promise { const brokerUpper = cex.trim().toUpperCase(); const fromUpper = fromToken.trim().toUpperCase(); const toUpper = toToken.trim().toUpperCase(); + const marketType = parseMarketType(marketTypeInput); const matchedPatterns = getMatchedMarketPatterns( policy.order.rule.markets, brokerUpper, fromUpper, toUpper, + marketType, ); if (matchedPatterns.length === 0) { return { valid: false, - error: `Market ${brokerUpper}:${fromUpper}/${toUpper} is not allowed. Allowed markets: ${policy.order.rule.markets.join(", ")}`, + error: `Market ${brokerUpper}:${fromUpper}/${toUpper} (${marketType}) is not allowed. Allowed markets: ${policy.order.rule.markets.join(", ")}`, matchedPatterns, }; } - const directSymbol = `${fromUpper}/${toUpper}`; - const reverseSymbol = `${toUpper}/${fromUpper}`; - const hasDirectSymbol = await doesExchangeSupportSymbol(broker, directSymbol); - const hasReverseSymbol = await doesExchangeSupportSymbol( + const tradable = await findTradableSymbol( broker, - reverseSymbol, + fromUpper, + toUpper, + marketType, ); - if (!hasDirectSymbol && !hasReverseSymbol) { + if (!tradable) { return { valid: false, - error: `Exchange ${brokerUpper} does not support ${directSymbol} or ${reverseSymbol}`, + error: `Exchange ${brokerUpper} does not support ${fromUpper}/${toUpper} for marketType ${marketType}`, matchedPatterns, }; } @@ -854,10 +627,10 @@ export async function resolveOrderExecution( } } - if (hasDirectSymbol) { + if (tradable.side === "sell") { return { valid: true, - symbol: directSymbol, + symbol: tradable.symbol, side: "sell", amountBase: amount, limitsApplied: limits.length > 0, @@ -877,7 +650,7 @@ export async function resolveOrderExecution( return { valid: true, - symbol: reverseSymbol, + symbol: tradable.symbol, side: "buy", amountBase: amount / price, limitsApplied: limits.length > 0, @@ -901,7 +674,7 @@ export function validateDeposit( } const exchangeNorm = exchange.trim().toUpperCase(); - const networkNorm = network.trim().toUpperCase(); + const networkNorm = normalizeBrokerNetworkId(network); const tickerNorm = ticker.trim().toUpperCase(); const matchingRules = normalizedPolicy.deposit.rule diff --git a/src/helpers/market-data-archive/capture.ts b/src/helpers/market-data-archive/capture.ts new file mode 100644 index 0000000..fa0ef9d --- /dev/null +++ b/src/helpers/market-data-archive/capture.ts @@ -0,0 +1,229 @@ +import type { BrokerArchiveRow } from "../broker-execution-archive/types"; +import { + type BrokerExecutionArchiver, + rethrowArchiveDurabilityError, +} from "../broker-execution-archive/writer"; +import { log } from "../logger"; +import type { OtelMetrics } from "../otel"; +import { OhlcvBarTracker } from "./ohlcv-bar-tracker"; +import { isMarketArchiveEnabled, OrderbookSampler } from "./orderbook-sampler"; +import { extractTrades, parseTicker } from "./parse-stream"; +import { + buildCandleRow, + buildCexStreamEventRow, + buildCexTickerEventRow, + buildCexTradeRow, + buildOrderbookSnapshotRow, +} from "./rows"; +import type { + CexStreamArchiveInput, + OhlcvArchiveInput, + OrderbookArchiveInput, + TickerArchiveInput, + TradesArchiveInput, +} from "./types"; + +type WatchStream = "orderbook" | "ohlcv" | "trades" | "ticker" | "stream"; + +async function recordWatchMetric( + otelMetrics: OtelMetrics | undefined, + metricName: string, + labels: Record, +): Promise { + try { + await otelMetrics?.recordCounter(metricName, 1, labels); + } catch { + // Metrics must not affect subscribe behavior. + } +} + +function watchLabels( + stream: WatchStream, + input: { exchange: string; symbol: string }, +): Record { + return { + stream, + exchange: input.exchange, + symbol: input.symbol, + }; +} + +export function archiveOrderbookInBackground( + archiver: BrokerExecutionArchiver | undefined, + otelMetrics: OtelMetrics | undefined, + input: OrderbookArchiveInput, + options?: { sampledOut?: boolean }, +): void { + const labels = watchLabels("orderbook", input); + void recordWatchMetric( + otelMetrics, + "cex_watch_frames_received_total", + labels, + ); + + if (!isMarketArchiveEnabled() || !archiver?.isEnabled()) { + return; + } + + if (options?.sampledOut) { + void recordWatchMetric( + otelMetrics, + "cex_watch_frames_sampled_out_total", + labels, + ); + return; + } + + queueMicrotask(() => { + try { + const row = buildOrderbookSnapshotRow(input); + if (row) { + archiver.enqueue(row); + void recordWatchMetric( + otelMetrics, + "cex_watch_frames_archived_total", + labels, + ); + } + } catch (error) { + rethrowArchiveDurabilityError(error); + log.warn("Failed to archive orderbook snapshot", { error }); + } + }); +} + +/** @deprecated Use archiveOrderbookInBackground */ +export const archiveOrderbookSnapshotInBackground = + archiveOrderbookInBackground; + +/** @deprecated Use archiveOrderbookInBackground */ +export const archiveOrderbookTobInBackground = archiveOrderbookInBackground; + +export function archiveOhlcvInBackground( + archiver: BrokerExecutionArchiver | undefined, + otelMetrics: OtelMetrics | undefined, + tracker: OhlcvBarTracker, + input: OhlcvArchiveInput, +): void { + const labels = watchLabels("ohlcv", input); + void recordWatchMetric( + otelMetrics, + "cex_watch_frames_received_total", + labels, + ); + + if (!isMarketArchiveEnabled() || !archiver?.isEnabled()) { + return; + } + + queueMicrotask(() => { + try { + const candidates = tracker.process( + input.payload, + input.receivedTimestamp, + ); + for (const candidate of candidates) { + const row = buildCandleRow({ + context: input, + bar: candidate.bar, + isClosed: candidate.isClosed, + brokerVersion: candidate.brokerVersion, + receivedTimestamp: input.receivedTimestamp, + }); + archiver.enqueue(row); + } + if (candidates.length > 0) { + void recordWatchMetric( + otelMetrics, + "cex_watch_frames_archived_total", + labels, + ); + } + } catch (error) { + rethrowArchiveDurabilityError(error); + log.warn("Failed to archive OHLCV candle", { error }); + } + }); +} + +export function createOrderbookSampler(): OrderbookSampler { + return new OrderbookSampler(); +} + +/** @deprecated Use createOrderbookSampler */ +export const createOrderbookTobSampler = createOrderbookSampler; + +export function createOhlcvBarTracker(): OhlcvBarTracker { + return new OhlcvBarTracker(); +} + +function archiveMarketRowsInBackground( + archiver: BrokerExecutionArchiver | undefined, + otelMetrics: OtelMetrics | undefined, + stream: WatchStream, + input: { exchange: string; symbol: string }, + enqueueRows: () => BrokerArchiveRow[], +): void { + const labels = watchLabels(stream, input); + void recordWatchMetric( + otelMetrics, + "cex_watch_frames_received_total", + labels, + ); + + if (!isMarketArchiveEnabled() || !archiver?.isEnabled()) { + return; + } + + queueMicrotask(() => { + try { + const rows = enqueueRows(); + for (const row of rows) { + archiver.enqueue(row); + } + if (rows.length > 0) { + void recordWatchMetric( + otelMetrics, + "cex_watch_frames_archived_total", + labels, + ); + } + } catch (error) { + rethrowArchiveDurabilityError(error); + log.warn(`Failed to archive ${stream} market data`, { error }); + } + }); +} + +export function archiveTradesInBackground( + archiver: BrokerExecutionArchiver | undefined, + otelMetrics: OtelMetrics | undefined, + input: TradesArchiveInput, +): void { + archiveMarketRowsInBackground(archiver, otelMetrics, "trades", input, () => + extractTrades(input.payload, input.receivedTimestamp).map((trade) => + buildCexTradeRow(input, trade), + ), + ); +} + +export function archiveTickerInBackground( + archiver: BrokerExecutionArchiver | undefined, + otelMetrics: OtelMetrics | undefined, + input: TickerArchiveInput, +): void { + archiveMarketRowsInBackground(archiver, otelMetrics, "ticker", input, () => { + const ticker = parseTicker(input.payload, input.receivedTimestamp); + return ticker ? [buildCexTickerEventRow(input, ticker)] : []; + }); +} + +export function archiveCexStreamEventInBackground( + archiver: BrokerExecutionArchiver | undefined, + otelMetrics: OtelMetrics | undefined, + input: CexStreamArchiveInput, +): void { + archiveMarketRowsInBackground(archiver, otelMetrics, "stream", input, () => [ + buildCexStreamEventRow(input), + ]); +} diff --git a/src/helpers/market-data-archive/index.ts b/src/helpers/market-data-archive/index.ts new file mode 100644 index 0000000..dad0dea --- /dev/null +++ b/src/helpers/market-data-archive/index.ts @@ -0,0 +1,54 @@ +export { + archiveCexStreamEventInBackground, + archiveOhlcvInBackground, + archiveOrderbookInBackground, + archiveOrderbookSnapshotInBackground, + archiveOrderbookTobInBackground, + archiveTickerInBackground, + archiveTradesInBackground, + createOhlcvBarTracker, + createOrderbookSampler, + createOrderbookTobSampler, +} from "./capture"; +export { + extractLatestOhlcvBar, + extractOhlcvBars, + OhlcvBarTracker, + parseOhlcvBar, +} from "./ohlcv-bar-tracker"; +export { resolveOhlcvBootstrapLimit } from "./ohlcv-bootstrap"; +export { bootstrapOhlcvHistory } from "./ohlcv-history"; +export { + getOrderbookArchiveDepthLimit, + splitOrderBookSide, +} from "./orderbook-depth"; +export { + getOrderbookIntervalMs, + getOrderbookTobIntervalMs, + isMarketArchiveEnabled, + OrderbookSampler, + OrderbookTobSampler, +} from "./orderbook-sampler"; +export { extractTrades, parseTicker, parseTrade } from "./parse-stream"; +export { + buildCandleRow, + buildCexStreamEventRow, + buildCexTickerEventRow, + buildCexTradeRow, + buildOrderbookDepthRow, + buildOrderbookSnapshotRow, + buildOrderbookTobRow, +} from "./rows"; +export type { + CexStreamArchiveInput, + CexStreamType, + MarketArchiveContext, + OhlcvArchiveCandidate, + OhlcvArchiveInput, + OrderbookArchiveInput, + OrderbookSnapshotArchiveInput, + OrderbookTobArchiveInput, + ParsedOhlcvBar, + TickerArchiveInput, + TradesArchiveInput, +} from "./types"; diff --git a/src/helpers/market-data-archive/ohlcv-bar-tracker.ts b/src/helpers/market-data-archive/ohlcv-bar-tracker.ts new file mode 100644 index 0000000..d7a49bb --- /dev/null +++ b/src/helpers/market-data-archive/ohlcv-bar-tracker.ts @@ -0,0 +1,169 @@ +import type { OhlcvArchiveCandidate, ParsedOhlcvBar } from "./types"; + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +export function parseOhlcvBar(value: unknown): ParsedOhlcvBar | null { + if (!Array.isArray(value) || value.length < 6) { + return null; + } + const [openTimeMs, open, high, low, close, volume, quoteVolume] = value; + if ( + !isFiniteNumber(openTimeMs) || + !isFiniteNumber(open) || + !isFiniteNumber(high) || + !isFiniteNumber(low) || + !isFiniteNumber(close) || + !isFiniteNumber(volume) + ) { + return null; + } + const bar: ParsedOhlcvBar = { + openTimeMs, + open, + high, + low, + close, + volume, + }; + if (isFiniteNumber(quoteVolume)) { + bar.quoteVolume = quoteVolume; + } + return bar; +} + +export function extractOhlcvBars(payload: unknown): ParsedOhlcvBar[] { + if (!Array.isArray(payload) || payload.length === 0) { + return []; + } + + const rawBars = Array.isArray(payload[0]) ? payload : [payload]; + const byOpenTime = new Map(); + for (const entry of rawBars) { + const bar = parseOhlcvBar(entry); + if (bar) { + byOpenTime.set(bar.openTimeMs, bar); + } + } + + return [...byOpenTime.values()].sort((a, b) => a.openTimeMs - b.openTimeMs); +} + +export function extractLatestOhlcvBar(payload: unknown): ParsedOhlcvBar | null { + const bars = extractOhlcvBars(payload); + return bars[bars.length - 1] ?? null; +} + +export class OhlcvBarTracker { + private lastOpenTimeMs: number | null = null; + private lastBar: ParsedOhlcvBar | null = null; + + process(payload: unknown, brokerVersion: number): OhlcvArchiveCandidate[] { + const bars = extractOhlcvBars(payload); + if (bars.length === 0) { + return []; + } + if (bars.length === 1) { + const [bar] = bars; + return bar ? this.processSingleBar(bar, brokerVersion) : []; + } + return this.processBatch(bars, brokerVersion); + } + + private processSingleBar( + currentBar: ParsedOhlcvBar, + brokerVersion: number, + ): OhlcvArchiveCandidate[] { + if ( + this.lastOpenTimeMs !== null && + currentBar.openTimeMs < this.lastOpenTimeMs + ) { + return []; + } + + const candidates: OhlcvArchiveCandidate[] = []; + + if ( + this.lastOpenTimeMs !== null && + this.lastBar !== null && + currentBar.openTimeMs !== this.lastOpenTimeMs + ) { + candidates.push({ + bar: this.lastBar, + isClosed: true, + brokerVersion, + }); + } + + candidates.push({ + bar: currentBar, + isClosed: false, + brokerVersion, + }); + + this.lastOpenTimeMs = currentBar.openTimeMs; + this.lastBar = currentBar; + return candidates; + } + + private processBatch( + bars: ParsedOhlcvBar[], + brokerVersion: number, + ): OhlcvArchiveCandidate[] { + const firstBar = bars[0]; + const lastBar = bars[bars.length - 1]; + if (!firstBar || !lastBar) { + return []; + } + const lastOpenTimeMs = this.lastOpenTimeMs; + + if (lastOpenTimeMs !== null && lastBar.openTimeMs < lastOpenTimeMs) { + return []; + } + const barsToProcess = + lastOpenTimeMs === null + ? bars + : bars.filter((bar) => bar.openTimeMs >= lastOpenTimeMs); + const firstBarToProcess = barsToProcess[0]; + const lastBarToProcess = barsToProcess[barsToProcess.length - 1]; + if (!firstBarToProcess || !lastBarToProcess) { + return []; + } + + const candidates: OhlcvArchiveCandidate[] = []; + + if ( + this.lastBar !== null && + lastOpenTimeMs !== null && + lastOpenTimeMs < firstBarToProcess.openTimeMs + ) { + candidates.push({ + bar: this.lastBar, + isClosed: true, + brokerVersion, + }); + } + + for (let index = 0; index < barsToProcess.length - 1; index += 1) { + const bar = barsToProcess[index]; + if (bar) { + candidates.push({ + bar, + isClosed: true, + brokerVersion, + }); + } + } + + candidates.push({ + bar: lastBarToProcess, + isClosed: false, + brokerVersion, + }); + + this.lastOpenTimeMs = lastBarToProcess.openTimeMs; + this.lastBar = lastBarToProcess; + return candidates; + } +} diff --git a/src/helpers/market-data-archive/ohlcv-bootstrap.ts b/src/helpers/market-data-archive/ohlcv-bootstrap.ts new file mode 100644 index 0000000..5af6d17 --- /dev/null +++ b/src/helpers/market-data-archive/ohlcv-bootstrap.ts @@ -0,0 +1,21 @@ +const DEFAULT_OHLCV_BOOTSTRAP_LIMIT = 100; +const MAX_OHLCV_BOOTSTRAP_LIMIT = 1_000; + +export function resolveOhlcvBootstrapLimit( + optionValue: string | undefined, +): number { + const raw = + optionValue?.trim() || + process.env.CEX_BROKER_OHLCV_ARCHIVE_BOOTSTRAP_LIMIT?.trim(); + if (raw === "0" || raw?.toLowerCase() === "false") { + return 0; + } + if (!raw) { + return DEFAULT_OHLCV_BOOTSTRAP_LIMIT; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_OHLCV_BOOTSTRAP_LIMIT; + } + return Math.min(parsed, MAX_OHLCV_BOOTSTRAP_LIMIT); +} diff --git a/src/helpers/market-data-archive/ohlcv-history.ts b/src/helpers/market-data-archive/ohlcv-history.ts new file mode 100644 index 0000000..0e495a6 --- /dev/null +++ b/src/helpers/market-data-archive/ohlcv-history.ts @@ -0,0 +1,60 @@ +import type { Exchange } from "@usherlabs/ccxt"; +import type { BrokerExecutionArchiver } from "../broker-execution-archive/writer"; +import { log } from "../logger"; +import type { OtelMetrics } from "../otel"; +import { archiveOhlcvInBackground } from "./capture"; +import type { OhlcvBarTracker } from "./ohlcv-bar-tracker"; +import { resolveOhlcvBootstrapLimit } from "./ohlcv-bootstrap"; +import type { OhlcvArchiveInput } from "./types"; + +function supportsFetchOhlcv(broker: Exchange): boolean { + const fetchOHLCV = (broker as unknown as { fetchOHLCV?: unknown }).fetchOHLCV; + const hasValue = (broker.has as Record | undefined) + ?.fetchOHLCV; + return typeof fetchOHLCV === "function" && hasValue !== false; +} + +export async function bootstrapOhlcvHistory( + broker: Exchange, + archiver: BrokerExecutionArchiver | undefined, + otelMetrics: OtelMetrics | undefined, + tracker: OhlcvBarTracker, + input: OhlcvArchiveInput, + options?: { + bootstrapLimit?: string; + }, +): Promise { + const limit = resolveOhlcvBootstrapLimit(options?.bootstrapLimit); + if (limit <= 0 || !supportsFetchOhlcv(broker)) { + return null; + } + + try { + const fetchOHLCV = broker.fetchOHLCV.bind(broker); + const payload = await fetchOHLCV( + input.symbol, + input.timeframe ?? "1m", + undefined, + limit, + ); + if (!Array.isArray(payload) || payload.length === 0) { + return null; + } + + const receivedTimestamp = Date.now(); + archiveOhlcvInBackground(archiver, otelMetrics, tracker, { + ...input, + payload, + receivedTimestamp, + }); + return payload; + } catch (error) { + log.warn("OHLCV bootstrap fetch failed", { + error, + symbol: input.symbol, + exchange: input.exchange, + timeframe: input.timeframe, + }); + return null; + } +} diff --git a/src/helpers/market-data-archive/orderbook-depth.ts b/src/helpers/market-data-archive/orderbook-depth.ts new file mode 100644 index 0000000..209332e --- /dev/null +++ b/src/helpers/market-data-archive/orderbook-depth.ts @@ -0,0 +1,40 @@ +const DEFAULT_ORDERBOOK_ARCHIVE_DEPTH_LIMIT = 25; +const MAX_ORDERBOOK_ARCHIVE_DEPTH_LIMIT = 500; + +export function getOrderbookArchiveDepthLimit(): number { + const raw = process.env.CEX_BROKER_ORDERBOOK_ARCHIVE_DEPTH_LIMIT; + if (!raw) { + return DEFAULT_ORDERBOOK_ARCHIVE_DEPTH_LIMIT; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_ORDERBOOK_ARCHIVE_DEPTH_LIMIT; + } + return Math.min(parsed, MAX_ORDERBOOK_ARCHIVE_DEPTH_LIMIT); +} + +export function splitOrderBookSide( + levels: number[][], + limit: number, +): { prices: number[]; sizes: number[] } { + const prices: number[] = []; + const sizes: number[] = []; + for (const level of levels.slice(0, limit)) { + if (!Array.isArray(level) || level.length < 2) { + continue; + } + const price = level[0]; + const size = level[1]; + if ( + price === undefined || + size === undefined || + !Number.isFinite(price) || + !Number.isFinite(size) + ) { + continue; + } + prices.push(price); + sizes.push(size); + } + return { prices, sizes }; +} diff --git a/src/helpers/market-data-archive/orderbook-sampler.ts b/src/helpers/market-data-archive/orderbook-sampler.ts new file mode 100644 index 0000000..c206b96 --- /dev/null +++ b/src/helpers/market-data-archive/orderbook-sampler.ts @@ -0,0 +1,42 @@ +const DEFAULT_ORDERBOOK_INTERVAL_MS = 1_000; + +export function getOrderbookIntervalMs(): number { + const raw = + process.env.CEX_BROKER_ORDERBOOK_INTERVAL_MS ?? + process.env.CEX_BROKER_ORDERBOOK_TOB_INTERVAL_MS; + if (!raw) { + return DEFAULT_ORDERBOOK_INTERVAL_MS; + } + const parsed = Number.parseInt(raw, 10); + return Number.isFinite(parsed) && parsed > 0 + ? parsed + : DEFAULT_ORDERBOOK_INTERVAL_MS; +} + +export function isMarketArchiveEnabled(): boolean { + return process.env.CEX_BROKER_MARKET_ARCHIVE_ENABLED !== "false"; +} + +export class OrderbookSampler { + private lastEmitMs: number | null = null; + + constructor(private readonly intervalMs = getOrderbookIntervalMs()) {} + + shouldEmit(nowMs: number = Date.now()): boolean { + if (this.lastEmitMs !== null && nowMs < this.lastEmitMs) { + this.lastEmitMs = nowMs; + return true; + } + if (this.lastEmitMs !== null && nowMs - this.lastEmitMs < this.intervalMs) { + return false; + } + this.lastEmitMs = nowMs; + return true; + } +} + +/** @deprecated Use getOrderbookIntervalMs */ +export const getOrderbookTobIntervalMs = getOrderbookIntervalMs; + +/** @deprecated Use OrderbookSampler */ +export const OrderbookTobSampler = OrderbookSampler; diff --git a/src/helpers/market-data-archive/parse-stream.ts b/src/helpers/market-data-archive/parse-stream.ts new file mode 100644 index 0000000..7a620c2 --- /dev/null +++ b/src/helpers/market-data-archive/parse-stream.ts @@ -0,0 +1,149 @@ +import { asRecord } from "../shared/guards"; + +function isFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value); +} + +function toNumber(value: unknown): number | undefined { + if (isFiniteNumber(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return undefined; +} + +function toStringId(value: unknown): string | undefined { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + return undefined; +} + +function scalarTimestampMs(value: unknown, fallbackMs: number): number { + const numeric = toNumber(value); + if (numeric !== undefined) { + return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric; + } + if (typeof value === "string") { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return fallbackMs; +} + +export type ParsedTrade = { + tradeId: string; + eventTimeMs: number; + side: string; + price: number; + amount: number; + cost?: number; + takerOrMaker?: string; +}; + +export type ParsedTicker = { + eventTimeMs: number; + last?: number; + bid?: number; + ask?: number; + high?: number; + low?: number; + open?: number; + close?: number; + baseVolume?: number; + quoteVolume?: number; + change?: number; + percentage?: number; +}; + +export function parseTrade( + value: unknown, + fallbackMs: number = Date.now(), +): ParsedTrade | null { + const record = asRecord(value); + if (!record) { + return null; + } + + const tradeId = toStringId(record.id); + const price = toNumber(record.price); + const amount = toNumber(record.amount); + const side = + typeof record.side === "string" ? record.side.toLowerCase() : undefined; + if (!tradeId || price === undefined || amount === undefined || !side) { + return null; + } + + const parsed: ParsedTrade = { + tradeId, + eventTimeMs: scalarTimestampMs(record.timestamp, fallbackMs), + side, + price, + amount, + }; + const cost = toNumber(record.cost); + if (cost !== undefined) { + parsed.cost = cost; + } + if (typeof record.takerOrMaker === "string") { + parsed.takerOrMaker = record.takerOrMaker; + } + return parsed; +} + +export function extractTrades( + payload: unknown, + fallbackMs: number = Date.now(), +): ParsedTrade[] { + if (Array.isArray(payload)) { + return payload + .map((entry) => parseTrade(entry, fallbackMs)) + .filter((entry): entry is ParsedTrade => entry !== null); + } + const single = parseTrade(payload, fallbackMs); + return single ? [single] : []; +} + +export function parseTicker( + value: unknown, + fallbackMs: number, +): ParsedTicker | null { + const record = asRecord(value); + if (!record) { + return null; + } + + const parsed: ParsedTicker = { + eventTimeMs: scalarTimestampMs(record.timestamp, fallbackMs), + }; + const fields: Array<[keyof ParsedTicker, unknown]> = [ + ["last", record.last], + ["bid", record.bid], + ["ask", record.ask], + ["high", record.high], + ["low", record.low], + ["open", record.open], + ["close", record.close], + ["baseVolume", record.baseVolume], + ["quoteVolume", record.quoteVolume], + ["change", record.change], + ["percentage", record.percentage], + ]; + for (const [key, rawValue] of fields) { + const numeric = toNumber(rawValue); + if (numeric !== undefined) { + parsed[key] = numeric; + } + } + return parsed; +} diff --git a/src/helpers/market-data-archive/rows.ts b/src/helpers/market-data-archive/rows.ts new file mode 100644 index 0000000..0c760c1 --- /dev/null +++ b/src/helpers/market-data-archive/rows.ts @@ -0,0 +1,279 @@ +import { redactStreamPayload } from "../broker-execution-archive/redact"; +import { buildCommonArchiveTags } from "../broker-execution-archive/rows"; +import type { BrokerArchiveRow } from "../broker-execution-archive/types"; +import type { NormalizedOrderBookSnapshot } from "../order-book"; +import { + getOrderbookArchiveDepthLimit, + splitOrderBookSide, +} from "./orderbook-depth"; +import type { ParsedTicker, ParsedTrade } from "./parse-stream"; +import type { + CexStreamArchiveInput, + MarketArchiveContext, + OrderbookArchiveInput, + ParsedOhlcvBar, + TickerArchiveInput, + TradesArchiveInput, +} from "./types"; + +function compactUndefined( + record: Record, +): Record { + return Object.fromEntries( + Object.entries(record).filter(([, value]) => value !== undefined), + ); +} + +function scalarTimestampMs(value: number | string | boolean | null): number { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + if (/^\d+$/.test(value)) { + const numeric = Number.parseInt(value, 10); + if (Number.isFinite(numeric)) { + return numeric; + } + } + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return Date.now(); +} + +function parseSequence( + value: number | string | boolean | undefined, +): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string" && /^\d+$/.test(value)) { + return Number.parseInt(value, 10); + } + return undefined; +} + +function topOfBookLevel( + levels: number[][], +): { price: number; size: number } | null { + const level = levels[0]; + if (!level || level.length < 2) { + return null; + } + const price = level[0]; + const size = level[1]; + if ( + price === undefined || + size === undefined || + !Number.isFinite(price) || + !Number.isFinite(size) + ) { + return null; + } + return { price, size }; +} + +function computeSpreadBps(bestBid: number, bestAsk: number): number { + if (bestBid <= 0 || bestAsk <= 0 || bestAsk < bestBid) { + return 0; + } + const mid = (bestBid + bestAsk) / 2; + if (mid <= 0) { + return 0; + } + return ((bestAsk - bestBid) / mid) * 10_000; +} + +function buildOrderbookArchiveTags( + input: OrderbookArchiveInput, + receivedTimeMs: number, +) { + return buildCommonArchiveTags({ + deploymentId: input.deploymentId, + accountSelector: input.accountSelector, + exchange: input.exchange, + symbol: input.symbol, + brokerObservedTimestamp: new Date(receivedTimeMs).toISOString(), + }); +} + +export function buildOrderbookSnapshotRow( + input: OrderbookArchiveInput, +): BrokerArchiveRow | null { + const bid = topOfBookLevel(input.snapshot.bids); + const ask = topOfBookLevel(input.snapshot.asks); + if (!bid || !ask) { + return null; + } + + const archiveDepthLimit = getOrderbookArchiveDepthLimit(); + const bids = splitOrderBookSide(input.snapshot.bids, archiveDepthLimit); + const asks = splitOrderBookSide(input.snapshot.asks, archiveDepthLimit); + if (bids.prices.length === 0 || asks.prices.length === 0) { + return null; + } + + const eventTimeMs = scalarTimestampMs(input.snapshot.timestamp); + const receivedTimeMs = input.snapshot.receivedTimestamp; + const mid = (bid.price + ask.price) / 2; + const sequence = parseSequence(input.snapshot.sequence); + + return { + table: "market_data.orderbook_snapshots", + row: compactUndefined({ + ...buildOrderbookArchiveTags(input, receivedTimeMs), + asset_type: input.assetType, + event_time_ms: eventTimeMs, + received_time_ms: receivedTimeMs, + best_bid: bid.price, + best_ask: ask.price, + bid_size: bid.size, + ask_size: ask.size, + mid, + spread_bps: computeSpreadBps(bid.price, ask.price), + depth_limit: archiveDepthLimit, + bid_levels: bids.prices.length, + ask_levels: asks.prices.length, + bids_price: bids.prices, + bids_size: bids.sizes, + asks_price: asks.prices, + asks_size: asks.sizes, + sequence, + }), + }; +} + +/** @deprecated Use buildOrderbookSnapshotRow */ +export const buildOrderbookTobRow = buildOrderbookSnapshotRow; + +/** @deprecated Use buildOrderbookSnapshotRow */ +export const buildOrderbookDepthRow = buildOrderbookSnapshotRow; + +export function buildCandleRow(input: { + context: MarketArchiveContext; + bar: ParsedOhlcvBar; + isClosed: boolean; + brokerVersion: number; + receivedTimestamp: number; +}): BrokerArchiveRow { + const { context, bar, isClosed, brokerVersion, receivedTimestamp } = input; + const tags = buildCommonArchiveTags({ + deploymentId: context.deploymentId, + accountSelector: context.accountSelector, + exchange: context.exchange, + symbol: context.symbol, + brokerObservedTimestamp: new Date(receivedTimestamp).toISOString(), + }); + + return { + table: "market_data.candles", + row: compactUndefined({ + ...tags, + asset_type: context.assetType, + timeframe: context.timeframe ?? "1m", + open_time_ms: bar.openTimeMs, + open: bar.open, + high: bar.high, + low: bar.low, + close: bar.close, + volume: bar.volume, + quote_volume: bar.quoteVolume, + is_closed: isClosed ? 1 : 0, + broker_version: brokerVersion, + }), + }; +} + +export function buildCexStreamEventRow( + input: CexStreamArchiveInput, +): BrokerArchiveRow { + const receivedTimeMs = input.receivedTimestamp; + const redactedPayload = redactStreamPayload(input.payload); + const tags = buildCommonArchiveTags({ + deploymentId: input.deploymentId, + accountSelector: input.accountSelector, + exchange: input.exchange, + symbol: input.symbol, + brokerObservedTimestamp: new Date(receivedTimeMs).toISOString(), + }); + + return { + table: "market_data.cex_stream_events", + row: compactUndefined({ + ...tags, + asset_type: input.assetType, + stream_type: input.streamType, + event_time_ms: input.eventTimeMs ?? receivedTimeMs, + received_time_ms: receivedTimeMs, + payload_json: JSON.stringify(redactedPayload), + }), + }; +} + +export function buildCexTickerEventRow( + input: TickerArchiveInput, + ticker: ParsedTicker, +): BrokerArchiveRow { + const receivedTimeMs = input.receivedTimestamp; + const tags = buildCommonArchiveTags({ + deploymentId: input.deploymentId, + accountSelector: input.accountSelector, + exchange: input.exchange, + symbol: input.symbol, + brokerObservedTimestamp: new Date(receivedTimeMs).toISOString(), + }); + + return { + table: "market_data.cex_ticker_events", + row: compactUndefined({ + ...tags, + asset_type: input.assetType, + event_time_ms: ticker.eventTimeMs, + received_time_ms: receivedTimeMs, + last: ticker.last, + bid: ticker.bid, + ask: ticker.ask, + high: ticker.high, + low: ticker.low, + open: ticker.open, + close: ticker.close, + base_volume: ticker.baseVolume, + quote_volume: ticker.quoteVolume, + change: ticker.change, + percentage: ticker.percentage, + payload_json: JSON.stringify(redactStreamPayload(input.payload)), + }), + }; +} + +export function buildCexTradeRow( + input: TradesArchiveInput, + trade: ParsedTrade, +): BrokerArchiveRow { + const receivedTimeMs = input.receivedTimestamp; + const tags = buildCommonArchiveTags({ + deploymentId: input.deploymentId, + accountSelector: input.accountSelector, + exchange: input.exchange, + symbol: input.symbol, + brokerObservedTimestamp: new Date(receivedTimeMs).toISOString(), + }); + + return { + table: "market_data.cex_trades", + row: compactUndefined({ + ...tags, + asset_type: input.assetType, + trade_id: trade.tradeId, + event_time_ms: trade.eventTimeMs, + received_time_ms: receivedTimeMs, + side: trade.side, + price: trade.price, + amount: trade.amount, + cost: trade.cost, + taker_or_maker: trade.takerOrMaker, + }), + }; +} diff --git a/src/helpers/market-data-archive/types.ts b/src/helpers/market-data-archive/types.ts new file mode 100644 index 0000000..f57d541 --- /dev/null +++ b/src/helpers/market-data-archive/types.ts @@ -0,0 +1,92 @@ +import type { BrokerArchiveRow } from "../broker-execution-archive/types"; +import type { BrokerMarketType } from "../market-type"; +import type { NormalizedOrderBookSnapshot } from "../order-book"; + +export type MarketArchiveTable = + | "market_data.orderbook_snapshots" + | "market_data.candles" + | "market_data.cex_stream_events" + | "market_data.cex_ticker_events" + | "market_data.cex_trades"; + +export type CexStreamType = + | "BALANCE" + | "ORDERS" + | "ORDERBOOK" + | "TRADES" + | "TICKER" + | "OHLCV"; + +export type ParsedOhlcvBar = { + openTimeMs: number; + open: number; + high: number; + low: number; + close: number; + volume: number; + quoteVolume?: number; +}; + +export type OhlcvArchiveCandidate = { + bar: ParsedOhlcvBar; + isClosed: boolean; + brokerVersion: number; +}; + +export type MarketArchiveContext = { + exchange: string; + symbol: string; + assetType: BrokerMarketType; + timeframe?: string; + accountSelector?: string; + deploymentId: string; +}; + +export type OrderbookArchiveInput = MarketArchiveContext & { + snapshot: NormalizedOrderBookSnapshot; +}; + +/** @deprecated Use OrderbookArchiveInput */ +export type OrderbookSnapshotArchiveInput = OrderbookArchiveInput; + +/** @deprecated Use OrderbookArchiveInput */ +export type OrderbookTobArchiveInput = OrderbookArchiveInput; + +export type OhlcvArchiveInput = MarketArchiveContext & { + payload: unknown; + receivedTimestamp: number; + timeframe: string; +}; + +export type TradesArchiveInput = MarketArchiveContext & { + payload: unknown; + receivedTimestamp: number; +}; + +export type TickerArchiveInput = MarketArchiveContext & { + payload: unknown; + receivedTimestamp: number; +}; + +export type CexStreamArchiveInput = MarketArchiveContext & { + streamType: CexStreamType; + payload: unknown; + receivedTimestamp: number; + eventTimeMs?: number; +}; + +const MARKET_ARCHIVE_TABLES = new Set([ + "market_data.orderbook_snapshots", + "market_data.candles", + "market_data.cex_stream_events", + "market_data.cex_ticker_events", + "market_data.cex_trades", +]); + +export function isMarketArchiveTable( + table: string, +): table is MarketArchiveTable { + return MARKET_ARCHIVE_TABLES.has(table); +} + +export type { BrokerArchiveRow }; diff --git a/src/helpers/market-type.ts b/src/helpers/market-type.ts new file mode 100644 index 0000000..1aaf8e8 --- /dev/null +++ b/src/helpers/market-type.ts @@ -0,0 +1,180 @@ +import type { Exchange } from "@usherlabs/ccxt"; + +export type BrokerMarketType = "spot" | "swap" | "future"; + +export type TradableSymbolMatch = { + symbol: string; + side: "buy" | "sell"; + marketType: BrokerMarketType; +}; + +type CcxtMarket = { + symbol?: string; + base?: string; + quote?: string; + type?: string; + spot?: boolean; + swap?: boolean; + future?: boolean; +}; + +export function parseMarketType(value: unknown): BrokerMarketType { + if (typeof value !== "string") { + return "spot"; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "swap" || normalized === "perp") { + return "swap"; + } + if (normalized === "future" || normalized === "futures") { + return "future"; + } + return "spot"; +} + +export function marketTypeToCcxtType( + marketType: BrokerMarketType, +): "spot" | "swap" | "future" { + return marketType; +} + +function marketMatchesType( + market: CcxtMarket, + marketType: BrokerMarketType, +): boolean { + if (marketType === "spot") { + return market.spot === true || market.type === "spot"; + } + if (marketType === "swap") { + return market.swap === true || market.type === "swap"; + } + return market.future === true || market.type === "future"; +} + +function findMarketByPair( + markets: Record, + fromToken: string, + toToken: string, + marketType: BrokerMarketType, + preferSide: "sell" | "buy", +): TradableSymbolMatch | null { + const directPair = `${fromToken}/${toToken}`; + const reversePair = `${toToken}/${fromToken}`; + const orderedPairs = + preferSide === "sell" + ? [directPair, reversePair] + : [reversePair, directPair]; + + for (const pair of orderedPairs) { + for (const market of Object.values(markets)) { + if (!market?.symbol) { + continue; + } + const marketBase = market.base?.toUpperCase(); + const marketQuote = market.quote?.toUpperCase(); + const [pairBase, pairQuote] = pair.split("/"); + if (marketBase !== pairBase || marketQuote !== pairQuote) { + continue; + } + if (!marketMatchesType(market, marketType)) { + continue; + } + return { + symbol: market.symbol, + side: pair === directPair ? "sell" : "buy", + marketType, + }; + } + } + + return null; +} + +export async function findTradableSymbol( + broker: Exchange, + fromToken: string, + toToken: string, + marketType: BrokerMarketType = "spot", +): Promise { + const fromUpper = fromToken.trim().toUpperCase(); + const toUpper = toToken.trim().toUpperCase(); + await broker.loadMarkets(); + const markets = ( + broker as Exchange & { markets?: Record } + ).markets; + if (!markets || typeof markets !== "object") { + return null; + } + + const direct = findMarketByPair( + markets, + fromUpper, + toUpper, + marketType, + "sell", + ); + if (direct) { + return direct; + } + + return findMarketByPair(markets, fromUpper, toUpper, marketType, "buy"); +} + +export async function resolveSubscriptionSymbol( + broker: Exchange, + symbol: string, + marketTypeInput: unknown, +): Promise { + const trimmed = symbol.trim(); + if (trimmed.includes(":")) { + return trimmed; + } + if (!trimmed.includes("/")) { + return trimmed; + } + + const marketType = parseMarketType(marketTypeInput); + if (marketType === "spot") { + return trimmed; + } + + const [base, quote] = trimmed.split("/"); + if (!base || !quote) { + return trimmed; + } + + const match = await findTradableSymbol(broker, base, quote, marketType); + return match?.symbol ?? trimmed; +} + +export type ParsedMarketPattern = { + symbolPattern: string; + requiredMarketType?: BrokerMarketType; +}; + +export function parseMarketPattern(symbolPattern: string): ParsedMarketPattern { + const atIndex = symbolPattern.lastIndexOf("@"); + if (atIndex <= 0) { + return { symbolPattern }; + } + + const basePattern = symbolPattern.slice(0, atIndex); + const suffix = symbolPattern + .slice(atIndex + 1) + .trim() + .toLowerCase(); + if ( + suffix === "spot" || + suffix === "swap" || + suffix === "perp" || + suffix === "future" || + suffix === "futures" + ) { + return { + symbolPattern: basePattern, + requiredMarketType: parseMarketType(suffix), + }; + } + + return { symbolPattern }; +} diff --git a/src/helpers/order-activity-tracker.ts b/src/helpers/order-activity-tracker.ts new file mode 100644 index 0000000..8ca273d --- /dev/null +++ b/src/helpers/order-activity-tracker.ts @@ -0,0 +1,59 @@ +export type OrderActivityEntry = { + exchangeId: string; + accountLabel: string; + symbol: string; + lastActivityAt: number; +}; + +// Entries not touched within this window are dropped from the poll set: their +// trades have long since been archived and re-polling them wastes venue rate limit. +const DEFAULT_MAX_AGE_MS = 6 * 60 * 60 * 1000; + +/** + * Records the (exchange, account, symbol) tuples that saw order activity through + * the execute-action path, so the fill poller scans only those markets instead of + * the whole exchange. Process-lifetime, in-memory only (no persistence needed: on + * restart the poller re-derives the set from fresh order activity and read-time + * dedup absorbs the lookback re-scan). + */ +export class OrderActivityTracker { + readonly #entries = new Map(); + readonly #maxAgeMs: number; + + constructor(options?: { maxAgeMs?: number }) { + this.#maxAgeMs = options?.maxAgeMs ?? DEFAULT_MAX_AGE_MS; + } + + record( + exchangeId: string, + accountLabel: string, + symbol: string, + now: number = Date.now(), + ): void { + const exchange = exchangeId.trim().toLowerCase(); + const trimmedSymbol = symbol.trim(); + if (!exchange || !accountLabel.trim() || !trimmedSymbol) { + return; + } + const key = `${exchange}|${accountLabel}|${trimmedSymbol}`; + this.#entries.set(key, { + exchangeId: exchange, + accountLabel, + symbol: trimmedSymbol, + lastActivityAt: now, + }); + } + + /** Active entries (seen within maxAge). Prunes stale entries as a side effect. */ + list(now: number = Date.now()): OrderActivityEntry[] { + const active: OrderActivityEntry[] = []; + for (const [key, entry] of this.#entries) { + if (now - entry.lastActivityAt > this.#maxAgeMs) { + this.#entries.delete(key); + continue; + } + active.push(entry); + } + return active; + } +} diff --git a/src/helpers/order-book.ts b/src/helpers/order-book.ts new file mode 100644 index 0000000..654b21f --- /dev/null +++ b/src/helpers/order-book.ts @@ -0,0 +1,368 @@ +import type { Exchange } from "@usherlabs/ccxt"; +import { isRecord } from "./shared/guards"; + +export const ORDER_BOOK_CALL_METHODS = { + FETCH_CAPABILITY: "fetch_order_book_capability", + FETCH_SNAPSHOT: "fetch_order_book_snapshot", + FETCH_HISTORICAL_SNAPSHOTS: "fetch_historical_order_book_snapshots", +} as const; + +export type OrderBookCallMethod = + (typeof ORDER_BOOK_CALL_METHODS)[keyof typeof ORDER_BOOK_CALL_METHODS]; + +export const ORDER_BOOK_CONSTRUCTION_MODES = { + SAMPLED_TOP_N_SNAPSHOT: "sampled_top_n_snapshot", + EXACT_L2_RECONSTRUCTION: "exact_l2_reconstruction", +} as const; + +export type OrderBookConstructionMode = + (typeof ORDER_BOOK_CONSTRUCTION_MODES)[keyof typeof ORDER_BOOK_CONSTRUCTION_MODES]; + +export const HISTORICAL_ORDER_BOOK_PROVIDER_UNSUPPORTED = + "historical_order_book_provider_unsupported"; + +const ORDER_BOOK_METHOD_VALUES = new Set( + Object.values(ORDER_BOOK_CALL_METHODS), +); + +const ORDER_BOOK_CONSTRUCTION_MODE_VALUES = new Set( + Object.values(ORDER_BOOK_CONSTRUCTION_MODES), +); + +export type OrderBookCallPayload = { + method: OrderBookCallMethod; + exchange: string; + symbol: string; + depthLimit: number; + constructionMode: OrderBookConstructionMode; + start?: string; + end?: string; + cadence?: string; +}; + +export type OrderBookCallParseResult = + | { kind: "not_order_book" } + | { kind: "error"; message: string } + | { kind: "order_book"; payload: OrderBookCallPayload }; + +export type NormalizedOrderBookSnapshot = { + bids: number[][]; + asks: number[][]; + timestamp: number | string | boolean | null; + receivedTimestamp: number; + exchange: string; + symbol: string; + depthLimit: number; + sequence?: number | string | boolean; +}; + +type Scalar = string | number | boolean; + +function isScalar(value: unknown): value is Scalar { + return ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +} + +function nonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function payloadValue( + payload: Record | undefined, + ...aliases: string[] +): string | undefined { + for (const alias of aliases) { + const value = nonEmptyString(payload?.[alias]); + if (value !== undefined) { + return value; + } + } + return undefined; +} + +function parsePositiveInteger(value: string | undefined, field: string) { + if (value === undefined) { + return { + ok: false as const, + message: `ValidationError: ${field} is required`, + }; + } + if (!/^[1-9]\d*$/.test(value)) { + return { + ok: false as const, + message: `ValidationError: ${field} must be a positive integer`, + }; + } + return { ok: true as const, value: Number.parseInt(value, 10) }; +} + +function parseConstructionMode(value: string | undefined) { + const mode = value ?? ORDER_BOOK_CONSTRUCTION_MODES.SAMPLED_TOP_N_SNAPSHOT; + if (!ORDER_BOOK_CONSTRUCTION_MODE_VALUES.has(mode)) { + return { + ok: false as const, + message: `ValidationError: constructionMode must be ${Array.from( + ORDER_BOOK_CONSTRUCTION_MODE_VALUES, + ).join(" or ")}`, + }; + } + return { ok: true as const, value: mode as OrderBookConstructionMode }; +} + +function parseHistoricalTimestamp(value: string | undefined, field: string) { + if (value === undefined) { + return { + ok: false as const, + message: `ValidationError: ${field} is required`, + }; + } + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) { + return { + ok: false as const, + message: `ValidationError: ${field} must be an ISO timestamp`, + }; + } + return { ok: true as const, value, timestamp }; +} + +function parseCadence(value: string | undefined) { + if (value === undefined) { + return { + ok: false as const, + message: "ValidationError: cadence is required", + }; + } + if (!/^[1-9]\d*(ms|s|m|h)$/.test(value)) { + return { + ok: false as const, + message: + "ValidationError: cadence must be a positive duration such as 1s", + }; + } + return { ok: true as const, value }; +} + +export function isOrderBookCallMethod( + value: unknown, +): value is OrderBookCallMethod { + return typeof value === "string" && ORDER_BOOK_METHOD_VALUES.has(value); +} + +export function parseOrderBookCallPayload( + payload: Record | undefined, + request: { exchange?: string; symbol?: string }, +): OrderBookCallParseResult { + const method = payloadValue(payload, "method", "functionName"); + if (!isOrderBookCallMethod(method)) { + return { kind: "not_order_book" }; + } + + const exchange = nonEmptyString(request.exchange); + if (exchange === undefined) { + return { kind: "error", message: "ValidationError: cex is required" }; + } + const symbol = nonEmptyString(request.symbol); + if (symbol === undefined) { + return { kind: "error", message: "ValidationError: symbol is required" }; + } + + const parsedDepthLimit = parsePositiveInteger( + payloadValue(payload, "depthLimit", "depth_limit"), + "depthLimit", + ); + if (!parsedDepthLimit.ok) { + return { kind: "error", message: parsedDepthLimit.message }; + } + + const parsedConstructionMode = parseConstructionMode( + payloadValue(payload, "constructionMode", "construction_mode"), + ); + if (!parsedConstructionMode.ok) { + return { kind: "error", message: parsedConstructionMode.message }; + } + + const parsed: OrderBookCallPayload = { + method, + exchange, + symbol, + depthLimit: parsedDepthLimit.value, + constructionMode: parsedConstructionMode.value, + }; + + if (method !== ORDER_BOOK_CALL_METHODS.FETCH_HISTORICAL_SNAPSHOTS) { + return { kind: "order_book", payload: parsed }; + } + + const parsedStart = parseHistoricalTimestamp( + payloadValue(payload, "start"), + "start", + ); + if (!parsedStart.ok) { + return { kind: "error", message: parsedStart.message }; + } + const parsedEnd = parseHistoricalTimestamp( + payloadValue(payload, "end"), + "end", + ); + if (!parsedEnd.ok) { + return { kind: "error", message: parsedEnd.message }; + } + if (parsedStart.timestamp >= parsedEnd.timestamp) { + return { + kind: "error", + message: "ValidationError: start must be before end", + }; + } + const parsedCadence = parseCadence(payloadValue(payload, "cadence")); + if (!parsedCadence.ok) { + return { kind: "error", message: parsedCadence.message }; + } + + return { + kind: "order_book", + payload: { + ...parsed, + start: parsedStart.value, + end: parsedEnd.value, + cadence: parsedCadence.value, + }, + }; +} + +export function parseOptionalDepthLimit( + value: string | undefined, +): number | undefined { + const parsed = parsePositiveInteger(nonEmptyString(value), "depthLimit"); + return parsed.ok ? parsed.value : undefined; +} + +function scalarByAlias( + payload: Record, + aliases: string[], +): Scalar | undefined { + for (const alias of aliases) { + const value = payload[alias]; + if (isScalar(value)) { + return value; + } + } + return undefined; +} + +function normalizeSide( + payload: Record, + side: "bids" | "asks", + depthLimit: number, +) { + const rawLevels = payload[side]; + if (!Array.isArray(rawLevels)) { + throw new Error(`Malformed order book: ${side} must be an array`); + } + return rawLevels.slice(0, depthLimit).map((level, index) => { + if (!Array.isArray(level) || level.length < 2) { + throw new Error( + `Malformed order book: ${side}[${index}] must be [price, amount]`, + ); + } + const price = Number(level[0]); + const amount = Number(level[1]); + if (!Number.isFinite(price) || !Number.isFinite(amount)) { + throw new Error( + `Malformed order book: ${side}[${index}] must be numeric`, + ); + } + return [price, amount]; + }); +} + +export function normalizeOrderBookSnapshot( + orderBook: unknown, + options: { + exchange: string; + symbol: string; + depthLimit: number; + receivedTimestamp?: number; + }, +): NormalizedOrderBookSnapshot { + if (!isRecord(orderBook)) { + throw new Error("Malformed order book: expected object"); + } + + const receivedTimestamp = options.receivedTimestamp ?? Date.now(); + const timestamp = + scalarByAlias(orderBook, ["timestamp"]) ?? receivedTimestamp; + const sequence = scalarByAlias(orderBook, [ + "sequence", + "updateId", + "lastUpdateId", + "nonce", + ]); + const normalized: NormalizedOrderBookSnapshot = { + bids: normalizeSide(orderBook, "bids", options.depthLimit), + asks: normalizeSide(orderBook, "asks", options.depthLimit), + timestamp, + receivedTimestamp, + exchange: options.exchange, + symbol: options.symbol, + depthLimit: options.depthLimit, + }; + if (sequence !== undefined) { + normalized.sequence = sequence; + } + return normalized; +} + +function supportsBrokerMethod(broker: Exchange, method: string): boolean { + const fn = (broker as unknown as Record)[method]; + const hasValue = (broker.has as Record | undefined)?.[ + method + ]; + return typeof fn === "function" && hasValue !== false; +} + +export function buildOrderBookCapability( + broker: Exchange, + payload: Pick< + OrderBookCallPayload, + "exchange" | "symbol" | "depthLimit" | "constructionMode" + >, +) { + return { + exchange: payload.exchange, + symbol: payload.symbol, + provider: "ccxt_order_book", + maxDepth: payload.depthLimit, + timestampPrecision: "milliseconds", + constructionMode: payload.constructionMode, + supportsCurrentSnapshot: supportsBrokerMethod(broker, "fetchOrderBook"), + supportsLiveStream: supportsBrokerMethod(broker, "watchOrderBook"), + supportsHistoricalSnapshots: false, + supportsSampledTopN: false, + supportsExactL2Reconstruction: false, + }; +} + +export function buildHistoricalOrderBookUnsupported( + payload: OrderBookCallPayload, +) { + return { + exchange: payload.exchange, + symbol: payload.symbol, + provider: "ccxt_order_book", + constructionMode: payload.constructionMode, + depthLimit: payload.depthLimit, + start: payload.start, + end: payload.end, + cadence: payload.cadence, + unsupported: true, + unsupportedReason: HISTORICAL_ORDER_BOOK_PROVIDER_UNSUPPORTED, + }; +} diff --git a/src/helpers/order-telemetry.ts b/src/helpers/order-telemetry.ts new file mode 100644 index 0000000..f9c4f6a --- /dev/null +++ b/src/helpers/order-telemetry.ts @@ -0,0 +1,372 @@ +import { log } from "./logger"; +import type { OtelMetrics } from "./otel"; +import { REDACTED_ERROR_MESSAGE } from "./shared/errors"; +import { asRecord } from "./shared/guards"; + +type JsonRecord = Record; + +export type OrderTelemetryAction = + | "CreateOrder" + | "CancelOrder" + | "GetOrderDetails"; + +export type OrderTelemetryContext = { + action: OrderTelemetryAction; + cex: string; + accountLabel?: string; + symbol?: string; + side?: string; + orderType?: string; + requestedQuantity?: number; + requestedNotional?: number; + orderAuthor?: string; + clientOrderId?: string; + idempotencyId?: string; + makerActionId?: string; + brokerObservedTimestamp?: string; +}; + +export type OrderExecutionTelemetry = { + event: "cex_market_action_execution"; + action: OrderTelemetryAction; + cex: string; + accountLabel: string; + symbol: string; + side: string; + orderType: string; + orderId?: string; + orderAuthor?: string; + clientOrderId?: string; + idempotencyId?: string; + makerActionId?: string; + status: string; + requestedQuantity?: number; + requestedNotional?: number; + executedBaseQuantity?: number; + executedQuoteQuantity?: number; + averageExecutionPrice?: number; + filledAmount?: number; + remainingAmount?: number; + feeAmount?: number; + feeCurrency?: string; + feeRate?: number; + exchangeTimestamp?: string; + brokerObservedTimestamp: string; + errorType?: string; + errorMessage?: string; +}; + +type NumericTelemetryKey = + | "requestedQuantity" + | "requestedNotional" + | "executedBaseQuantity" + | "executedQuoteQuantity" + | "averageExecutionPrice" + | "filledAmount" + | "remainingAmount" + | "feeAmount" + | "feeRate"; + +const NUMERIC_METRICS: Array<[NumericTelemetryKey, string]> = [ + ["requestedQuantity", "cex_market_action_requested_quantity"], + ["requestedNotional", "cex_market_action_requested_notional"], + ["executedBaseQuantity", "cex_market_action_executed_base_quantity"], + ["executedQuoteQuantity", "cex_market_action_executed_quote_quantity"], + ["averageExecutionPrice", "cex_market_action_average_execution_price"], + ["filledAmount", "cex_market_action_filled_amount"], + ["remainingAmount", "cex_market_action_remaining_amount"], + ["feeAmount", "cex_market_action_fee_amount"], + ["feeRate", "cex_market_action_fee_rate"], +]; + +export async function emitOrderExecutionTelemetry( + otelMetrics: OtelMetrics | undefined, + context: OrderTelemetryContext, + order: unknown, + error?: unknown, +): Promise { + try { + const telemetry = buildOrderExecutionTelemetry(context, order, error); + log.info("CEX market action execution telemetry", telemetry); + + const labels = { + action: telemetry.action, + cex: telemetry.cex, + account: telemetry.accountLabel, + symbol: telemetry.symbol, + side: telemetry.side, + order_type: telemetry.orderType, + status: telemetry.status, + }; + + await otelMetrics?.recordCounter("cex_market_action_executions_total", 1, { + ...labels, + result: error ? "error" : "ok", + }); + + for (const [key, metricName] of NUMERIC_METRICS) { + const value = telemetry[key]; + if (typeof value === "number" && Number.isFinite(value)) { + await otelMetrics?.recordHistogram(metricName, value, labels); + } + } + + return telemetry; + } catch (telemetryError) { + try { + log.error("Failed to emit CEX order telemetry", { + error: telemetryError, + }); + } catch { + // Telemetry must never alter order execution behavior. + } + return undefined; + } +} + +export function buildOrderExecutionTelemetry( + context: OrderTelemetryContext, + order: unknown, + error?: unknown, +): OrderExecutionTelemetry { + const record = asRecord(order); + const info = asRecord(record?.info); + const fees = getFees(record, info); + const fee = summarizeFees(fees); + const executedBaseQuantity = + firstNumber(record?.filled, info?.executedQty, info?.cumExecQty) ?? + computeFilledFromAmount(record); + const executedQuoteQuantity = firstNumber( + record?.cost, + info?.cummulativeQuoteQty, + info?.cumQuote, + info?.cumExecValue, + ); + const averageExecutionPrice = + firstNumber(record?.average, info?.avgPrice) ?? + computeAveragePrice(executedBaseQuantity, executedQuoteQuantity); + const exchangeTimestamp = normalizeTimestamp( + firstValue( + record?.timestamp, + info?.time, + info?.transactTime, + record?.datetime, + ), + ); + const status = + firstString(record?.status, info?.status) ?? (error ? "failed" : "unknown"); + const errorRecord = error instanceof Error ? error : undefined; + + return compactUndefined({ + event: "cex_market_action_execution", + action: context.action, + cex: context.cex.trim().toLowerCase() || "unknown", + accountLabel: context.accountLabel ?? "unknown", + symbol: + firstString(record?.symbol, info?.symbol, context.symbol) ?? "unknown", + side: firstString(record?.side, info?.side, context.side) ?? "unknown", + orderType: + firstString(record?.type, info?.type, context.orderType) ?? "unknown", + orderId: firstString(record?.id, info?.orderId, info?.orderID), + orderAuthor: context.orderAuthor, + clientOrderId: firstString( + context.clientOrderId, + record?.clientOrderId, + record?.clientOrderID, + record?.clientOid, + info?.clientOrderId, + info?.clientOrderID, + info?.clientOid, + ), + idempotencyId: context.idempotencyId, + makerActionId: context.makerActionId, + status: status.toLowerCase(), + requestedQuantity: context.requestedQuantity ?? firstNumber(record?.amount), + requestedNotional: context.requestedNotional, + executedBaseQuantity, + executedQuoteQuantity, + averageExecutionPrice, + filledAmount: firstNumber(record?.filled, info?.executedQty), + remainingAmount: firstNumber(record?.remaining, info?.remainingQty), + feeAmount: fee.amount, + feeCurrency: fee.currency, + feeRate: fee.rate, + exchangeTimestamp, + brokerObservedTimestamp: + context.brokerObservedTimestamp ?? new Date().toISOString(), + errorType: errorRecord?.name, + errorMessage: errorRecord ? REDACTED_ERROR_MESSAGE : undefined, + }) as OrderExecutionTelemetry; +} + +export function emitOrderExecutionTelemetryInBackground( + otelMetrics: OtelMetrics | undefined, + context: OrderTelemetryContext, + order: unknown, + error?: unknown, +): void { + void emitOrderExecutionTelemetry(otelMetrics, context, order, error).catch( + (telemetryError) => { + try { + log.warn("Telemetry emit failed", { error: telemetryError }); + } catch { + console.warn("Telemetry emit failed", telemetryError); + } + }, + ); +} + +export function extractOrderTelemetryIds( + params: Record | undefined, +): Pick< + OrderTelemetryContext, + "clientOrderId" | "idempotencyId" | "makerActionId" +> { + const record = params ?? {}; + return { + clientOrderId: firstString( + record.clientOrderId, + record.clientOrderID, + record.newClientOrderId, + record.clientOid, + ), + idempotencyId: firstString( + record.idempotencyId, + record.idempotencyID, + record.idempotencyKey, + record.requestId, + record.requestID, + ), + makerActionId: firstString( + record.makerActionId, + record.maker_action_id, + record.actionId, + record.action_id, + ), + }; +} + +function firstValue(...values: unknown[]): unknown { + return values.find((value) => value !== undefined && value !== null); +} + +function firstString(...values: unknown[]): string | undefined { + for (const value of values) { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + } + return undefined; +} + +function firstNumber(...values: unknown[]): number | undefined { + for (const value of values) { + const numberValue = + typeof value === "number" + ? value + : typeof value === "string" && value.trim() + ? Number(value) + : Number.NaN; + if (Number.isFinite(numberValue)) { + return numberValue; + } + } + return undefined; +} + +function computeFilledFromAmount( + record: JsonRecord | undefined, +): number | undefined { + const amount = firstNumber(record?.amount); + const remaining = firstNumber(record?.remaining); + if (amount === undefined || remaining === undefined) { + return undefined; + } + return amount - remaining; +} + +function computeAveragePrice( + executedBaseQuantity: number | undefined, + executedQuoteQuantity: number | undefined, +): number | undefined { + if ( + executedBaseQuantity === undefined || + executedQuoteQuantity === undefined || + executedBaseQuantity === 0 + ) { + return undefined; + } + return executedQuoteQuantity / executedBaseQuantity; +} + +function normalizeTimestamp(value: unknown): string | undefined { + if (typeof value === "string" && value.trim()) { + return value.trim(); + } + const timestamp = firstNumber(value); + if (timestamp === undefined) { + return undefined; + } + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +} + +function getFees(record: JsonRecord | undefined, info: JsonRecord | undefined) { + const fees: unknown[] = []; + if (record?.fee) fees.push(record.fee); + if (Array.isArray(record?.fees)) fees.push(...record.fees); + if (Array.isArray(record?.trades)) { + for (const trade of record.trades) { + const tradeRecord = asRecord(trade); + if (tradeRecord?.fee) fees.push(tradeRecord.fee); + if (Array.isArray(tradeRecord?.fees)) fees.push(...tradeRecord.fees); + } + } + if (Array.isArray(info?.fills)) { + for (const fill of info.fills) { + const fillRecord = asRecord(fill); + const commission = firstNumber(fillRecord?.commission); + if (commission !== undefined) { + fees.push({ + cost: commission, + currency: firstString(fillRecord?.commissionAsset), + }); + } + } + } + return fees; +} + +function summarizeFees(fees: unknown[]) { + let amount = 0; + let amountFound = false; + let currency: string | undefined; + let rate: number | undefined; + + for (const rawFee of fees) { + const fee = asRecord(rawFee); + if (!fee) continue; + const cost = firstNumber(fee.cost, fee.amount); + if (cost !== undefined) { + amount += cost; + amountFound = true; + } + currency ??= firstString(fee.currency); + rate ??= firstNumber(fee.rate); + } + + return { + amount: amountFound ? amount : undefined, + currency, + rate, + }; +} + +function compactUndefined(record: JsonRecord): JsonRecord { + return Object.fromEntries( + Object.entries(record).filter(([, value]) => value !== undefined), + ); +} diff --git a/src/helpers/otel.ts b/src/helpers/otel.ts index 535423b..d93dcc7 100644 --- a/src/helpers/otel.ts +++ b/src/helpers/otel.ts @@ -1,6 +1,10 @@ -import { logs, type LogRecord } from "@opentelemetry/api-logs"; +import { + type Attributes, + metrics, + type ObservableGauge, +} from "@opentelemetry/api"; +import { type LogRecord, logs } from "@opentelemetry/api-logs"; import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-http"; -import { metrics } from "@opentelemetry/api"; import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-http"; import { resourceFromAttributes } from "@opentelemetry/resources"; import { @@ -42,6 +46,13 @@ export interface MetricData { service: string; } +export interface OtelMetricsEnvOptions { + /** Service name used when OTEL_SERVICE_NAME is not configured. */ + defaultServiceName?: string; + /** Whether broker-specific legacy collector host variables are accepted. */ + allowLegacyBrokerConfig?: boolean; +} + const DEFAULT_SERVICE = "cex-broker"; const DEFAULT_OTLP_PORT = 4318; const EXPORT_INTERVAL_MS = 5_000; @@ -141,6 +152,13 @@ export class OtelMetrics extends BaseOtelSignal { string, ReturnType["createHistogram"]> >(); + private readonly observableGauges = new Map< + string, + { + instrument: ObservableGauge; + observations: Map; + } + >(); constructor(config?: OtelConfig) { super(config, "metrics"); @@ -267,6 +285,47 @@ export class OtelMetrics extends BaseOtelSignal { } } + /** + * Set a gauge value that is observed on every export. This is suitable for + * staleness signals where a quiet live process must keep exporting its last value. + */ + public async setObservableGauge( + metricName: string, + value: number, + labels: Record, + service: string = this.getServiceName(), + ): Promise { + const provider = this.getProvider(); + if (!this.isOtelEnabled() || !provider) return; + try { + let state = this.observableGauges.get(metricName); + if (!state) { + const observations = new Map< + string, + { value: number; attributes: Attributes } + >(); + const instrument = provider + .getMeter("cex-broker-metrics", "1.0.0") + .createObservableGauge(metricName, { description: metricName }); + instrument.addCallback((result) => { + for (const observation of observations.values()) { + result.observe(observation.value, observation.attributes); + } + }); + state = { instrument, observations }; + this.observableGauges.set(metricName, state); + } + + const attributes = toAttributes(labels, service); + state.observations.set(stableAttributeKey(attributes), { + value, + attributes, + }); + } catch (error) { + log.error("Failed to set observable gauge:", error); + } + } + public async recordHistogram( metricName: string, value: number, @@ -420,32 +479,50 @@ function getOtelProtocolFromEnv(): "http" | "https" { return (protocol as "http" | "https") || "http"; } -export function createOtelMetricsFromEnv(): OtelMetrics { +export function createOtelMetricsFromEnv( + options: OtelMetricsEnvOptions = {}, +): OtelMetrics { const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; - const host = getOtelHostFromEnv(); + const serviceName = + process.env.OTEL_SERVICE_NAME || + options.defaultServiceName || + DEFAULT_SERVICE; if (otlpEndpoint) { return new OtelMetrics({ - otlpEndpoint: otlpEndpoint.replace(/\/v1\/metrics\/?$/, ""), - serviceName: process.env.OTEL_SERVICE_NAME || DEFAULT_SERVICE, + otlpEndpoint, + serviceName, }); } - if (!host) { - return new OtelMetrics(); + if (options.allowLegacyBrokerConfig === false) { + return new OtelMetrics({ serviceName }); } + const host = getOtelHostFromEnv(); + if (!host) return new OtelMetrics({ serviceName }); + const port = getOtelPortFromEnv(); const config: OtelConfig = { host, port: port ?? DEFAULT_OTLP_PORT, protocol: getOtelProtocolFromEnv(), - serviceName: process.env.OTEL_SERVICE_NAME || DEFAULT_SERVICE, + serviceName, }; return new OtelMetrics(config); } +function stableAttributeKey( + attributes: Record, +): string { + return JSON.stringify( + Object.entries(attributes).sort(([left], [right]) => + left.localeCompare(right), + ), + ); +} + export function createOtelLogsFromEnv(): OtelLogs { const logsEndpoint = process.env.OTEL_EXPORTER_OTLP_LOGS_ENDPOINT; const genericEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT; diff --git a/src/helpers/passive-order.ts b/src/helpers/passive-order.ts new file mode 100644 index 0000000..4b5d52f --- /dev/null +++ b/src/helpers/passive-order.ts @@ -0,0 +1,73 @@ +import ccxt from "@usherlabs/ccxt"; +import { getErrorMessage } from "./shared/errors"; + +export const PASSIVE_ORDER_ERROR_CODES = { + unsupported: "passive_order_unsupported", + rejected: "passive_order_rejected", + wouldCross: "passive_order_would_cross", +} as const; + +export type PassiveOrderErrorCode = + (typeof PASSIVE_ORDER_ERROR_CODES)[keyof typeof PASSIVE_ORDER_ERROR_CODES]; + +export type PassiveOrderSubmissionErrorCode = + | PassiveOrderErrorCode + | "AuthenticationError" + | "InsufficientFunds"; + +function identifiesWouldCross(message: string): boolean { + const normalized = message.toLowerCase(); + // Post-only venues reject a crossing limit instead of resting it. Binance + // says it "would immediately match and take"; Hyperliquid and other venues + // use equivalent explicit immediate-execution wording. + return ( + normalized.includes("would immediately match and take") || + /post[\s-]?only\b.*\bwould\b.*\bimmediately\b.*\b(?:execute|fill|match)/.test( + normalized, + ) || + /post[\s-]?only\b.*\bwould\b.*\b(?:execute|fill|match)\w*\b.*\bimmediately/.test( + normalized, + ) + ); +} + +function identifiesUnsupported(message: string): boolean { + const normalized = message.toLowerCase(); + return ( + /post[\s-]?only\b.*\b(?:not supported|unsupported|does not support)\b/.test( + normalized, + ) || + /\b(?:not supported|unsupported|does not support)\b.*\bpost[\s-]?only\b/.test( + normalized, + ) + ); +} + +export function classifyPassiveOrderError( + error: unknown, +): PassiveOrderSubmissionErrorCode { + // A passive_* code tells the client the venue refused to REST the order for a + // post-only reason, so the rung may be re-placed at a new price. Balance and + // credential faults fail that promise: re-placing repeats them verbatim, and + // labelling them passive turns a shortfall into an unbounded repost loop. + // Both carry a typed ccxt class, so classify them before the post-only checks + // and report their own stable code — never the passive catch-all below. + if (error instanceof ccxt.InsufficientFunds) { + return "InsufficientFunds"; + } + // PermissionDenied extends AuthenticationError, so this covers both. + if (error instanceof ccxt.AuthenticationError) { + return "AuthenticationError"; + } + const message = getErrorMessage(error); + if ( + error instanceof ccxt.OrderImmediatelyFillable || + identifiesWouldCross(message) + ) { + return PASSIVE_ORDER_ERROR_CODES.wouldCross; + } + if (error instanceof ccxt.NotSupported || identifiesUnsupported(message)) { + return PASSIVE_ORDER_ERROR_CODES.unsupported; + } + return PASSIVE_ORDER_ERROR_CODES.rejected; +} diff --git a/src/helpers/shared/errors.ts b/src/helpers/shared/errors.ts new file mode 100644 index 0000000..80f44ac --- /dev/null +++ b/src/helpers/shared/errors.ts @@ -0,0 +1,84 @@ +import { log } from "../logger"; + +/** Upper bound for a surfaced error detail. Long enough for a full ccxt venue + * message, short enough to keep gRPC trailers small. */ +const MAX_ERROR_DETAIL_LENGTH = 512; +export const REDACTED_ERROR_MESSAGE = "redacted_error"; + +export function getErrorMessage(error: unknown): string { + return error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Unknown error"; +} + +/** Constructor/class name of a caught error (ccxt classes like InsufficientFunds, + * BadSymbol, OrderNotFound carry the actionable signal). The generic `Error` name + * adds nothing, so it is dropped; returns undefined for non-Error values. */ +export function errorClassName(error: unknown): string | undefined { + if (!(error instanceof Error)) { + return undefined; + } + const name = error.constructor?.name || error.name; + return name && name !== "Error" ? name : undefined; +} + +function errorCode(error: unknown): string | undefined { + if (error === null || typeof error !== "object") { + return undefined; + } + const code = (error as { code?: unknown }).code; + if (typeof code === "string") { + return code.trim() || undefined; + } + if (typeof code === "number" && Number.isFinite(code)) { + return String(code); + } + if (typeof code === "bigint") { + return String(code); + } + return undefined; +} + +/** Sanitized, single-line, length-capped detail for a caught error: the venue + * error class, optional primitive code, and message. It never includes a stack + * or attached payload. */ +export function sanitizeErrorDetail( + error: unknown, + options: { includeCode?: boolean } = {}, +): string { + const className = errorClassName(error); + const message = getErrorMessage(error); + const code = options.includeCode ? errorCode(error) : undefined; + const prefix = [className, code ? `[code=${code}]` : undefined] + .filter((part): part is string => Boolean(part)) + .join(" "); + const detail = prefix ? `${prefix}: ${message}` : message; + return detail.replace(/\s+/g, " ").trim().slice(0, MAX_ERROR_DETAIL_LENGTH); +} + +export function safeLogError(context: string, error: unknown): void { + try { + log.error(context, { error }); + } catch { + console.error(context, error); + } +} + +export function safeLogRedactedError(context: string, error: unknown): void { + const errorType = + errorClassName(error) ?? + (error instanceof Error ? error.name : typeof error); + try { + log.error(context, { + error_type: errorType, + error_message: REDACTED_ERROR_MESSAGE, + }); + } catch { + console.error(context, { + error_type: errorType, + error_message: REDACTED_ERROR_MESSAGE, + }); + } +} diff --git a/src/helpers/shared/guards.ts b/src/helpers/shared/guards.ts new file mode 100644 index 0000000..f53b338 --- /dev/null +++ b/src/helpers/shared/guards.ts @@ -0,0 +1,9 @@ +export function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function asRecord< + T extends Record = Record, +>(value: unknown): T | undefined { + return isRecord(value) ? (value as T) : undefined; +} diff --git a/src/helpers/transfer-network.ts b/src/helpers/transfer-network.ts new file mode 100644 index 0000000..9dda300 --- /dev/null +++ b/src/helpers/transfer-network.ts @@ -0,0 +1,100 @@ +import type { Exchange } from "@usherlabs/ccxt"; +import { normalizeBrokerNetworkId } from "./index"; +import { safeLogError } from "./shared/errors"; +import { isRecord } from "./shared/guards"; +import { fetchCurrencyMetadata } from "./treasury-discovery"; + +export type TransferNetworkResolution = { + operatorAlias: string; + brokerNetworkId: string; + exchangeNetworkId: string; + networkKey: string | null; +}; + +function networkAliasSet( + brokerNetworkId: string, + networkKey: string, +): string[] { + const aliases = new Set([ + brokerNetworkId, + networkKey.trim().toUpperCase(), + ]); + if (brokerNetworkId === "BNB") { + aliases.add("BNB"); + aliases.add("BSC"); + aliases.add("BEP20"); + } + return [...aliases].filter((alias) => alias.length > 0); +} + +export function buildTransferNetworkEvidence( + currencyInfo: Record, +) { + const rawNetworks = isRecord(currencyInfo.networks) + ? currencyInfo.networks + : {}; + const networks: Record = {}; + const aliases: Record = {}; + + for (const [networkKey, networkValue] of Object.entries(rawNetworks)) { + const networkRecord = isRecord(networkValue) ? networkValue : {}; + const exchangeNetworkId = String( + networkRecord.id ?? networkRecord.network ?? networkKey, + ); + const brokerNetworkId = normalizeBrokerNetworkId( + String(networkRecord.network ?? networkKey), + ); + const evidence = { + operatorAlias: networkKey, + brokerNetworkId, + exchangeNetworkId, + networkKey, + }; + networks[networkKey] = { + ...networkRecord, + operatorAlias: networkKey, + brokerNetworkId, + exchangeNetworkId, + }; + for (const alias of networkAliasSet(brokerNetworkId, networkKey)) { + aliases[alias] = { ...evidence, operatorAlias: alias }; + } + } + + return { networks, aliases }; +} + +export async function resolveTransferNetwork( + broker: Exchange, + assetCode: string, + operatorAlias: string, +): Promise { + const requestedAlias = operatorAlias.trim().toUpperCase(); + const brokerNetworkId = normalizeBrokerNetworkId(requestedAlias); + let currencyInfo: Record | null | undefined = null; + try { + currencyInfo = await fetchCurrencyMetadata(broker, assetCode); + } catch (error) { + safeLogError( + `Network discovery failed for ${assetCode}/${operatorAlias}; using operator alias as exchange network id`, + error, + ); + } + if (currencyInfo) { + const evidence = buildTransferNetworkEvidence(currencyInfo); + const resolved = + evidence.aliases[requestedAlias] ?? evidence.aliases[brokerNetworkId]; + if (resolved) { + return { ...resolved, operatorAlias: requestedAlias, brokerNetworkId }; + } + throw new Error( + `network_alias_unresolved: ${assetCode}/${requestedAlias} is not available in discovered transfer networks`, + ); + } + return { + operatorAlias: requestedAlias, + brokerNetworkId, + exchangeNetworkId: requestedAlias, + networkKey: null, + }; +} diff --git a/src/helpers/travel-rule-deposit-reconciler.ts b/src/helpers/travel-rule-deposit-reconciler.ts new file mode 100644 index 0000000..bdfba98 --- /dev/null +++ b/src/helpers/travel-rule-deposit-reconciler.ts @@ -0,0 +1,900 @@ +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; +import type { Exchange } from "@usherlabs/ccxt"; +import type { + PolicyConfig, + TravelRuleDepositConfig, + TravelRuleDepositQuestionnaire, +} from "../types"; +import type { BrokerAccount, BrokerPoolEntry } from "./broker"; +import { depositField } from "./deposit"; +import { log } from "./logger"; +import type { OtelMetrics } from "./otel"; +import { + getEnabledTravelRuleDepositConfig, + resolveDepositOriginatorQuestionnaire, +} from "./travel-rule"; + +/** + * Auto-clear reconciler for Binance travel-rule-frozen DEPOSITS. + * + * Under the AUSTRAC travel rule Binance credits an inbound deposit but holds it + * in `getUserAsset.freeze` (invisible to free+locked balances) until a per-deposit + * questionnaire is answered. This reconciler polls `localentity/deposit/history` + * for such frozen deposits and submits the questionnaire — but only for deposits + * whose on-chain sender is PROVEN (via `eth_getTransactionByHash`) to be one of + * our configured originator wallets. Everything else is left frozen and surfaced. + * + * Compliance invariant: a deposit is NEVER auto-declared unless its origin is + * proven ours. Undeclared origin, unresolvable sender, entity drift, or any + * uncertainty all fail closed (skip + surface), never "declare anyway". + * + * This runs entirely broker-internal (not through the gRPC verity path), so + * provide-info carries no verity proof — acceptable for a compliance attestation + * that is not a trading action; see the plan's edge-case notes. + */ + +// --------------------------------------------------------------------------- +// Parsed deposit record +// --------------------------------------------------------------------------- + +export type LocalEntityDeposit = { + tranId: string; + coin: string; + amount: string; + network: string; + txId: string; + // travelRuleStatusV2, uppercased: PENDING | PASSED | FAILED (others pass through). + travelRuleStatus: string; + requireQuestionnaire: boolean; + raw: Record; +}; + +function toBool(value: unknown): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") return value.trim().toLowerCase() === "true"; + return false; +} + +/** + * Parses one raw `localentity/deposit/history` row. Returns null when the row + * lacks a tranId (the only id valid for provide-info — the `capital/deposit/hisrec` + * id is a different id space and yields "Deposit request not found"). + */ +export function parseLocalEntityDeposit( + raw: Record, +): LocalEntityDeposit | null { + // Only `tranId` is accepted: it is the sole id `provide-info` recognizes. A + // generic `id` fallback risks the `capital/deposit/hisrec` id space, which + // provide-info rejects with "Deposit request not found". + const tranId = depositField(raw, ["tranId"]); + if (tranId === undefined) return null; + return { + tranId: String(tranId), + coin: String(depositField(raw, ["coin", "asset"]) ?? ""), + amount: String(depositField(raw, ["amount"]) ?? ""), + network: String(depositField(raw, ["network"]) ?? ""), + txId: String( + depositField(raw, ["txId", "txid", "tx_hash", "txHash"]) ?? "", + ), + travelRuleStatus: String( + depositField(raw, ["travelRuleStatusV2", "travelRuleStatus"]) ?? "", + ) + .trim() + .toUpperCase(), + requireQuestionnaire: toBool( + raw.requireQuestionnaire ?? raw.requireQuestionnaireV2, + ), + raw, + }; +} + +// --------------------------------------------------------------------------- +// On-chain origin proof +// --------------------------------------------------------------------------- + +const EVM_TX_HASH = /^0x[0-9a-fA-F]{64}$/; + +/** + * Resolves the on-chain sender (`tx.from`) of a deposit's transaction hash via a + * JSON-RPC `eth_getTransactionByHash`. Returns the lowercased sender, or null + * when the hash is not a well-formed EVM tx hash or the tx is not found (pruned + * node / reorg / not yet mined) — both of which mean the origin is UNPROVEN and + * the caller must skip rather than assume. + * + * `tx.from` is the EOA that signed the deposit transaction. For our funding + * churn (owner wallet → Binance via a direct ERC20 transfer) that equals the + * token originator. A transfer routed through a contract could differ; hardening + * to the ERC20 Transfer event's `from` is possible later if that case arises. + * + * Uses `node:https` rather than the global `fetch`: inside the Gramine SGX + * enclave undici (which backs global fetch) fails to establish outbound + * connections — the same failure mode as the enclave's dead Binance user-data + * WebSocket — while the ccxt HTTP path (node http/https) works. Using node's + * request keeps this on the transport that is proven to work in the enclave. + */ +export function resolveOnChainSender( + rpcUrl: string, + txHash: string, + timeoutMs = 10_000, +): Promise { + if (!EVM_TX_HASH.test(txHash)) return Promise.resolve(null); + const body = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "eth_getTransactionByHash", + params: [txHash], + }); + const url = new URL(rpcUrl); + const doRequest = url.protocol === "http:" ? httpRequest : httpsRequest; + return new Promise((resolve, reject) => { + const req = doRequest( + url, + { + method: "POST", + headers: { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + }, + // Bound the request: a hung RPC would otherwise stall the whole + // reconciler tick (candidates resolve sequentially) and block the next poll. + timeout: timeoutMs, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk) => chunks.push(chunk as Buffer)); + res.on("end", () => { + const status = res.statusCode ?? 0; + if (status < 200 || status >= 300) { + reject(new Error(`travel_rule_rpc_http_${status}`)); + return; + } + try { + const json = JSON.parse(Buffer.concat(chunks).toString("utf8")) as { + result?: { from?: unknown } | null; + error?: unknown; + }; + if (json.error) { + reject( + new Error( + `travel_rule_rpc_error: ${JSON.stringify(json.error)}`, + ), + ); + return; + } + const from = json.result?.from; + resolve( + typeof from === "string" && from.length > 0 + ? from.toLowerCase() + : null, + ); + } catch (parseError) { + reject( + new Error(`travel_rule_rpc_parse_error: ${String(parseError)}`), + ); + } + }); + }, + ); + req.on("error", reject); + req.on("timeout", () => { + req.destroy(new Error("travel_rule_rpc_timeout")); + }); + req.write(body); + req.end(); + }); +} + +// --------------------------------------------------------------------------- +// Error classification +// --------------------------------------------------------------------------- + +function errorText(error: unknown): string { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +/** Binance rate-limit signals (-1003 / HTTP 429 / DDoS guard). */ +export function isRateLimitError(error: unknown): boolean { + const text = errorText(error).toLowerCase(); + return ( + text.includes("-1003") || + text.includes("too many requests") || + text.includes("429") || + text.includes("ddosprotection") || + text.includes("ratelimitexceeded") + ); +} + +/** + * A provide-info call that Binance treats as already-satisfied. Re-submitting a + * tranId that was already provided (a manual release, or a reconciler restart) + * must be idempotent, so these responses/errors count as success. + */ +export function isAlreadyProvidedError(error: unknown): boolean { + const text = errorText(error).toLowerCase(); + return ( + text.includes("already") && + (text.includes("provided") || + text.includes("processed") || + text.includes("passed") || + text.includes("submit")) + ); +} + +function isAcceptedResponse(response: Record): boolean { + // { "accepted": true } on success. Some paths return no explicit flag; treat + // an absent `accepted` as accepted (no error was thrown) but an explicit + // `accepted: false` as a content rejection. + return response.accepted !== false; +} + +// --------------------------------------------------------------------------- +// Per-account reconcile core (pure orchestration over injected I/O) +// --------------------------------------------------------------------------- + +export type ReconcilerAccountState = { + // tranIds we have successfully submitted (or that Binance reported already + // provided). Skipped on subsequent cycles — idempotency keyed by tranId. + submittedTranIds: Set; + // FAILED deposits already surfaced once (terminal; not re-logged every cycle). + surfacedFailed: Set; + // tranId → epoch-ms before which we will not re-attempt. Prevents hot-looping + // on content errors, unresolved senders, and undeclared origins. + backoffUntil: Map; + // account-wide cooldown after a rate-limit signal (epoch-ms). + rateLimitedUntil: number; +}; + +export function createAccountState(): ReconcilerAccountState { + return { + submittedTranIds: new Set(), + surfacedFailed: new Set(), + backoffUntil: new Map(), + rateLimitedUntil: 0, + }; +} + +export type ReconcileOutcome = + | { kind: "submitted"; deposit: LocalEntityDeposit; sender: string } + | { kind: "already-provided"; deposit: LocalEntityDeposit; sender: string } + | { kind: "undeclared-origin"; deposit: LocalEntityDeposit; sender: string } + | { kind: "unproven-origin"; deposit: LocalEntityDeposit; error?: string } + | { + kind: "entity-drift"; + deposit: LocalEntityDeposit; + country: string | null; + } + | { kind: "failed-terminal"; deposit: LocalEntityDeposit } + | { kind: "submit-error"; deposit: LocalEntityDeposit; error: string } + | { kind: "poll-error"; error: string }; + +export type ReconcileAccountReport = { + accountLabel: string; + frozenDeposits: LocalEntityDeposit[]; + outcomes: ReconcileOutcome[]; + // Whether there was actionable work this cycle — drives active vs idle cadence. + hadActionableWork: boolean; +}; + +export type ReconcileAccountDeps = { + accountLabel: string; + depositConfig: TravelRuleDepositConfig; + expectedCountry: string; + failureBackoffMs: number; + rateLimitCooldownMs: number; + now: number; + state: ReconcilerAccountState; + fetchDepositHistory: () => Promise>>; + fetchQuestionnaireCountry: () => Promise; + resolveSender: (network: string, txId: string) => Promise; + submitProvideInfo: ( + tranId: string, + questionnaire: TravelRuleDepositQuestionnaire, + ) => Promise>; + resolveQuestionnaire: ( + config: TravelRuleDepositConfig, + sender: string, + ) => TravelRuleDepositQuestionnaire | null; +}; + +function isInBackoff( + state: ReconcilerAccountState, + tranId: string, + now: number, +): boolean { + const until = state.backoffUntil.get(tranId); + return until !== undefined && now < until; +} + +/** + * Reconciles a single account once. Pure orchestration: all I/O is injected so + * the compliance invariants (never submit an unproven/undeclared/entity-drifted + * deposit) are unit-testable without a network. Fetch/poll errors are captured + * as outcomes rather than thrown so one bad account never stalls the loop. + */ +export async function reconcileAccountOnce( + deps: ReconcileAccountDeps, +): Promise { + const { state, now, accountLabel } = deps; + const outcomes: ReconcileOutcome[] = []; + + if (now < state.rateLimitedUntil) { + return { + accountLabel, + frozenDeposits: [], + outcomes, + hadActionableWork: false, + }; + } + + let rawDeposits: Array>; + try { + rawDeposits = await deps.fetchDepositHistory(); + } catch (error) { + if (isRateLimitError(error)) { + state.rateLimitedUntil = now + deps.rateLimitCooldownMs; + } + return { + accountLabel, + frozenDeposits: [], + outcomes: [{ kind: "poll-error", error: errorText(error) }], + hadActionableWork: false, + }; + } + + const deposits = rawDeposits + .map(parseLocalEntityDeposit) + .filter((d): d is LocalEntityDeposit => d !== null); + const frozen = deposits.filter( + (d) => d.requireQuestionnaire && d.travelRuleStatus === "PENDING", + ); + + // Terminal FAILED deposits: surface once, never auto-submit. + for (const deposit of deposits) { + if ( + deposit.travelRuleStatus === "FAILED" && + !state.surfacedFailed.has(deposit.tranId) + ) { + state.surfacedFailed.add(deposit.tranId); + outcomes.push({ kind: "failed-terminal", deposit }); + } + } + + const candidates = frozen.filter( + (d) => + !state.submittedTranIds.has(d.tranId) && + !isInBackoff(state, d.tranId, now), + ); + if (candidates.length === 0) { + return { + accountLabel, + frozenDeposits: frozen, + outcomes, + hadActionableWork: false, + }; + } + + // Entity gate: only submit AU-shaped answers to an AU entity. Checked once per + // cycle, only when there is a candidate, and fail-closed on drift/unavailable. + let country: string | null = null; + try { + country = await deps.fetchQuestionnaireCountry(); + } catch (error) { + if (isRateLimitError(error)) { + state.rateLimitedUntil = now + deps.rateLimitCooldownMs; + } + country = null; + } + if (country !== deps.expectedCountry) { + for (const deposit of candidates) { + outcomes.push({ kind: "entity-drift", deposit, country }); + } + return { + accountLabel, + frozenDeposits: frozen, + outcomes, + hadActionableWork: true, + }; + } + + for (const deposit of candidates) { + // A rate-limit signal from an earlier candidate this cycle set an + // account-wide cooldown; stop hitting the API for the rest and let the next + // tick wait it out rather than compounding the -1003. + if (now < state.rateLimitedUntil) break; + let sender: string | null = null; + let resolveError: string | undefined; + try { + sender = await deps.resolveSender(deposit.network, deposit.txId); + } catch (error) { + sender = null; + // Capture the underlying RPC failure so the anomaly log distinguishes a + // real call failure (http/timeout/network) from a legitimately absent + // sender (no RPC configured, or tx not found). + resolveError = errorText(error); + if (isRateLimitError(error)) { + state.rateLimitedUntil = now + deps.rateLimitCooldownMs; + } + } + if (!sender) { + state.backoffUntil.set(deposit.tranId, now + deps.failureBackoffMs); + outcomes.push({ kind: "unproven-origin", deposit, error: resolveError }); + continue; + } + + const questionnaire = deps.resolveQuestionnaire(deps.depositConfig, sender); + if (!questionnaire) { + state.backoffUntil.set(deposit.tranId, now + deps.failureBackoffMs); + outcomes.push({ kind: "undeclared-origin", deposit, sender }); + continue; + } + + try { + const response = await deps.submitProvideInfo( + deposit.tranId, + questionnaire, + ); + if (isAcceptedResponse(response)) { + state.submittedTranIds.add(deposit.tranId); + state.backoffUntil.delete(deposit.tranId); + outcomes.push({ kind: "submitted", deposit, sender }); + } else { + state.backoffUntil.set(deposit.tranId, now + deps.failureBackoffMs); + outcomes.push({ + kind: "submit-error", + deposit, + error: JSON.stringify(response), + }); + } + } catch (error) { + if (isAlreadyProvidedError(error)) { + state.submittedTranIds.add(deposit.tranId); + state.backoffUntil.delete(deposit.tranId); + outcomes.push({ kind: "already-provided", deposit, sender }); + } else { + if (isRateLimitError(error)) { + state.rateLimitedUntil = now + deps.rateLimitCooldownMs; + } + state.backoffUntil.set(deposit.tranId, now + deps.failureBackoffMs); + outcomes.push({ + kind: "submit-error", + deposit, + error: errorText(error), + }); + } + } + } + + return { + accountLabel, + frozenDeposits: frozen, + outcomes, + hadActionableWork: true, + }; +} + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +export type TravelRuleDepositReconcilerConfig = { + // Upper-cased Binance network code (e.g. "ARBITRUM") → EVM JSON-RPC URL used to + // prove a deposit's on-chain sender. A frozen deposit on a network with no RPC + // configured is treated as unproven and left frozen (fail-closed). + rpcUrlsByNetwork: Record; + pollIntervalActiveMs: number; + pollIntervalIdleMs: number; + expectedQuestionnaireCountry: string; + failureBackoffMs: number; + rateLimitCooldownMs: number; +}; + +const DEFAULTS = { + pollIntervalActiveMs: 60_000, + pollIntervalIdleMs: 600_000, + expectedQuestionnaireCountry: "AU", + failureBackoffMs: 15 * 60_000, + rateLimitCooldownMs: 5 * 60_000, +}; + +function envSecondsToMs( + env: Record, + key: string, + fallbackMs: number, +): number { + const raw = env[key]; + if (raw === undefined) return fallbackMs; + const secs = Number(raw); + return Number.isFinite(secs) && secs > 0 ? secs * 1000 : fallbackMs; +} + +/** + * Builds reconciler config from the environment. RPC URLs are read from + * `TRAVEL_RULE_RPC_URL_` vars (network suffix must match Binance's + * network code, e.g. `TRAVEL_RULE_RPC_URL_ARBITRUM`). These are intentionally NOT + * `CEX_BROKER_`-prefixed so the credential env scan ignores them. Cadence and the + * expected questionnaire country are overridable but default to the AU rollout. + */ +export function loadTravelRuleDepositReconcilerConfigFromEnv( + env: Record, +): TravelRuleDepositReconcilerConfig { + const rpcUrlsByNetwork: Record = {}; + const prefix = "TRAVEL_RULE_RPC_URL_"; + for (const [key, value] of Object.entries(env)) { + if (key.startsWith(prefix) && value) { + rpcUrlsByNetwork[key.slice(prefix.length).toUpperCase()] = value; + } + } + return { + rpcUrlsByNetwork, + pollIntervalActiveMs: envSecondsToMs( + env, + "TRAVEL_RULE_DEPOSIT_POLL_ACTIVE_SECS", + DEFAULTS.pollIntervalActiveMs, + ), + pollIntervalIdleMs: envSecondsToMs( + env, + "TRAVEL_RULE_DEPOSIT_POLL_IDLE_SECS", + DEFAULTS.pollIntervalIdleMs, + ), + expectedQuestionnaireCountry: + env.TRAVEL_RULE_QUESTIONNAIRE_COUNTRY?.trim().toUpperCase() || + DEFAULTS.expectedQuestionnaireCountry, + failureBackoffMs: envSecondsToMs( + env, + "TRAVEL_RULE_DEPOSIT_FAILURE_BACKOFF_SECS", + DEFAULTS.failureBackoffMs, + ), + rateLimitCooldownMs: envSecondsToMs( + env, + "TRAVEL_RULE_DEPOSIT_RATE_LIMIT_COOLDOWN_SECS", + DEFAULTS.rateLimitCooldownMs, + ), + }; +} + +// --------------------------------------------------------------------------- +// Binance localentity implicit-method surface (registered via defineRestApi) +// --------------------------------------------------------------------------- + +type BinanceLocalEntityDeposit = Exchange & { + sapiGetLocalentityDepositHistory?: ( + params: Record, + ) => Promise; + sapiGetLocalentityQuestionnaireRequirements?: ( + params: Record, + ) => Promise; + sapiPutLocalentityDepositProvideInfo?: ( + params: Record, + ) => Promise>; +}; + +function parseQuestionnaireCountry(response: unknown): string | null { + if (!response || typeof response !== "object") return null; + const code = (response as Record).questionnaireCountryCode; + return typeof code === "string" ? code.trim().toUpperCase() : null; +} + +// --------------------------------------------------------------------------- +// Reconciler loop +// --------------------------------------------------------------------------- + +type ReconcilerTarget = { + exchangeId: string; + account: BrokerAccount; + depositConfig: TravelRuleDepositConfig; +}; + +export class TravelRuleDepositReconciler { + #timer: ReturnType | null = null; + #stopped = false; + #running = false; + readonly #states = new Map(); + + constructor( + private readonly params: { + policy: PolicyConfig; + brokers: Record; + config: TravelRuleDepositReconcilerConfig; + metrics?: OtelMetrics; + }, + ) {} + + /** True when at least one exchange has deposit auto-clear enabled in policy. */ + static hasEnabledExchange(policy: PolicyConfig): boolean { + return (policy.travelRule?.rule ?? []).some((rule) => + getEnabledTravelRuleDepositConfig(policy, rule.exchange), + ); + } + + start(): void { + if (this.#timer || this.#stopped) return; + if (!TravelRuleDepositReconciler.hasEnabledExchange(this.params.policy)) { + return; + } + if (Object.keys(this.params.config.rpcUrlsByNetwork).length === 0) { + log.warn( + "⚠️ Travel-rule deposit auto-clear is enabled but no TRAVEL_RULE_RPC_URL_ is configured; every frozen deposit will be treated as unproven (left frozen).", + ); + } + log.info("🛂 Travel-rule deposit auto-clear reconciler started"); + this.#scheduleImmediate(); + } + + stop(): void { + this.#stopped = true; + if (this.#timer) { + clearTimeout(this.#timer); + this.#timer = null; + } + } + + #scheduleImmediate(): void { + this.#timer = setTimeout(() => void this.#tick(), 0); + } + + #stateFor(key: string): ReconcilerAccountState { + let state = this.#states.get(key); + if (!state) { + state = createAccountState(); + this.#states.set(key, state); + } + return state; + } + + #targets(): ReconcilerTarget[] { + const targets: ReconcilerTarget[] = []; + for (const rule of this.params.policy.travelRule?.rule ?? []) { + const depositConfig = getEnabledTravelRuleDepositConfig( + this.params.policy, + rule.exchange, + ); + if (!depositConfig) continue; + const exchangeId = rule.exchange.trim().toLowerCase(); + const pool = this.params.brokers[exchangeId]; + if (!pool) continue; + // Reconcile every account: the origin-proof gate makes it safe, and a + // frozen deposit that landed on the wrong account (master) still releases. + for (const account of [pool.primary, ...pool.secondaryBrokers]) { + targets.push({ exchangeId, account, depositConfig }); + } + } + return targets; + } + + async #tick(): Promise { + if (this.#stopped || this.#running) return; + this.#running = true; + let anyActionable = false; + try { + for (const target of this.#targets()) { + if (this.#stopped) break; + const report = await this.#reconcileTarget(target); + this.#emit(target, report); + if (report.hadActionableWork) anyActionable = true; + } + } catch (error) { + log.error("Travel-rule deposit reconciler tick failed", error); + } finally { + this.#running = false; + if (!this.#stopped) { + const delay = anyActionable + ? this.params.config.pollIntervalActiveMs + : this.params.config.pollIntervalIdleMs; + this.#timer = setTimeout(() => void this.#tick(), delay); + } + } + } + + #reconcileTarget(target: ReconcilerTarget): Promise { + const exchange = target.account.exchange as BinanceLocalEntityDeposit; + const accountLabel = `${target.exchangeId}:${target.account.label}`; + const { config } = this.params; + return reconcileAccountOnce({ + accountLabel, + depositConfig: target.depositConfig, + expectedCountry: config.expectedQuestionnaireCountry, + failureBackoffMs: config.failureBackoffMs, + rateLimitCooldownMs: config.rateLimitCooldownMs, + now: Date.now(), + state: this.#stateFor(accountLabel), + fetchDepositHistory: async () => { + if (typeof exchange.sapiGetLocalentityDepositHistory !== "function") { + throw new Error( + "binance_localentity_deposit_history_unavailable: endpoint not registered", + ); + } + const result = await exchange.sapiGetLocalentityDepositHistory({}); + return Array.isArray(result) + ? (result as Array>) + : []; + }, + fetchQuestionnaireCountry: async () => { + if ( + typeof exchange.sapiGetLocalentityQuestionnaireRequirements !== + "function" + ) { + return null; + } + const result = + await exchange.sapiGetLocalentityQuestionnaireRequirements({}); + return parseQuestionnaireCountry(result); + }, + resolveSender: (network, txId) => { + const url = config.rpcUrlsByNetwork[network.trim().toUpperCase()]; + if (!url) return Promise.resolve(null); + return resolveOnChainSender(url, txId); + }, + submitProvideInfo: (tranId, questionnaire) => { + if ( + typeof exchange.sapiPutLocalentityDepositProvideInfo !== "function" + ) { + throw new Error( + "binance_localentity_provide_info_unavailable: endpoint not registered", + ); + } + return exchange.sapiPutLocalentityDepositProvideInfo({ + tranId, + questionnaire: JSON.stringify(questionnaire), + }); + }, + resolveQuestionnaire: resolveDepositOriginatorQuestionnaire, + }); + } + + #emit(target: ReconcilerTarget, report: ReconcileAccountReport): void { + const { metrics } = this.params; + const base = { exchange: target.exchangeId, account: target.account.label }; + + void metrics?.recordGauge( + "travel_rule_frozen_deposits", + report.frozenDeposits.length, + base, + ); + + for (const outcome of report.outcomes) { + switch (outcome.kind) { + case "submitted": + case "already-provided": { + // Compliance attestation — must be auditable. + log.info("🛂 Travel-rule deposit auto-declared", { + result: outcome.kind, + account: report.accountLabel, + tranId: outcome.deposit.tranId, + coin: outcome.deposit.coin, + amount: outcome.deposit.amount, + network: outcome.deposit.network, + txId: outcome.deposit.txId, + originator: outcome.sender, + }); + void metrics?.recordCounter( + "travel_rule_deposit_submissions_total", + 1, + { + ...base, + coin: outcome.deposit.coin, + result: outcome.kind, + }, + ); + break; + } + case "undeclared-origin": + log.warn( + "🛂 Travel-rule deposit from UNDECLARED originator — left frozen", + { + account: report.accountLabel, + tranId: outcome.deposit.tranId, + coin: outcome.deposit.coin, + amount: outcome.deposit.amount, + sender: outcome.sender, + }, + ); + void metrics?.recordCounter( + "travel_rule_deposit_anomalies_total", + 1, + { + ...base, + reason: "undeclared_origin", + }, + ); + break; + case "unproven-origin": + log.warn( + "🛂 Travel-rule deposit origin UNPROVEN (tx sender unresolved) — left frozen", + { + account: report.accountLabel, + tranId: outcome.deposit.tranId, + coin: outcome.deposit.coin, + network: outcome.deposit.network, + txId: outcome.deposit.txId, + // undefined when there was simply no RPC / tx not found; set when + // the RPC call itself failed (the reason it couldn't be proven). + rpcError: outcome.error ?? null, + }, + ); + void metrics?.recordCounter( + "travel_rule_deposit_anomalies_total", + 1, + { + ...base, + reason: "unproven_origin", + }, + ); + break; + case "entity-drift": + log.warn( + "🛂 Travel-rule deposit skipped — questionnaire entity is not the expected country", + { + account: report.accountLabel, + tranId: outcome.deposit.tranId, + observedCountry: outcome.country, + expectedCountry: this.params.config.expectedQuestionnaireCountry, + }, + ); + void metrics?.recordCounter( + "travel_rule_deposit_anomalies_total", + 1, + { + ...base, + reason: "entity_drift", + }, + ); + break; + case "failed-terminal": + log.warn( + "🛂 Travel-rule deposit is FAILED (terminal) — needs manual handling", + { + account: report.accountLabel, + tranId: outcome.deposit.tranId, + coin: outcome.deposit.coin, + }, + ); + void metrics?.recordCounter( + "travel_rule_deposit_anomalies_total", + 1, + { + ...base, + reason: "failed_status", + }, + ); + break; + case "submit-error": + log.error("🛂 Travel-rule deposit provide-info failed", { + account: report.accountLabel, + tranId: outcome.deposit.tranId, + error: outcome.error, + }); + void metrics?.recordCounter( + "travel_rule_deposit_anomalies_total", + 1, + { + ...base, + reason: "submit_error", + }, + ); + break; + case "poll-error": + log.error("🛂 Travel-rule deposit history poll failed", { + account: report.accountLabel, + error: outcome.error, + }); + void metrics?.recordCounter( + "travel_rule_deposit_anomalies_total", + 1, + { + ...base, + reason: "poll_error", + }, + ); + break; + } + } + } +} diff --git a/src/helpers/travel-rule.ts b/src/helpers/travel-rule.ts new file mode 100644 index 0000000..88615cc --- /dev/null +++ b/src/helpers/travel-rule.ts @@ -0,0 +1,293 @@ +import type { Dict, Exchange } from "@usherlabs/ccxt"; +import Joi from "joi"; +import type { + PolicyConfig, + TravelRuleDepositConfig, + TravelRuleDepositQuestionnaire, + TravelRuleQuestionnaire, +} from "../types"; + +/** + * Binance travel-rule ("local entity") withdrawal support. + * + * Some jurisdictions (Australia from 2026-07-01 under AUSTRAC) require Binance + * withdrawals to carry beneficiary metadata. Binance enforces this by rejecting + * the standard `POST /sapi/v1/capital/withdraw/apply` endpoint with error -4104 + * and only accepting `POST /sapi/v1/localentity/withdraw/apply`, which takes an + * extra required `questionnaire` field (a JSON string of beneficiary answers). + * + * ccxt has no wrapper for the localentity endpoint, so we register it at runtime + * via the exchange's own `defineRestApi` and call the generated implicit method. + * The questionnaire answers are static per destination address and are validated + * at policy-load time by {@link australiaQuestionnaireSchema}. + */ + +// Australia questionnaire shape per Binance's travel-rule docs. Conditional +// requirements mirror the official spec: +// - bnfType required only when sending to another beneficiary (isAddressOwner=2) +// - individual name/location fields required only for bnfType=0 (individual) +// - corporate fields required only for bnfType=1 (corporate/entity) +// - vasp required only when sending to another VASP (sendTo=2) +// - vaspName required only when the VASP is not in Binance's list (vasp="others") +// Inapplicable fields are forbidden so config mistakes fail fast at startup. +// `is` schemas are explicitly `.required()` so that an ABSENT referenced field +// does not match (an optional Joi schema treats `undefined` as valid, which would +// otherwise wrongly trigger the required branch when e.g. bnfType is omitted). +const isAnotherBeneficiary = Joi.number().valid(2).required(); +const isIndividual = Joi.number().valid(0).required(); +const isCorporate = Joi.number().valid(1).required(); +const isSendToVasp = Joi.number().valid(2).required(); +const isVaspOthers = Joi.string().valid("others").required(); + +/** `base`, made required when sibling `ref` matches `is`, and forbidden otherwise. */ +function requiredWhen( + base: Joi.Schema, + ref: string, + is: Joi.Schema, +): Joi.Schema { + return base.when(ref, { + is, + // biome-ignore lint/suspicious/noThenProperty: Joi .when() options object, not a thenable + then: Joi.required(), + otherwise: Joi.forbidden(), + }); +} + +export const australiaQuestionnaireSchema = Joi.object({ + isAddressOwner: Joi.number().valid(1, 2).required(), + sendTo: Joi.number().valid(1, 2).required(), + // Binance requires an affirmative declaration; a false value can never yield a + // successful withdrawal, so reject it when the static config is loaded. + declaration: Joi.boolean().valid(true).required(), + bnfType: requiredWhen( + Joi.number().valid(0, 1), + "isAddressOwner", + isAnotherBeneficiary, + ), + bnfFirstName: requiredWhen(Joi.string(), "bnfType", isIndividual), + bnfLastName: requiredWhen(Joi.string(), "bnfType", isIndividual), + country: requiredWhen(Joi.string(), "bnfType", isIndividual), + city: requiredWhen(Joi.string(), "bnfType", isIndividual), + bnfCorpName: requiredWhen(Joi.string(), "bnfType", isCorporate), + bnfCorpCountry: requiredWhen(Joi.string(), "bnfType", isCorporate), + bnfCorpCity: requiredWhen(Joi.string(), "bnfType", isCorporate), + vasp: requiredWhen(Joi.string(), "sendTo", isSendToVasp), + vaspName: requiredWhen(Joi.string(), "vasp", isVaspOthers), +}); + +export type TravelRuleDecision = + | { mode: "standard" } + | { mode: "localentity"; questionnaire: TravelRuleQuestionnaire } + | { mode: "denied"; error: string }; + +/** + * Decides whether a withdrawal must use Binance's travel-rule endpoint. + * + * Travel rule is opt-in per exchange via the `enabled` flag, so a non-AU account + * keeps using the standard endpoint. When enabled, the destination address must + * have a configured questionnaire; if it does not we fail closed rather than fall + * back to the standard endpoint (which would just reproduce the -4104 rejection). + */ +export function resolveTravelRuleDecision( + policy: PolicyConfig, + exchange: string, + recipientAddress: string, +): TravelRuleDecision { + const rules = policy.travelRule?.rule ?? []; + const exchangeNorm = exchange.trim().toUpperCase(); + const entry = rules.find( + (rule) => rule.exchange.trim().toUpperCase() === exchangeNorm, + ); + if (!entry || !entry.enabled) { + return { mode: "standard" }; + } + + const addressNorm = recipientAddress.trim().toLowerCase(); + const match = Object.entries(entry.addresses).find( + ([address]) => address.trim().toLowerCase() === addressNorm, + ); + if (!match) { + return { + mode: "denied", + error: `no travel-rule questionnaire configured for ${exchangeNorm} address ${recipientAddress}`, + }; + } + + return { mode: "localentity", questionnaire: match[1].questionnaire }; +} + +/** + * Registers Binance's `localentity/withdraw/apply` endpoint on the exchange + * instance. No-op for non-Binance exchanges. Idempotent — ccxt just reassigns + * the generated implicit method if called again. + */ +export function registerBinanceTravelRuleWithdrawEndpoint( + exchange: Exchange, +): void { + if (exchange.id !== "binance") { + return; + } + // Same rate-limit weight as capital/withdraw/apply; the signing path is shared + // by all sapi private POSTs, so no ccxt fork change is needed. + exchange.defineRestApi( + { sapi: { post: { "localentity/withdraw/apply": 4.0002 } } }, + "request", + ); +} + +// ----------------------------------------------------------------------------- +// Deposit side: auto-clearing travel-rule-frozen deposits. +// +// The AUSTRAC travel rule also freezes INBOUND deposits: Binance credits the +// deposit but holds it in `getUserAsset.freeze` (invisible to free+locked +// balances) until a per-deposit questionnaire is answered via +// `PUT /sapi/v1/localentity/deposit/provide-info`. Unlike the withdraw leg the +// answer is keyed by the on-chain SENDER, not a static destination, so a deposit +// is only auto-declared when its sender is PROVEN (on-chain) to be one of our +// configured originator wallets. See travel-rule-deposit-reconciler.ts. +// ----------------------------------------------------------------------------- + +// Australia DEPOSIT questionnaire. Distinct shape from the withdraw schema +// (`depositOriginator`/`receiveFrom` vs `isAddressOwner`/`sendTo`). Restricted +// to the self-owned case (value 1) because that is the only case the reconciler +// can attest: it declares a deposit only after proving the sender is our own +// wallet. Declaring a third-party origin (value 2) would require beneficiary +// identity fields we do not carry, so reject it at policy-load rather than +// silently submit an incomplete questionnaire. Unknown keys are rejected by +// Joi's default so a copy-paste of the withdraw questionnaire fails fast. +export const australiaDepositQuestionnaireSchema = Joi.object({ + depositOriginator: Joi.number().valid(1).required(), + receiveFrom: Joi.number().valid(1).required(), + // Binance requires an affirmative declaration; false can never clear a deposit. + declaration: Joi.boolean().valid(true).required(), +}); + +/** + * Returns the enabled deposit travel-rule config for an exchange, or null when + * the feature is absent or disabled. Gated solely by `deposits.enabled` (not the + * entry's withdraw `enabled` flag): when null the reconciler does nothing for the + * exchange, preserving exact pre-feature behavior. + */ +export function getEnabledTravelRuleDepositConfig( + policy: PolicyConfig, + exchange: string, +): TravelRuleDepositConfig | null { + const rules = policy.travelRule?.rule ?? []; + const exchangeNorm = exchange.trim().toUpperCase(); + const entry = rules.find( + (rule) => rule.exchange.trim().toUpperCase() === exchangeNorm, + ); + const deposits = entry?.deposits; + if (!deposits || !deposits.enabled) { + return null; + } + return deposits; +} + +/** + * Looks up the questionnaire for a PROVEN on-chain sender. Matching is + * case-insensitive. Returns null when the sender is not a declared originator — + * the reconciler must then leave the deposit frozen and surface an anomaly + * rather than attest an origin it cannot prove is ours. + */ +export function resolveDepositOriginatorQuestionnaire( + config: TravelRuleDepositConfig, + senderAddress: string, +): TravelRuleDepositQuestionnaire | null { + const senderNorm = senderAddress.trim().toLowerCase(); + const match = Object.entries(config.originators).find( + ([address]) => address.trim().toLowerCase() === senderNorm, + ); + return match ? match[1].questionnaire : null; +} + +/** + * Registers Binance's localentity deposit endpoints used by the reconciler: + * the two read endpoints (deposit history + questionnaire requirements) and the + * provide-info write. No-op for non-Binance exchanges. Idempotent. + * + * `localentity/deposit/provide-info` must be signed with the questionnaire JSON + * RAW (unencoded) — see the @usherlabs/ccxt patch (`binance.sign` rawencode + * branch). Signing it url-encoded yields Binance error -1022. The two GETs carry + * only simple params and sign fine either way. + * + * provide-info is a 600-weight UID-limited write (same class as + * capital/withdraw/apply), so it carries ccxt-binance's `4.0002` cost encoding + * (`.0002` = charge the UID limiter) rather than a naive `1`, which would + * under-throttle the write and invite -1003 once attestation is active. + */ +export function registerBinanceTravelRuleDepositEndpoints( + exchange: Exchange, +): void { + if (exchange.id !== "binance") { + return; + } + exchange.defineRestApi( + { + sapi: { + get: { + "localentity/deposit/history": 1, + "localentity/questionnaire-requirements": 1, + }, + put: { "localentity/deposit/provide-info": 4.0002 }, + }, + }, + "request", + ); +} + +type LocalEntityWithdrawArgs = { + code: string; + amount: number; + address: string; + network: string; + questionnaire: TravelRuleQuestionnaire; + // Extra caller params (memo/tag, withdrawOrderId, etc.), forwarded to keep + // parity with the standard withdraw path. The fixed fields below win on + // collision so params can never override coin/amount/network/questionnaire. + params?: Record; +}; + +type BinanceLocalEntityWithdraw = { + sapiPostLocalentityWithdrawApply?: ( + params: Record, + ) => Promise; +}; + +/** + * Mirrors ccxt's `binance.withdraw` currency/network/precision handling, but + * targets the travel-rule endpoint and attaches the questionnaire. ccxt's + * `urlencode` applies `encodeURIComponent` to the value, satisfying Binance's + * requirement that the questionnaire JSON be URL-encoded in the request body. + */ +export async function withdrawViaLocalEntity( + broker: Exchange, + args: LocalEntityWithdrawArgs, +) { + const exchange = broker as Exchange & BinanceLocalEntityWithdraw; + if (typeof exchange.sapiPostLocalentityWithdrawApply !== "function") { + throw new Error( + "binance_localentity_withdraw_unavailable: travel-rule withdraw endpoint is not registered on this exchange instance", + ); + } + + broker.checkAddress(args.address); + await broker.loadMarkets(); + const currency = broker.currency(args.code); + + const networks = broker.safeDict(broker.options, "networks", {}); + const networkUpper = args.network.trim().toUpperCase(); + const mappedNetwork = broker.safeString(networks, networkUpper, networkUpper); + + const request: Record = { + ...args.params, + coin: currency.id, + address: args.address, + amount: broker.currencyToPrecision(args.code, args.amount), + network: mappedNetwork, + questionnaire: JSON.stringify(args.questionnaire), + }; + + const response = await exchange.sapiPostLocalentityWithdrawApply(request); + return broker.parseTransaction(response, currency); +} diff --git a/src/helpers/treasury-discovery.ts b/src/helpers/treasury-discovery.ts new file mode 100644 index 0000000..3d930f2 --- /dev/null +++ b/src/helpers/treasury-discovery.ts @@ -0,0 +1,101 @@ +import type { Exchange } from "@usherlabs/ccxt"; + +export type ExchangeWithDiscovery = Exchange & { + has?: Record; + markets?: Record; + currencies?: Record; + fetchMarkets?: (...args: unknown[]) => Promise; + fetchCurrencies?: (...args: unknown[]) => Promise>; + fetchDeposits?: ( + code?: string, + since?: number, + limit?: number, + params?: Record, + ) => Promise>>; +}; + +export function callArgs( + args: unknown[] | undefined, + params: Record | undefined, +): unknown[] { + const argsArray = Array.isArray(args) ? [...args] : []; + if (params && Object.keys(params).length > 0) { + argsArray.push(params); + } + return argsArray; +} + +export async function handleTreasuryDiscoveryCall( + broker: Exchange, + functionName: string, + args: unknown[], + params: Record, +): Promise<{ handled: true; result: unknown } | { handled: false }> { + const discoveryBroker = broker as ExchangeWithDiscovery; + + if (functionName === "fetchMarkets") { + if ( + typeof discoveryBroker.fetchMarkets === "function" && + discoveryBroker.has?.fetchMarkets !== false + ) { + return { + handled: true, + result: await discoveryBroker.fetchMarkets(...callArgs(args, params)), + }; + } + if (typeof discoveryBroker.loadMarkets === "function") { + const loaded = await discoveryBroker.loadMarkets(false, params); + const markets = + loaded && typeof loaded === "object" && !Array.isArray(loaded) + ? Object.values(loaded as Record) + : Object.values(discoveryBroker.markets ?? {}); + return { handled: true, result: markets }; + } + throw new Error( + "venue_discovery_unavailable: fetchMarkets unavailable on broker", + ); + } + + if (functionName === "fetchCurrencies") { + if ( + typeof discoveryBroker.fetchCurrencies === "function" && + discoveryBroker.has?.fetchCurrencies !== false + ) { + return { + handled: true, + result: await discoveryBroker.fetchCurrencies( + ...callArgs(args, params), + ), + }; + } + if ( + discoveryBroker.currencies && + Object.keys(discoveryBroker.currencies).length > 0 + ) { + return { handled: true, result: discoveryBroker.currencies }; + } + throw new Error( + "venue_discovery_unavailable: fetchCurrencies unavailable on broker", + ); + } + + return { handled: false }; +} + +export async function fetchCurrencyMetadata( + broker: Exchange, + assetCode: string, +): Promise | undefined> { + const discoveryBroker = broker as ExchangeWithDiscovery; + const normalizedAsset = assetCode.trim().toUpperCase(); + if ( + typeof discoveryBroker.fetchCurrencies === "function" && + discoveryBroker.has?.fetchCurrencies !== false + ) { + const currencies = await discoveryBroker.fetchCurrencies(); + return currencies[normalizedAsset] as Record | undefined; + } + return discoveryBroker.currencies?.[normalizedAsset] as + | Record + | undefined; +} diff --git a/src/helpers/verity.ts b/src/helpers/verity.ts new file mode 100644 index 0000000..dc144d1 --- /dev/null +++ b/src/helpers/verity.ts @@ -0,0 +1,52 @@ +import type { Metadata } from "@grpc/grpc-js"; +import type { + HttpClientOverride, + HttpOverridePredicate, +} from "@usherlabs/ccxt"; +import { VerityClient } from "@usherlabs/verity-client"; +import { CCXT_METHODS_WITH_VERITY } from "./constants"; + +export function createVerityHttpClientOverride( + verityProverUrl: string, + onProofCallback: (proof: string, notaryPubKey?: string) => void, +) { + const client = new VerityClient({ proverUrl: verityProverUrl }); + return (redact: string, proofTimeout: number): HttpClientOverride => + async ({ url, config }) => { + // { method, url, config, data, meta } + let pending = client.get(url, config, { proofTimeout }); + if (redact) { + pending = pending.redact(redact || ""); + } + const response = await pending; + if (response.proof) { + onProofCallback(response.proof, response.notary_pub_key); + } + return response; + }; +} + +export function buildHttpClientOverrideFromMetadata( + metadata: Metadata, + verityProverUrl: string, + onProofCallback: (proof: string, notaryPubKey?: string) => void, +): HttpClientOverride { + const redact = metadata.get("verity-t-redacted")?.[0]?.toString() || ""; + const rawTimeout = metadata.get("verity-proof-timeout")?.[0]?.toString(); + const proofTimeout = rawTimeout ? parseInt(rawTimeout, 10) : 5 * 60 * 1000; // default 5 minutes + const factory = createVerityHttpClientOverride( + verityProverUrl, + onProofCallback, + ); + return factory(redact, proofTimeout); +} + +export const verityHttpClientOverridePredicate: HttpOverridePredicate = ({ + method, + methodCalled, +}) => { + return ( + ["get", "post"].includes(method.toLowerCase()) && + CCXT_METHODS_WITH_VERITY.includes(methodCalled) + ); +}; diff --git a/src/index.ts b/src/index.ts index e06ce85..f14eea7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,9 +6,20 @@ import { type BrokerPoolEntry, createBrokerPool, loadPolicy, + loadTravelRuleDepositReconcilerConfigFromEnv, normalizePolicyConfig, + TravelRuleDepositReconciler, } from "./helpers"; +import { AccountBalanceArchivePoller } from "./helpers/account-balance-archive-poller"; +import { + type BrokerExecutionArchiver, + createBrokerExecutionArchiverFromEnv, + WithdrawalObservationTracker, +} from "./helpers/broker-execution-archive"; +import { DepositArchivePoller } from "./helpers/deposit-archive-poller"; +import { FillArchivePoller } from "./helpers/fill-archive-poller"; import { log } from "./helpers/logger"; +import { OrderActivityTracker } from "./helpers/order-activity-tracker"; import { createOtelLogsFromEnv, createOtelMetricsFromEnv, @@ -44,6 +55,18 @@ export default class CEXBroker { private useVerity: boolean = false; private otelMetrics?: OtelMetrics; private otelLogs?: OtelLogs; + private brokerArchiver?: BrokerExecutionArchiver; + private depositReconciler?: TravelRuleDepositReconciler; + // Order activity feeds the fill poller its per-market poll set; shared with the + // execute-action handler so orders record the (account, symbol) they touch. + private readonly orderActivityTracker = new OrderActivityTracker(); + // Persist across server rebuilds on policy reload so repeated venue polling is + // suppressed for the lifetime of this broker process. + private readonly withdrawalObservationTracker = + new WithdrawalObservationTracker(); + private fillArchivePoller?: FillArchivePoller; + private depositArchivePoller?: DepositArchivePoller; + private accountBalanceArchivePoller?: AccountBalanceArchivePoller; /** * Loads environment variables prefixed with CEX_BROKER_ @@ -219,6 +242,10 @@ export default class CEXBroker { this.otelMetrics = createOtelMetricsFromEnv(); this.otelLogs = createOtelLogsFromEnv(); } + this.brokerArchiver = createBrokerExecutionArchiverFromEnv( + this.otelLogs, + this.otelMetrics, + ); this.loadExchangeCredentials(apiCredentials); this.whitelistIps = [ @@ -257,9 +284,28 @@ export default class CEXBroker { unwatchFile(this.#policyFilePath); log.info(`Stopped watching policy file: ${this.#policyFilePath}`); } + if (this.depositReconciler) { + this.depositReconciler.stop(); + this.depositReconciler = undefined; + } + if (this.fillArchivePoller) { + this.fillArchivePoller.stop(); + this.fillArchivePoller = undefined; + } + if (this.depositArchivePoller) { + await this.depositArchivePoller.stop(); + this.depositArchivePoller = undefined; + } + if (this.accountBalanceArchivePoller) { + await this.accountBalanceArchivePoller.stop(); + this.accountBalanceArchivePoller = undefined; + } if (this.server) { await this.server.forceShutdown(); } + if (this.brokerArchiver) { + await this.brokerArchiver.close(); + } if (this.otelMetrics) { await this.otelMetrics.close(); } @@ -275,6 +321,24 @@ export default class CEXBroker { if (this.server) { await this.server.forceShutdown(); } + // run() is re-invoked on policy hot-reload; tear down the prior reconciler and + // poller so they are rebuilt rather than duplicated. + if (this.depositReconciler) { + this.depositReconciler.stop(); + this.depositReconciler = undefined; + } + if (this.fillArchivePoller) { + this.fillArchivePoller.stop(); + this.fillArchivePoller = undefined; + } + if (this.depositArchivePoller) { + await this.depositArchivePoller.stop(); + this.depositArchivePoller = undefined; + } + if (this.accountBalanceArchivePoller) { + await this.accountBalanceArchivePoller.stop(); + this.accountBalanceArchivePoller = undefined; + } log.info(`Running CEXBroker at ${new Date().toISOString()}`); // Initialize OTel metrics if enabled @@ -289,6 +353,9 @@ export default class CEXBroker { this.useVerity, this.#verityProverUrl, this.otelMetrics, + this.brokerArchiver, + this.orderActivityTracker, + this.withdrawalObservationTracker, ); this.server.bindAsync( @@ -302,6 +369,47 @@ export default class CEXBroker { log.info(`Your server as started on port ${port}`); }, ); + + // Start the travel-rule deposit auto-clear reconciler. It self-disables when + // no exchange has `travelRule.rule[].deposits.enabled` in policy, so this is a + // no-op (exact current behavior) unless the feature is turned on. + this.depositReconciler = new TravelRuleDepositReconciler({ + policy: this.policy, + brokers: this.brokers, + config: loadTravelRuleDepositReconcilerConfigFromEnv(process.env), + metrics: this.otelMetrics, + }); + this.depositReconciler.start(); + + // Fill capture starts only after the archive configuration has passed its + // forwarder and durable loss-journal validation. + if (this.brokerArchiver?.isEnabled()) { + this.fillArchivePoller = new FillArchivePoller({ + brokers: this.brokers, + archiver: this.brokerArchiver, + tracker: this.orderActivityTracker, + metrics: this.otelMetrics, + }); + this.fillArchivePoller.start(); + + this.depositArchivePoller = new DepositArchivePoller({ + brokers: this.brokers, + archiver: this.brokerArchiver, + metrics: this.otelMetrics, + }); + this.depositArchivePoller.start(); + } + + if (this.brokerArchiver?.canPersistAccountBalanceSnapshots()) { + // Balance coverage is advertised only with the HTTP forwarder: OTel logs + // are an observability mirror, not durable replay evidence. + this.accountBalanceArchivePoller = new AccountBalanceArchivePoller({ + brokers: this.brokers, + archiver: this.brokerArchiver, + metrics: this.otelMetrics, + }); + this.accountBalanceArchivePoller.start(); + } return this; } } diff --git a/src/proto-loader-options.ts b/src/proto-loader-options.ts new file mode 100644 index 0000000..6c1ff5e --- /dev/null +++ b/src/proto-loader-options.ts @@ -0,0 +1,9 @@ +import type { Options } from "@grpc/proto-loader"; + +export const PROTO_LOADER_OPTIONS = { + keepCase: true, + longs: String, + enums: String, + defaults: true, + oneofs: true, +} satisfies Options; diff --git a/src/proto-package-definition.ts b/src/proto-package-definition.ts new file mode 100644 index 0000000..5d10b21 --- /dev/null +++ b/src/proto-package-definition.ts @@ -0,0 +1,8 @@ +import * as protoLoader from "@grpc/proto-loader"; +import descriptor from "./proto/node.descriptor.ts"; +import { PROTO_LOADER_OPTIONS } from "./proto-loader-options"; + +export const CEX_BROKER_PACKAGE_DEFINITION = protoLoader.fromJSON( + descriptor as unknown as Record, + PROTO_LOADER_OPTIONS, +); diff --git a/src/proto/node.descriptor.ts b/src/proto/node.descriptor.ts new file mode 100644 index 0000000..8c407d1 --- /dev/null +++ b/src/proto/node.descriptor.ts @@ -0,0 +1,129 @@ +// Auto-generated from src/proto/node.proto. Do not edit manually. +const descriptor = { + nested: { + cex_broker: { + nested: { + ActionRequest: { + fields: { + action: { + type: "Action", + id: 1, + }, + payload: { + keyType: "string", + type: "string", + id: 2, + }, + cex: { + type: "string", + id: 3, + }, + symbol: { + type: "string", + id: 4, + }, + }, + }, + ActionResponse: { + fields: { + result: { + type: "string", + id: 1, + }, + proof: { + type: "string", + id: 2, + }, + }, + }, + SubscribeRequest: { + fields: { + cex: { + type: "string", + id: 1, + }, + symbol: { + type: "string", + id: 2, + }, + type: { + type: "SubscriptionType", + id: 3, + }, + options: { + keyType: "string", + type: "string", + id: 4, + }, + }, + }, + SubscribeResponse: { + fields: { + data: { + type: "string", + id: 1, + }, + timestamp: { + type: "int64", + id: 2, + }, + symbol: { + type: "string", + id: 3, + }, + type: { + type: "SubscriptionType", + id: 4, + }, + }, + }, + SubscriptionType: { + values: { + NO_ACTION: 0, + ORDERBOOK: 1, + TRADES: 2, + TICKER: 3, + OHLCV: 4, + BALANCE: 5, + ORDERS: 6, + }, + }, + cex_service: { + methods: { + ExecuteAction: { + requestType: "ActionRequest", + responseType: "ActionResponse", + }, + Subscribe: { + requestType: "SubscribeRequest", + responseType: "SubscribeResponse", + responseStream: true, + }, + }, + }, + Action: { + values: { + NoAction: 0, + Deposit: 1, + Withdraw: 2, + CreateOrder: 3, + GetOrderDetails: 4, + CancelOrder: 5, + FetchBalances: 6, + FetchDepositAddresses: 7, + FetchTicker: 8, + FetchCurrency: 9, + Call: 10, + FetchAccountId: 11, + FetchFees: 12, + InternalTransfer: 13, + GetPerpConfigState: 14, + SetPerpConfigState: 15, + }, + }, + }, + }, + }, +} as const; + +export default descriptor; diff --git a/src/proto/node.proto b/src/proto/node.proto index 93a421c..53dd0b9 100644 --- a/src/proto/node.proto +++ b/src/proto/node.proto @@ -58,4 +58,6 @@ enum Action { FetchAccountId=11; FetchFees= 12; InternalTransfer= 13; + GetPerpConfigState = 14; + SetPerpConfigState = 15; } \ No newline at end of file diff --git a/src/schemas/action-payloads.ts b/src/schemas/action-payloads.ts index 613a6b4..e438470 100644 --- a/src/schemas/action-payloads.ts +++ b/src/schemas/action-payloads.ts @@ -41,6 +41,7 @@ export const DepositPayloadSchema = z.object({ export const CallPayloadSchema = z.object({ functionName: z.string().regex(/^[A-Za-z][A-Za-z0-9]*$/), args: z.preprocess(parseJsonString, z.array(z.unknown())).default([]), + orderAuthor: z.string().min(1).optional(), params: z .preprocess(parseJsonString, z.record(z.string(), z.unknown())) .default({}), @@ -66,15 +67,40 @@ export const InternalTransferPayloadSchema = z.object({ toAccount: z.string().min(1).optional(), }); +const marketTypeSchema = z + .enum(["spot", "swap", "perp", "future", "futures"]) + .optional(); + +const unknownParamsSchema = z.preprocess( + parseJsonString, + z.record(z.string(), z.unknown()), +); + export const CreateOrderPayloadSchema = z.object({ orderType: z.enum(["market", "limit"]).default("limit"), + orderIntent: z.enum(["passive_only"]).optional(), amount: z.coerce.number().positive(), fromToken: z.string().min(1), toToken: z.string().min(1), price: z.coerce.number().positive(), + marketType: marketTypeSchema, + clientOrderId: z.string().min(1).optional(), + orderAuthor: z.string().min(1).optional(), params: z.preprocess(parseJsonString, stringNumberRecordSchema).default({}), }); +export const GetPerpConfigStatePayloadSchema = z.object({ + symbol: z.string().min(1).optional(), + params: unknownParamsSchema.default({}), +}); + +export const SetPerpConfigStatePayloadSchema = z.object({ + symbol: z.string().min(1), + leverage: z.coerce.number().positive(), + marginMode: z.enum(["cross", "isolated"]).optional(), + params: unknownParamsSchema.default({}), +}); + export const GetOrderDetailsPayloadSchema = z.object({ orderId: z.string().min(1), params: z.preprocess(parseJsonString, stringNumberRecordSchema).default({}), @@ -100,6 +126,12 @@ export type InternalTransferPayload = z.infer< typeof InternalTransferPayloadSchema >; export type CreateOrderPayload = z.infer; +export type GetPerpConfigStatePayload = z.infer< + typeof GetPerpConfigStatePayloadSchema +>; +export type SetPerpConfigStatePayload = z.infer< + typeof SetPerpConfigStatePayloadSchema +>; export type GetOrderDetailsPayload = z.infer< typeof GetOrderDetailsPayloadSchema >; diff --git a/src/server.ts b/src/server.ts index b919b9e..2357529 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,85 +1,20 @@ import * as grpc from "@grpc/grpc-js"; -import * as protoLoader from "@grpc/proto-loader"; -import ccxt, { type Exchange } from "@usherlabs/ccxt"; -import path from "path"; -import { fileURLToPath } from "url"; -import type { z } from "zod"; -import { - authenticateRequest, - BrokerAccountPreconditionError, - type BrokerPoolEntry, - buildHttpClientOverrideFromMetadata, - createBroker, - getCurrentBrokerSelector, - resolveBrokerAccount, - resolveOrderExecution, - selectBroker, - transferBinanceInternal, - validateDeposit, - validateWithdraw, - verityHttpClientOverridePredicate, -} from "./helpers"; -import { - Action, - getActionName, - getSubscriptionTypeName, - type Action as ActionType, - resolveSubscriptionType, - SubscriptionType, - type SubscriptionType as SubscriptionTypeValue, -} from "./helpers/constants"; -import { log } from "./helpers/logger"; +import { createExecuteActionHandler } from "./handlers/execute-action"; +import type { SubscribeBrokerLifecycle } from "./handlers/subscribe"; +import { createSubscribeHandler } from "./handlers/subscribe"; +import type { BrokerPoolEntry } from "./helpers"; +import type { + BrokerExecutionArchiver, + WithdrawalObservationTracker, +} from "./helpers/broker-execution-archive"; +import type { OrderActivityTracker } from "./helpers/order-activity-tracker"; import type { OtelMetrics } from "./helpers/otel"; -import { - CallPayloadSchema, - CancelOrderPayloadSchema, - CreateOrderPayloadSchema, - DepositPayloadSchema, - FetchDepositAddressesPayloadSchema, - FetchFeesPayloadSchema, - GetOrderDetailsPayloadSchema, - InternalTransferPayloadSchema, - WithdrawPayloadSchema, -} from "./schemas/action-payloads"; +import { CEX_BROKER_PACKAGE_DEFINITION } from "./proto-package-definition"; import type { PolicyConfig } from "./types"; -type ActionRequest = { - action?: ActionType; - payload?: Record; - cex?: string; - symbol?: string; -}; - -type ActionResponse = { - result: string; - proof?: string; -}; - -type SubscribeRequest = { - cex?: string; - symbol?: string; - type?: SubscriptionTypeValue; - options?: Record; -}; - -type SubscribeResponse = { - data: string; - timestamp: number; - symbol: string; - type: SubscriptionTypeValue; -}; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); -const protoPath = path.join(__dirname, "proto", "node.proto"); - -const packageDef = protoLoader.loadSync(protoPath, { - keepCase: true, - longs: String, - defaults: true, - oneofs: true, -}); -const grpcObj = grpc.loadPackageDefinition(packageDef) as unknown as { +const grpcObj = grpc.loadPackageDefinition( + CEX_BROKER_PACKAGE_DEFINITION, +) as unknown as { cex_broker: { cex_service: { service: grpc.ServiceDefinition; @@ -88,62 +23,6 @@ const grpcObj = grpc.loadPackageDefinition(packageDef) as unknown as { }; const cexNode = grpcObj.cex_broker; -function parsePayload( - schema: z.ZodType, - rawPayload: Record | undefined, -): { success: true; data: T } | { success: false; message: string } { - const parsed = schema.safeParse(rawPayload ?? {}); - if (parsed.success) { - return { success: true, data: parsed.data }; - } - const firstIssue = parsed.error.issues[0]; - const path = - firstIssue && firstIssue.path.length > 0 - ? `${firstIssue.path.join(".")}: ` - : ""; - return { - success: false, - message: `ValidationError: ${path}${firstIssue?.message ?? "Invalid payload"}`, - }; -} - -function getErrorMessage(error: unknown): string { - return error instanceof Error - ? error.message - : typeof error === "string" - ? error - : "Unknown error"; -} - -function safeLogError(context: string, error: unknown): void { - try { - log.error(context, { error }); - } catch { - console.error(context, error); - } -} - -/** Maps CCXT typed errors to appropriate gRPC status codes. Returns undefined for unrecognized errors. */ -function mapCcxtErrorToGrpcStatus(error: unknown): grpc.status | undefined { - if (error instanceof ccxt.AuthenticationError) - return grpc.status.UNAUTHENTICATED; - if (error instanceof ccxt.PermissionDenied) - return grpc.status.PERMISSION_DENIED; - if (error instanceof ccxt.InsufficientFunds) - return grpc.status.FAILED_PRECONDITION; - if (error instanceof ccxt.InvalidAddress) return grpc.status.INVALID_ARGUMENT; - if (error instanceof ccxt.BadSymbol) return grpc.status.NOT_FOUND; - if (error instanceof ccxt.BadRequest) return grpc.status.INVALID_ARGUMENT; - if (error instanceof ccxt.NotSupported) return grpc.status.UNIMPLEMENTED; - if (error instanceof ccxt.RateLimitExceeded) - return grpc.status.RESOURCE_EXHAUSTED; - if (error instanceof ccxt.OnMaintenance) return grpc.status.UNAVAILABLE; - if (error instanceof ccxt.ExchangeNotAvailable) - return grpc.status.UNAVAILABLE; - if (error instanceof ccxt.NetworkError) return grpc.status.UNAVAILABLE; - return undefined; -} - export function getServer( policy: PolicyConfig, brokers: Record, @@ -151,1395 +30,32 @@ export function getServer( useVerity: boolean, verityProverUrl: string, otelMetrics?: OtelMetrics, + brokerArchiver?: BrokerExecutionArchiver, + orderActivityTracker?: OrderActivityTracker, + withdrawalObservationTracker?: WithdrawalObservationTracker, + subscribeBrokerLifecycle?: SubscribeBrokerLifecycle, ) { const server = new grpc.Server(); server.addService(cexNode.cex_service.service, { - ExecuteAction: async ( - call: grpc.ServerUnaryCall, - callback: grpc.sendUnaryData, - ) => { - const startTime = Date.now(); - const { action, cex, symbol } = call.request; - let actionCompleted = false; - - // Wrap callback to track success/failure - const wrappedCallback: grpc.sendUnaryData = ( - error, - value, - ) => { - if (!actionCompleted) { - actionCompleted = true; - const latency = Date.now() - startTime; - - // Record latency histogram - const actionName = getActionName(action); - otelMetrics?.recordHistogram("execute_action_duration_ms", latency, { - action: actionName, - cex: cex || "unknown", - }); - - if (error) { - // Record failure - otelMetrics?.recordCounter("execute_action_errors_total", 1, { - action: actionName, - cex: cex || "unknown", - error_type: error.code - ? grpc.status[error.code] || "unknown" - : "unknown", - }); - } else { - // Record success - otelMetrics?.recordCounter("execute_action_success_total", 1, { - action: actionName, - cex: cex || "unknown", - }); - } - } - callback(error, value); - }; - - try { - // Log incoming request - log.info(`Request - ExecuteAction:`, { - action, - cex, - symbol, - }); - - // Record request counter - const actionName = getActionName(action); - otelMetrics?.recordCounter("execute_action_requests_total", 1, { - action: actionName, - cex: cex || "unknown", - }); - - // IP Authentication - if (!authenticateRequest(call, whitelistIps)) { - return wrappedCallback( - { - code: grpc.status.PERMISSION_DENIED, - message: "Access denied: Unauthorized IP", - }, - null, - ); - } - // Read incoming metadata - const metadata = call.metadata; - // Validate required fields - if (!action || !cex) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: "`action` AND `cex` fields are required", - }, - null, - ); - } - - const normalizedCex = cex.trim().toLowerCase(); - - // If the Exchange is not already pre-loaded for preset API credentials via constructor - createBroker for non-gated APIs may be available for other exchanges. - const broker = - selectBroker( - brokers[normalizedCex as keyof typeof brokers], - metadata, - ) ?? createBroker(normalizedCex, metadata); - - if (!broker) { - return wrappedCallback( - { - code: grpc.status.UNAUTHENTICATED, - message: `This Exchange is not registered and No API metadata ws found`, - }, - null, - ); - } - - // Verity only for ExecuteAction - let verityProof = ""; - if (useVerity) { - const override = buildHttpClientOverrideFromMetadata( - metadata, - verityProverUrl, - (proof, notaryPubKey) => { - verityProof = proof; - log.debug(`Verity proof:`, { proof, notaryPubKey }); - }, - ); - broker.setHttpClientOverride( - override, - verityHttpClientOverridePredicate, - ); - } - - switch (action) { - case Action.Deposit: { - const parsedPayload = parsePayload( - DepositPayloadSchema, - call.request.payload, - ); - if (!parsedPayload.success) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: parsedPayload.message, - }, - null, - ); - } - const value = parsedPayload.data; - try { - const deposits = await broker.fetchDeposits( - symbol, - value.since, - 50, - { ...(value.params ?? {}) }, - ); - const deposit = deposits.find( - (deposit) => - deposit.id === value.transactionHash || - deposit.txid === value.transactionHash, - ); - - if (deposit) { - log.info( - `Amount ${value.amount} at ${value.transactionHash} . Paid to ${value.recipientAddress}`, - ); - return wrappedCallback(null, { - proof: verityProof, - result: JSON.stringify({ ...deposit }), - }); - } - wrappedCallback( - { - code: grpc.status.INTERNAL, - message: "Deposit confirmation failed", - }, - null, - ); - } catch (error) { - safeLogError("Deposit confirmation failed", error); - wrappedCallback( - { - code: grpc.status.INTERNAL, - message: "Deposit confirmation failed", - }, - null, - ); - } - break; - } - - case Action.FetchCurrency: { - if (!symbol) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: `ValidationError: Symbol requied`, - }, - null, - ); - } - try { - const currencies = await broker.fetchCurrencies(symbol); - const currencyInfo = currencies[symbol]; - if (!currencyInfo) { - return wrappedCallback( - { - code: grpc.status.NOT_FOUND, - message: `Currency not found for ${symbol}`, - }, - null, - ); - } - wrappedCallback(null, { - proof: verityProof, - result: JSON.stringify(currencyInfo), - }); - } catch (error) { - safeLogError( - `Error fetching currency ${symbol} from ${cex}`, - error, - ); - wrappedCallback( - { - code: grpc.status.INTERNAL, - message: `Failed to fetch currency for ${symbol} from ${cex}`, - }, - null, - ); - } - break; - } - - case Action.FetchAccountId: { - try { - const accountId = await broker.fetchAccountId(); - - // Return normalized response - return wrappedCallback(null, { - proof: verityProof, - result: JSON.stringify({ accountId }), - }); - } catch (error) { - safeLogError(`Error fetching account ID ${cex}`, error); - wrappedCallback( - { - code: grpc.status.INTERNAL, - message: `Error fetching account ID from ${cex}`, - }, - null, - ); - } - break; - } - - case Action.FetchFees: { - if (!symbol) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: `ValidationError: Symbol required`, - }, - null, - ); - } - const parsedPayload = parsePayload( - FetchFeesPayloadSchema, - call.request.payload, - ); - if (!parsedPayload.success) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: parsedPayload.message, - }, - null, - ); - } - const includeAllFees = - parsedPayload.data.includeAllFees || - parsedPayload.data.includeFundingFees === true; - try { - await broker.loadMarkets(); - const fetchFundingFees = async (currencyCodes: string[]) => { - let fundingFeeSource: - | "fetchDepositWithdrawFees" - | "currencies" - | "unavailable" = "unavailable"; - const fundingFeesByCurrency: Record = {}; - - if (broker.has.fetchDepositWithdrawFees) { - try { - const feeMap = (await broker.fetchDepositWithdrawFees( - currencyCodes, - )) as unknown as Record< - string, - { - deposit?: unknown; - withdraw?: unknown; - networks?: unknown; - fee?: number; - percentage?: boolean; - } - >; - for (const code of currencyCodes) { - const feeInfo = feeMap[code]; - if (!feeInfo) { - continue; - } - const fallbackFee = - feeInfo.fee !== undefined || - feeInfo.percentage !== undefined - ? { - fee: feeInfo.fee ?? null, - percentage: feeInfo.percentage ?? null, - } - : null; - fundingFeesByCurrency[code] = { - deposit: feeInfo.deposit ?? fallbackFee, - withdraw: feeInfo.withdraw ?? fallbackFee, - networks: feeInfo.networks ?? {}, - }; - } - if (Object.keys(fundingFeesByCurrency).length > 0) { - fundingFeeSource = "fetchDepositWithdrawFees"; - } - } catch (error) { - safeLogError( - `Error fetching deposit/withdraw fee map for ${symbol} from ${cex}`, - error, - ); - } - } - - if (fundingFeeSource === "unavailable") { - try { - const currencies = await broker.fetchCurrencies(); - for (const code of currencyCodes) { - const currency = currencies[code]; - if (!currency) { - continue; - } - fundingFeesByCurrency[code] = { - deposit: { - enabled: currency.deposit ?? null, - }, - withdraw: { - enabled: currency.withdraw ?? null, - fee: currency.fee ?? null, - limits: currency.limits?.withdraw ?? null, - }, - networks: currency.networks ?? {}, - }; - } - if (Object.keys(fundingFeesByCurrency).length > 0) { - fundingFeeSource = "currencies"; - } - } catch (error) { - safeLogError( - `Error fetching currency metadata for fees for ${symbol} from ${cex}`, - error, - ); - } - } - - return { fundingFeeSource, fundingFeesByCurrency }; - }; - - const isMarketSymbol = symbol.includes("/"); - if (isMarketSymbol) { - const market = await broker.market(symbol); - const generalFee = broker.fees ?? null; - const feeStatus = broker.fees ? "available" : "unknown"; - - if (!broker.fees) { - log.warn(`Fee metadata unavailable for ${cex}`, { symbol }); - } - - if (!includeAllFees) { - return wrappedCallback(null, { - proof: verityProof, - result: JSON.stringify({ - feeScope: "market", - generalFee, - feeStatus, - market, - }), - }); - } - - const currencyCodes = Array.from( - new Set([market.base, market.quote]), - ); - const { fundingFeeSource, fundingFeesByCurrency } = - await fetchFundingFees(currencyCodes); - return wrappedCallback(null, { - proof: verityProof, - result: JSON.stringify({ - feeScope: "market+funding", - generalFee, - feeStatus, - market, - fundingFeeSource, - fundingFeesByCurrency, - }), - }); - } - - const tokenCode = symbol.toUpperCase(); - const { fundingFeeSource, fundingFeesByCurrency } = - await fetchFundingFees([tokenCode]); - return wrappedCallback(null, { - proof: verityProof, - result: JSON.stringify({ - feeScope: "token", - symbol: tokenCode, - fundingFeeSource, - fundingFeesByCurrency, - }), - }); - } catch (error) { - safeLogError( - `Error fetching fees for ${symbol} from ${cex}`, - error, - ); - wrappedCallback( - { - code: grpc.status.INTERNAL, - message: `Error fetching fees from ${cex}`, - }, - null, - ); - } - break; - } - - case Action.Call: { - const parsedPayload = parsePayload( - CallPayloadSchema, - call.request.payload, - ); - if (!parsedPayload.success) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: parsedPayload.message, - }, - null, - ); - } - const callValue = parsedPayload.data; - - try { - // Ensure function exists and is callable on the broker - const fn = (broker as unknown as Record)[ - callValue.functionName - ]; - if ( - typeof fn !== "function" || - !broker.has[callValue.functionName] - ) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: `Function not found on broker: ${callValue.functionName}`, - }, - null, - ); - } - - // Prevent access to dangerous names - if ( - callValue.functionName.startsWith("_") || - callValue.functionName.includes("constructor") || - callValue.functionName.includes("prototype") - ) { - return wrappedCallback( - { - code: grpc.status.PERMISSION_DENIED, - message: "Access to the requested function is denied", - }, - null, - ); - } - - // Prepare arguments - const argsArray: unknown[] = Array.isArray(callValue.args) - ? [...callValue.args] - : []; - const paramsObject = callValue.params ?? {}; - if (Object.keys(paramsObject).length > 0) { - argsArray.push(paramsObject); - } - - // Invoke - // biome-ignore lint/suspicious/noExplicitAny: dynamic call required for generic broker methods - const result = await (fn as any).apply(broker, argsArray); - - wrappedCallback(null, { - proof: verityProof, - result: JSON.stringify(result), - }); - } catch (error: unknown) { - safeLogError("Call failed", error); - const message = getErrorMessage(error); - wrappedCallback( - { - code: grpc.status.INTERNAL, - message: `Call failed: ${message}`, - }, - null, - ); - } - break; - } - - case Action.FetchDepositAddresses: { - if (!symbol) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: `ValidationError: Symbol requied`, - }, - null, - ); - } - const parsedPayload = parsePayload( - FetchDepositAddressesPayloadSchema, - call.request.payload, - ); - if (!parsedPayload.success) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: parsedPayload.message, - }, - null, - ); - } - const fetchDepositAddresses = parsedPayload.data; - const depositValidation = validateDeposit( - policy, - cex, - fetchDepositAddresses.chain, - symbol, - ); - if (!depositValidation.valid) { - return wrappedCallback( - { - code: grpc.status.PERMISSION_DENIED, - message: depositValidation.error, - }, - null, - ); - } - try { - const depositAddresses = - broker.has.fetchDepositAddress === true - ? [ - await broker.fetchDepositAddress(symbol, { - network: fetchDepositAddresses.chain, - ...(fetchDepositAddresses.params ?? {}), - }), - ] - : await broker.fetchDepositAddressesByNetwork(symbol, { - network: fetchDepositAddresses.chain, - ...(fetchDepositAddresses.params ?? {}), - }); - - if (depositAddresses.length > 0) { - return wrappedCallback(null, { - proof: verityProof, - result: JSON.stringify(depositAddresses), - }); - } - wrappedCallback( - { - code: grpc.status.INTERNAL, - message: "Deposit confirmation failed", - }, - null, - ); - } catch (error: unknown) { - safeLogError( - "Fetch Deposit Addresses confirmation failed", - error, - ); - const message = getErrorMessage(error); - wrappedCallback( - { - code: grpc.status.INTERNAL, - message: - "Fetch Deposit Addresses confirmation failed: " + message, - }, - null, - ); - } - break; - } - case Action.Withdraw: { - if (!symbol) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: `ValidationError: Symbol requied`, - }, - null, - ); - } - const parsedPayload = parsePayload( - WithdrawPayloadSchema, - call.request.payload, - ); - if (!parsedPayload.success) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: parsedPayload.message, - }, - null, - ); - } - const transferValue = parsedPayload.data; - const transferValidation = validateWithdraw( - policy, - cex, - transferValue.chain, - transferValue.recipientAddress, - transferValue.amount, - symbol, - ); - if (!transferValidation.valid) { - return wrappedCallback( - { - code: grpc.status.PERMISSION_DENIED, - message: transferValidation.error, - }, - null, - ); - } - try { - const transaction = await broker.withdraw( - symbol, - transferValue.amount, - transferValue.recipientAddress, - undefined, - { - ...(transferValue.params ?? {}), - network: transferValue.chain, - }, - ); - log.info(`Withdraw Result: ${JSON.stringify(transaction)}`); - - wrappedCallback(null, { - proof: verityProof, - result: JSON.stringify({ ...transaction }), - }); - } catch (error) { - safeLogError("Withdraw failed", error); - const code = - mapCcxtErrorToGrpcStatus(error) ?? grpc.status.INTERNAL; - wrappedCallback( - { - code, - message: `Withdraw failed: ${getErrorMessage(error)}`, - }, - null, - ); - } - break; - } - - case Action.CreateOrder: { - const parsedPayload = parsePayload( - CreateOrderPayloadSchema, - call.request.payload, - ); - if (!parsedPayload.success) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: parsedPayload.message, - }, - null, - ); - } - const orderValue = parsedPayload.data; - - try { - if (!broker) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: `Invalid CEX key: ${cex}. Supported keys: ${Object.keys(brokers).join(", ")}`, - }, - null, - ); - } - const resolution = await resolveOrderExecution( - policy, - broker, - cex, - orderValue.fromToken, - orderValue.toToken, - orderValue.amount, - orderValue.price, - ); - if (!resolution.valid || !resolution.symbol || !resolution.side) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: - resolution.error ?? - "Order rejected by policy: market or limits not satisfied", - }, - null, - ); - } - - const order = await broker.createOrder( - resolution.symbol, - orderValue.orderType, - resolution.side, - resolution.amountBase ?? orderValue.amount, - orderValue.price, - orderValue.params ?? {}, - ); - - wrappedCallback(null, { result: JSON.stringify({ ...order }) }); - } catch (error) { - safeLogError("Order Creation failed", error); - wrappedCallback( - { - code: grpc.status.INTERNAL, - message: "Order Creation failed", - }, - null, - ); - } - - break; - } - - case Action.GetOrderDetails: { - const parsedPayload = parsePayload( - GetOrderDetailsPayloadSchema, - call.request.payload, - ); - if (!parsedPayload.success) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: parsedPayload.message, - }, - null, - ); - } - const getOrderValue = parsedPayload.data; - - try { - // Validate CEX key - if (!broker) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: `Invalid CEX key: ${cex}. Supported keys: ${Object.keys(brokers).join(", ")}`, - }, - null, - ); - } - - const orderDetails = await broker.fetchOrder( - getOrderValue.orderId, - symbol, - { ...getOrderValue.params }, - ); - - wrappedCallback(null, { - result: JSON.stringify({ - orderId: orderDetails.id, - status: orderDetails.status, - amount: orderDetails.amount, - filled: orderDetails.filled, - remaining: orderDetails.remaining, - symbol: orderDetails.symbol, - side: orderDetails.side, - price: orderDetails.price, - }), - }); - } catch (error) { - safeLogError(`Error fetching order details from ${cex}`, error); - wrappedCallback( - { - code: grpc.status.INTERNAL, - message: `Failed to fetch order details from ${cex}`, - }, - null, - ); - } - break; - } - case Action.CancelOrder: { - const parsedPayload = parsePayload( - CancelOrderPayloadSchema, - call.request.payload, - ); - if (!parsedPayload.success) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: parsedPayload.message, - }, - null, - ); - } - const cancelOrderValue = parsedPayload.data; - - try { - const cancelledOrder = await broker.cancelOrder( - cancelOrderValue.orderId, - symbol, - cancelOrderValue.params ?? {}, - ); - - wrappedCallback(null, { - result: JSON.stringify({ ...cancelledOrder }), - }); - } catch (error) { - safeLogError(`Error cancelling order from ${cex}`, error); - wrappedCallback( - { - code: grpc.status.INTERNAL, - message: `Failed to cancel order from ${cex}`, - }, - null, - ); - } - break; - } - case Action.FetchBalances: - try { - // Determine balance type: free | used | total (default: total) - const payload = - (call.request.payload as Record) || {}; - const providedBalanceType = payload.balanceType as - | string - | undefined; - const balanceType = (providedBalanceType ?? "total").toString(); - const validBalanceTypes = new Set(["free", "used", "total"]); - if (!validBalanceTypes.has(balanceType)) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: `ValidationError: invalid balanceType '${providedBalanceType}'. Expected one of: free | used | total`, - }, - null, - ); - } - - const params = { ...payload } as Record; - delete (params as Record).balanceType; // Remove balanceType from params before passing to CCXT - // Default market type to spot unless explicitly provided - if (params.type === undefined) { - params.type = "spot"; - } - - // Always return the same schema with empty objects when not requested - let responseBalances: Record = {}; - - if (balanceType === "free") { - // biome-ignore lint/suspicious/noExplicitAny: ccxt typing quirk for partial balances - const partial = (await broker.fetchFreeBalance(params)) as any; - responseBalances = partial ?? {}; - } else if (balanceType === "used") { - // biome-ignore lint/suspicious/noExplicitAny: ccxt typing quirk for partial balances - const partial = (await broker.fetchUsedBalance(params)) as any; - responseBalances = partial ?? {}; - } else if (balanceType === "total") { - // biome-ignore lint/suspicious/noExplicitAny: ccxt typing quirk for partial balances - const partial = (await broker.fetchTotalBalance(params)) as any; - responseBalances = partial ?? {}; - } - - // Extract and isolate the symbol if it exists. - if (symbol) { - if (typeof responseBalances[symbol] === "number") { - responseBalances = { - [symbol]: responseBalances[symbol] ?? 0, - }; - } else { - responseBalances = {}; - } - } - - wrappedCallback(null, { - proof: verityProof, - result: JSON.stringify({ - balances: responseBalances, - balanceType, - }), - }); - } catch (error) { - safeLogError(`Error fetching balance from ${cex}`, error); - wrappedCallback( - { - code: grpc.status.INTERNAL, - message: `Failed to fetch balance from ${cex}`, - }, - null, - ); - } - break; - - case Action.FetchTicker: - if (!symbol) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: `ValidationError: Symbol requied`, - }, - null, - ); - } - try { - const ticker = await broker.fetchTicker(symbol); - wrappedCallback(null, { - proof: verityProof, - result: JSON.stringify(ticker), - }); - } catch (error) { - safeLogError(`Error fetching ticker from ${cex}`, error); - wrappedCallback( - { - code: grpc.status.INTERNAL, - message: `Failed to fetch ticker from ${cex}`, - }, - null, - ); - } - break; - - case Action.InternalTransfer: { - if (!symbol) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: `ValidationError: Symbol required`, - }, - null, - ); - } - const parsedPayload = parsePayload( - InternalTransferPayloadSchema, - call.request.payload, - ); - if (!parsedPayload.success) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: parsedPayload.message, - }, - null, - ); - } - const transferPayload = parsedPayload.data; - - if (normalizedCex !== "binance") { - return wrappedCallback( - { - code: grpc.status.UNIMPLEMENTED, - message: `InternalTransfer is only supported for Binance`, - }, - null, - ); - } - - const pool = brokers[normalizedCex as keyof typeof brokers]; - if (!pool) { - return wrappedCallback( - { - code: grpc.status.FAILED_PRECONDITION, - message: `No broker accounts configured for ${normalizedCex}`, - }, - null, - ); - } - - const fromSelector = - transferPayload.fromAccount ?? getCurrentBrokerSelector(metadata); - const toSelector = transferPayload.toAccount ?? "primary"; - - const sourceAccount = resolveBrokerAccount(pool, fromSelector); - if (!sourceAccount) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: `Source account "${fromSelector}" is not configured`, - }, - null, - ); - } - - const destAccount = resolveBrokerAccount(pool, toSelector); - if (!destAccount) { - return wrappedCallback( - { - code: grpc.status.INVALID_ARGUMENT, - message: `Destination account "${toSelector}" is not configured`, - }, - null, - ); - } - - try { - if (useVerity) { - sourceAccount.exchange.setHttpClientOverride( - buildHttpClientOverrideFromMetadata( - metadata, - verityProverUrl, - (proof, notaryPubKey) => { - verityProof = proof; - log.debug(`Verity proof:`, { proof, notaryPubKey }); - }, - ), - verityHttpClientOverridePredicate, - ); - } - const result = await transferBinanceInternal( - sourceAccount, - destAccount, - symbol, - transferPayload.amount, - ); - wrappedCallback(null, { - proof: verityProof, - result: JSON.stringify(result), - }); - } catch (error) { - safeLogError("InternalTransfer failed", error); - if (error instanceof BrokerAccountPreconditionError) { - return wrappedCallback( - { - code: grpc.status.FAILED_PRECONDITION, - message: getErrorMessage(error), - }, - null, - ); - } - const msg = getErrorMessage(error); - let code: grpc.status; - if (msg.includes("Unsupported transfer direction")) { - code = grpc.status.INVALID_ARGUMENT; - } else if (msg.includes("unavailable in this CCXT build")) { - code = grpc.status.UNIMPLEMENTED; - } else { - code = mapCcxtErrorToGrpcStatus(error) ?? grpc.status.INTERNAL; - } - wrappedCallback( - { - code, - message: `InternalTransfer failed: ${msg}`, - }, - null, - ); - } - break; - } - - default: - return wrappedCallback({ - code: grpc.status.INVALID_ARGUMENT, - message: "Invalid Action", - }); - } - } catch (error) { - safeLogError("ExecuteAction unhandled error", error); - return wrappedCallback( - { - code: grpc.status.INTERNAL, - message: "ExecuteAction failed unexpectedly", - }, - null, - ); - } - }, - - Subscribe: async ( - call: grpc.ServerWritableStream, - ) => { - const subscribeStartTime = Date.now(); - // IP Authentication - if (!authenticateRequest(call, whitelistIps)) { - otelMetrics?.recordCounter("subscribe_errors_total", 1, { - error_type: "permission_denied", - }); - call.emit( - "error", - { - code: grpc.status.PERMISSION_DENIED, - message: "Access denied: Unauthorized IP", - }, - null, - ); - call.destroy(new Error("Access denied: Unauthorized IP")); - return; - } - // Read incoming metadata - const metadata = call.metadata; - let broker: Exchange | null = null; - - try { - // For ServerWritableStream, we need to get the request from the call - // The request should be available in the call object - const request = call.request as SubscribeRequest; - const { cex, symbol, type, options } = request; - - // proto-loader with defaults:true materializes omitted enums as NO_ACTION. - const subscriptionType = resolveSubscriptionType(type); - - log.info(`Request - Subscribe:`, { - cex: request.cex, - symbol: request.symbol, - type: subscriptionType, - }); - - // Record subscription request - const subscriptionTypeName = getSubscriptionTypeName(subscriptionType); - otelMetrics?.recordCounter("subscribe_requests_total", 1, { - cex: cex || "unknown", - symbol: symbol || "unknown", - type: subscriptionTypeName, - }); - - // Validate required fields - if (!cex || !symbol) { - call.write({ - data: JSON.stringify({ - error: "cex, symbol, and type are required", - }), - timestamp: Date.now(), - symbol: symbol || "", - type: subscriptionType, - }); - call.end(); - return; - } - - // Get or create broker (no Verity override in Subscribe) - broker = - selectBroker(brokers[cex as keyof typeof brokers], metadata) ?? - createBroker(cex, metadata); - - if (!broker) { - call.write({ - data: JSON.stringify({ - error: "Exchange not registered and no API metadata found", - }), - timestamp: Date.now(), - symbol, - type: subscriptionType, - }); - call.end(); - return; - } - - // Handle different subscription types - switch (subscriptionType) { - case SubscriptionType.ORDERBOOK: - try { - while (true) { - const orderbook = await broker.watchOrderBook(symbol); - call.write({ - data: JSON.stringify(orderbook), - timestamp: Date.now(), - symbol, - type: subscriptionType, - }); - } - } catch (error: unknown) { - log.error( - `Error fetching orderbook for ${symbol} on ${cex}:`, - error, - ); - const message = - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : "Unknown error"; - call.write({ - data: JSON.stringify({ - error: `Failed to fetch orderbook: ${message}`, - }), - timestamp: Date.now(), - symbol, - type: subscriptionType, - }); - } - break; - - case SubscriptionType.TRADES: - try { - while (true) { - const trades = await broker.watchTrades(symbol); - call.write({ - data: JSON.stringify(trades), - timestamp: Date.now(), - symbol, - type: subscriptionType, - }); - } - } catch (error: unknown) { - const message = - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : "Unknown error"; - log.error( - `Error fetching trades for ${symbol} on ${cex}:`, - error, - ); - call.write({ - data: JSON.stringify({ - error: `Failed to fetch trades: ${message}`, - }), - timestamp: Date.now(), - symbol, - type: subscriptionType, - }); - } - break; - - case SubscriptionType.TICKER: - try { - while (true) { - const ticker = await broker.watchTicker(symbol); - call.write({ - data: JSON.stringify(ticker), - timestamp: Date.now(), - symbol, - type: subscriptionType, - }); - } - } catch (error: unknown) { - const message = - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : "Unknown error"; - log.error( - `Error fetching ticker for ${symbol} on ${cex}:`, - error, - ); - call.write({ - data: JSON.stringify({ - error: `Failed to fetch ticker: ${message}`, - }), - timestamp: Date.now(), - symbol, - type: subscriptionType, - }); - } - break; - - case SubscriptionType.OHLCV: - try { - while (true) { - const timeframe = options?.timeframe || "1m"; - const ohlcv = await broker.fetchOHLCVWs(symbol, timeframe); - call.write({ - data: JSON.stringify(ohlcv), - timestamp: Date.now(), - symbol, - type: subscriptionType, - }); - } - } catch (error: unknown) { - log.error(`Error fetching OHLCV for ${symbol} on ${cex}:`, error); - const message = - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : "Unknown error"; - call.write({ - data: JSON.stringify({ - error: `Failed to fetch OHLCV: ${message}`, - }), - timestamp: Date.now(), - symbol, - type: subscriptionType, - }); - } - break; - - case SubscriptionType.BALANCE: - try { - while (true) { - const balance = await broker.watchBalance(); - call.write({ - data: JSON.stringify(balance), - timestamp: Date.now(), - symbol, - type: subscriptionType, - }); - } - } catch (error: unknown) { - const message = - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : "Unknown error"; - log.error(`Error fetching balance for ${cex}:`, error); - call.write({ - data: JSON.stringify({ - error: `Failed to fetch balance: ${message}`, - }), - timestamp: Date.now(), - symbol, - type: subscriptionType, - }); - } - break; - - case SubscriptionType.ORDERS: - try { - while (true) { - const orders = await broker.watchOrders(symbol); - call.write({ - data: JSON.stringify(orders), - timestamp: Date.now(), - symbol, - type: subscriptionType, - }); - } - } catch (error: unknown) { - log.error( - `Error fetching orders for ${symbol} on ${cex}:`, - error, - ); - const message = - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : "Unknown error"; - call.write({ - data: JSON.stringify({ - error: `Failed to fetch orders: ${message}`, - }), - timestamp: Date.now(), - symbol, - type: subscriptionType, - }); - } - break; - - default: - call.write({ - data: JSON.stringify({ error: "Invalid subscription type" }), - timestamp: Date.now(), - symbol, - type: subscriptionType, - }); - } - } catch (error) { - log.error("Error in Subscribe stream:", error); - const message = - error instanceof Error - ? error.message - : typeof error === "string" - ? error - : "Unknown error"; - call.write({ - data: JSON.stringify({ error: `Internal server error: ${message}` }), - timestamp: Date.now(), - symbol: "", - type: SubscriptionType.ORDERBOOK, - }); - } - - call.on("end", () => { - log.info("Subscribe stream ended"); - const duration = Date.now() - subscribeStartTime; - otelMetrics?.recordHistogram("subscribe_duration_ms", duration, { - cex: call.request?.cex || "unknown", - symbol: call.request?.symbol || "unknown", - }); - }); - - call.on("error", (error) => { - log.error("Subscribe stream error:", error); - otelMetrics?.recordCounter("subscribe_errors_total", 1, { - error_type: error instanceof Error ? error.message : "unknown", - }); - }); - }, + ExecuteAction: createExecuteActionHandler({ + policy, + brokers, + whitelistIps, + useVerity, + verityProverUrl, + otelMetrics, + brokerArchiver, + orderActivityTracker, + withdrawalObservationTracker, + }), + Subscribe: createSubscribeHandler({ + brokers, + whitelistIps, + otelMetrics, + brokerArchiver, + brokerLifecycle: subscribeBrokerLifecycle, + }), }); return server; } diff --git a/src/types.ts b/src/types.ts index f215e59..acd90f6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -26,6 +26,77 @@ export type OrderRule = { }>; }; +// Binance travel-rule questionnaire answers (Australia). Optional fields are +// conditionally required; see australiaQuestionnaireSchema for the exact rules. +export type TravelRuleQuestionnaire = { + isAddressOwner: number; + sendTo: number; + declaration: boolean; + bnfType?: number; + bnfFirstName?: string; + bnfLastName?: string; + country?: string; + city?: string; + bnfCorpName?: string; + bnfCorpCountry?: string; + bnfCorpCity?: string; + vasp?: string; + vaspName?: string; +}; + +export type TravelRuleAddressEntry = { + questionnaire: TravelRuleQuestionnaire; +}; + +// Binance travel-rule DEPOSIT questionnaire answers (Australia). This is a +// DIFFERENT shape from the withdraw questionnaire: it uses +// `depositOriginator`/`receiveFrom` rather than `isAddressOwner`/`sendTo`. Only +// the self-owned case is exercised by the auto-clear reconciler (which only ever +// declares deposits provably sent from our own configured wallets); the proven +// answer for that case is { depositOriginator: 1, receiveFrom: 1, declaration: +// true }. Validated at policy-load by australiaDepositQuestionnaireSchema. +export type TravelRuleDepositQuestionnaire = { + depositOriginator: number; + receiveFrom: number; + declaration: boolean; +}; + +export type TravelRuleDepositOriginatorEntry = { + questionnaire: TravelRuleDepositQuestionnaire; +}; + +// Deposit-side travel-rule config, nested under a TravelRuleEntry. Absent or +// `enabled: false` means the deposit auto-clear reconciler does nothing for the +// exchange — byte-identical to the pre-feature behavior. +export type TravelRuleDepositConfig = { + enabled: boolean; + // Free-text note documenting intent (e.g. that keys are SENDERS, and how to add + // a new funding wallet). Ignored by the runtime. + description?: string; + // Keyed by the on-chain SENDER (originator) address. A travel-rule-frozen + // deposit is auto-declared only when its PROVEN on-chain sender matches one of + // these entries; matching is case-insensitive. A deposit from any other sender + // is left frozen and surfaced — never auto-attested. + originators: Record; +}; + +export type TravelRuleEntry = { + exchange: string; + // Opt-in switch: only when true are withdrawals for this exchange routed + // through the travel-rule endpoint. Keeps non-AU accounts on the standard path. + enabled: boolean; + // Free-text note explaining why this entry exists (e.g. which jurisdiction + // requires it). Ignored by the runtime; documents intent next to the config. + description?: string; + // Keyed by destination address; the questionnaire is resolved on demand at + // withdraw time. Matching is case-insensitive. + addresses: Record; + // Deposit-side auto-clear config. Independent of `enabled` (which gates the + // withdraw leg only): the deposit reconciler is gated solely by + // `deposits.enabled` so the two legs can be toggled separately. + deposits?: TravelRuleDepositConfig; +}; + export type PolicyConfig = { withdraw: { rule: WithdrawRuleEntry[]; @@ -36,6 +107,9 @@ export type PolicyConfig = { order: { rule: OrderRule; }; + travelRule?: { + rule: TravelRuleEntry[]; + }; }; // Dynamic type mapping using CCXT's exchange classes diff --git a/test/account-balance-archive-poller.test.ts b/test/account-balance-archive-poller.test.ts new file mode 100644 index 0000000..550f4c1 --- /dev/null +++ b/test/account-balance-archive-poller.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, test } from "bun:test"; +import { AccountBalanceArchivePoller } from "../src/helpers/account-balance-archive-poller"; +import type { BrokerAccount, BrokerPoolEntry } from "../src/helpers/broker"; +import { + type BrokerArchiveRow, + BrokerExecutionArchiveDurabilityError, + type BrokerExecutionArchiver, +} from "../src/helpers/broker-execution-archive"; +import type { OtelMetrics } from "../src/helpers/otel"; + +function account( + exchange: unknown, + label: BrokerAccount["label"], + index?: number, +): BrokerAccount { + return { exchange, label, index } as unknown as BrokerAccount; +} + +function fakeArchiver( + sink: BrokerArchiveRow[], + durable = true, +): BrokerExecutionArchiver { + return { + canPersistAccountBalanceSnapshots: () => durable, + getDeploymentId: () => "deploy-a", + enqueue: (row: BrokerArchiveRow) => sink.push(row), + } as unknown as BrokerExecutionArchiver; +} + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; +} { + let resolve = (_value: T) => {}; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("AccountBalanceArchivePoller", () => { + test("polls every primary and secondary sequentially with explicit spot scope and isolates failures", async () => { + const calls: string[] = []; + const params: unknown[] = []; + let inFlight = 0; + let maxInFlight = 0; + const exchange = (name: string, shouldFail = false) => ({ + fetchBalance: async (value: unknown) => { + calls.push(name); + params.push(value); + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + await Promise.resolve(); + inFlight -= 1; + if (shouldFail) { + throw new Error("rate limited"); + } + return { + free: { USDC: 80 }, + used: { USDC: 20 }, + total: { USDC: 100 }, + }; + }, + }); + const brokers: Record = { + binance: { + primary: account(exchange("primary"), "primary"), + secondaryBrokers: [ + account(exchange("secondary:1", true), "secondary:1", 1), + account(exchange("secondary:2"), "secondary:2", 2), + ], + }, + }; + const sink: BrokerArchiveRow[] = []; + const counters: Array<{ + name: string; + labels: Record; + }> = []; + const gauges: Array<{ + name: string; + labels: Record; + }> = []; + const metrics = { + recordCounter: async ( + name: string, + _value: number, + labels: Record, + ) => counters.push({ name, labels }), + recordGauge: async ( + name: string, + _value: number, + labels: Record, + ) => gauges.push({ name, labels }), + } as unknown as OtelMetrics; + const poller = new AccountBalanceArchivePoller({ + brokers, + archiver: fakeArchiver(sink), + metrics, + }); + + expect(await poller.pollAllOnce()).toBe(true); + + expect(calls).toEqual(["primary", "secondary:1", "secondary:2"]); + expect(params).toEqual([ + { type: "spot" }, + { type: "spot" }, + { type: "spot" }, + ]); + expect(maxInFlight).toBe(1); + expect(sink).toHaveLength(2); + expect(sink.map((entry) => entry.row.account_selector)).toEqual([ + "primary", + "secondary:2", + ]); + expect( + counters.filter(({ name }) => name.endsWith("attempts_total")), + ).toHaveLength(3); + expect( + counters.filter(({ name }) => name.endsWith("successes_total")), + ).toHaveLength(2); + expect( + counters.filter(({ name }) => name.endsWith("failures_total")), + ).toHaveLength(1); + expect(gauges.map(({ name }) => name).sort()).toEqual([ + "cex_account_balance_poll_freshness_seconds", + "cex_account_balance_poll_freshness_seconds", + "cex_account_balance_poll_last_success_timestamp_seconds", + "cex_account_balance_poll_last_success_timestamp_seconds", + ]); + for (const metric of [...counters, ...gauges]) { + expect(metric.labels).toMatchObject({ + exchange: "binance", + balance_scope: "spot", + }); + expect(["primary", "secondary:1", "secondary:2"]).toContain( + metric.labels.account_selector, + ); + } + }); + + test("rejects overlapping passes", async () => { + const gate = deferred(); + let calls = 0; + const sink: BrokerArchiveRow[] = []; + const brokers: Record = { + binance: { + primary: account( + { + fetchBalance: () => { + calls += 1; + return gate.promise; + }, + }, + "primary", + ), + secondaryBrokers: [], + }, + }; + const poller = new AccountBalanceArchivePoller({ + brokers, + archiver: fakeArchiver(sink), + }); + + const first = poller.pollAllOnce(); + expect(await poller.pollAllOnce()).toBe(false); + expect(calls).toBe(1); + gate.resolve({ free: {}, used: {}, total: {} }); + expect(await first).toBe(true); + expect(sink).toHaveLength(1); + }); + + test("stop waits for the active account and prevents later accounts and ticks", async () => { + const gate = deferred(); + const calls: string[] = []; + const sink: BrokerArchiveRow[] = []; + const brokers: Record = { + binance: { + primary: account( + { + fetchBalance: () => { + calls.push("primary"); + return gate.promise; + }, + }, + "primary", + ), + secondaryBrokers: [ + account( + { + fetchBalance: async () => { + calls.push("secondary:1"); + return {}; + }, + }, + "secondary:1", + 1, + ), + ], + }, + }; + const poller = new AccountBalanceArchivePoller({ + brokers, + archiver: fakeArchiver(sink), + }); + + const pass = poller.pollAllOnce(); + const stopped = poller.stop(); + gate.resolve({ total: { USDC: 1 } }); + await stopped; + await pass; + + expect(calls).toEqual(["primary"]); + expect(await poller.pollAllOnce()).toBe(false); + }); + + test("rethrows archive durability failures instead of treating them as poll failures", async () => { + const durabilityError = new BrokerExecutionArchiveDurabilityError( + "loss journal write failed", + ); + const brokers: Record = { + binance: { + primary: account( + { + fetchBalance: async () => ({ total: { USDC: 1 } }), + }, + "primary", + ), + secondaryBrokers: [], + }, + }; + const archiver = { + canPersistAccountBalanceSnapshots: () => true, + getDeploymentId: () => "deploy-a", + enqueue: () => { + throw durabilityError; + }, + } as unknown as BrokerExecutionArchiver; + const poller = new AccountBalanceArchivePoller({ brokers, archiver }); + + await expect(poller.pollAllOnce()).rejects.toBe(durabilityError); + }); + + test("does not advertise or poll balance coverage without a durable forwarder", async () => { + let calls = 0; + const brokers: Record = { + binance: { + primary: account( + { + fetchBalance: async () => { + calls += 1; + return {}; + }, + }, + "primary", + ), + secondaryBrokers: [], + }, + }; + const poller = new AccountBalanceArchivePoller({ + brokers, + archiver: fakeArchiver([], false), + }); + + poller.start(); + expect(await poller.pollAllOnce()).toBe(false); + await Promise.resolve(); + expect(calls).toBe(0); + await poller.stop(); + }); +}); diff --git a/test/archive-forwarder-contract.test.ts b/test/archive-forwarder-contract.test.ts new file mode 100644 index 0000000..742ab0f --- /dev/null +++ b/test/archive-forwarder-contract.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { parseArchiveBatchRequest } from "../services/archive-forwarder/router"; +import { isSupportedTable } from "../services/archive-forwarder/types"; + +// Golden envelope authored by the HB-runtime ArchiveEmitter (fiet-maker: +// packages/hb-maker-shared/tests/fixtures/archive_forwarder_envelope.json) and +// copied here byte-for-byte. It pins the cross-repo wire contract: the emitter +// produces this shape and this forwarder must accept it. The contract was +// broken once (emitter sent flat rows -> every strategy_data batch 400'd), so a +// drift on either side must break a test in both repos. +const fixture = JSON.parse( + readFileSync( + path.join(import.meta.dir, "fixtures", "archive_forwarder_envelope.json"), + "utf-8", + ), +) as unknown; + +describe("archive forwarder wire contract (shared golden fixture)", () => { + test("parseArchiveBatchRequest accepts the emitter envelope with no rejected rows", () => { + const parsed = parseArchiveBatchRequest(fixture); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + expect(parsed.rejectedRowCount).toBe(0); + expect(parsed.inputRowCount).toBe(parsed.batch.rows.length); + expect(parsed.batch.source).toBe("hb_runtime"); + }); + + test("every fixture row targets a supported table, covering all strategy_data tables", () => { + const parsed = parseArchiveBatchRequest(fixture); + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + + for (const row of parsed.batch.rows) { + expect(isSupportedTable(row.table)).toBe(true); + } + const tables = new Set(parsed.batch.rows.map((row) => row.table)); + expect(tables).toEqual( + new Set([ + "strategy_data.policy_evaluation_events", + "strategy_data.strategy_policy_snapshots", + "strategy_data.inventory_settlement_events", + ]), + ); + }); +}); diff --git a/test/archive-forwarder-server.ts b/test/archive-forwarder-server.ts new file mode 100644 index 0000000..426d759 --- /dev/null +++ b/test/archive-forwarder-server.ts @@ -0,0 +1,69 @@ +import http from "node:http"; + +export type CapturedRequest = { + method: string; + headers: http.IncomingHttpHeaders; + body: { source?: string; deployment_id?: string; rows?: unknown[] } & Record< + string, + unknown + >; +}; + +export type ForwarderReply = { status?: number; destroy?: boolean }; + +export type ForwarderResponder = ( + req: CapturedRequest, +) => ForwarderReply | Promise; + +export type ForwarderServer = { + url: string; + requests: CapturedRequest[]; + close: () => Promise; +}; + +/** + * A real local HTTP forwarder for archive-writer tests. Exercises the production + * node:http transport end to end (the enclave-safe path) instead of stubbing the + * global fetch, which the writer no longer uses. The responder scripts the reply + * per request: an HTTP `status`, or `destroy: true` to abort the socket and drive + * the client's network-error path. Returning a Promise lets a test hold the + * response in flight. + */ +export function startForwarderServer( + responder: ForwarderResponder = () => ({ status: 200 }), +): Promise { + const requests: CapturedRequest[] = []; + const server = http.createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(chunk as Buffer)); + req.on("end", () => { + void (async () => { + const raw = Buffer.concat(chunks).toString("utf8"); + const captured: CapturedRequest = { + method: req.method ?? "", + headers: req.headers, + body: raw ? JSON.parse(raw) : {}, + }; + requests.push(captured); + const { status = 200, destroy = false } = await responder(captured); + if (destroy) { + res.destroy(); + return; + } + res.writeHead(status, { "content-type": "application/json" }); + res.end("{}"); + })(); + }); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + resolve({ + url: `http://127.0.0.1:${port}/archive`, + requests, + close: () => new Promise((r) => server.close(() => r())), + }); + }); + }); +} diff --git a/test/archive-forwarder-telemetry.test.ts b/test/archive-forwarder-telemetry.test.ts new file mode 100644 index 0000000..31b354e --- /dev/null +++ b/test/archive-forwarder-telemetry.test.ts @@ -0,0 +1,238 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { handleArchiveRequest } from "../services/archive-forwarder/request"; +import { + ARCHIVE_FORWARDER_METRICS, + ArchiveForwarderTelemetry, + type ArchiveMetricsRecorder, + createArchiveForwarderTelemetry, +} from "../services/archive-forwarder/telemetry"; + +type CounterRecord = { + name: string; + value: number; + labels: Record; +}; + +type GaugeRecord = CounterRecord; + +function createCapturingTelemetry(): { + telemetry: ArchiveForwarderTelemetry; + counters: CounterRecord[]; + gauges: GaugeRecord[]; +} { + const counters: CounterRecord[] = []; + const gauges: GaugeRecord[] = []; + const recorder: ArchiveMetricsRecorder = { + recordCounter: async (name, value, labels) => { + counters.push({ name, value, labels }); + }, + setObservableGauge: async (name, value, labels) => { + gauges.push({ name, value, labels }); + }, + }; + return { + telemetry: new ArchiveForwarderTelemetry(recorder), + counters, + gauges, + }; +} + +function archiveRequest(rows: unknown[]): Request { + return new Request("http://localhost/archive", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + source: "broker_write", + deployment_id: "deploy-a", + rows, + }), + }); +} + +describe("archive forwarder telemetry", () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + test("records inserted rows by table and updates the successful-flush heartbeat", async () => { + const { telemetry, counters, gauges } = createCapturingTelemetry(); + const response = await handleArchiveRequest( + archiveRequest([ + { table: "market_data.candles", row: { open_time_ms: 1 } }, + { table: "market_data.candles", row: { open_time_ms: 2 } }, + { + table: "broker_execution.order_events", + row: { order_id: "order-1" }, + }, + ]), + { inserter: async () => {}, telemetry }, + ); + + expect(response.status).toBe(200); + expect(counters).toEqual( + expect.arrayContaining([ + { + name: ARCHIVE_FORWARDER_METRICS.rowsInserted, + value: 2, + labels: { table: "market_data.candles" }, + }, + { + name: ARCHIVE_FORWARDER_METRICS.rowsInserted, + value: 1, + labels: { table: "broker_execution.order_events" }, + }, + ]), + ); + expect(gauges).toHaveLength(1); + expect(gauges[0]?.name).toBe(ARCHIVE_FORWARDER_METRICS.lastSuccessfulFlush); + expect(gauges[0]?.value).toBeGreaterThan(0); + }); + + test("an empty batch does not advance the successful-flush heartbeat", async () => { + const { telemetry, gauges } = createCapturingTelemetry(); + const response = await handleArchiveRequest(archiveRequest([]), { + inserter: async () => {}, + telemetry, + }); + + expect(response.status).toBe(200); + // A batch that inserts nothing must not look like a successful flush: a + // staleness alert built on this gauge would stay green while no data reaches + // ClickHouse, which is exactly the condition it exists to detect. + expect(gauges).toHaveLength(0); + }); + + test("records every rejected row by table, including malformed rows", async () => { + const { telemetry, counters } = createCapturingTelemetry(); + let insertCalled = false; + const response = await handleArchiveRequest( + archiveRequest([ + { table: "strategy_data.unknown", row: {} }, + { table: "strategy_data.unknown", row: {} }, + { table: 42, row: {} }, + ]), + { + inserter: async () => { + insertCalled = true; + }, + telemetry, + }, + ); + + expect(response.status).toBe(400); + expect(insertCalled).toBe(false); + // Unknown table names are client-controlled, so they are bucketed rather than + // used verbatim as a label. The raw name stays in the response and the log. + expect(counters).toEqual( + expect.arrayContaining([ + { + name: ARCHIVE_FORWARDER_METRICS.rowsRejected, + value: 2, + labels: { table: "(unsupported)" }, + }, + { + name: ARCHIVE_FORWARDER_METRICS.rowsRejected, + value: 1, + labels: { table: "(malformed)" }, + }, + ]), + ); + }); + + test("bounds rejected-row label cardinality no matter how many table names a client invents", async () => { + const { telemetry, counters } = createCapturingTelemetry(); + const response = await handleArchiveRequest( + archiveRequest( + Array.from({ length: 50 }, (_, index) => ({ + table: `strategy_data.attacker_${index}`, + row: {}, + })), + ), + { inserter: async () => {}, telemetry }, + ); + + expect(response.status).toBe(400); + const rejected = counters.filter( + (entry) => entry.name === ARCHIVE_FORWARDER_METRICS.rowsRejected, + ); + // One series, not fifty: each distinct label value would otherwise persist in + // the metrics SDK, letting a client grow our memory without bound. + expect(rejected).toEqual([ + { + name: ARCHIVE_FORWARDER_METRICS.rowsRejected, + value: 50, + labels: { table: "(unsupported)" }, + }, + ]); + }); + + test("records insert failures by table and coarse error class", async () => { + const { telemetry, counters, gauges } = createCapturingTelemetry(); + const response = await handleArchiveRequest( + archiveRequest([ + { table: "market_data.cex_trades", row: { trade_id: "trade-1" } }, + ]), + { + inserter: async () => { + throw new Error("unknown table market_data.cex_trades"); + }, + telemetry, + }, + ); + + expect(response.status).toBe(500); + expect(counters).toContainEqual({ + name: ARCHIVE_FORWARDER_METRICS.insertFailures, + value: 1, + labels: { + table: "market_data.cex_trades", + error_class: "schema", + }, + }); + expect(gauges).toHaveLength(0); + }); + + test("keeps a successful request independent of throwing telemetry", async () => { + const throwingRecorder: ArchiveMetricsRecorder = { + recordCounter: async () => { + throw new Error("exporter failed"); + }, + setObservableGauge: () => { + throw new Error("exporter failed"); + }, + }; + const insertedTables: string[] = []; + const response = await handleArchiveRequest( + archiveRequest([ + { table: "market_data.candles", row: { open_time_ms: 1 } }, + ]), + { + inserter: async (table) => { + insertedTables.push(table); + }, + telemetry: new ArchiveForwarderTelemetry(throwingRecorder), + }, + ); + + expect(response.status).toBe(200); + expect(insertedTables).toEqual(["market_data.candles"]); + }); + + test("keeps telemetry optional when no OTLP endpoint is configured", async () => { + delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT; + delete process.env.OTEL_EXPORTER_OTLP_METRICS_ENDPOINT; + const response = await handleArchiveRequest( + archiveRequest([ + { table: "market_data.candles", row: { open_time_ms: 1 } }, + ]), + { + inserter: async () => {}, + telemetry: createArchiveForwarderTelemetry(), + }, + ); + + expect(response.status).toBe(200); + }); +}); diff --git a/test/archive-forwarder.test.ts b/test/archive-forwarder.test.ts new file mode 100644 index 0000000..f88daa5 --- /dev/null +++ b/test/archive-forwarder.test.ts @@ -0,0 +1,404 @@ +import { describe, expect, test } from "bun:test"; +import type { ClickHouseClient } from "@clickhouse/client"; +import { + countSkippedRows, + groupRowsByTable, + insertArchiveRows, +} from "../services/archive-forwarder/insert"; +import { + handleArchiveBatch, + parseArchiveBatchRequest, +} from "../services/archive-forwarder/router"; +import { ensureArchiveSchema } from "../services/archive-forwarder/schema"; +import { isSupportedTable } from "../services/archive-forwarder/types"; + +describe("archive forwarder batch parsing", () => { + test("parseArchiveBatchRequest accepts broker_write payloads", () => { + const parsed = parseArchiveBatchRequest({ + source: "broker_write", + deployment_id: "deploy-a", + rows: [ + { + table: "market_data.candles", + row: { symbol: "BTC/USDT", open_time_ms: 1_000 }, + }, + ], + }); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + expect(parsed.rejectedRowCount).toBe(0); + expect(parsed.batch).toEqual({ + source: "broker_write", + deployment_id: "deploy-a", + rows: [ + { + table: "market_data.candles", + row: { symbol: "BTC/USDT", open_time_ms: 1_000 }, + }, + ], + }); + }); + + test("parseArchiveBatchRequest rejects invalid payloads", () => { + expect(parseArchiveBatchRequest(null).ok).toBe(false); + expect(parseArchiveBatchRequest({ source: "x" }).ok).toBe(false); + }); + + test("parseArchiveBatchRequest rejects array rows and malformed entries", () => { + const parsed = parseArchiveBatchRequest({ + source: "broker_write", + deployment_id: "deploy-a", + rows: [ + { table: "market_data.candles", row: { open_time_ms: 1 } }, + { table: "market_data.candles", row: [] }, + { table: "market_data.candles", row: null }, + ], + }); + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + expect(parsed.inputRowCount).toBe(3); + expect(parsed.rejectedRowCount).toBe(2); + expect(parsed.batch.rows).toHaveLength(1); + }); + + test("accepts the broker execution and account balance archive tables", () => { + expect(isSupportedTable("broker_execution.transfer_events")).toBe(true); + expect(isSupportedTable("broker_execution.fill_events")).toBe(true); + expect(isSupportedTable("broker_account.balance_snapshots")).toBe(true); + expect(isSupportedTable("broker_account.unknown")).toBe(false); + + const parsed = parseArchiveBatchRequest({ + source: "broker_write", + deployment_id: "deploy-a", + rows: [ + { + table: "broker_execution.transfer_events", + row: { external_id: "wd-1", event_kind: "withdrawal" }, + }, + { + table: "broker_execution.fill_events", + row: { order_id: "o-1", trade_id: "t-1" }, + }, + { + table: "broker_account.balance_snapshots", + row: { observation_id: "obs-1", balance_scope: "spot" }, + }, + ], + }); + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + expect(parsed.rejectedRowCount).toBe(0); + expect(parsed.batch.rows).toHaveLength(3); + }); + + test("names the offending tables in rejectedTables so a bad rollout is visible", () => { + const parsed = parseArchiveBatchRequest({ + source: "broker_write", + deployment_id: "deploy-a", + rows: [ + { table: "broker_execution.order_events", row: { order_id: "1" } }, + { table: "broker_execution.mystery_table", row: { x: 1 } }, + { table: 123, row: {} }, + ], + }); + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + expect(parsed.rejectedRowCount).toBe(2); + expect(parsed.batch.rows).toHaveLength(1); + expect(parsed.rejectedTables).toEqual( + expect.arrayContaining(["broker_execution.mystery_table", "(malformed)"]), + ); + expect(parsed.rejectedRowsByTable).toEqual({ + "broker_execution.mystery_table": 1, + "(malformed)": 1, + }); + }); + + test("parseArchiveBatchRequest accepts broker_execution and strategy_data, rejects unknown tables", () => { + const parsed = parseArchiveBatchRequest({ + source: "hb_runtime", + deployment_id: "deploy-a", + rows: [ + { table: "market_data.candles", row: { open_time_ms: 1 } }, + { table: "broker_execution.order_events", row: { order_id: "1" } }, + { + table: "strategy_data.policy_evaluation_events", + row: { event_time_ms: 1 }, + }, + { table: "market_data.unknown_table", row: { symbol: "BTC/USDT" } }, + ], + }); + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + expect(parsed.rejectedRowCount).toBe(1); + expect(parsed.batch.rows).toHaveLength(3); + expect(parsed.batch.rows.map((row) => row.table)).toEqual([ + "market_data.candles", + "broker_execution.order_events", + "strategy_data.policy_evaluation_events", + ]); + }); + + test("accepts control-plane snapshots and policy replay cursors", () => { + const parsed = parseArchiveBatchRequest({ + source: "hb_runtime", + deployment_id: "deploy-a", + rows: [ + { + table: "strategy_data.policy_evaluation_events", + row: { + event_time_ms: 1, + source_cursor: "block:12345680:log:3", + }, + }, + { + table: "strategy_data.market_identity", + row: { event_time_ms: 2, canonical_core_pool_id: "pool-1" }, + }, + { + table: "strategy_data.symbol_mapping", + row: { + event_time_ms: 3, + exchange: "binance", + trading_pair: "BTC-USDT", + }, + }, + ], + }); + + expect(parsed.ok).toBe(true); + if (!parsed.ok) { + return; + } + expect(parsed.rejectedRowCount).toBe(0); + expect(parsed.batch.rows).toHaveLength(3); + }); +}); + +describe("archive forwarder routing", () => { + test("groups every supported archive database and skips unknown tables", () => { + const rows = [ + { + table: "market_data.candles", + row: { symbol: "BTC/USDT" }, + }, + { + table: "broker_execution.order_events", + row: { order_id: "1" }, + }, + { + table: "broker_account.balance_snapshots", + row: { observation_id: "obs-1" }, + }, + { + table: "strategy_data.inventory_settlement_events", + row: { event_time_ms: 1 }, + }, + { + table: "strategy_data.market_identity", + row: { event_time_ms: 2 }, + }, + { + table: "strategy_data.symbol_mapping", + row: { event_time_ms: 3 }, + }, + { + table: "market_data.unknown_table", + row: { symbol: "ETH/USDT" }, + }, + ]; + + const grouped = groupRowsByTable(rows); + expect(grouped.get("market_data.candles")).toHaveLength(1); + expect(grouped.get("broker_execution.order_events")).toHaveLength(1); + expect(grouped.get("broker_account.balance_snapshots")).toHaveLength(1); + expect( + grouped.get("strategy_data.inventory_settlement_events"), + ).toHaveLength(1); + expect(grouped.get("strategy_data.market_identity")).toHaveLength(1); + expect(grouped.get("strategy_data.symbol_mapping")).toHaveLength(1); + expect(countSkippedRows(rows)).toBe(1); + expect(isSupportedTable("market_data.candles")).toBe(true); + expect(isSupportedTable("broker_execution.order_events")).toBe(true); + expect(isSupportedTable("broker_account.balance_snapshots")).toBe(true); + expect(isSupportedTable("strategy_data.policy_evaluation_events")).toBe( + true, + ); + expect(isSupportedTable("strategy_data.market_identity")).toBe(true); + expect(isSupportedTable("strategy_data.symbol_mapping")).toBe(true); + expect(isSupportedTable("market_data.unknown_table")).toBe(false); + }); + + test("insertArchiveRows calls inserter per supported table", async () => { + const inserts: Array<{ table: string; count: number }> = []; + const result = await insertArchiveRows( + async (table, tableRows) => { + inserts.push({ table, count: tableRows.length }); + }, + [ + { table: "market_data.candles", row: { open_time_ms: 1 } }, + { table: "market_data.candles", row: { open_time_ms: 2 } }, + { table: "market_data.unknown_table", row: { order_id: "9" } }, + ], + ); + + expect(result).toEqual({ + inserted: 2, + skipped: 1, + failed: 0, + byTable: { "market_data.candles": 2 }, + failedTables: [], + }); + expect(inserts).toEqual([{ table: "market_data.candles", count: 2 }]); + }); + + test("insertArchiveRows routes broker_execution and strategy_data tables", async () => { + const inserts: Array<{ table: string; count: number }> = []; + const result = await insertArchiveRows( + async (table, tableRows) => { + inserts.push({ table, count: tableRows.length }); + }, + [ + { table: "broker_execution.order_events", row: { order_id: "1" } }, + { + table: "strategy_data.policy_evaluation_events", + row: { event_time_ms: 1 }, + }, + { + table: "strategy_data.policy_evaluation_events", + row: { event_time_ms: 2 }, + }, + ], + ); + + expect(result.inserted).toBe(3); + expect(result.skipped).toBe(0); + expect(inserts).toContainEqual({ + table: "broker_execution.order_events", + count: 1, + }); + expect(inserts).toContainEqual({ + table: "strategy_data.policy_evaluation_events", + count: 2, + }); + }); + + test("insertArchiveRows continues when one table insert fails", async () => { + const inserts: string[] = []; + const result = await insertArchiveRows( + async (table) => { + if (table === "market_data.cex_trades") { + throw new Error("table missing"); + } + inserts.push(table); + }, + [ + { table: "market_data.candles", row: { open_time_ms: 1 } }, + { table: "market_data.cex_trades", row: { trade_id: "t-1" } }, + { table: "market_data.orderbook_snapshots", row: { best_bid: 1 } }, + ], + ); + + expect(result.inserted).toBe(2); + expect(result.failed).toBe(1); + expect(result.failedTables).toEqual(["market_data.cex_trades"]); + expect(inserts).toEqual([ + "market_data.candles", + "market_data.orderbook_snapshots", + ]); + }); + + test("handleArchiveBatch processes valid requests", async () => { + const inserted: string[] = []; + const result = await handleArchiveBatch( + async (table) => { + inserted.push(table); + }, + { + source: "broker_write", + deployment_id: "deploy-a", + rows: [{ table: "market_data.candles", row: { symbol: "BTC/USDT" } }], + }, + ); + + expect(result.inserted).toBe(1); + expect(inserted).toEqual(["market_data.candles"]); + }); +}); + +describe("archive forwarder schema init", () => { + test("ensureArchiveSchema applies every archive database from its SQL files", async () => { + const statements: string[] = []; + const client = { + command: async ({ query }: { query: string }) => { + statements.push(query); + }, + } as unknown as ClickHouseClient; + + await ensureArchiveSchema(client); + + const createdDatabases = statements + .map( + (query) => query.match(/CREATE DATABASE IF NOT EXISTS\s+(\w+)/i)?.[1], + ) + .filter((name): name is string => Boolean(name)); + expect(createdDatabases).toEqual( + expect.arrayContaining([ + "market_data", + "broker_execution", + "broker_account", + "strategy_data", + ]), + ); + + const createdTables = statements + .map( + (query) => query.match(/CREATE TABLE IF NOT EXISTS\s+([\w.]+)/i)?.[1], + ) + .filter((name): name is string => Boolean(name)); + expect(createdTables).toEqual( + expect.arrayContaining([ + "broker_execution.order_events", + "broker_execution.market_metadata_snapshots", + "broker_execution.transfer_events", + "broker_execution.fill_events", + "broker_account.balance_snapshots", + "strategy_data.policy_evaluation_events", + "strategy_data.strategy_policy_snapshots", + "strategy_data.market_identity", + "strategy_data.symbol_mapping", + "strategy_data.inventory_settlement_events", + ]), + ); + + const balanceTable = statements.find((query) => + /CREATE TABLE IF NOT EXISTS\s+broker_account\.balance_snapshots/i.test( + query, + ), + ); + expect(balanceTable).toContain( + "broker_observed_timestamp DateTime64(3, 'UTC')", + ); + expect(balanceTable).toContain( + "exchange_timestamp Nullable(DateTime64(3, 'UTC'))", + ); + expect(balanceTable).toContain("free_balances Map(String, String)"); + expect(balanceTable).toContain("used_balances Map(String, String)"); + expect(balanceTable).toContain("total_balances Map(String, String)"); + expect(balanceTable).toContain( + "ORDER BY (exchange, account_selector, balance_scope, broker_observed_timestamp, observation_id)", + ); + expect(balanceTable).not.toContain("TTL"); + }); +}); diff --git a/test/auth-helper.test.ts b/test/auth-helper.test.ts new file mode 100644 index 0000000..c32a376 --- /dev/null +++ b/test/auth-helper.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { authenticateRequest } from "../src/helpers/auth"; + +function callFromPeer(peer: string) { + return { + getPeer: () => peer, + }; +} + +describe("authenticateRequest", () => { + test.each([ + ["127.0.0.1:1234", "127.0.0.1"], + ["ipv4:127.0.0.1:1234", "127.0.0.1"], + ["ipv6:[::1]:50051", "::1"], + ["[::1]:50051", "::1"], + ["127.0.0.1", "127.0.0.1"], + ["::1", "::1"], + ["localhost", "localhost"], + ["localhost:50051", "localhost"], + ])("allows whitelisted peer %s", (peer, whitelistIp) => { + expect(authenticateRequest(callFromPeer(peer), [whitelistIp])).toBe(true); + }); + + test.each([ + ["192.168.1.10:1234", ["127.0.0.1"]], + ["ipv4:192.168.1.10:1234", ["127.0.0.1"]], + ["ipv6:[2001:db8::1]:50051", ["::1"]], + ["[2001:db8::1]:50051", ["::1"]], + ])("denies unlisted peer %s", (peer, whitelistIps) => { + expect(authenticateRequest(callFromPeer(peer), whitelistIps)).toBe(false); + }); + + test.each([ + "", + " ", + "ipv4:", + "ipv6:", + "ipv4:[::1]:50051", + "ipv6:127.0.0.1:50051", + "ipv6:[::1]:not-a-port", + "[2001:db8::1", + "2001:db8::1:50051", + "unix:/tmp/grpc.sock", + ])("denies malformed or unsupported peer %s", (peer) => { + expect(authenticateRequest(callFromPeer(peer), ["127.0.0.1", "::1"])).toBe( + false, + ); + }); + + test.each([ + "", + "ipv6:[2001:db8::1]:50051", + "unix:/tmp/grpc.sock", + ])("allows peer %s with wildcard whitelist", (peer) => { + expect(authenticateRequest(callFromPeer(peer), ["*"])).toBe(true); + }); +}); diff --git a/test/broker-execution-archive.test.ts b/test/broker-execution-archive.test.ts new file mode 100644 index 0000000..cbda363 --- /dev/null +++ b/test/broker-execution-archive.test.ts @@ -0,0 +1,1702 @@ +import { afterAll, describe, expect, spyOn, test } from "bun:test"; +import { + chmodSync, + closeSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { LogRecord } from "@opentelemetry/api-logs"; +import type { Exchange } from "@usherlabs/ccxt"; +import { MAX_ARCHIVE_BODY_BYTES } from "../services/archive-forwarder/limits"; +import type { ExecuteActionContext } from "../src/handlers/execute-action/context"; +import { handleInternalTransfer } from "../src/handlers/execute-action/internal-transfer"; +import { handleOrders } from "../src/handlers/execute-action/orders"; +import { handleTreasuryCall } from "../src/handlers/execute-action/treasury-call"; +import { handleWithdraw } from "../src/handlers/execute-action/withdraw"; +import { + redactSecretLiterals, + redactStreamPayload, +} from "../src/helpers/broker-execution-archive/redact"; +import { + buildAccountBalanceSnapshotRow, + buildCommonArchiveTags, + buildFillEventArchiveRow, + buildMarketMetadataSnapshotRow, + buildOrderEventArchiveRow, + buildSubscribeStreamArchiveRow, + buildTransferEventArchiveRow, + normalizeCcxtBalanceForArchive, + normalizeCcxtTradeForArchive, + normalizeCcxtTransactionForArchive, +} from "../src/helpers/broker-execution-archive/rows"; +import { + DEFAULT_WITHDRAWAL_OBSERVATION_TRACKER_MAX_ENTRIES, + WithdrawalObservationTracker, +} from "../src/helpers/broker-execution-archive/withdrawal-observation-tracker"; +import { + BrokerExecutionArchiveDurabilityError, + BrokerExecutionArchiver, + createBrokerExecutionArchiverFromEnv, + isArchiveOtelLogsEnabled, + isBrokerExecutionArchiveTable, + resolveArchiveForwarderUrlFromEnv, + rethrowArchiveDurabilityError, +} from "../src/helpers/broker-execution-archive/writer"; +import { Action } from "../src/helpers/constants"; +import { log } from "../src/helpers/logger"; +import { buildOrderExecutionTelemetry } from "../src/helpers/order-telemetry"; +import type { OtelLogs } from "../src/helpers/otel"; +import type { PolicyConfig } from "../src/types"; +import { startForwarderServer } from "./archive-forwarder-server"; + +const archiveTestDirectory = mkdtempSync( + join(tmpdir(), "cex-broker-archive-test-"), +); +let deadLetterFileIndex = 0; + +function createDeadLetterPath(): string { + deadLetterFileIndex += 1; + return join(archiveTestDirectory, `loss-${deadLetterFileIndex}.jsonl`); +} + +function readDeadLetters(path: string): Array> { + return readFileSync(path, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as Record); +} + +afterAll(() => { + rmSync(archiveTestDirectory, { recursive: true, force: true }); +}); + +function restoreEnv(key: string, original: string | undefined): void { + if (original === undefined) { + delete process.env[key]; + } else { + process.env[key] = original; + } +} + +class MockOtelLogs implements OtelLogs { + readonly emits: LogRecord[] = []; + + isOtelEnabled(): boolean { + return true; + } + + emit(record: LogRecord): void { + this.emits.push(record); + } + + async close(): Promise {} +} + +describe("broker execution archive redaction", () => { + test("redacts secret literals and credential-shaped keys from stream payloads", () => { + const redacted = redactStreamPayload( + { + apiKey: "super-secret-key", + orderId: "123", + nested: { signature: "abc", status: "FILLED" }, + }, + ["super-secret-key"], + ); + + expect(redacted.apiKey).toBe("[redacted]"); + expect(redacted.orderId).toBe("123"); + expect(redacted.nested).toEqual({ + signature: "[redacted]", + status: "FILLED", + }); + expect(JSON.stringify(redacted)).not.toContain("super-secret-key"); + }); + + test("redacts secret literals in diagnostic strings", () => { + const message = redactSecretLiterals( + 'apiKey=live-key-123 and "secret":"hidden"', + ["live-key-123"], + ); + expect(message).not.toContain("live-key-123"); + expect(message).toContain("[redacted]"); + }); +}); + +describe("broker execution archive rows", () => { + test("builds one coherent spot balance row without reducing venue total for locked capital", () => { + const balance = normalizeCcxtBalanceForArchive({ + timestamp: 1_784_000_000_123, + free: { USDC: 80, BTC: 0.00000001 }, + used: { USDC: 20, BTC: 0 }, + total: { USDC: 100, BTC: 0.00000001 }, + USDC: { free: 80, used: 20, total: 100 }, + BTC: { free: 0.00000001, used: 0, total: 0.00000001 }, + }); + const row = buildAccountBalanceSnapshotRow({ + tags: buildCommonArchiveTags({ + deploymentId: "deploy-a", + accountSelector: "secondary:2", + exchange: "binance", + brokerObservedTimestamp: "2026-07-14T12:00:00.000Z", + }), + balance, + }); + + expect(row.table).toBe("broker_account.balance_snapshots"); + expect(row.row).toMatchObject({ + broker_observed_timestamp: "2026-07-14T12:00:00.000Z", + exchange_timestamp: new Date(1_784_000_000_123).toISOString(), + source: "broker_write", + deployment_id: "deploy-a", + schema_version: "1", + exchange: "binance", + account_selector: "secondary:2", + balance_scope: "spot", + reported_assets: ["BTC", "USDC"], + asset_entry_assets: ["BTC", "USDC"], + free_balances: { BTC: "0.00000001", USDC: "80" }, + used_balances: { BTC: "0", USDC: "20" }, + total_balances: { BTC: "0.00000001", USDC: "100" }, + aggregate_free_map_present: 1, + aggregate_used_map_present: 1, + aggregate_total_map_present: 1, + precision_basis: "ccxt_normalized_number", + }); + expect(row.row.observation_id).toMatch(/^[a-f0-9]{64}$/); + expect(row.row).not.toHaveProperty("payload_json"); + }); + + test("preserves sparse map and reported-asset semantics without inventing zeros", () => { + const normalized = normalizeCcxtBalanceForArchive({ + used: { USDC: 0, DOGE: null }, + total: { BTC: 2, XRP: "1.2300" }, + ETH: { free: 1, total: 1 }, + }); + + expect(normalized).toEqual({ + exchangeTimestamp: undefined, + reportedAssets: ["BTC", "DOGE", "ETH", "USDC", "XRP"], + assetEntryAssets: ["ETH"], + freeBalances: { ETH: "1" }, + usedBalances: { USDC: "0" }, + totalBalances: { BTC: "2", ETH: "1" }, + freeMapPresent: false, + usedMapPresent: true, + totalMapPresent: true, + }); + expect(normalized.freeBalances).not.toHaveProperty("BTC"); + expect(normalized.totalBalances).not.toHaveProperty("USDC"); + expect(normalized.totalBalances).not.toHaveProperty("XRP"); + expect(normalized.usedBalances).not.toHaveProperty("DOGE"); + }); + + test("excludes secrets and unfiltered info while keeping the row body bounded", () => { + const secret = "live-api-secret"; + const normalized = normalizeCcxtBalanceForArchive({ + free: { USDC: 1 }, + used: {}, + total: { USDC: 1 }, + info: { + apiKey: secret, + raw: "x".repeat(6 * 1024 * 1024), + }, + }); + const row = buildAccountBalanceSnapshotRow({ + tags: buildCommonArchiveTags({ + deploymentId: "deploy-a", + accountSelector: "primary", + exchange: "binance", + brokerObservedTimestamp: "2026-07-14T12:00:00.000Z", + }), + balance: normalized, + }); + const serialized = JSON.stringify(row); + + expect(serialized).not.toContain(secret); + expect(serialized).not.toContain('"info"'); + expect(Buffer.byteLength(serialized)).toBeLessThan(2_000); + }); + + test("keeps a default 10-row batch of 800-asset snapshots below the forwarder body limit", () => { + const assets = Array.from( + { length: 800 }, + (_, index) => `ASSET_${index.toString().padStart(4, "0")}`, + ); + const free = Object.fromEntries( + assets.map((asset, index) => [asset, index + 0.125]), + ); + const used = Object.fromEntries( + assets.map((asset, index) => [asset, index + 0.25]), + ); + const total = Object.fromEntries( + assets.map((asset, index) => [asset, index * 2 + 0.375]), + ); + const response: Record = { free, used, total }; + for (const asset of assets) { + response[asset] = { + free: free[asset], + used: used[asset], + total: total[asset], + }; + } + const balance = normalizeCcxtBalanceForArchive(response); + const rows = Array.from({ length: 10 }, (_, index) => + buildAccountBalanceSnapshotRow({ + tags: buildCommonArchiveTags({ + deploymentId: "deploy-a", + accountSelector: "primary", + exchange: "binance", + brokerObservedTimestamp: new Date( + Date.UTC(2026, 6, 14, 12, index), + ).toISOString(), + }), + balance, + }), + ); + const envelope = JSON.stringify({ + source: "broker_write", + deployment_id: "deploy-a", + rows, + }); + + expect(balance.reportedAssets).toHaveLength(800); + expect(rows).toHaveLength(10); + expect(Buffer.byteLength(envelope)).toBeLessThan(MAX_ARCHIVE_BODY_BYTES); + }); + + test("derives a stable observation id from the complete normalized observation", () => { + const balance = normalizeCcxtBalanceForArchive({ total: { USDC: 1 } }); + const tags = buildCommonArchiveTags({ + deploymentId: "deploy-a", + accountSelector: "primary", + exchange: "binance", + brokerObservedTimestamp: "2026-07-14T12:00:00.000Z", + }); + const first = buildAccountBalanceSnapshotRow({ tags, balance }); + const second = buildAccountBalanceSnapshotRow({ tags, balance }); + + expect(first.row.observation_id).toBe(second.row.observation_id); + }); + + test("builds order event rows tagged for broker_execution.order_events", () => { + const telemetry = buildOrderExecutionTelemetry( + { + action: "CancelOrder", + cex: "binance", + accountLabel: "primary", + symbol: "ARB/USDT", + orderAuthor: "maker-alpha", + clientOrderId: "client-1", + makerActionId: "maker-1", + }, + { id: "99", status: "canceled", symbol: "ARB/USDT", side: "sell" }, + ); + const row = buildOrderEventArchiveRow({ + tags: buildCommonArchiveTags({ + deploymentId: "deploy-a", + accountSelector: "primary", + exchange: "binance", + symbol: "ARB/USDT", + }), + action: "CancelOrder", + telemetry, + }); + + expect(row.table).toBe("broker_execution.order_events"); + expect(row.row).toMatchObject({ + source: "broker_write", + deployment_id: "deploy-a", + account_selector: "primary", + exchange: "binance", + action: "CancelOrder", + event_kind: "execute_action", + order_id: "99", + order_author: "maker-alpha", + client_order_id: "client-1", + maker_action_id: "maker-1", + }); + expect(String(row.row.payload_json)).not.toContain("apiSecret"); + }); + + test("builds subscribe stream rows without leaking secrets", () => { + const row = buildSubscribeStreamArchiveRow({ + tags: buildCommonArchiveTags({ + deploymentId: "deploy-a", + exchange: "binance", + symbol: "ARB/USDT", + }), + subscriptionType: "ORDERS", + streamPayload: { + e: "executionReport", + i: 42, + c: "client-1", + apiSecret: "must-not-appear", + }, + }); + + expect(row.row.event_kind).toBe("subscribe_stream"); + expect(row.row.subscription_type).toBe("ORDERS"); + expect(JSON.stringify(row.row)).not.toContain("must-not-appear"); + }); + + test("omits absent optional join keys so they insert as NULL, not empty string", () => { + const telemetry = buildOrderExecutionTelemetry( + { + action: "CreateOrder", + cex: "binance", + accountLabel: "primary", + symbol: "ARB/USDT", + }, + { status: "new", symbol: "ARB/USDT", side: "buy" }, + ); + const orderRow = buildOrderEventArchiveRow({ + tags: buildCommonArchiveTags({ + deploymentId: "deploy-a", + exchange: "binance", + symbol: "ARB/USDT", + }), + action: "CreateOrder", + telemetry, + }); + // Absent identifiers must be OMITTED from the payload (not "") so the + // Nullable(String) columns receive NULL and never spuriously join on ''. + for (const key of [ + "order_id", + "client_order_id", + "idempotency_id", + "maker_action_id", + "market_metadata_hash", + ]) { + expect(orderRow.row).not.toHaveProperty(key); + } + expect(orderRow.row.order_author).toBe(""); + + const snapshotRow = buildMarketMetadataSnapshotRow({ + tags: buildCommonArchiveTags({ + deploymentId: "deploy-a", + exchange: "binance", + symbol: "ARB/USDT", + }), + marketSnapshot: { bids: [], asks: [] }, + }); + for (const key of [ + "client_order_id", + "order_id", + "maker_action_id", + "idempotency_id", + ]) { + expect(snapshotRow.row).not.toHaveProperty(key); + } + // A snapshot always computes its content hash, so it is always present. + expect(snapshotRow.row).toHaveProperty("market_metadata_hash"); + }); + + test("builds transfer event rows in the contract column shape", () => { + const row = buildTransferEventArchiveRow({ + tags: buildCommonArchiveTags({ + deploymentId: "deploy-a", + accountSelector: "primary", + exchange: "binance", + symbol: "USDC", + }), + transfer: { + eventKind: "withdrawal", + lifecycleAction: "submit_withdrawal", + status: "ok", + amount: "100", + address: "0xdead", + network: "ARBITRUM", + externalId: "wd-1", + clientWithdrawalId: "lane-withdrawal-1", + txid: "0xabc", + feeAmount: "4.89", + feeCurrency: "USDC", + payload: { id: "wd-1" }, + }, + }); + + expect(row.table).toBe("broker_execution.transfer_events"); + expect(row.row).toMatchObject({ + source: "broker_write", + deployment_id: "deploy-a", + account_selector: "primary", + exchange: "binance", + symbol: "USDC", + // asset_symbol mirrors the shared symbol tag for transfers, per contract. + asset_symbol: "USDC", + schema_version: "1", + event_kind: "withdrawal", + lifecycle_action: "submit_withdrawal", + status: "ok", + amount: "100", + external_id: "wd-1", + client_withdrawal_id: "lane-withdrawal-1", + result_index: 0, + // Additive columns (ccxt exposes the withdrawal fee). + fee_amount: "4.89", + fee_currency: "USDC", + }); + }); + + test("transfer rows keep the read-key columns present when ids are absent", () => { + const row = buildTransferEventArchiveRow({ + tags: buildCommonArchiveTags({ + deploymentId: "deploy-a", + exchange: "binance", + symbol: "USDC", + }), + transfer: { + eventKind: "deposit", + lifecycleAction: "observe_deposit", + payload: {}, + }, + }); + expect(row.row.external_id).toBe(""); + expect(row.row.client_withdrawal_id).toBe(""); + expect(row.row.status).toBe(""); + expect(row.row.result_index).toBe(0); + expect(row.row.event_kind).toBe("deposit"); + }); + + test("normalizeCcxtTransactionForArchive captures the withdrawal fee as a string", () => { + const normalized = normalizeCcxtTransactionForArchive({ + id: "wd-1", + txid: "0xabc", + address: "0xdead", + currency: "USDC", + amount: 100, + status: "ok", + network: "ARBITRUM", + fee: { cost: 4.89, currency: "USDC" }, + datetime: "2026-07-04T00:00:00.000Z", + }); + expect(normalized).toMatchObject({ + externalId: "wd-1", + txid: "0xabc", + address: "0xdead", + network: "ARBITRUM", + amount: "100", + assetSymbol: "USDC", + status: "ok", + feeAmount: "4.89", + feeCurrency: "USDC", + exchangeTimestamp: "2026-07-04T00:00:00.000Z", + }); + }); + + test("normalizeCcxtTransactionForArchive prefers the venue raw string amount", () => { + const normalized = normalizeCcxtTransactionForArchive({ + amount: 7.5, + info: { amount: "7.50000000" }, + fee: { cost: 0.1 }, + }); + // Venue precision preserved over ccxt's parsed number. + expect(normalized.amount).toBe("7.50000000"); + }); + + test("normalizeCcxtTransactionForArchive preserves an explicit zero fee", () => { + const normalized = normalizeCcxtTransactionForArchive({ + fee: { cost: 0, currency: "USDC" }, + }); + expect(normalized.feeAmount).toBe("0"); + expect(normalized.feeCurrency).toBe("USDC"); + }); + + test("builds fill event rows in the contract column shape", () => { + const row = buildFillEventArchiveRow({ + tags: buildCommonArchiveTags({ + deploymentId: "deploy-a", + accountSelector: "primary", + exchange: "binance", + symbol: "USDC/USDT", + }), + fill: { + ...normalizeCcxtTradeForArchive({ + id: "t-1", + order: "o-1", + clientOrderId: "c-1", + side: "BUY", + type: "limit", + price: 1.0, + amount: 50, + cost: 50, + fee: { cost: 0.05, currency: "USDC", rate: 0.001 }, + timestamp: 1_700_000_000_000, + }), + fillIndex: 2, + }, + }); + + expect(row.table).toBe("broker_execution.fill_events"); + expect(row.row).toMatchObject({ + symbol: "USDC/USDT", + schema_version: "1", + // Honest provenance: trade-history poller, not createOrder trades[]. + event_kind: "trade_history_fill", + order_id: "o-1", + client_order_id: "c-1", + fill_id: "t-1", + fill_index: 2, + side: "buy", + order_type: "limit", + price: "1", + base_quantity: "50", + quote_quantity: "50", + fee_amount: "0.05", + fee_currency: "USDC", + fee_rate: "0.001", + }); + }); + + test("fill rows default order_id/fill_index (contract read keys) when the venue omits them", () => { + const row = buildFillEventArchiveRow({ + tags: buildCommonArchiveTags({ + deploymentId: "deploy-a", + exchange: "binance", + symbol: "USDC/USDT", + }), + fill: normalizeCcxtTradeForArchive({ price: 1 }), + }); + expect(row.row.order_id).toBe(""); + expect(row.row.fill_index).toBe(0); + }); + + // Guards the fiet-maker CEX_EXECUTION_ARCHIVE_CONTRACT: a fully-populated row must + // carry every consumer-required column so the sandbox proof harness queries hold. + test("transfer/fill rows cover every consumer-contract column", () => { + const CONTRACT_TRANSFER_COLUMNS = [ + "broker_observed_timestamp", + "source", + "deployment_id", + "schema_version", + "account_selector", + "exchange", + "symbol", + "event_kind", + "lifecycle_action", + "status", + "asset_symbol", + "amount", + "address", + "network", + "external_id", + "txid", + "result_index", + "exchange_timestamp", + "error_summary", + "payload_json", + ]; + const CONTRACT_FILL_COLUMNS = [ + "broker_observed_timestamp", + "source", + "deployment_id", + "schema_version", + "account_selector", + "exchange", + "symbol", + "event_kind", + "order_id", + "client_order_id", + "fill_id", + "fill_index", + "side", + "order_type", + "price", + "base_quantity", + "quote_quantity", + "fee_amount", + "fee_currency", + "fee_rate", + "exchange_timestamp", + "payload_json", + ]; + const tags = buildCommonArchiveTags({ + deploymentId: "deploy-a", + accountSelector: "secondary:1", + exchange: "binance", + symbol: "USDC", + }); + const transferRow = buildTransferEventArchiveRow({ + tags, + transfer: { + eventKind: "withdrawal", + lifecycleAction: "submit_withdrawal", + status: "ok", + amount: "7.5", + address: "0xwallet", + network: "ARBITRUM", + externalId: "wd-1", + txid: "0xabc", + resultIndex: 0, + feeAmount: "0.1", + feeCurrency: "USDC", + exchangeTimestamp: "2026-07-04T00:00:00.000Z", + errorSummary: "", + payload: {}, + }, + }).row; + for (const column of CONTRACT_TRANSFER_COLUMNS) { + expect(transferRow).toHaveProperty(column); + } + const fillRow = buildFillEventArchiveRow({ + tags, + fill: { + orderId: "o-1", + clientOrderId: "c-1", + fillId: "t-1", + fillIndex: 0, + side: "buy", + orderType: "limit", + price: "1", + baseQuantity: "5", + quoteQuantity: "5", + feeAmount: "0.01", + feeCurrency: "USDC", + feeRate: "0.001", + exchangeTimestamp: "2026-07-04T00:00:00.000Z", + payload: {}, + }, + }).row; + for (const column of CONTRACT_FILL_COLUMNS) { + expect(fillRow).toHaveProperty(column); + } + }); +}); + +describe("order author archive plumbing", () => { + test("archives authors from typed and Call createOrder without forwarding them to the venue", async () => { + const forwarder = await startForwarderServer(); + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: forwarder.url, + deadLetterPath: createDeadLetterPath(), + deploymentId: "test-deploy", + batchSize: 100, + flushIntervalMs: 60_000, + }); + const createOrderCalls: unknown[][] = []; + const broker = { + loadMarkets: async () => {}, + markets: { + "USDC/USDT": { + symbol: "USDC/USDT", + base: "USDC", + quote: "USDT", + spot: true, + type: "spot", + }, + }, + createOrder: async (...args: unknown[]) => { + createOrderCalls.push(args); + return { + id: `order-${createOrderCalls.length}`, + symbol: args[0], + type: args[1], + side: args[2], + amount: args[3], + status: "open", + filled: 0, + }; + }, + } as unknown as Exchange; + const policy = { + order: { rule: { markets: ["*"], limits: [] } }, + } as unknown as PolicyConfig; + const context = ( + action: (typeof Action)[keyof typeof Action], + payload: Record, + ) => + ({ + action, + call: { request: { payload } }, + wrappedCallback: () => {}, + policy, + brokers: {}, + normalizedCex: "binance", + cex: "binance", + symbol: "USDC/USDT", + selectedBrokerAccount: { exchange: broker, label: "primary" }, + broker, + verity: { proof: "" }, + brokerArchiver: archiver, + }) as unknown as ExecuteActionContext; + const typedPayload = (orderAuthor?: string) => ({ + orderType: "limit", + amount: "10", + fromToken: "USDC", + toToken: "USDT", + price: "1", + marketType: "spot", + ...(orderAuthor !== undefined && { orderAuthor }), + params: JSON.stringify({ timeInForce: "GTC" }), + }); + + try { + await handleOrders( + context(Action.CreateOrder, typedPayload("maker-alpha")), + ); + await handleTreasuryCall( + context(Action.Call, { + functionName: "createOrder", + args: JSON.stringify(["USDC/USDT", "limit", "buy", 5, 1]), + orderAuthor: "funding-executor", + params: JSON.stringify({ postOnly: true }), + }), + ); + await handleOrders(context(Action.CreateOrder, typedPayload())); + + expect(createOrderCalls[0]?.[5]).toEqual({ timeInForce: "GTC" }); + expect(createOrderCalls[1]?.[5]).toEqual({ postOnly: true }); + expect(createOrderCalls[2]?.[5]).toEqual({ timeInForce: "GTC" }); + for (const call of createOrderCalls) { + expect(call[5]).not.toHaveProperty("orderAuthor"); + } + + await Promise.resolve(); + await archiver.flush(); + const orderRows = forwarder.requests + .flatMap((request) => request.body.rows ?? []) + .filter( + (entry) => entry.table === "broker_execution.order_events", + ) as Array<{ row: Record }>; + const orderAuthors = Object.fromEntries( + orderRows.map(({ row }) => [row.order_id, row.order_author]), + ); + + expect(orderAuthors).toEqual({ + "order-1": "maker-alpha", + "order-2": "funding-executor", + "order-3": "", + }); + } finally { + await archiver.close(); + await forwarder.close(); + } + }); +}); + +describe("withdrawal observation tracker", () => { + function shouldArchive( + tracker: WithdrawalObservationTracker, + transaction: Record, + ): boolean { + return tracker.shouldArchive({ + exchange: "binance", + accountSelector: "primary", + assetSymbol: "USDC", + transaction, + normalized: normalizeCcxtTransactionForArchive(transaction), + }); + } + + test("suppresses identical records and captures every fingerprint field change", () => { + const baseline = { + id: "wd-1", + txid: "tx-1", + currency: "USDC", + status: "pending", + amount: "10", + fee: { cost: "0", currency: "USDC" }, + datetime: "2026-07-01T00:00:00.000Z", + info: { completeTime: "" }, + }; + const changedRecords = [ + { ...baseline, status: "ok" }, + { ...baseline, txid: "tx-2" }, + { ...baseline, amount: "11" }, + { ...baseline, fee: { cost: "1", currency: "USDC" } }, + { ...baseline, fee: { cost: "0", currency: "USDT" } }, + { ...baseline, address: "0xrecipient" }, + { ...baseline, network: "ARBITRUM" }, + { ...baseline, info: { completeTime: "2026-07-01T00:01:00Z" } }, + ]; + + for (const changed of changedRecords) { + const tracker = new WithdrawalObservationTracker(); + expect(shouldArchive(tracker, baseline)).toBe(true); + expect(shouldArchive(tracker, { ...baseline })).toBe(false); + expect(shouldArchive(tracker, changed)).toBe(true); + } + }); + + test("uses non-colliding identities when the venue omits ids", () => { + const tracker = new WithdrawalObservationTracker({ maxEntries: 2 }); + const unidentified = { + currency: "USDC", + status: "pending", + amount: "10", + }; + + expect(shouldArchive(tracker, unidentified)).toBe(true); + expect(shouldArchive(tracker, { ...unidentified })).toBe(true); + expect(tracker.getSize()).toBe(2); + }); + + test("evicts the oldest identity at the configured bound and permits replay", () => { + const tracker = new WithdrawalObservationTracker({ maxEntries: 2 }); + const transaction = (id: string) => ({ + id, + currency: "USDC", + status: "pending", + amount: "10", + }); + + expect(shouldArchive(tracker, transaction("wd-1"))).toBe(true); + expect(shouldArchive(tracker, transaction("wd-2"))).toBe(true); + expect(shouldArchive(tracker, transaction("wd-3"))).toBe(true); + expect(tracker.getSize()).toBe(2); + expect(shouldArchive(tracker, transaction("wd-1"))).toBe(true); + expect(tracker.getSize()).toBe(2); + }); + + test("falls back to the default bound for non-finite capacity overrides", () => { + for (const maxEntries of [Number.NaN, Number.POSITIVE_INFINITY]) { + const tracker = new WithdrawalObservationTracker({ maxEntries }); + for ( + let index = 0; + index <= DEFAULT_WITHDRAWAL_OBSERVATION_TRACKER_MAX_ENTRIES; + index += 1 + ) { + shouldArchive(tracker, { + id: `wd-${index}`, + currency: "USDC", + status: "pending", + amount: "10", + }); + } + expect(tracker.getSize()).toBe( + DEFAULT_WITHDRAWAL_OBSERVATION_TRACKER_MAX_ENTRIES, + ); + } + }); +}); + +describe("withdraw submission archive", () => { + test("carries valid caller ids on successful and failed submissions without deriving invalid ids", async () => { + const forwarder = await startForwarderServer(); + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: forwarder.url, + deadLetterPath: createDeadLetterPath(), + deploymentId: "test-deploy", + batchSize: 100, + flushIntervalMs: 60_000, + }); + const broker = { + has: { fetchCurrencies: false }, + currencies: { + USDC: { + networks: { + BSC: { id: "BSC", network: "BSC" }, + }, + }, + }, + withdraw: async ( + _code: string, + _amount: number, + _address: string, + _tag: undefined, + params: Record, + ) => { + if (params.withdrawOrderId === "failed-lane") { + throw new Error("venue rejected withdrawal"); + } + return { id: "venue-withdrawal-id" }; + }, + } as unknown as Exchange; + + const context = (withdrawOrderId: string | number) => + ({ + call: { + request: { + payload: { + recipientAddress: "0xrecipient", + amount: "10", + chain: "BNB", + params: JSON.stringify({ withdrawOrderId }), + }, + }, + }, + wrappedCallback: () => {}, + policy: { + withdraw: { + rule: [ + { + exchange: "BINANCE", + network: "BNB", + whitelist: ["0xrecipient"], + coins: ["USDC"], + }, + ], + }, + deposit: {}, + order: { rule: { markets: [], limits: [] } }, + }, + brokers: {}, + metadata: {}, + normalizedCex: "binance", + cex: "binance", + symbol: "USDC", + selectedBrokerAccount: { exchange: broker, label: "primary" }, + broker, + verity: { proof: "" }, + applyVerityToBroker: () => {}, + useVerity: false, + verityProverUrl: "", + brokerArchiver: archiver, + }) as unknown as ExecuteActionContext; + + try { + await handleWithdraw(context("successful-lane")); + await handleWithdraw(context("failed-lane")); + await handleWithdraw(context(123)); + await Promise.resolve(); + await archiver.flush(); + + const rows = forwarder.requests.flatMap( + (request) => request.body.rows ?? [], + ) as Array<{ row: Record }>; + expect(rows).toHaveLength(3); + expect(rows[0]?.row).toMatchObject({ + external_id: "venue-withdrawal-id", + client_withdrawal_id: "successful-lane", + status: "", + }); + expect(rows[1]?.row).toMatchObject({ + external_id: "", + client_withdrawal_id: "failed-lane", + status: "failed", + error_summary: "venue rejected withdrawal", + }); + expect(rows[2]?.row.client_withdrawal_id).toBe(""); + } finally { + await archiver.close(); + await forwarder.close(); + } + }); +}); + +describe("internal transfer submission archive", () => { + test("indexes Binance venue ids for every internal transfer direction", async () => { + const forwarder = await startForwarderServer(); + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: forwarder.url, + deadLetterPath: createDeadLetterPath(), + deploymentId: "test-deploy", + batchSize: 100, + flushIntervalMs: 60_000, + }); + const exchangeBase = { + loadMarkets: async () => {}, + currency: (code: string) => ({ id: code }), + currencyToPrecision: (_code: string, amount: number) => String(amount), + }; + const primaryExchange = { + ...exchangeBase, + sapiPostSubAccountUniversalTransfer: async () => ({ + tranId: "primary-to-sub-id", + }), + } as unknown as Exchange; + const secondaryExchange = { + ...exchangeBase, + sapiPostSubAccountTransferSubToMaster: async () => ({ + txnId: "sub-to-master-id", + }), + sapiPostSubAccountTransferSubToSub: async () => ({ + txnId: "sub-to-sub-id", + }), + } as unknown as Exchange; + const brokers = { + binance: { + primary: { exchange: primaryExchange, label: "primary" as const }, + secondaryBrokers: [ + { + exchange: secondaryExchange, + label: "secondary:1" as const, + index: 1, + }, + { + exchange: secondaryExchange, + label: "secondary:2" as const, + index: 2, + email: "secondary-2@example.com", + }, + ], + }, + }; + const context = (fromAccount: string, toAccount: string) => + ({ + call: { + request: { + payload: { amount: "10", fromAccount, toAccount }, + }, + }, + wrappedCallback: () => {}, + brokers, + metadata: {}, + normalizedCex: "binance", + cex: "binance", + symbol: "USDC", + broker: primaryExchange, + verity: { proof: "" }, + useVerity: false, + verityProverUrl: "", + brokerArchiver: archiver, + }) as unknown as ExecuteActionContext; + + try { + await handleInternalTransfer(context("secondary:1", "primary")); + await handleInternalTransfer(context("secondary:1", "secondary:2")); + await handleInternalTransfer(context("primary", "secondary:2")); + await Promise.resolve(); + await archiver.flush(); + + const rows = forwarder.requests.flatMap( + (request) => request.body.rows ?? [], + ) as Array<{ row: Record }>; + expect(rows).toHaveLength(3); + expect( + rows.map(({ row }) => ({ + from: row.account_selector, + to: (JSON.parse(String(row.payload_json)) as { to: string }).to, + externalId: row.external_id, + status: row.status, + amount: row.amount, + })), + ).toEqual([ + { + from: "secondary:1", + to: "primary", + externalId: "sub-to-master-id", + status: "ok", + amount: "10", + }, + { + from: "secondary:1", + to: "secondary:2", + externalId: "sub-to-sub-id", + status: "ok", + amount: "10", + }, + { + from: "primary", + to: "secondary:2", + externalId: "primary-to-sub-id", + status: "ok", + amount: "10", + }, + ]); + } finally { + await archiver.close(); + await forwarder.close(); + } + }); +}); + +describe("broker execution archiver queue", () => { + test("classifies only broker_execution tables for the OTel mirror", () => { + for (const table of [ + "broker_execution.order_events", + "broker_execution.market_metadata_snapshots", + "broker_execution.transfer_events", + "broker_execution.fill_events", + ] as const) { + expect(isBrokerExecutionArchiveTable(table)).toBe(true); + } + expect( + isBrokerExecutionArchiveTable("broker_account.balance_snapshots"), + ).toBe(false); + expect(isBrokerExecutionArchiveTable("market_data.candles")).toBe(false); + }); + + test("classifies and rethrows only archive durability failures", () => { + const durabilityError = new BrokerExecutionArchiveDurabilityError( + "loss journal write failed", + ); + expect(() => rethrowArchiveDurabilityError(durabilityError)).toThrow( + durabilityError, + ); + expect(() => + rethrowArchiveDurabilityError(new Error("ordinary capture failure")), + ).not.toThrow(); + }); + + test("creates loss journals as owner-only without chmodding existing files", async () => { + const newPath = createDeadLetterPath(); + const created = BrokerExecutionArchiver.create({ + forwarderUrl: "http://127.0.0.1:9/archive", + deadLetterPath: newPath, + flushIntervalMs: 60_000, + }); + expect(statSync(newPath).mode & 0o777).toBe(0o600); + await created.close(); + + const existingPath = createDeadLetterPath(); + writeFileSync(existingPath, ""); + chmodSync(existingPath, 0o640); + const existing = BrokerExecutionArchiver.create({ + forwarderUrl: "http://127.0.0.1:9/archive", + deadLetterPath: existingPath, + flushIntervalMs: 60_000, + }); + await existing.close(); + expect(statSync(existingPath).mode & 0o777).toBe(0o640); + }); + + test("retains the oldest queued row when loss journaling fails", async () => { + const deadLetterPath = createDeadLetterPath(); + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: "http://127.0.0.1:9/archive", + deadLetterPath, + maxQueueSize: 1, + batchSize: 10, + flushIntervalMs: 60_000, + }); + archiver.enqueue({ + table: "broker_execution.order_events", + row: { order_id: "oldest" }, + }); + + const deadLetterFd = Reflect.get(archiver, "deadLetterFd"); + expect(typeof deadLetterFd).toBe("number"); + closeSync(deadLetterFd as number); + + expect(() => + archiver.enqueue({ + table: "broker_execution.order_events", + row: { order_id: "new" }, + }), + ).toThrow(BrokerExecutionArchiveDurabilityError); + expect(archiver.getQueueDepth()).toBe(1); + expect(archiver.getStats().shed).toBe(0); + + // The retention assertion is complete; clear the private queue only to let + // close exercise and release the deliberately invalidated file handle. + (Reflect.get(archiver, "queue") as unknown[]).length = 0; + let closeError: unknown; + try { + await archiver.close(); + } catch (error) { + closeError = error; + } + expect(closeError).toBeInstanceOf(BrokerExecutionArchiveDurabilityError); + expect((closeError as Error).message).toContain( + "CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH", + ); + expect((closeError as Error).message).not.toContain(deadLetterPath); + }); + + test("posts JSON with the bearer token over the real node:http transport", async () => { + const server = await startForwarderServer(); + const originalToken = process.env.CEX_BROKER_ARCHIVE_FORWARDER_TOKEN; + process.env.CEX_BROKER_ARCHIVE_FORWARDER_TOKEN = "secret-token"; + + try { + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: server.url, + deadLetterPath: createDeadLetterPath(), + deploymentId: "test-deploy", + batchSize: 10, + flushIntervalMs: 60_000, + }); + + archiver.enqueue({ + table: "broker_execution.order_events", + row: { source: "broker_write", order_id: "1" }, + }); + + await archiver.flush(); + + expect(server.requests).toHaveLength(1); + const request = server.requests[0]; + expect(request?.method).toBe("POST"); + expect(request?.headers["content-type"]).toBe("application/json"); + expect(request?.headers.authorization).toBe("Bearer secret-token"); + expect(request?.body).toMatchObject({ + source: "broker_write", + deployment_id: "test-deploy", + }); + + await archiver.close(); + } finally { + await server.close(); + if (originalToken === undefined) { + delete process.env.CEX_BROKER_ARCHIVE_FORWARDER_TOKEN; + } else { + process.env.CEX_BROKER_ARCHIVE_FORWARDER_TOKEN = originalToken; + } + } + }); + + test("queue shedding journals the oldest row before discarding it", async () => { + const server = await startForwarderServer(); + try { + const otelLogs = new MockOtelLogs(); + const deadLetterPath = createDeadLetterPath(); + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: server.url, + deadLetterPath, + otelLogs, + deploymentId: "test-deploy", + maxQueueSize: 2, + batchSize: 10, + flushIntervalMs: 60_000, + }); + + archiver.enqueue({ + table: "broker_execution.order_events", + row: { source: "broker_write", order_id: "1" }, + }); + archiver.enqueue({ + table: "broker_execution.order_events", + row: { source: "broker_write", order_id: "2" }, + }); + archiver.enqueue({ + table: "broker_execution.order_events", + row: { source: "broker_write", order_id: "3" }, + }); + + expect(archiver.getStats().shed).toBe(1); + expect(archiver.getQueueDepth()).toBe(2); + const [loss] = readDeadLetters(deadLetterPath); + expect(loss).toMatchObject({ + deployment_id: "test-deploy", + reason: "queue_shed", + payload: { + table: "broker_execution.order_events", + row: { source: "broker_write", order_id: "1" }, + }, + }); + expect(new Date(String(loss?.timestamp)).toISOString()).toBe( + loss?.timestamp, + ); + + await archiver.flush(); + expect(server.requests).toHaveLength(1); + expect(server.requests[0]?.body.rows).toHaveLength(2); + expect(otelLogs.emits).toHaveLength(2); + + await archiver.close(); + } finally { + await server.close(); + } + }); + + test("mirrors only broker_execution rows to OTel logs and forwards every archive table", async () => { + const server = await startForwarderServer(); + try { + const otelLogs = new MockOtelLogs(); + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: server.url, + deadLetterPath: createDeadLetterPath(), + otelLogs, + deploymentId: "test-deploy", + batchSize: 10, + flushIntervalMs: 60_000, + }); + + archiver.enqueue({ + table: "broker_execution.order_events", + row: { + source: "broker_write", + order_id: "1", + error_message: "sensitive exchange rejection", + }, + }); + archiver.enqueue({ + table: "market_data.orderbook_snapshots", + row: { source: "broker_write", best_bid: 100 }, + }); + archiver.enqueue({ + table: "broker_account.balance_snapshots", + row: { source: "broker_write", reported_assets: ["USDC"] }, + }); + + await archiver.flush(); + + // Only execution rows mirror to OTel (market_data has no OTel schema)... + expect(otelLogs.emits).toHaveLength(1); + expect(otelLogs.emits[0]?.body).toBe("broker_execution.order_events"); + expect(otelLogs.emits[0]?.attributes?.error_message).toBe( + "redacted_error", + ); + // ...but the forwarder is the durable sink for both tables. + expect(server.requests).toHaveLength(1); + const forwardedRows = server.requests.flatMap((request) => + Array.isArray(request.body.rows) ? request.body.rows : [], + ) as Array<{ table?: string; row?: Record }>; + expect(forwardedRows).toEqual( + expect.arrayContaining([ + expect.objectContaining({ table: "broker_execution.order_events" }), + expect.objectContaining({ table: "market_data.orderbook_snapshots" }), + expect.objectContaining({ + table: "broker_account.balance_snapshots", + }), + ]), + ); + expect( + forwardedRows.find( + (entry) => entry.table === "broker_execution.order_events", + )?.row?.error_message, + ).toBe("sensitive exchange rejection"); + + await archiver.close(); + } finally { + await server.close(); + } + }); + + test("close journals every row left after a forwarder failure", async () => { + const server = await startForwarderServer(() => ({ status: 503 })); + try { + const deadLetterPath = createDeadLetterPath(); + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: server.url, + deadLetterPath, + deploymentId: "test-deploy", + batchSize: 10, + flushIntervalMs: 60_000, + }); + + archiver.enqueue({ + table: "market_data.candles", + row: { source: "broker_write", open_time_ms: 1_000 }, + }); + archiver.enqueue({ + table: "broker_execution.order_events", + row: { source: "broker_write", order_id: "shutdown-order" }, + }); + + await expect(archiver.close()).resolves.toBeUndefined(); + expect(archiver.getQueueDepth()).toBe(0); + expect(archiver.getStats().forwarderFailures).toBeGreaterThan(0); + expect(readDeadLetters(deadLetterPath)).toEqual([ + expect.objectContaining({ + deployment_id: "test-deploy", + reason: "shutdown_forwarder_failure", + payload: { + table: "market_data.candles", + row: { source: "broker_write", open_time_ms: 1_000 }, + }, + }), + expect.objectContaining({ + deployment_id: "test-deploy", + reason: "shutdown_forwarder_failure", + payload: { + table: "broker_execution.order_events", + row: { + source: "broker_write", + order_id: "shutdown-order", + }, + }, + }), + ]); + } finally { + await server.close(); + } + }); + + test("requeues the whole batch after a forwarder network failure", async () => { + // Abort the socket so the client's node:http request errors — the network + // failure path (distinct from the non-2xx path exercised elsewhere). + const server = await startForwarderServer(() => ({ destroy: true })); + try { + const otelLogs = new MockOtelLogs(); + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: server.url, + deadLetterPath: createDeadLetterPath(), + otelLogs, + deploymentId: "test-deploy", + batchSize: 10, + flushIntervalMs: 60_000, + }); + + archiver.enqueue({ + table: "broker_execution.order_events", + row: { source: "broker_write", order_id: "1" }, + }); + archiver.enqueue({ + table: "market_data.candles", + row: { source: "broker_write", open_time_ms: 1_000 }, + }); + + await archiver.flush(); + // The execution row still reached OTel (mirror happens before the post), + // and both rows are requeued for a later forwarder retry — neither is lost. + expect(otelLogs.emits).toHaveLength(1); + expect(archiver.getQueueDepth()).toBe(2); + expect(server.requests).toHaveLength(1); + + await archiver.close(); + } finally { + await server.close(); + } + }); + + test("posts broker_execution rows to the forwarder even without OTel logs", async () => { + const server = await startForwarderServer(); + try { + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: server.url, + deadLetterPath: createDeadLetterPath(), + deploymentId: "test-deploy", + batchSize: 10, + flushIntervalMs: 60_000, + }); + + archiver.enqueue({ + table: "broker_execution.order_events", + row: { source: "broker_write", order_id: "1" }, + }); + + await archiver.flush(); + expect(server.requests).toHaveLength(1); + expect(server.requests[0]?.body).toMatchObject({ + rows: expect.arrayContaining([ + expect.objectContaining({ table: "broker_execution.order_events" }), + ]), + }); + + await archiver.close(); + } finally { + await server.close(); + } + }); + + test("keeps the queue within maxQueueSize when a failed batch is requeued after refill", async () => { + // Gate the forwarder so a batch stays in flight while new rows refill the + // queue, then fail it — the classic over-cap window for the requeue path. + let releasePost: () => void = () => {}; + const postGate = new Promise((resolve) => { + releasePost = resolve; + }); + const server = await startForwarderServer(async () => { + await postGate; + return { status: 503 }; + }); + try { + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: server.url, + deadLetterPath: createDeadLetterPath(), + deploymentId: "test-deploy", + maxQueueSize: 3, + batchSize: 4, // above the 3 rows we enqueue, so only manual flush drains + flushIntervalMs: 60_000, + }); + const row = (id: string) => + ({ + table: "broker_execution.order_events", + row: { source: "broker_write", order_id: id }, + }) as const; + + archiver.enqueue(row("1")); + archiver.enqueue(row("2")); + archiver.enqueue(row("3")); + + // Splices [1,2,3] out and blocks on the gated forwarder response. + const flushPromise = archiver.flush(); + expect(archiver.getQueueDepth()).toBe(0); + + // Queue refills to the cap while the batch is in flight. + archiver.enqueue(row("4")); + archiver.enqueue(row("5")); + archiver.enqueue(row("6")); + expect(archiver.getQueueDepth()).toBe(3); + + releasePost(); + await flushPromise; + + // Without bound enforcement this would be 6 (3 refill + 3 requeued). + expect(archiver.getQueueDepth()).toBe(3); + expect(archiver.getStats().shed).toBeGreaterThanOrEqual(3); + + await archiver.close(); + } finally { + await server.close(); + } + }); + + test("canPersistMarketMetadataSnapshot is true with an enabled forwarder", async () => { + const forwarderOnly = BrokerExecutionArchiver.create({ + forwarderUrl: "http://127.0.0.1:9/archive", + deadLetterPath: createDeadLetterPath(), + deploymentId: "test-deploy", + flushIntervalMs: 60_000, + }); + expect(forwarderOnly.canPersistMarketMetadataSnapshot()).toBe(true); + + const disabled = BrokerExecutionArchiver.disabled(); + expect(disabled.canPersistMarketMetadataSnapshot()).toBe(false); + await forwarderOnly.close(); + }); + + test("advertises durable account balance snapshots for an enabled archive", async () => { + const forwarderOnly = BrokerExecutionArchiver.create({ + forwarderUrl: "http://127.0.0.1:9/archive", + deadLetterPath: createDeadLetterPath(), + flushIntervalMs: 60_000, + }); + const disabled = BrokerExecutionArchiver.disabled(); + + expect(forwarderOnly.canPersistAccountBalanceSnapshots()).toBe(true); + expect(disabled.canPersistAccountBalanceSnapshots()).toBe(false); + + await forwarderOnly.close(); + }); +}); + +describe("broker execution archiver env", () => { + test("only the exact true enable flag enables archive construction", async () => { + const originalEnabled = process.env.CEX_BROKER_ARCHIVE_ENABLED; + const originalForwarderUrl = process.env.CEX_BROKER_ARCHIVE_FORWARDER_URL; + const originalDeadLetterPath = + process.env.CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH; + delete process.env.CEX_BROKER_ARCHIVE_FORWARDER_URL; + delete process.env.CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH; + + try { + for (const enabled of [undefined, "false", "TRUE"]) { + if (enabled === undefined) { + delete process.env.CEX_BROKER_ARCHIVE_ENABLED; + } else { + process.env.CEX_BROKER_ARCHIVE_ENABLED = enabled; + } + const archiver = createBrokerExecutionArchiverFromEnv(); + expect(archiver.isEnabled()).toBe(false); + await archiver.close(); + } + } finally { + restoreEnv("CEX_BROKER_ARCHIVE_ENABLED", originalEnabled); + restoreEnv("CEX_BROKER_ARCHIVE_FORWARDER_URL", originalForwarderUrl); + restoreEnv("CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH", originalDeadLetterPath); + } + }); + + test("requires the explicit forwarder URL and dead-letter path when enabled", () => { + const originalEnabled = process.env.CEX_BROKER_ARCHIVE_ENABLED; + const originalForwarderUrl = process.env.CEX_BROKER_ARCHIVE_FORWARDER_URL; + const originalDeadLetterPath = + process.env.CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH; + process.env.CEX_BROKER_ARCHIVE_ENABLED = "true"; + delete process.env.CEX_BROKER_ARCHIVE_FORWARDER_URL; + delete process.env.CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH; + + try { + expect(() => createBrokerExecutionArchiverFromEnv()).toThrow( + "CEX_BROKER_ARCHIVE_FORWARDER_URL is missing", + ); + process.env.CEX_BROKER_ARCHIVE_FORWARDER_URL = + "http://127.0.0.1:8090/archive"; + expect(() => createBrokerExecutionArchiverFromEnv()).toThrow( + "CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH is missing", + ); + process.env.CEX_BROKER_ARCHIVE_FORWARDER_URL = "file:///tmp/archive"; + process.env.CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH = createDeadLetterPath(); + expect(() => createBrokerExecutionArchiverFromEnv()).toThrow( + "must use http or https", + ); + process.env.CEX_BROKER_ARCHIVE_FORWARDER_URL = + "http://127.0.0.1:8090/archive"; + process.env.CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH = archiveTestDirectory; + let openError: unknown; + try { + createBrokerExecutionArchiverFromEnv(); + } catch (error) { + openError = error; + } + expect(openError).toBeInstanceOf(Error); + expect((openError as Error).message).toContain( + "CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH", + ); + expect((openError as Error).message).not.toContain(archiveTestDirectory); + } finally { + restoreEnv("CEX_BROKER_ARCHIVE_ENABLED", originalEnabled); + restoreEnv("CEX_BROKER_ARCHIVE_FORWARDER_URL", originalForwarderUrl); + restoreEnv("CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH", originalDeadLetterPath); + } + }); + + test("posts to the explicit forwarder and mirrors to OTel only when requested", async () => { + const server = await startForwarderServer(); + const originalEnabled = process.env.CEX_BROKER_ARCHIVE_ENABLED; + const originalForwarderUrl = process.env.CEX_BROKER_ARCHIVE_FORWARDER_URL; + const originalDeadLetterPath = + process.env.CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH; + const originalOtelLogsEnabled = + process.env.CEX_BROKER_ARCHIVE_OTEL_LOGS_ENABLED; + process.env.CEX_BROKER_ARCHIVE_ENABLED = "true"; + process.env.CEX_BROKER_ARCHIVE_FORWARDER_URL = server.url; + process.env.CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH = createDeadLetterPath(); + process.env.CEX_BROKER_ARCHIVE_OTEL_LOGS_ENABLED = "true"; + + try { + expect(resolveArchiveForwarderUrlFromEnv()).toBe(server.url); + expect(isArchiveOtelLogsEnabled()).toBe(true); + + const otelLogs = new MockOtelLogs(); + const archiver = createBrokerExecutionArchiverFromEnv(otelLogs); + archiver.enqueue({ + table: "broker_execution.order_events", + row: { source: "broker_write", order_id: "1" }, + }); + await archiver.flush(); + expect(otelLogs.emits).toHaveLength(1); + expect(server.requests).toHaveLength(1); + await archiver.close(); + } finally { + await server.close(); + restoreEnv("CEX_BROKER_ARCHIVE_ENABLED", originalEnabled); + restoreEnv("CEX_BROKER_ARCHIVE_FORWARDER_URL", originalForwarderUrl); + restoreEnv("CEX_BROKER_ARCHIVE_DEAD_LETTER_PATH", originalDeadLetterPath); + restoreEnv( + "CEX_BROKER_ARCHIVE_OTEL_LOGS_ENABLED", + originalOtelLogsEnabled, + ); + } + }); + + test("disabled and enabled construction each announce startup state once", async () => { + const info = spyOn(log, "info").mockImplementation(() => {}); + const forwarderUrl = "http://archive.example.invalid/private/archive"; + const deadLetterPath = createDeadLetterPath(); + try { + const disabled = BrokerExecutionArchiver.disabled(); + const enabled = BrokerExecutionArchiver.create({ + forwarderUrl, + deadLetterPath, + otelLogs: new MockOtelLogs(), + deploymentId: "announce-test", + flushIntervalMs: 60_000, + }); + expect( + info.mock.calls.filter( + ([message]) => message === "Broker execution archive disabled", + ), + ).toHaveLength(1); + expect( + info.mock.calls.filter( + ([message]) => message === "Broker execution archive enabled", + ), + ).toHaveLength(1); + const enabledCall = info.mock.calls.find( + ([message]) => message === "Broker execution archive enabled", + ); + expect(enabledCall?.[1]).toEqual({ + enabled: true, + otel_mirror_enabled: true, + }); + const startupOutput = JSON.stringify(enabledCall); + expect(startupOutput).not.toContain(forwarderUrl); + expect(startupOutput).not.toContain(deadLetterPath); + expect(startupOutput).not.toContain("announce-test"); + await disabled.close(); + await enabled.close(); + } finally { + info.mockRestore(); + } + }); +}); diff --git a/test/candle-viewer.test.ts b/test/candle-viewer.test.ts new file mode 100644 index 0000000..9225007 --- /dev/null +++ b/test/candle-viewer.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test"; +import { + type CandleRow, + candleFingerprint, + toChartCandle, +} from "../research/candle-viewer/candles"; +import { + type SeriesSnapshot, + shouldReplaceCandleSeries, +} from "../research/candle-viewer/chart-update"; +import { + chartPriceFormat, + formatPrice, + PRICE_DECIMAL_PLACES, +} from "../research/candle-viewer/format"; +import { rollupCandles } from "../research/candle-viewer/timeframes"; + +describe("candle viewer candles", () => { + test("toChartCandle converts ms to unix seconds", () => { + const row: CandleRow = { + open_time_ms: 1_700_000_000_000, + open: 100, + high: 110, + low: 90, + close: 105, + volume: 12.5, + is_closed: 0, + broker_version: 1, + }; + expect(toChartCandle(row)).toEqual({ + time: 1_700_000_000, + open: 100, + high: 110, + low: 90, + close: 105, + volume: 12.5, + isClosed: false, + brokerVersion: 1, + }); + }); + + test("toChartCandle rejects malformed numeric strings and negative uints", () => { + const base: CandleRow = { + open_time_ms: 1_700_000_000_000, + open: 100, + high: 110, + low: 90, + close: 105, + volume: 12.5, + is_closed: 0, + broker_version: 1, + }; + expect( + toChartCandle({ ...base, open: "123abc" as unknown as number }), + ).toBeNull(); + expect(toChartCandle({ ...base, open_time_ms: -1 })).toBeNull(); + expect(toChartCandle({ ...base, is_closed: 1.5 })).toBeNull(); + }); + + test("candleFingerprint changes when close updates", () => { + const base = { + time: 1_700_000_000, + open: 1, + high: 2, + low: 0.5, + close: 1.5, + volume: 10, + isClosed: false, + brokerVersion: 100, + }; + const before = candleFingerprint([base]); + const after = candleFingerprint([{ ...base, close: 1.6 }]); + expect(before).not.toBe(after); + }); +}); + +describe("candle viewer price format", () => { + test("uses 6 decimal places for sub-$2 assets like DOGE", () => { + expect(formatPrice(0.07692)).toBe( + (0.07692).toLocaleString(undefined, { + minimumFractionDigits: 6, + maximumFractionDigits: 6, + }), + ); + }); + + test("chart price format uses 6dp min move", () => { + expect(chartPriceFormat()).toEqual({ + type: "price", + precision: PRICE_DECIMAL_PLACES, + minMove: 0.000001, + }); + }); +}); + +describe("candle viewer chart update", () => { + test("replaces series when the window slides but count stays fixed", () => { + const previous: SeriesSnapshot = { + count: 2, + firstTime: 1_000, + lastTime: 1_001, + }; + const candles = [{ time: 1_001 }, { time: 1_002 }]; + expect(shouldReplaceCandleSeries(previous, candles)).toBe(true); + }); + + test("updates in place when only the forming bar time is unchanged", () => { + const previous: SeriesSnapshot = { + count: 2, + firstTime: 1_000, + lastTime: 1_060, + }; + const candles = [{ time: 1_000 }, { time: 1_060 }]; + expect(shouldReplaceCandleSeries(previous, candles)).toBe(false); + }); + + test("replaces series when a new bar opens", () => { + const previous: SeriesSnapshot = { + count: 2, + firstTime: 1_000, + lastTime: 1_001, + }; + const candles = [{ time: 1_000 }, { time: 1_002 }]; + expect(shouldReplaceCandleSeries(previous, candles)).toBe(true); + }); +}); + +describe("candle viewer rollups", () => { + test("rollupCandles carries brokerVersion from source bars", () => { + const rolled = rollupCandles( + [ + { + time: 1_700_000_000, + open: 1, + high: 2, + low: 0.5, + close: 1.5, + volume: 10, + isClosed: true, + brokerVersion: 100, + }, + { + time: 1_700_000_060, + open: 1.5, + high: 2.5, + low: 1.4, + close: 2, + volume: 12, + isClosed: false, + brokerVersion: 200, + }, + ], + "5m", + ); + expect(rolled).toHaveLength(1); + expect(rolled[0]?.brokerVersion).toBe(200); + }); +}); diff --git a/test/cex-broker.test.ts b/test/cex-broker.test.ts index 3e874e1..1770a89 100644 --- a/test/cex-broker.test.ts +++ b/test/cex-broker.test.ts @@ -245,8 +245,12 @@ describe("CEXBroker", () => { /^(\d{1,3}\.){3}\d{1,3}$/.test(ip) && ip.split(".").every((part) => Number(part) >= 0 && Number(part) <= 255); - validIPs.forEach((ip) => expect(isValidIPv4(ip)).toBe(true)); - invalidIPs.forEach((ip) => expect(isValidIPv4(ip)).toBe(false)); + for (const ip of validIPs) { + expect(isValidIPv4(ip)).toBe(true); + } + for (const ip of invalidIPs) { + expect(isValidIPv4(ip)).toBe(false); + } }); test("should validate Verity URL", () => { diff --git a/test/clickhouse-schema.integration.test.ts b/test/clickhouse-schema.integration.test.ts new file mode 100644 index 0000000..4c1f6cc --- /dev/null +++ b/test/clickhouse-schema.integration.test.ts @@ -0,0 +1,515 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { type ClickHouseClient, createClient } from "@clickhouse/client"; +import { createClickHouseInserter } from "../services/archive-forwarder/insert"; +import { handleArchiveBatch } from "../services/archive-forwarder/router"; +import { ensureArchiveSchema } from "../services/archive-forwarder/schema"; + +const CLICKHOUSE_URL = + process.env.CLICKHOUSE_TEST_URL?.trim() || + `http://${process.env.CLICKHOUSE_HOST?.trim() || "localhost"}:${process.env.CLICKHOUSE_PORT?.trim() || "18123"}`; + +const TEST_DEPLOYMENT = `clickhouse-integration-test-${Date.now()}`; +const TEST_EVENT_MS = 1_900_000_000_000; + +let client: ClickHouseClient | undefined; +let clickhouseAvailable = false; + +function requireClient(): ClickHouseClient { + if (!client) { + throw new Error("ClickHouse client is not initialized"); + } + return client; +} + +async function probeClickHouse(): Promise { + const probe = createClient({ + url: CLICKHOUSE_URL, + database: "market_data", + }); + try { + const result = await probe.query({ + query: "SELECT 1 AS ok", + format: "JSONEachRow", + }); + const rows = (await result.json()) as Array<{ ok: number }>; + return rows[0]?.ok === 1; + } catch { + return false; + } finally { + await probe.close(); + } +} + +async function tableEngine(name: string): Promise { + const activeClient = requireClient(); + const result = await activeClient.query({ + query: ` + SELECT engine + FROM system.tables + WHERE database = 'market_data' AND name = {name:String} + `, + query_params: { name }, + format: "JSONEachRow", + }); + const rows = (await result.json()) as Array<{ engine: string }>; + return rows[0]?.engine ?? null; +} + +async function cleanupTestRows(): Promise { + const activeClient = requireClient(); + await activeClient.command({ + query: ` + ALTER TABLE orderbook_snapshots + DELETE WHERE deployment_id = {deployment_id:String} + `, + query_params: { deployment_id: TEST_DEPLOYMENT }, + }); + await activeClient.command({ + query: ` + ALTER TABLE candles + DELETE WHERE deployment_id = {deployment_id:String} + `, + query_params: { deployment_id: TEST_DEPLOYMENT }, + }); + await activeClient.command({ + query: "OPTIMIZE TABLE orderbook_snapshots FINAL", + }); + await activeClient.command({ + query: "OPTIMIZE TABLE candles FINAL", + }); + for (const table of [ + "policy_evaluation_events", + "market_identity", + "symbol_mapping", + ]) { + await activeClient.command({ + query: ` + ALTER TABLE strategy_data.${table} + DELETE WHERE deployment_id = {deployment_id:String} + `, + query_params: { deployment_id: TEST_DEPLOYMENT }, + }); + await activeClient.command({ + query: `OPTIMIZE TABLE strategy_data.${table} FINAL`, + }); + } +} + +describe("ClickHouse market_data schema integration", () => { + beforeAll(async () => { + clickhouseAvailable = await probeClickHouse(); + if (!clickhouseAvailable) { + return; + } + client = createClient({ + url: CLICKHOUSE_URL, + database: "market_data", + }); + await ensureArchiveSchema(client); + try { + await cleanupTestRows(); + } catch { + // Tables may not exist yet on a fresh instance. + } + }); + + afterAll(async () => { + if (!clickhouseAvailable || !client) { + return; + } + try { + await cleanupTestRows(); + } catch { + // Best-effort cleanup for local dev runs. + } + await client.close(); + }); + + test("orderbook_tob and orderbook_depth are views over orderbook_snapshots", async () => { + if (!clickhouseAvailable || !client) { + return; + } + + expect(await tableEngine("orderbook_snapshots")).toBe("MergeTree"); + expect(await tableEngine("orderbook_tob")).toBe("View"); + expect(await tableEngine("orderbook_depth")).toBe("View"); + expect(await tableEngine("candles_closed")).toBe("View"); + }); + + test("orderbook views reflect inserts into orderbook_snapshots", async () => { + if (!clickhouseAvailable || !client) { + return; + } + + const row = { + source: "broker_write", + deployment_id: TEST_DEPLOYMENT, + account_selector: "", + exchange: "binance", + asset_type: "spot", + symbol: "TEST/CH", + event_time_ms: TEST_EVENT_MS, + received_time_ms: TEST_EVENT_MS + 1, + best_bid: 100, + best_ask: 101, + bid_size: 1.5, + ask_size: 2, + mid: 100.5, + spread_bps: 99.5, + depth_limit: 2, + bid_levels: 2, + ask_levels: 2, + bids_price: [100, 99.5], + bids_size: [1.5, 2], + asks_price: [101, 101.5], + asks_size: [2, 1.5], + sequence: 42, + }; + + await client.insert({ + table: "orderbook_snapshots", + values: [row], + format: "JSONEachRow", + }); + + const snapshotCount = await client.query({ + query: ` + SELECT count() AS c + FROM orderbook_snapshots + WHERE deployment_id = {deployment_id:String} + AND symbol = 'TEST/CH' + AND event_time_ms = {event_time_ms:UInt64} + `, + query_params: { + deployment_id: TEST_DEPLOYMENT, + event_time_ms: TEST_EVENT_MS, + }, + format: "JSONEachRow", + }); + expect( + Number(((await snapshotCount.json()) as Array<{ c: string }>)[0]?.c), + ).toBe(1); + + const tob = await client.query({ + query: ` + SELECT best_bid, best_ask, mid, spread_bps, sequence + FROM orderbook_tob + WHERE deployment_id = {deployment_id:String} + AND symbol = 'TEST/CH' + AND event_time_ms = {event_time_ms:UInt64} + `, + query_params: { + deployment_id: TEST_DEPLOYMENT, + event_time_ms: TEST_EVENT_MS, + }, + format: "JSONEachRow", + }); + const tobRow = (await tob.json())[0] as Record; + expect(Number(tobRow.best_bid)).toBe(100); + expect(Number(tobRow.best_ask)).toBe(101); + expect(Number(tobRow.mid)).toBe(100.5); + expect(Number(tobRow.sequence)).toBe(42); + + const depth = await client.query({ + query: ` + SELECT depth_limit, bid_levels, ask_levels, bids_price, asks_price + FROM orderbook_depth + WHERE deployment_id = {deployment_id:String} + AND symbol = 'TEST/CH' + AND event_time_ms = {event_time_ms:UInt64} + `, + query_params: { + deployment_id: TEST_DEPLOYMENT, + event_time_ms: TEST_EVENT_MS, + }, + format: "JSONEachRow", + }); + const depthRow = (await depth.json())[0] as Record; + expect(Number(depthRow.depth_limit)).toBe(2); + expect(Number(depthRow.bid_levels)).toBe(2); + expect(Number(depthRow.ask_levels)).toBe(2); + expect(depthRow.bids_price).toEqual([100, 99.5]); + expect(depthRow.asks_price).toEqual([101, 101.5]); + }); + + test("candles_closed view returns only closed bars", async () => { + if (!clickhouseAvailable || !client) { + return; + } + + const openTimeMs = TEST_EVENT_MS + 60_000; + await client.insert({ + table: "candles", + values: [ + { + source: "broker_write", + deployment_id: TEST_DEPLOYMENT, + account_selector: "", + exchange: "binance", + asset_type: "spot", + symbol: "TEST/CH", + timeframe: "1m", + open_time_ms: openTimeMs, + open: 1, + high: 2, + low: 0.5, + close: 1.5, + volume: 10, + is_closed: 0, + broker_version: openTimeMs, + }, + { + source: "broker_write", + deployment_id: TEST_DEPLOYMENT, + account_selector: "", + exchange: "binance", + asset_type: "spot", + symbol: "TEST/CH", + timeframe: "1m", + open_time_ms: openTimeMs + 60_000, + open: 2, + high: 3, + low: 1.5, + close: 2.5, + volume: 12, + is_closed: 1, + broker_version: openTimeMs + 60_000, + }, + ], + format: "JSONEachRow", + }); + + const closed = await client.query({ + query: ` + SELECT count() AS c + FROM candles_closed + WHERE deployment_id = {deployment_id:String} + AND symbol = 'TEST/CH' + `, + query_params: { deployment_id: TEST_DEPLOYMENT }, + format: "JSONEachRow", + }); + expect(Number(((await closed.json()) as Array<{ c: string }>)[0]?.c)).toBe( + 1, + ); + + const forming = await client.query({ + query: ` + SELECT count() AS c + FROM candles FINAL + WHERE deployment_id = {deployment_id:String} + AND symbol = 'TEST/CH' + AND is_closed = 0 + `, + query_params: { deployment_id: TEST_DEPLOYMENT }, + format: "JSONEachRow", + }); + expect(Number(((await forming.json()) as Array<{ c: string }>)[0]?.c)).toBe( + 1, + ); + }); + + test("archive forwarder inserts into base tables (not views)", async () => { + if (!clickhouseAvailable || !client) { + return; + } + + const eventMs = TEST_EVENT_MS + 120_000; + const result = await handleArchiveBatch(createClickHouseInserter(client), { + source: "broker_write", + deployment_id: TEST_DEPLOYMENT, + rows: [ + { + table: "market_data.orderbook_snapshots", + row: { + source: "broker_write", + deployment_id: TEST_DEPLOYMENT, + account_selector: "", + exchange: "binance", + asset_type: "spot", + symbol: "TEST/FWD", + event_time_ms: eventMs, + received_time_ms: eventMs + 1, + best_bid: 50, + best_ask: 51, + bid_size: 1, + ask_size: 1, + mid: 50.5, + spread_bps: 10, + depth_limit: 1, + bid_levels: 1, + ask_levels: 1, + bids_price: [50], + bids_size: [1], + asks_price: [51], + asks_size: [1], + }, + }, + ], + }); + + expect(result.failed).toBe(0); + expect(result.inserted).toBe(1); + + const viaView = await client.query({ + query: ` + SELECT best_bid, best_ask + FROM orderbook_tob + WHERE deployment_id = {deployment_id:String} + AND symbol = 'TEST/FWD' + AND event_time_ms = {event_time_ms:UInt64} + `, + query_params: { + deployment_id: TEST_DEPLOYMENT, + event_time_ms: eventMs, + }, + format: "JSONEachRow", + }); + const viewRow = (await viaView.json())[0] as Record; + expect(Number(viewRow.best_bid)).toBe(50); + expect(Number(viewRow.best_ask)).toBe(51); + }); + + test("schema migration adds source_cursor to the existing policy table", async () => { + if (!clickhouseAvailable || !client) { + return; + } + + await client.command({ + query: ` + ALTER TABLE strategy_data.policy_evaluation_events + DROP COLUMN IF EXISTS source_cursor + `, + }); + try { + await ensureArchiveSchema(client); + + const result = await client.query({ + query: ` + SELECT name + FROM system.columns + WHERE database = 'strategy_data' + AND table = 'policy_evaluation_events' + AND name = 'source_cursor' + `, + format: "JSONEachRow", + }); + expect(await result.json()).toEqual([{ name: "source_cursor" }]); + } finally { + await ensureArchiveSchema(client); + } + }); + + test("archive forwarder stores policy cursors and control-plane snapshots", async () => { + if (!clickhouseAvailable || !client) { + return; + } + + const eventMs = TEST_EVENT_MS + 180_000; + const sourceCursor = "block:12345680:log:3"; + const result = await handleArchiveBatch(createClickHouseInserter(client), { + source: "hb_runtime", + deployment_id: TEST_DEPLOYMENT, + rows: [ + { + table: "strategy_data.policy_evaluation_events", + row: { + event_time_ms: eventMs, + emitted_at_ms: eventMs, + source: "hb_runtime", + deployment_id: TEST_DEPLOYMENT, + schema_version: "1", + controller_id: "controller-1", + controller_type: "layer12", + connector_name: "binance", + exchange: "binance", + trading_pair: "BTC-USDT", + market_id: "market-1", + run_id: "run-1", + policy_epoch: "epoch-1", + fidelity: "live", + lag_ms: 0, + fallback_reason: "", + source_cursor: sourceCursor, + decision_kind: "quote", + payload_json: "{}", + }, + }, + { + table: "strategy_data.market_identity", + row: { + event_time_ms: eventMs + 1, + emitted_at_ms: eventMs + 1, + source: "hb_runtime", + deployment_id: TEST_DEPLOYMENT, + schema_version: "1", + controller_id: "controller-1", + controller_type: "layer12", + connector_name: "binance", + exchange: "binance", + trading_pair: "BTC-USDT", + market_id: "market-1", + run_id: "run-1", + snapshot_reason: "startup", + source_hash: "identity-hash", + canonical_core_pool_id: "pool-1", + payload_json: "{}", + }, + }, + { + table: "strategy_data.symbol_mapping", + row: { + event_time_ms: eventMs + 2, + emitted_at_ms: eventMs + 2, + source: "hb_runtime", + deployment_id: TEST_DEPLOYMENT, + schema_version: "1", + controller_id: "controller-1", + controller_type: "layer12", + connector_name: "binance", + exchange: "binance", + trading_pair: "BTC-USDT", + market_id: "market-1", + run_id: "run-1", + snapshot_reason: "startup", + source_hash: "symbol-hash", + payload_json: "{}", + }, + }, + ], + }); + + expect(result).toMatchObject({ inserted: 3, failed: 0, skipped: 0 }); + + const policy = await client.query({ + query: ` + SELECT source_cursor + FROM strategy_data.policy_evaluation_events + WHERE deployment_id = {deployment_id:String} + AND event_time_ms = {event_time_ms:Int64} + `, + query_params: { + deployment_id: TEST_DEPLOYMENT, + event_time_ms: eventMs, + }, + format: "JSONEachRow", + }); + expect(await policy.json()).toEqual([{ source_cursor: sourceCursor }]); + + for (const [table, sourceHash] of [ + ["market_identity", "identity-hash"], + ["symbol_mapping", "symbol-hash"], + ] as const) { + const snapshot = await client.query({ + query: ` + SELECT source_hash + FROM strategy_data.${table} + WHERE deployment_id = {deployment_id:String} + `, + query_params: { deployment_id: TEST_DEPLOYMENT }, + format: "JSONEachRow", + }); + expect(await snapshot.json()).toEqual([{ source_hash: sourceHash }]); + } + }); +}); diff --git a/test/deposit-archive-poller.test.ts b/test/deposit-archive-poller.test.ts new file mode 100644 index 0000000..cdd3c46 --- /dev/null +++ b/test/deposit-archive-poller.test.ts @@ -0,0 +1,362 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import type { BrokerAccount, BrokerPoolEntry } from "../src/helpers/broker"; +import type { + BrokerArchiveRow, + BrokerExecutionArchiver, +} from "../src/helpers/broker-execution-archive"; +import { + DepositArchivePoller, + nextDepositCursor, +} from "../src/helpers/deposit-archive-poller"; +import { log } from "../src/helpers/logger"; + +function account( + exchange: unknown, + label: BrokerAccount["label"] = "primary", + index?: number, +): BrokerAccount { + return { exchange, label, index } as unknown as BrokerAccount; +} + +function poolWith(exchange: unknown): Record { + return { + binance: { + primary: account(exchange), + secondaryBrokers: [], + }, + }; +} + +function fakeArchiver(sink: BrokerArchiveRow[]): BrokerExecutionArchiver { + return { + isEnabled: () => true, + getDeploymentId: () => "deploy-a", + enqueue: (row: BrokerArchiveRow) => sink.push(row), + } as unknown as BrokerExecutionArchiver; +} + +describe("nextDepositCursor", () => { + test("stops at the oldest pending deposit while advancing past older terminal deposits", () => { + expect( + nextDepositCursor( + [ + { timestamp: 100, status: "ok" }, + { timestamp: 300, status: "complete" }, + { timestamp: 200, status: "processing" }, + ], + 0, + 50, + ), + ).toBe(200); + }); + + test("holds the watermark for a full batch under either venue ordering", () => { + const oldestFirst = [ + { timestamp: 100, status: "ok" }, + { timestamp: 200, status: "ok" }, + ]; + const newestFirst = [...oldestFirst].reverse(); + + expect(nextDepositCursor(oldestFirst, 50, 2)).toBe(50); + expect(nextDepositCursor(newestFirst, 50, 2)).toBe(50); + }); +}); + +describe("DepositArchivePoller.pollAllOnce", () => { + test("archives deposits with the transfer-event row shape", async () => { + const rawDeposit = { + id: "deposit-1", + txid: "0xdeposit", + currency: "USDC", + amount: "25.500000", + address: "0xrecipient", + network: "ARBITRUM", + timestamp: 1_775_000_000_000, + creditedAt: "2026-04-01T12:00:00.000Z", + info: { status: "OK", venueField: "preserved" }, + }; + const calls: unknown[][] = []; + const exchange = { + has: { fetchDeposits: true }, + fetchDeposits: async (...args: unknown[]) => { + calls.push(args); + return [rawDeposit]; + }, + }; + const sink: BrokerArchiveRow[] = []; + const poller = new DepositArchivePoller({ + brokers: poolWith(exchange), + archiver: fakeArchiver(sink), + }); + + expect(await poller.pollAllOnce()).toBe(true); + + expect(calls).toHaveLength(1); + expect(calls[0]?.[0]).toBeUndefined(); + expect(calls[0]?.[2]).toBe(50); + expect(sink).toHaveLength(1); + expect(sink[0]?.table).toBe("broker_execution.transfer_events"); + expect(sink[0]?.row).toMatchObject({ + source: "broker_write", + deployment_id: "deploy-a", + exchange: "binance", + account_selector: "primary", + symbol: "USDC", + asset_symbol: "USDC", + schema_version: "1", + event_kind: "deposit", + lifecycle_action: "observe_deposit", + status: "ok", + amount: "25.500000", + address: "0xrecipient", + network: "ARBITRUM", + external_id: "0xdeposit", + client_withdrawal_id: "", + txid: "0xdeposit", + result_index: 0, + exchange_timestamp: "2026-04-01T12:00:00.000Z", + payload_json: JSON.stringify(rawDeposit), + }); + }); + + test("advances the account cursor and does not re-archive within a session", async () => { + const depositTimestamp = Date.now() + 10_000; + const deposit = { + txid: "0xonce", + currency: "USDT", + amount: 10, + status: "ok", + timestamp: depositTimestamp, + }; + const sinceValues: Array = []; + const exchange = { + has: { fetchDeposits: true }, + fetchDeposits: async (code?: string, since?: number, limit?: number) => { + expect(code).toBeUndefined(); + expect(limit).toBe(50); + sinceValues.push(since); + return since !== undefined && since <= depositTimestamp + ? [deposit] + : []; + }, + }; + const sink: BrokerArchiveRow[] = []; + const poller = new DepositArchivePoller({ + brokers: poolWith(exchange), + archiver: fakeArchiver(sink), + }); + + await poller.pollAllOnce(); + await poller.pollAllOnce(); + + expect(sinceValues).toHaveLength(2); + expect(sinceValues[1]).toBe(depositTimestamp + 1); + expect(sink).toHaveLength(1); + expect(sink[0]?.row).toMatchObject({ + symbol: "USDT", + external_id: "0xonce", + }); + }); + + test("re-observes a pending deposit until its normalized status is terminal", async () => { + const depositTimestamp = Date.now() + 10_000; + const sinceValues: Array = []; + let calls = 0; + const exchange = { + has: { fetchDeposits: true }, + fetchDeposits: async (code?: string, since?: number, limit?: number) => { + expect(code).toBeUndefined(); + expect(limit).toBe(50); + sinceValues.push(since); + calls += 1; + return [ + { + txid: "0xtransition", + currency: "USDC", + amount: 15, + status: calls === 1 ? "pending" : "ok", + timestamp: depositTimestamp, + }, + ]; + }, + }; + const sink: BrokerArchiveRow[] = []; + const poller = new DepositArchivePoller({ + brokers: poolWith(exchange), + archiver: fakeArchiver(sink), + }); + + await poller.pollAllOnce(); + await poller.pollAllOnce(); + + expect(sinceValues).toHaveLength(2); + expect(sinceValues[1]).toBe(depositTimestamp); + expect(sink).toHaveLength(2); + expect(sink.map(({ row }) => row.status)).toEqual(["pending", "ok"]); + expect(sink[1]?.row).toMatchObject({ + lifecycle_action: "observe_deposit", + external_id: "0xtransition", + status: "ok", + }); + }); + + test("archives Binance locked and unlocked deposit states as distinct rows", async () => { + const depositTimestamp = Date.now() + 10_000; + const sinceValues: Array = []; + let calls = 0; + const exchange = { + has: { fetchDeposits: true }, + fetchDeposits: async (code?: string, since?: number, limit?: number) => { + expect(code).toBeUndefined(); + expect(limit).toBe(50); + sinceValues.push(since); + calls += 1; + return since !== undefined && since <= depositTimestamp + ? [ + { + txid: "0xlocked", + currency: "ARB", + amount: 25, + status: "ok", + timestamp: depositTimestamp, + info: { status: calls === 1 ? "6" : "1" }, + }, + ] + : []; + }, + }; + const sink: BrokerArchiveRow[] = []; + const poller = new DepositArchivePoller({ + brokers: poolWith(exchange), + archiver: fakeArchiver(sink), + }); + + await poller.pollAllOnce(); + await poller.pollAllOnce(); + + expect(sinceValues).toHaveLength(2); + expect(sinceValues[1]).toBe(depositTimestamp); + expect(sink.map(({ row }) => row.status)).toEqual([ + "credited_not_withdrawable", + "ok", + ]); + expect(sink[0]?.row).toMatchObject({ + external_id: "0xlocked", + status: "credited_not_withdrawable", + }); + + await poller.pollAllOnce(); + + expect(sinceValues[2]).toBe(depositTimestamp + 1); + expect(sink).toHaveLength(2); + }); + + test("does not re-archive an unchanged Binance locked deposit", async () => { + const depositTimestamp = Date.now() + 10_000; + const sinceValues: Array = []; + const exchange = { + has: { fetchDeposits: true }, + fetchDeposits: async (code?: string, since?: number, limit?: number) => { + expect(code).toBeUndefined(); + expect(limit).toBe(50); + sinceValues.push(since); + return [ + { + txid: "0xstill-locked", + currency: "ARB", + amount: 25, + status: "ok", + timestamp: depositTimestamp, + info: { status: 6 }, + }, + ]; + }, + }; + const sink: BrokerArchiveRow[] = []; + const poller = new DepositArchivePoller({ + brokers: poolWith(exchange), + archiver: fakeArchiver(sink), + }); + + await poller.pollAllOnce(); + await poller.pollAllOnce(); + + expect(sinceValues[1]).toBe(depositTimestamp); + expect(sink).toHaveLength(1); + expect(sink[0]?.row.status).toBe("credited_not_withdrawable"); + }); + + test("logs once and cleanly skips an account without fetchDeposits", async () => { + const info = spyOn(log, "info").mockImplementation(() => {}); + try { + const sink: BrokerArchiveRow[] = []; + const poller = new DepositArchivePoller({ + brokers: poolWith({ has: { fetchDeposits: false } }), + archiver: fakeArchiver(sink), + }); + + expect(await poller.pollAllOnce()).toBe(true); + expect(await poller.pollAllOnce()).toBe(true); + + expect(sink).toHaveLength(0); + expect(info).toHaveBeenCalledTimes(1); + expect(info).toHaveBeenCalledWith( + "Deposit archive poll skipped: fetchDeposits unsupported", + { + exchange: "binance", + account: "primary", + }, + ); + } finally { + info.mockRestore(); + } + }); + + test("logs a fetch error and archives on the next pass", async () => { + const warn = spyOn(log, "warn").mockImplementation(() => {}); + try { + let calls = 0; + const exchange = { + has: { fetchDeposits: true }, + fetchDeposits: async () => { + calls += 1; + if (calls === 1) { + throw new Error("rate limited"); + } + return [ + { + txid: "0xrecovered", + currency: "USDC", + amount: "5", + status: "ok", + timestamp: Date.now() + 10_000, + }, + ]; + }, + }; + const sink: BrokerArchiveRow[] = []; + const poller = new DepositArchivePoller({ + brokers: poolWith(exchange), + archiver: fakeArchiver(sink), + }); + + expect(await poller.pollAllOnce()).toBe(true); + expect(await poller.pollAllOnce()).toBe(true); + + expect(calls).toBe(2); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toBe("Deposit archive poll failed"); + expect(warn.mock.calls[0]?.[1]).toMatchObject({ + exchange: "binance", + account: "primary", + }); + expect(sink).toHaveLength(1); + expect(sink[0]?.row).toMatchObject({ + external_id: "0xrecovered", + status: "ok", + }); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/test/deposit-helper.test.ts b/test/deposit-helper.test.ts new file mode 100644 index 0000000..30549c8 --- /dev/null +++ b/test/deposit-helper.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { + depositMatchesTransaction, + normalizeAddress, + normalizeDepositStatus, + stringAmountEquals, +} from "../src/helpers/deposit"; + +describe("deposit helpers", () => { + test("normalizeDepositStatus maps credited states", () => { + expect(normalizeDepositStatus("ok")).toBe("credited"); + expect(normalizeDepositStatus("pending")).toBe("pending"); + }); + + test("normalizeDepositStatus keeps empty and unknown states pending", () => { + expect(normalizeDepositStatus(undefined)).toBe("pending"); + expect(normalizeDepositStatus("")).toBe("pending"); + expect(normalizeDepositStatus("not-yet-indexed")).toBe("pending"); + }); + + test("depositMatchesTransaction matches txid", () => { + expect(depositMatchesTransaction({ txid: "0xabc" }, "0xabc")).toBe(true); + }); + + test("stringAmountEquals compares numeric strings", () => { + expect(stringAmountEquals("1.0", 1)).toBe(true); + }); + + test("normalizeAddress lowercases", () => { + expect(normalizeAddress(" 0xABC ")).toBe("0xabc"); + }); +}); diff --git a/test/exchange-credentials.test.ts b/test/exchange-credentials.test.ts new file mode 100644 index 0000000..0f8e38e --- /dev/null +++ b/test/exchange-credentials.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +import { createBroker } from "../src/helpers/broker"; +import { + buildCcxtConfig, + isWalletBasedExchange, +} from "../src/helpers/exchange-credentials"; + +const WALLET_ADDRESS = "0x1234567890abcdef1234567890abcdef12345678"; +const PRIVATE_KEY = + "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; + +describe("exchange-credentials", () => { + describe("isWalletBasedExchange", () => { + test("returns true for wallet-authenticated exchanges", () => { + expect(isWalletBasedExchange("hyperliquid")).toBe(true); + expect(isWalletBasedExchange("vertex")).toBe(true); + expect(isWalletBasedExchange("paradex")).toBe(true); + expect(isWalletBasedExchange("derive")).toBe(true); + }); + + test("returns false for API-key exchanges", () => { + expect(isWalletBasedExchange("binance")).toBe(false); + expect(isWalletBasedExchange("mexc")).toBe(false); + }); + + test("returns false for dex exchanges that still use API keys", () => { + expect(isWalletBasedExchange("woofipro")).toBe(false); + expect(isWalletBasedExchange("modetrade")).toBe(false); + }); + + test("returns false for unknown exchanges", () => { + expect(isWalletBasedExchange("not-a-real-exchange")).toBe(false); + }); + }); + + describe("buildCcxtConfig", () => { + test("maps apiKey/apiSecret to walletAddress/privateKey for hyperliquid", () => { + const config = buildCcxtConfig("hyperliquid", { + apiKey: WALLET_ADDRESS, + apiSecret: PRIVATE_KEY, + }); + + expect(config).toEqual({ + walletAddress: WALLET_ADDRESS, + privateKey: PRIVATE_KEY, + }); + expect(config).not.toHaveProperty("apiKey"); + expect(config).not.toHaveProperty("secret"); + }); + + test("normalizes bare 64-char hex private keys with 0x prefix", () => { + const barePrivateKey = + "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; + const config = buildCcxtConfig("hyperliquid", { + apiKey: WALLET_ADDRESS, + apiSecret: barePrivateKey, + }); + + expect(config).toEqual({ + walletAddress: WALLET_ADDRESS, + privateKey: `0x${barePrivateKey}`, + }); + }); + + test("keeps apiKey/secret mapping for centralized exchanges", () => { + const config = buildCcxtConfig("binance", { + apiKey: "binance-key", + apiSecret: "binance-secret", + }); + + expect(config).toEqual({ + apiKey: "binance-key", + secret: "binance-secret", + }); + }); + + test("returns null when credentials are missing", () => { + expect( + buildCcxtConfig("hyperliquid", { apiKey: "", apiSecret: PRIVATE_KEY }), + ).toBeNull(); + expect( + buildCcxtConfig("binance", { + apiKey: "binance-key", + apiSecret: "", + }), + ).toBeNull(); + }); + }); + + describe("createBroker", () => { + test("sets wallet credentials on hyperliquid exchange instances", () => { + const broker = createBroker("hyperliquid", { + apiKey: WALLET_ADDRESS, + apiSecret: PRIVATE_KEY, + }); + + expect(broker).not.toBeNull(); + expect(broker?.walletAddress).toBe(WALLET_ADDRESS); + expect(broker?.privateKey).toBe(PRIVATE_KEY); + expect(broker?.apiKey).toBeUndefined(); + expect(broker?.secret).toBeUndefined(); + }); + + test("sets api credentials on centralized exchange instances", () => { + const broker = createBroker("binance", { + apiKey: "binance-key", + apiSecret: "binance-secret", + }); + + expect(broker).not.toBeNull(); + expect(broker?.apiKey).toBe("binance-key"); + expect(broker?.secret).toBe("binance-secret"); + }); + }); +}); diff --git a/test/execute-action-registry.test.ts b/test/execute-action-registry.test.ts new file mode 100644 index 0000000..d64ff63 --- /dev/null +++ b/test/execute-action-registry.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import * as grpc from "@grpc/grpc-js"; +import { + ACTION_HANDLERS, + dispatchExecuteAction, +} from "../src/handlers/execute-action/registry"; +import { Action } from "../src/helpers/constants"; + +const REGISTERED_ACTIONS = [ + Action.Deposit, + Action.Withdraw, + Action.Call, + Action.InternalTransfer, + Action.CreateOrder, + Action.GetOrderDetails, + Action.CancelOrder, + Action.FetchCurrency, + Action.FetchAccountId, + Action.FetchFees, + Action.FetchDepositAddresses, + Action.FetchBalances, + Action.FetchTicker, +] as const; + +describe("execute-action registry", () => { + test("registers all supported ExecuteAction handlers", () => { + for (const action of REGISTERED_ACTIONS) { + expect(typeof ACTION_HANDLERS[action]).toBe("function"); + } + }); + + test("rejects invalid actions with a unary callback error and null response", async () => { + const calls: unknown[][] = []; + + await dispatchExecuteAction({ + action: "InvalidAction", + wrappedCallback: (...args) => { + calls.push(args); + }, + } as unknown as Parameters[0]); + + expect(calls).toEqual([ + [ + { + code: grpc.status.INVALID_ARGUMENT, + message: "Invalid Action", + }, + null, + ], + ]); + }); +}); diff --git a/test/fill-archive-poller.test.ts b/test/fill-archive-poller.test.ts new file mode 100644 index 0000000..d069e03 --- /dev/null +++ b/test/fill-archive-poller.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, test } from "bun:test"; +import type { BrokerAccount, BrokerPoolEntry } from "../src/helpers/broker"; +import type { + BrokerArchiveRow, + BrokerExecutionArchiver, +} from "../src/helpers/broker-execution-archive"; +import { + FillArchivePoller, + nextFillCursor, +} from "../src/helpers/fill-archive-poller"; +import { OrderActivityTracker } from "../src/helpers/order-activity-tracker"; + +function fakeArchiver(sink: BrokerArchiveRow[]): BrokerExecutionArchiver { + return { + isEnabled: () => true, + getDeploymentId: () => "deploy-a", + enqueue: (row: BrokerArchiveRow) => sink.push(row), + } as unknown as BrokerExecutionArchiver; +} + +function poolWith(exchange: unknown): Record { + const account = { exchange, label: "primary" } as unknown as BrokerAccount; + return { binance: { primary: account, secondaryBrokers: [] } }; +} + +describe("OrderActivityTracker", () => { + test("records distinct (exchange, account, symbol) and normalizes the exchange", () => { + const tracker = new OrderActivityTracker(); + tracker.record("Binance", "primary", "USDC/USDT", 1_000); + tracker.record("binance", "primary", "USDC/USDT", 2_000); + tracker.record("binance", "secondary:1", "ARB/USDT", 2_000); + + const entries = tracker.list(2_000); + expect(entries).toHaveLength(2); + expect(entries).toContainEqual({ + exchangeId: "binance", + accountLabel: "primary", + symbol: "USDC/USDT", + lastActivityAt: 2_000, + }); + }); + + test("ignores empty inputs and prunes entries past maxAge", () => { + const tracker = new OrderActivityTracker({ maxAgeMs: 1_000 }); + tracker.record("binance", "", "USDC/USDT", 0); + tracker.record("binance", "primary", "USDC/USDT", 0); + + expect(tracker.list(500)).toHaveLength(1); + expect(tracker.list(2_000)).toHaveLength(0); + }); +}); + +describe("nextFillCursor", () => { + test("advances past the newest trade timestamp, tolerating out-of-order batches", () => { + expect( + nextFillCursor( + [{ timestamp: 100 }, { timestamp: 250 }, { timestamp: 200 }], + 0, + ), + ).toBe(251); + }); + + test("keeps the current cursor when the batch has no usable timestamps", () => { + expect(nextFillCursor([], 42)).toBe(42); + expect(nextFillCursor([{ id: "no-ts" }], 42)).toBe(42); + }); +}); + +describe("FillArchivePoller.pollTrackedOnce", () => { + test("archives fetched trades and advances the cursor so the next poll is incremental", async () => { + // Newer than the poller's lookback floor (now - 24h) so the cursor actually + // advances past it on the first pass. + const tradeTs = Date.now() + 10_000; + const trades = [ + { + id: "t-1", + order: "o-1", + side: "buy", + price: 1, + amount: 50, + cost: 50, + timestamp: tradeTs, + fee: { cost: 0.05, currency: "USDC" }, + }, + ]; + let calls = 0; + let lastSince: number | undefined; + const exchange = { + has: { fetchMyTrades: true }, + fetchMyTrades: async (_symbol: string, since?: number) => { + calls += 1; + lastSince = since; + return calls === 1 ? trades : []; + }, + }; + const sink: BrokerArchiveRow[] = []; + const tracker = new OrderActivityTracker(); + tracker.record("binance", "primary", "USDC/USDT"); + const poller = new FillArchivePoller({ + brokers: poolWith(exchange), + archiver: fakeArchiver(sink), + tracker, + }); + + await poller.pollTrackedOnce(); + expect(sink).toHaveLength(1); + expect(sink[0]?.table).toBe("broker_execution.fill_events"); + expect(sink[0]?.row).toMatchObject({ + order_id: "o-1", + fill_id: "t-1", + fill_index: 0, + event_kind: "trade_history_fill", + symbol: "USDC/USDT", + account_selector: "primary", + }); + + // Second pass asks strictly after the last seen trade and archives nothing new. + await poller.pollTrackedOnce(); + expect(calls).toBe(2); + expect(lastSince).toBe(tradeTs + 1); + expect(sink).toHaveLength(1); + }); + + test("skips accounts whose exchange has no fetchMyTrades", async () => { + const sink: BrokerArchiveRow[] = []; + const tracker = new OrderActivityTracker(); + tracker.record("binance", "primary", "USDC/USDT"); + const poller = new FillArchivePoller({ + brokers: poolWith({ has: { fetchMyTrades: false } }), + archiver: fakeArchiver(sink), + tracker, + }); + + await poller.pollTrackedOnce(); + expect(sink).toHaveLength(0); + }); + + test("a failing venue call does not stall the other tracked symbols", async () => { + const good = { + has: { fetchMyTrades: true }, + fetchMyTrades: async () => [ + { id: "t-9", order: "o-9", timestamp: 5, price: 2, amount: 1, cost: 2 }, + ], + }; + const bad = { + has: { fetchMyTrades: true }, + fetchMyTrades: async () => { + throw new Error("rate limited"); + }, + }; + const account = (exchange: unknown, label: string, index?: number) => + ({ exchange, label, index }) as unknown as BrokerAccount; + const brokers: Record = { + binance: { + primary: account(bad, "primary"), + secondaryBrokers: [account(good, "secondary:1", 1)], + }, + }; + const sink: BrokerArchiveRow[] = []; + const tracker = new OrderActivityTracker(); + tracker.record("binance", "primary", "USDC/USDT"); + tracker.record("binance", "secondary:1", "ARB/USDT"); + const poller = new FillArchivePoller({ + brokers, + archiver: fakeArchiver(sink), + tracker, + }); + + await poller.pollTrackedOnce(); + // The good account still produced its fill despite the bad account throwing. + expect(sink).toHaveLength(1); + expect(sink[0]?.row).toMatchObject({ fill_id: "t-9", symbol: "ARB/USDT" }); + }); +}); diff --git a/test/fixtures/archive_forwarder_envelope.json b/test/fixtures/archive_forwarder_envelope.json new file mode 100644 index 0000000..0cdd565 --- /dev/null +++ b/test/fixtures/archive_forwarder_envelope.json @@ -0,0 +1,77 @@ +{ + "deployment_id": "arb-usdc-015", + "rows": [ + { + "row": { + "connector_name": "fiet_cex", + "controller_id": "layer12-live-arb-usdc-015", + "controller_type": "layer12_live", + "decision_kind": "dex_lead", + "deployment_id": "arb-usdc-015", + "emitted_at_ms": 1720000000000, + "event_time_ms": 1719999999988, + "exchange": "binance", + "fidelity": "hb_runtime_policy_clock", + "lag_ms": 12, + "market_id": "arb-usdc-015", + "payload_json": "{\"execution_role\":\"dex_lead\",\"mid_price\":\"1.0234\"}", + "policy_epoch": "42", + "run_id": "run-2026-07-02T00-00-00Z", + "schema_version": "1", + "seq": 1, + "source": "hb_runtime", + "source_cursor": "block:12345680:log:3", + "trading_pair": "ARB-USDC" + }, + "table": "strategy_data.policy_evaluation_events" + }, + { + "row": { + "config_file_path": "conf/controllers/layer12_live_arb_usdc_015.yml", + "connector_name": "fiet_cex", + "controller_id": "layer12-live-arb-usdc-015", + "controller_type": "layer12_live", + "deployment_id": "arb-usdc-015", + "emitted_at_ms": 1720000000000, + "event_time_ms": 1719999999995, + "exchange": "binance", + "market_id": "arb-usdc-015", + "payload_json": "{\"lower_tick_offset\":-60,\"position_type\":\"OffsetFromCurrent\",\"upper_tick_offset\":60}", + "policy_epoch": "42", + "run_id": "run-2026-07-02T00-00-00Z", + "schema_version": "1", + "seq": 2, + "snapshot_reason": "content_change", + "source": "hb_runtime", + "source_hash": "1ed025b4ae41b0865651beb829e16eb62119ecb867c5148858fbe4bfe35e547e", + "trading_pair": "ARB-USDC" + }, + "table": "strategy_data.strategy_policy_snapshots" + }, + { + "row": { + "account": "secondary:1", + "connector_name": "fiet_cex", + "controller_id": "layer12-live-arb-usdc-015", + "controller_type": "layer12_live", + "deployment_id": "arb-usdc-015", + "emitted_at_ms": 1720000000000, + "event_kind": "reservation_release", + "event_time_ms": 1719999999997, + "exchange": "binance", + "market_id": "arb-usdc-015", + "payload_json": "{\"amount\":\"99.81\",\"reason\":\"fill\"}", + "reservation_id": "resv-0001", + "run_id": "run-2026-07-02T00-00-00Z", + "schema_version": "1", + "seq": 3, + "source": "hb_runtime", + "token": "ARB", + "trading_pair": "ARB-USDC", + "workflow_state": "released" + }, + "table": "strategy_data.inventory_settlement_events" + } + ], + "source": "hb_runtime" +} diff --git a/test/fixtures/ohlcv-collector-fake-exchange.ts b/test/fixtures/ohlcv-collector-fake-exchange.ts new file mode 100644 index 0000000..b6d0e21 --- /dev/null +++ b/test/fixtures/ohlcv-collector-fake-exchange.ts @@ -0,0 +1,50 @@ +import ccxt from "@usherlabs/ccxt"; + +class ShutdownTestExchange { + readonly id = "shutdown-test"; + readonly has = { fetchOHLCV: false }; + enableRateLimit = false; + timeout = 0; + #keepAlive: ReturnType | undefined; + #fetchCount = 0; + + extendExchangeOptions(): void {} + + async watchOHLCV(): Promise { + this.#fetchCount += 1; + const countPath = process.env.OHLCV_TEST_EXCHANGE_COUNT_PATH; + if (countPath) { + await Bun.write(countPath, String(this.#fetchCount)); + } + if (!this.#keepAlive) { + this.#keepAlive = setInterval(() => {}, 1_000); + const activePath = process.env.OHLCV_TEST_EXCHANGE_ACTIVE_PATH; + if (activePath) { + await Bun.write(activePath, "active"); + } + } + await Bun.sleep(20); + return [[Date.now(), 1, 2, 0.5, 1.5, 10]]; + } + + async close(): Promise { + const closedPath = process.env.OHLCV_TEST_EXCHANGE_CLOSED_PATH; + if (closedPath) { + await Bun.write(closedPath, "close_attempted"); + } + if (process.env.OHLCV_TEST_EXCHANGE_CLOSE_HANG === "true") { + await new Promise(() => {}); + } + if (this.#keepAlive) { + clearInterval(this.#keepAlive); + this.#keepAlive = undefined; + } + if (closedPath) { + await Bun.write(closedPath, "closed"); + } + return []; + } +} + +(ccxt.pro as unknown as Record).binance = + ShutdownTestExchange; diff --git a/test/grpc-status.test.ts b/test/grpc-status.test.ts new file mode 100644 index 0000000..e85b0bf --- /dev/null +++ b/test/grpc-status.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "bun:test"; +import * as grpc from "@grpc/grpc-js"; +import ccxt from "@usherlabs/ccxt"; +import { + mapCcxtErrorToGrpcStatus, + resolveGrpcError, + stableGrpcErrorCode, +} from "../src/helpers/grpc/status"; + +describe("grpc status", () => { + test("stableGrpcErrorCode maps known prefixes", () => { + expect(stableGrpcErrorCode("AuthenticationError: x")).toBe( + grpc.status.UNAUTHENTICATED, + ); + expect(stableGrpcErrorCode("InsufficientFunds: x")).toBe( + grpc.status.FAILED_PRECONDITION, + ); + expect(stableGrpcErrorCode("venue_discovery_unavailable: x")).toBe( + grpc.status.UNIMPLEMENTED, + ); + expect(stableGrpcErrorCode("network_alias_unresolved: x")).toBe( + grpc.status.INVALID_ARGUMENT, + ); + expect(stableGrpcErrorCode("deposit_amount_mismatch: x")).toBe( + grpc.status.FAILED_PRECONDITION, + ); + expect(stableGrpcErrorCode("policy_deposit_denied: x")).toBe( + grpc.status.PERMISSION_DENIED, + ); + expect(stableGrpcErrorCode("passive_order_unsupported: x")).toBe( + grpc.status.UNIMPLEMENTED, + ); + expect(stableGrpcErrorCode("passive_order_rejected: x")).toBe( + grpc.status.FAILED_PRECONDITION, + ); + expect(stableGrpcErrorCode("passive_order_would_cross: x")).toBe( + grpc.status.FAILED_PRECONDITION, + ); + }); + + test("mapCcxtErrorToGrpcStatus maps authentication errors", () => { + expect(mapCcxtErrorToGrpcStatus(new ccxt.AuthenticationError("auth"))).toBe( + grpc.status.UNAUTHENTICATED, + ); + }); + + test("resolveGrpcError prefers stable prefix over CCXT", () => { + const err = new ccxt.BadRequest("network_alias_unresolved: BTC"); + const resolved = resolveGrpcError(err); + expect(resolved.code).toBe(grpc.status.INVALID_ARGUMENT); + }); +}); diff --git a/test/helpers-constants.test.ts b/test/helpers-constants.test.ts index 1ed559e..9891897 100644 --- a/test/helpers-constants.test.ts +++ b/test/helpers-constants.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; import { getActionName, + getSubscriptionTypeName, + resolveAction, resolveSubscriptionType, SubscriptionType, } from "../src/helpers/constants"; @@ -19,8 +21,26 @@ describe("Helper Constants", () => { ); }); + test("defaults invalid numeric subscription types to ORDERBOOK", () => { + expect(resolveSubscriptionType(999 as never)).toBe( + SubscriptionType.ORDERBOOK, + ); + }); + + test("rejects invalid numeric actions", () => { + expect(resolveAction(999 as never)).toBeUndefined(); + }); + test("returns stable action labels for metrics", () => { expect(getActionName(11)).toBe("FetchAccountId"); expect(getActionName(undefined)).toBe("unknown_undefined"); }); + + test("reports inherited action labels as unknown", () => { + expect(getActionName("__proto__")).toBe("unknown___proto__"); + }); + + test("reports inherited subscription type labels as unknown", () => { + expect(getSubscriptionTypeName("__proto__")).toBe("unknown___proto__"); + }); }); diff --git a/test/helpers.test.ts b/test/helpers.test.ts index b0dd369..169faa4 100644 --- a/test/helpers.test.ts +++ b/test/helpers.test.ts @@ -9,6 +9,7 @@ import { createBrokerPool, getCurrentBrokerSelector, loadPolicy, + normalizeBrokerNetworkId, resolveBrokerAccount, resolveOrderExecution, transferBinanceInternal, @@ -107,6 +108,61 @@ describe("Helper Functions", () => { fs.unlinkSync(tempPath); } }); + + test("should load narrow Binance/MEXC USDC BEP20 corridor policy", () => { + const policy = loadPolicy( + "./policy/policy.binance-mexc-usdc-bep20.example.json", + ); + + expect(policy.withdraw.rule).toEqual([ + { + exchange: "BINANCE", + network: "BNB", + coins: ["USDC"], + whitelist: ["0x1111111111111111111111111111111111111111"], + }, + { + exchange: "MEXC", + network: "BNB", + coins: ["USDC"], + whitelist: ["0x2222222222222222222222222222222222222222"], + }, + ]); + expect( + validateWithdraw( + policy, + "BINANCE", + "BSC", + "0x1111111111111111111111111111111111111111", + 100, + "USDC", + ).valid, + ).toBe(true); + expect( + validateWithdraw( + policy, + "BINANCE", + "BEP20", + "0x1111111111111111111111111111111111111111", + 100, + "ARB", + ).valid, + ).toBe(false); + expect( + validateWithdraw( + policy, + "MEXC", + "BNB", + "0x1111111111111111111111111111111111111111", + 100, + "USDC", + ).valid, + ).toBe(false); + expect(validateDeposit(policy, "MEXC", "BEP20", "USDC").valid).toBe(true); + expect(validateDeposit(policy, "MEXC", "BEP20", "USDT").valid).toBe( + false, + ); + }); }); describe("validateWithdraw", () => { @@ -135,7 +191,7 @@ describe("Helper Functions", () => { ); expect(result.valid).toBe(false); - expect(result.error).toContain("Network ETH is not allowed"); + expect(result.error).toContain("Network ETHEREUM is not allowed"); }); test("should reject non-whitelisted address", () => { @@ -443,6 +499,45 @@ describe("Helper Functions", () => { ); expect(result.valid).toBe(true); }); + + test("should match BNB, BSC, and BEP20 network aliases", () => { + const policy: PolicyConfig = { + ...testPolicy, + withdraw: { + rule: [ + { + exchange: "BINANCE", + network: "BNB", + whitelist: ["0x9d467fa9062b6e9b1a46e26007ad82db116c67cb"], + coins: ["USDC"], + }, + ], + }, + }; + + expect(normalizeBrokerNetworkId("BEP20")).toBe("BNB"); + expect(normalizeBrokerNetworkId("BSC")).toBe("BNB"); + expect( + validateWithdraw( + policy, + "BINANCE", + "BEP20", + "0x9d467fa9062b6e9b1a46e26007ad82db116c67cb", + 1000, + "USDC", + ).valid, + ).toBe(true); + expect( + validateWithdraw( + policy, + "BINANCE", + "BSC", + "0x9d467fa9062b6e9b1a46e26007ad82db116c67cb", + 1000, + "USDC", + ).valid, + ).toBe(true); + }); }); describe("validateOrder", () => { @@ -610,8 +705,28 @@ describe("Helper Functions", () => { }); describe("resolveOrderExecution", () => { - function createBrokerMock(symbols: string[]): Exchange { - const markets = Object.fromEntries(symbols.map((symbol) => [symbol, {}])); + function createBrokerMock( + symbols: string[], + marketType: "spot" | "swap" = "spot", + ): Exchange { + const markets = Object.fromEntries( + symbols.map((symbol) => { + const [baseQuote, settle] = symbol.split(":"); + const [base, quote] = baseQuote.split("/"); + return [ + symbol, + { + symbol, + base, + quote, + type: marketType, + spot: marketType === "spot", + swap: marketType === "swap", + settle, + }, + ]; + }), + ); return { markets, loadMarkets: async () => markets, @@ -692,9 +807,71 @@ describe("Helper Functions", () => { expect(result.valid).toBe(false); expect(result.error).toContain( - "Exchange BINANCE does not support BTC/ETH or ETH/BTC", + "Exchange BINANCE does not support BTC/ETH for marketType spot", ); }); + + test("should resolve swap market when marketType is swap", async () => { + const broker = createBrokerMock(["ETH/USDC", "ETH/USDC:USDC"], "swap"); + (broker as Exchange & { markets: Record }).markets[ + "ETH/USDC" + ] = { + symbol: "ETH/USDC", + base: "ETH", + quote: "USDC", + type: "spot", + spot: true, + swap: false, + }; + const policy: PolicyConfig = { + ...testPolicy, + order: { + rule: { + markets: ["HYPERLIQUID:ETH/USDC@swap"], + limits: [], + }, + }, + }; + const result = await resolveOrderExecution( + policy, + broker, + "HYPERLIQUID", + "ETH", + "USDC", + 1, + 2500, + "swap", + ); + + expect(result.valid).toBe(true); + expect(result.symbol).toBe("ETH/USDC:USDC"); + }); + + test("should reject swap request when policy requires spot only", async () => { + const broker = createBrokerMock(["ETH/USDC:USDC"], "swap"); + const policy: PolicyConfig = { + ...testPolicy, + order: { + rule: { + markets: ["HYPERLIQUID:ETH/USDC@spot"], + limits: [], + }, + }, + }; + const result = await resolveOrderExecution( + policy, + broker, + "HYPERLIQUID", + "ETH", + "USDC", + 1, + 2500, + "swap", + ); + + expect(result.valid).toBe(false); + expect(result.error).toContain("is not allowed"); + }); }); describe("validateDeposit", () => { @@ -819,6 +996,21 @@ describe("Helper Functions", () => { expect(result.valid).toBe(true); }); + test("should match BNB, BSC, and BEP20 deposit aliases", () => { + const policy: PolicyConfig = { + ...testPolicy, + deposit: { + rule: [{ exchange: "BINANCE", network: "BSC", coins: ["USDC"] }], + }, + }; + expect(validateDeposit(policy, "BINANCE", "BNB", "USDC").valid).toBe( + true, + ); + expect(validateDeposit(policy, "BINANCE", "BEP20", "USDC").valid).toBe( + true, + ); + }); + test("should allow all coins when coins is empty array", () => { const policy: PolicyConfig = { ...testPolicy, @@ -885,7 +1077,7 @@ describe("Helper Functions", () => { expect(resolveBrokerAccount(pool, "secondary:3")).toBeNull(); }); - test("should preserve sparse secondary indices and metadata from env-style config", () => { + test("should store env-style secondary accounts densely while resolving by configured index", () => { const pool = createBrokerPool({ binance: { apiKey: "primary-key", @@ -896,18 +1088,32 @@ describe("Helper Functions", () => { apiSecret: "secondary-secret-2", role: "subaccount", email: "sub2@example.com", + subAccountId: "sub-account-2", + uid: "uid-2", }, }, }, }); + expect(pool.binance?.secondaryBrokers).toHaveLength(1); + expect(pool.binance?.secondaryBrokers[0]).toMatchObject({ + label: "secondary:2", + index: 2, + role: "subaccount", + email: "sub2@example.com", + subAccountId: "sub-account-2", + uid: "uid-2", + }); expect(resolveBrokerAccount(pool.binance, "secondary:1")).toBeNull(); expect(resolveBrokerAccount(pool.binance, "secondary:2")).toMatchObject({ label: "secondary:2", index: 2, role: "subaccount", email: "sub2@example.com", + subAccountId: "sub-account-2", + uid: "uid-2", }); + expect(resolveBrokerAccount(pool.binance, "secondary:3")).toBeNull(); }); }); diff --git a/test/integration.test.ts b/test/integration.test.ts index 71f3049..c225911 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -1,5 +1,5 @@ -import { readFileSync } from "node:fs"; import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; describe("Integration Tests", () => { describe("Policy Integration", () => { diff --git a/test/internal-transfer-rpc.test.ts b/test/internal-transfer-rpc.test.ts index 182a171..d328539 100644 --- a/test/internal-transfer-rpc.test.ts +++ b/test/internal-transfer-rpc.test.ts @@ -3,16 +3,14 @@ import * as grpc from "@grpc/grpc-js"; import * as protoLoader from "@grpc/proto-loader"; import type { Exchange } from "@usherlabs/ccxt"; import type { BrokerPoolEntry } from "../src/helpers/index"; +import { PROTO_LOADER_OPTIONS } from "../src/proto-loader-options"; import { getServer } from "../src/server"; import type { PolicyConfig } from "../src/types"; -const packageDef = protoLoader.loadSync("src/proto/node.proto", { - keepCase: true, - longs: String, - enums: String, - defaults: true, - oneofs: true, -}); +const packageDef = protoLoader.loadSync( + "src/proto/node.proto", + PROTO_LOADER_OPTIONS, +); const grpcObj = grpc.loadPackageDefinition(packageDef) as { cex_broker: { cex_service: new ( diff --git a/test/market-data-archive.test.ts b/test/market-data-archive.test.ts new file mode 100644 index 0000000..e4640b0 --- /dev/null +++ b/test/market-data-archive.test.ts @@ -0,0 +1,396 @@ +import { describe, expect, test } from "bun:test"; +import { + extractLatestOhlcvBar, + extractOhlcvBars, + OhlcvBarTracker, + parseOhlcvBar, +} from "../src/helpers/market-data-archive/ohlcv-bar-tracker"; +import { resolveOhlcvBootstrapLimit } from "../src/helpers/market-data-archive/ohlcv-bootstrap"; +import { splitOrderBookSide } from "../src/helpers/market-data-archive/orderbook-depth"; +import { OrderbookSampler } from "../src/helpers/market-data-archive/orderbook-sampler"; +import { + extractTrades, + parseTicker, +} from "../src/helpers/market-data-archive/parse-stream"; +import { + buildCandleRow, + buildCexStreamEventRow, + buildCexTickerEventRow, + buildCexTradeRow, + buildOrderbookSnapshotRow, +} from "../src/helpers/market-data-archive/rows"; +import type { NormalizedOrderBookSnapshot } from "../src/helpers/order-book"; + +function createSnapshot( + overrides: Partial = {}, +): NormalizedOrderBookSnapshot { + return { + bids: [[100, 1.5]], + asks: [[101, 2]], + timestamp: 1_700_000_000_000, + receivedTimestamp: 1_700_000_000_123, + exchange: "binance", + symbol: "BTC/USDT", + depthLimit: 5, + ...overrides, + }; +} + +describe("market data archive rows", () => { + test("buildOrderbookSnapshotRow stores TOB scalars and depth arrays", () => { + const row = buildOrderbookSnapshotRow({ + deploymentId: "deploy-a", + exchange: "binance", + symbol: "BTC/USDT", + assetType: "spot", + snapshot: createSnapshot({ + bids: [ + [100, 1.5], + [99.5, 2], + [99, 3], + ], + asks: [ + [101, 2], + [101.5, 1.5], + [102, 4], + ], + depthLimit: 3, + }), + }); + + expect(row?.table).toBe("market_data.orderbook_snapshots"); + expect(row?.row).toMatchObject({ + exchange: "binance", + symbol: "BTC/USDT", + asset_type: "spot", + best_bid: 100, + best_ask: 101, + bid_size: 1.5, + ask_size: 2, + bid_levels: 3, + ask_levels: 3, + bids_price: [100, 99.5, 99], + bids_size: [1.5, 2, 3], + asks_price: [101, 101.5, 102], + asks_size: [2, 1.5, 4], + }); + expect(row?.row.mid).toBeCloseTo(100.5); + expect(row?.row.spread_bps).toBeGreaterThan(0); + }); + + test("splitOrderBookSide ignores malformed levels", () => { + expect( + splitOrderBookSide( + [[100, 1], ["bad", 2] as unknown as number[], [99, 3]], + 5, + ).prices, + ).toEqual([100, 99]); + }); + + test("buildCandleRow maps OHLCV fields and closed flag", () => { + const row = buildCandleRow({ + context: { + deploymentId: "deploy-a", + exchange: "binance", + symbol: "BTC/USDT", + assetType: "swap", + timeframe: "5m", + }, + bar: { + openTimeMs: 1_700_000_000_000, + open: 1, + high: 2, + low: 0.5, + close: 1.5, + volume: 100, + }, + isClosed: true, + brokerVersion: 1_700_000_000_500, + receivedTimestamp: 1_700_000_000_500, + }); + + expect(row.table).toBe("market_data.candles"); + expect(row.row).toMatchObject({ + timeframe: "5m", + asset_type: "swap", + open_time_ms: 1_700_000_000_000, + is_closed: 1, + broker_version: 1_700_000_000_500, + }); + }); + + test("buildCexTradeRow maps trade fields", () => { + const row = buildCexTradeRow( + { + deploymentId: "deploy-a", + exchange: "binance", + symbol: "BTC/USDT", + assetType: "spot", + payload: {}, + receivedTimestamp: 1_700_000_000_500, + }, + { + tradeId: "t-1", + eventTimeMs: 1_700_000_000_000, + side: "buy", + price: 100, + amount: 0.5, + }, + ); + + expect(row.table).toBe("market_data.cex_trades"); + expect(row.row).toMatchObject({ + trade_id: "t-1", + side: "buy", + price: 100, + amount: 0.5, + }); + }); + + test("buildCexTickerEventRow maps ticker fields", () => { + const row = buildCexTickerEventRow( + { + deploymentId: "deploy-a", + exchange: "binance", + symbol: "BTC/USDT", + assetType: "spot", + payload: { last: 100 }, + receivedTimestamp: 1_700_000_000_500, + }, + { + eventTimeMs: 1_700_000_000_000, + last: 100, + bid: 99.5, + ask: 100.5, + }, + ); + + expect(row.table).toBe("market_data.cex_ticker_events"); + expect(row.row.last).toBe(100); + }); + + test("buildCexStreamEventRow stores redacted stream payload", () => { + const row = buildCexStreamEventRow({ + deploymentId: "deploy-a", + exchange: "binance", + symbol: "BTC/USDT", + assetType: "spot", + streamType: "BALANCE", + payload: { apiSecret: "hidden", total: 100 }, + receivedTimestamp: 1_700_000_000_500, + }); + + expect(row.table).toBe("market_data.cex_stream_events"); + expect(JSON.stringify(row.row)).not.toContain("hidden"); + }); +}); + +describe("orderbook sampler", () => { + test("emits first sample immediately then respects interval", () => { + const sampler = new OrderbookSampler(1_000); + + expect(sampler.shouldEmit(1_000)).toBe(true); + expect(sampler.shouldEmit(1_500)).toBe(false); + expect(sampler.shouldEmit(2_000)).toBe(true); + }); + + test("resets sampling window after clock rollback", () => { + const sampler = new OrderbookSampler(1_000); + + expect(sampler.shouldEmit(2_000)).toBe(true); + expect(sampler.shouldEmit(1_500)).toBe(true); + expect(sampler.shouldEmit(1_600)).toBe(false); + expect(sampler.shouldEmit(2_600)).toBe(true); + }); +}); + +describe("ohlcv bar tracker", () => { + const snapshot = Array.from({ length: 500 }, (_, index) => [ + 1_700_000_000_000 + index * 60_000, + 1, + 2, + 0.5, + 1.5, + 10, + ]); + + test("parseOhlcvBar accepts CCXT tuple shape", () => { + expect(parseOhlcvBar([1_000, 1, 2, 0.5, 1.5, 10, 15])).toEqual({ + openTimeMs: 1_000, + open: 1, + high: 2, + low: 0.5, + close: 1.5, + volume: 10, + quoteVolume: 15, + }); + }); + + test("extractLatestOhlcvBar uses the last bar from arrays", () => { + expect( + extractLatestOhlcvBar([ + [1_000, 1, 2, 0.5, 1.5, 10], + [2_000, 2, 3, 1.5, 2.5, 20], + ])?.openTimeMs, + ).toBe(2_000); + }); + + test("extractOhlcvBars deduplicates and sorts bars", () => { + expect( + extractOhlcvBars([ + [2_000, 2, 3, 1.5, 2.5, 20], + [1_000, 1, 2, 0.5, 1.5, 10], + ]).map((bar) => bar.openTimeMs), + ).toEqual([1_000, 2_000]); + }); + + test("closes previous bar when open time advances", () => { + const tracker = new OhlcvBarTracker(); + + expect(tracker.process([[1_000, 1, 2, 0.5, 1.5, 10]], 100)).toEqual([ + { + bar: { + openTimeMs: 1_000, + open: 1, + high: 2, + low: 0.5, + close: 1.5, + volume: 10, + }, + isClosed: false, + brokerVersion: 100, + }, + ]); + + expect(tracker.process([[2_000, 2, 3, 1.5, 2.5, 20]], 200)).toEqual([ + { + bar: { + openTimeMs: 1_000, + open: 1, + high: 2, + low: 0.5, + close: 1.5, + volume: 10, + }, + isClosed: true, + brokerVersion: 200, + }, + { + bar: { + openTimeMs: 2_000, + open: 2, + high: 3, + low: 1.5, + close: 2.5, + volume: 20, + }, + isClosed: false, + brokerVersion: 200, + }, + ]); + }); + + test("preserves all bars in the first batch and leaves the newest open", () => { + const tracker = new OhlcvBarTracker(); + + const candidates = tracker.process(snapshot, 100); + + expect(candidates).toHaveLength(500); + expect( + candidates.map(({ bar, isClosed }) => ({ + openTimeMs: bar.openTimeMs, + isClosed, + })), + ).toEqual( + snapshot.map(([openTimeMs], index) => ({ + openTimeMs, + isClosed: index < snapshot.length - 1, + })), + ); + }); + + test("repeated snapshot only re-emits the open bar update", () => { + const tracker = new OhlcvBarTracker(); + tracker.process(snapshot, 100); + + const candidates = tracker.process(snapshot, 200); + + expect(candidates).toHaveLength(1); + expect(candidates[0]).toMatchObject({ + bar: { openTimeMs: snapshot.at(-1)?.[0] }, + isClosed: false, + brokerVersion: 200, + }); + }); + + test("overlapping snapshot closes the previous open bar and emits the new open bar", () => { + const tracker = new OhlcvBarTracker(); + tracker.process(snapshot, 100); + const previousOpenTimeMs = snapshot.at(-1)?.[0] ?? 0; + const nextOpenTimeMs = previousOpenTimeMs + 60_000; + const overlappingSnapshot = [ + ...snapshot.slice(1), + [nextOpenTimeMs, 1.5, 2.5, 1, 2, 12], + ]; + + const candidates = tracker.process(overlappingSnapshot, 200); + + expect( + candidates.map(({ bar, isClosed, brokerVersion }) => ({ + openTimeMs: bar.openTimeMs, + isClosed, + brokerVersion, + })), + ).toEqual([ + { openTimeMs: previousOpenTimeMs, isClosed: true, brokerVersion: 200 }, + { openTimeMs: nextOpenTimeMs, isClosed: false, brokerVersion: 200 }, + ]); + }); +}); + +describe("ohlcv bootstrap limit", () => { + test("resolveOhlcvBootstrapLimit clamps to configured bounds", () => { + expect(resolveOhlcvBootstrapLimit("5000")).toBe(1000); + expect(resolveOhlcvBootstrapLimit("50")).toBe(50); + expect(resolveOhlcvBootstrapLimit("0")).toBe(0); + }); +}); + +describe("parse stream helpers", () => { + test("parseTicker extracts ticker fields", () => { + expect( + parseTicker({ + last: 100, + bid: 99.5, + ask: 100.5, + timestamp: 1_000, + }), + ).toMatchObject({ + last: 100, + bid: 99.5, + ask: 100.5, + eventTimeMs: 1_000_000, + }); + }); + + test("extractTrades normalizes trade arrays", () => { + expect( + extractTrades([ + { + id: "1", + timestamp: 1_000, + side: "buy", + price: 100, + amount: 0.1, + }, + ]), + ).toEqual([ + expect.objectContaining({ + tradeId: "1", + side: "buy", + price: 100, + amount: 0.1, + }), + ]); + }); +}); diff --git a/test/market-type.test.ts b/test/market-type.test.ts new file mode 100644 index 0000000..6cb14a3 --- /dev/null +++ b/test/market-type.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import type { Exchange } from "@usherlabs/ccxt"; +import { + findTradableSymbol, + parseMarketPattern, + parseMarketType, + resolveSubscriptionSymbol, +} from "../src/helpers/market-type"; + +function createMarket( + symbol: string, + type: "spot" | "swap" | "future", +): Record { + const [baseQuote, settle] = symbol.split(":"); + const [base, quote] = baseQuote.split("/"); + return { + symbol, + base, + quote, + type, + spot: type === "spot", + swap: type === "swap", + future: type === "future", + settle, + }; +} + +function createBrokerMock( + marketDefs: Array<{ symbol: string; type: "spot" | "swap" | "future" }>, +): Exchange { + const markets = Object.fromEntries( + marketDefs.map(({ symbol, type }) => [symbol, createMarket(symbol, type)]), + ); + return { + markets, + loadMarkets: async () => markets, + } as unknown as Exchange; +} + +describe("market-type helper", () => { + test("parseMarketType defaults to spot and accepts perp alias", () => { + expect(parseMarketType(undefined)).toBe("spot"); + expect(parseMarketType("spot")).toBe("spot"); + expect(parseMarketType("perp")).toBe("swap"); + expect(parseMarketType("swap")).toBe("swap"); + expect(parseMarketType("future")).toBe("future"); + }); + + test("parseMarketPattern extracts @swap suffix", () => { + expect(parseMarketPattern("ETH/USDC@swap")).toEqual({ + symbolPattern: "ETH/USDC", + requiredMarketType: "swap", + }); + expect(parseMarketPattern("ETH/USDC")).toEqual({ + symbolPattern: "ETH/USDC", + }); + }); + + test("findTradableSymbol resolves spot markets", async () => { + const broker = createBrokerMock([ + { symbol: "ETH/USDC", type: "spot" }, + { symbol: "ETH/USDC:USDC", type: "swap" }, + ]); + const result = await findTradableSymbol(broker, "ETH", "USDC", "spot"); + expect(result).toEqual({ + symbol: "ETH/USDC", + side: "sell", + marketType: "spot", + }); + }); + + test("findTradableSymbol resolves swap markets with settle suffix", async () => { + const broker = createBrokerMock([ + { symbol: "ETH/USDC", type: "spot" }, + { symbol: "ETH/USDC:USDC", type: "swap" }, + ]); + const result = await findTradableSymbol(broker, "ETH", "USDC", "swap"); + expect(result).toEqual({ + symbol: "ETH/USDC:USDC", + side: "sell", + marketType: "swap", + }); + }); + + test("resolveSubscriptionSymbol keeps explicit perp symbol", async () => { + const broker = createBrokerMock([ + { symbol: "ETH/USDC:USDC", type: "swap" }, + ]); + await expect( + resolveSubscriptionSymbol(broker, "ETH/USDC:USDC", "swap"), + ).resolves.toBe("ETH/USDC:USDC"); + }); + + test("resolveSubscriptionSymbol upgrades bare pair when marketType is swap", async () => { + const broker = createBrokerMock([ + { symbol: "ETH/USDC", type: "spot" }, + { symbol: "ETH/USDC:USDC", type: "swap" }, + ]); + await expect( + resolveSubscriptionSymbol(broker, "ETH/USDC", "swap"), + ).resolves.toBe("ETH/USDC:USDC"); + }); +}); diff --git a/test/ohlcv-collector-config.test.ts b/test/ohlcv-collector-config.test.ts new file mode 100644 index 0000000..2f83028 --- /dev/null +++ b/test/ohlcv-collector-config.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; +import { + loadOhlcvCollectorConfig, + OHLCV_COLLECTOR_CONFIG_ENV, + parseOhlcvCollectorConfig, +} from "../services/ohlcv-collector/config"; + +describe("OHLCV collector config", () => { + test("parses subscriptions and defaults the timeframe to 1m", () => { + expect( + parseOhlcvCollectorConfig([ + { exchange: " Binance ", symbol: " BTC/USDT " }, + { exchange: "kraken", symbol: "ETH/USD", timeframe: "5m" }, + ]), + ).toEqual([ + { exchange: "binance", symbol: "BTC/USDT", timeframe: "1m" }, + { exchange: "kraken", symbol: "ETH/USD", timeframe: "5m" }, + ]); + }); + + test.each([ + { input: undefined }, + { input: null }, + { input: {} }, + { input: [] }, + { input: [{ exchange: "binance" }] }, + { input: [{ exchange: "binance", symbol: "BTC/USDT", extra: true }] }, + { + input: [ + { exchange: "binance", symbol: "BTC/USDT" }, + { exchange: "BINANCE", symbol: "BTC/USDT", timeframe: "1m" }, + ], + }, + ])("rejects malformed config fail-closed", ({ input }) => { + expect(() => parseOhlcvCollectorConfig(input)).toThrow( + "Invalid OHLCV collector config", + ); + }); + + test("rejects missing and invalid JSON config files", async () => { + await expect(loadOhlcvCollectorConfig(undefined)).rejects.toThrow( + `${OHLCV_COLLECTOR_CONFIG_ENV} must point to a JSON file`, + ); + + const path = `${process.cwd()}/test/.ohlcv-collector-invalid-${crypto.randomUUID()}.json`; + await Bun.write(path, "not-json"); + try { + await expect(loadOhlcvCollectorConfig(path)).rejects.toThrow( + "is not valid JSON", + ); + } finally { + await Bun.file(path).delete(); + } + }); +}); diff --git a/test/ohlcv-collector-shutdown.test.ts b/test/ohlcv-collector-shutdown.test.ts new file mode 100644 index 0000000..7692ec9 --- /dev/null +++ b/test/ohlcv-collector-shutdown.test.ts @@ -0,0 +1,127 @@ +import { expect, test } from "bun:test"; +import path from "node:path"; + +async function waitForFile(filePath: string): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (await Bun.file(filePath).exists()) { + return; + } + await Bun.sleep(10); + } + throw new Error(`Timed out waiting for ${filePath}`); +} + +async function waitForFetchCount( + filePath: string, + minimum: number, +): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + if (await Bun.file(filePath).exists()) { + const count = Number(await Bun.file(filePath).text()); + if (count >= minimum) { + return count; + } + } + await Bun.sleep(10); + } + throw new Error(`Timed out waiting for ${minimum} fetches in ${filePath}`); +} + +async function runShutdownCase(exchangeCloseHangs: boolean): Promise<{ + exitCode: number; + closeMarker: string; + countBeforeShutdown: number; + countAtShutdown: number; + output: string; +}> { + const fixtureId = crypto.randomUUID(); + const configPath = `/tmp/ohlcv-shutdown-${fixtureId}.json`; + const activePath = `/tmp/ohlcv-shutdown-${fixtureId}.active`; + const countPath = `/tmp/ohlcv-shutdown-${fixtureId}.count`; + const closedPath = `/tmp/ohlcv-shutdown-${fixtureId}.closed`; + await Bun.write( + configPath, + JSON.stringify([{ exchange: "binance", symbol: "BTC/USDT" }]), + ); + + const child = Bun.spawn({ + cmd: [ + process.execPath, + "--preload", + path.resolve("test/fixtures/ohlcv-collector-fake-exchange.ts"), + path.resolve("services/ohlcv-collector/index.ts"), + ], + cwd: process.cwd(), + env: { + ...process.env, + CEX_BROKER_OHLCV_COLLECTOR_CONFIG: configPath, + CEX_BROKER_OHLCV_ARCHIVE_BOOTSTRAP_LIMIT: "0", + OHLCV_TEST_EXCHANGE_ACTIVE_PATH: activePath, + OHLCV_TEST_EXCHANGE_COUNT_PATH: countPath, + OHLCV_TEST_EXCHANGE_CLOSED_PATH: closedPath, + OHLCV_TEST_EXCHANGE_CLOSE_HANG: String(exchangeCloseHangs), + }, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = new Response(child.stdout).text(); + const stderr = new Response(child.stderr).text(); + + try { + await waitForFile(activePath); + const countBeforeShutdown = await waitForFetchCount(countPath, 5); + expect(await Bun.file(closedPath).exists()).toBe(false); + await Bun.sleep(100); + const countAtShutdown = await waitForFetchCount( + countPath, + countBeforeShutdown + 1, + ); + expect(await Bun.file(closedPath).exists()).toBe(false); + child.kill("SIGTERM"); + const result = await Promise.race([ + child.exited.then((exitCode) => ({ exitCode })), + Bun.sleep(5_000).then(() => null), + ]); + if (!result) { + child.kill("SIGKILL"); + await child.exited; + throw new Error( + `Collector did not exit within 5s\nstdout:\n${await stdout}\nstderr:\n${await stderr}`, + ); + } + return { + exitCode: result.exitCode, + closeMarker: await Bun.file(closedPath).text(), + countBeforeShutdown, + countAtShutdown, + output: `${await stdout}\n${await stderr}`, + }; + } finally { + if (child.exitCode === null) { + child.kill("SIGKILL"); + await child.exited; + } + await Promise.all( + [configPath, activePath, countPath, closedPath].map(async (filePath) => { + if (await Bun.file(filePath).exists()) { + await Bun.file(filePath).delete(); + } + }), + ); + } +} + +test("entrypoint exits promptly on SIGTERM after an exchange stream opens", async () => { + const result = await runShutdownCase(false); + expect(result.exitCode).toBe(0); + expect(result.closeMarker).toBe("closed"); + expect(result.countAtShutdown).toBeGreaterThan(result.countBeforeShutdown); +}); + +test("entrypoint bounds shutdown when an exchange close does not resolve", async () => { + const result = await runShutdownCase(true); + expect(result.exitCode).toBe(0); + expect(result.closeMarker).toBe("close_attempted"); + expect(result.output).toContain("OHLCV collector shutdown path timed out"); + expect(result.output).toContain("subscribe_brokers"); +}); diff --git a/test/ohlcv-collector.test.ts b/test/ohlcv-collector.test.ts new file mode 100644 index 0000000..d79c460 --- /dev/null +++ b/test/ohlcv-collector.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from "bun:test"; +import * as grpc from "@grpc/grpc-js"; +import { OhlcvCollector } from "../services/ohlcv-collector/collector"; +import { CEX_BROKER_PACKAGE_DEFINITION } from "../src/proto-package-definition"; + +type SubscribeRequest = { + cex: string; + symbol: string; + type: string; + options: Record; +}; + +type SubscribeCall = grpc.ServerWritableStream; + +const grpcObject = grpc.loadPackageDefinition( + CEX_BROKER_PACKAGE_DEFINITION, +) as unknown as { + cex_broker: { + cex_service: { + service: grpc.ServiceDefinition; + }; + }; +}; + +function bindServer(server: grpc.Server): Promise { + return new Promise((resolve, reject) => { + server.bindAsync( + "127.0.0.1:0", + grpc.ServerCredentials.createInsecure(), + (error, port) => { + if (error) { + reject(error); + return; + } + resolve(port); + }, + ); + }); +} + +async function startSubscribeServer( + onSubscribe: (call: SubscribeCall) => void, +): Promise<{ server: grpc.Server; port: number }> { + const server = new grpc.Server(); + server.addService(grpcObject.cex_broker.cex_service.service, { + Subscribe: onSubscribe, + }); + return { server, port: await bindServer(server) }; +} + +async function waitFor(condition: () => boolean): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (condition()) { + return; + } + await Bun.sleep(5); + } + throw new Error("Timed out waiting for OHLCV collector condition"); +} + +function endWithError(call: SubscribeCall): void { + call.emit( + "error", + Object.assign(new Error("test stream unavailable"), { + code: grpc.status.UNAVAILABLE, + details: "test stream unavailable", + metadata: new grpc.Metadata(), + }), + ); +} + +function writeBar(call: SubscribeCall): void { + call.write({ + data: JSON.stringify([1_700_000_000_000, 1, 2, 0.5, 1.5, 10]), + timestamp: Date.now(), + symbol: call.request.symbol, + type: "OHLCV", + }); +} + +class CapturingMetrics { + readonly counters: Array<{ + name: string; + value: number; + labels: Record; + }> = []; + + async recordCounter( + name: string, + value: number, + labels: Record, + ): Promise { + this.counters.push({ name, value, labels }); + } +} + +describe("OHLCV collector supervision", () => { + test.each([ + { terminal: "error" as const }, + { terminal: "end" as const }, + ])("resubscribes after stream $terminal", async ({ terminal }) => { + const requests: SubscribeRequest[] = []; + const { server, port } = await startSubscribeServer((call) => { + requests.push(call.request); + if (requests.length === 1) { + queueMicrotask(() => { + if (terminal === "error") { + endWithError(call); + } else { + call.end(); + } + }); + return; + } + writeBar(call); + }); + const metrics = new CapturingMetrics(); + const abort = new AbortController(); + const collector = new OhlcvCollector({ + brokerUrl: `127.0.0.1:${port}`, + subscriptions: [ + { exchange: "binance", symbol: "BTC/USDT", timeframe: "1m" }, + ], + metrics, + retry: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, + }); + const runPromise = collector.run(abort.signal); + + try { + await waitFor( + () => + requests.length >= 2 && + metrics.counters.some( + (counter) => + counter.name === "cex_ohlcv_collector_bars_received_total", + ), + ); + expect(requests.slice(0, 2)).toEqual([ + { + cex: "binance", + symbol: "BTC/USDT", + type: "OHLCV", + options: { timeframe: "1m" }, + }, + { + cex: "binance", + symbol: "BTC/USDT", + type: "OHLCV", + options: { timeframe: "1m" }, + }, + ]); + expect(metrics.counters).toContainEqual({ + name: "cex_ohlcv_collector_reconnects_total", + value: 1, + labels: { + exchange: "binance", + symbol: "BTC/USDT", + timeframe: "1m", + }, + }); + expect(metrics.counters).toContainEqual({ + name: "cex_ohlcv_collector_bars_received_total", + value: 1, + labels: { + exchange: "binance", + symbol: "BTC/USDT", + timeframe: "1m", + }, + }); + } finally { + abort.abort(); + await runPromise; + server.forceShutdown(); + } + }); + + test("one pair erroring leaves the other pair stream open", async () => { + const subscriptionCounts = new Map(); + let healthyStreamClosed = false; + const { server, port } = await startSubscribeServer((call) => { + const symbol = call.request.symbol; + const count = (subscriptionCounts.get(symbol) ?? 0) + 1; + subscriptionCounts.set(symbol, count); + if (symbol === "ETH/USDT") { + call.once("cancelled", () => { + healthyStreamClosed = true; + }); + writeBar(call); + return; + } + if (count === 1) { + queueMicrotask(() => endWithError(call)); + } + }); + const abort = new AbortController(); + const collector = new OhlcvCollector({ + brokerUrl: `127.0.0.1:${port}`, + subscriptions: [ + { exchange: "binance", symbol: "BTC/USDT", timeframe: "1m" }, + { exchange: "binance", symbol: "ETH/USDT", timeframe: "1m" }, + ], + retry: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, + }); + const runPromise = collector.run(abort.signal); + + try { + await waitFor( + () => + (subscriptionCounts.get("BTC/USDT") ?? 0) >= 2 && + (subscriptionCounts.get("ETH/USDT") ?? 0) === 1, + ); + await Bun.sleep(20); + expect(subscriptionCounts.get("BTC/USDT")).toBe(2); + expect(subscriptionCounts.get("ETH/USDT")).toBe(1); + expect(healthyStreamClosed).toBe(false); + } finally { + abort.abort(); + await runPromise; + server.forceShutdown(); + } + }); +}); diff --git a/test/order-book-helper.test.ts b/test/order-book-helper.test.ts new file mode 100644 index 0000000..f1ac053 --- /dev/null +++ b/test/order-book-helper.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { + ORDER_BOOK_CALL_METHODS, + parseOrderBookCallPayload, +} from "../src/helpers/order-book"; + +describe("order-book helper parser", () => { + test("supports depth_limit and construction_mode aliases", () => { + const payload = parseOrderBookCallPayload( + { + method: ORDER_BOOK_CALL_METHODS.FETCH_SNAPSHOT, + depth_limit: "3", + construction_mode: "sampled_top_n_snapshot", + }, + { exchange: "binance", symbol: "BTC/USDT" }, + ); + + expect(payload).toMatchObject({ + kind: "order_book", + payload: { + method: ORDER_BOOK_CALL_METHODS.FETCH_SNAPSHOT, + depthLimit: 3, + constructionMode: "sampled_top_n_snapshot", + }, + }); + }); + + test("rejects malformed historical cadence", () => { + const payload = parseOrderBookCallPayload( + { + method: ORDER_BOOK_CALL_METHODS.FETCH_HISTORICAL_SNAPSHOTS, + depthLimit: "5", + start: "2026-06-02T00:00:00Z", + end: "2026-06-02T00:01:00Z", + cadence: "fast", + }, + { exchange: "binance", symbol: "BTC/USDT" }, + ); + + expect(payload).toMatchObject({ + kind: "error", + message: + "ValidationError: cadence must be a positive duration such as 1s", + }); + }); + + test("rejects historical requests where start is after end", () => { + const payload = parseOrderBookCallPayload( + { + method: ORDER_BOOK_CALL_METHODS.FETCH_HISTORICAL_SNAPSHOTS, + depthLimit: "5", + start: "2026-06-02T00:02:00Z", + end: "2026-06-02T00:01:00Z", + cadence: "1s", + }, + { exchange: "binance", symbol: "BTC/USDT" }, + ); + + expect(payload).toMatchObject({ + kind: "error", + message: "ValidationError: start must be before end", + }); + }); +}); diff --git a/test/order-book-rpc.test.ts b/test/order-book-rpc.test.ts new file mode 100644 index 0000000..cea0569 --- /dev/null +++ b/test/order-book-rpc.test.ts @@ -0,0 +1,599 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import * as grpc from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; +import type { Exchange } from "@usherlabs/ccxt"; +import { Action } from "../src/helpers/constants"; +import type { BrokerPoolEntry } from "../src/helpers/index"; +import { PROTO_LOADER_OPTIONS } from "../src/proto-loader-options"; +import { getServer } from "../src/server"; +import type { PolicyConfig } from "../src/types"; + +const packageDef = protoLoader.loadSync( + "src/proto/node.proto", + PROTO_LOADER_OPTIONS, +); +const grpcObj = grpc.loadPackageDefinition(packageDef) as { + cex_broker: { + cex_service: new ( + address: string, + credentials: grpc.ChannelCredentials, + ) => { + ExecuteAction( + request: Record, + callback: grpc.requestCallback<{ result: string; proof: string }>, + ): void; + Subscribe(request: Record): grpc.ClientReadableStream<{ + data: string; + timestamp: string; + symbol: string; + type: string; + }>; + close(): void; + }; + }; +}; + +const testPolicy: PolicyConfig = { + withdraw: { rule: [] }, + deposit: {}, + order: { rule: { markets: [], limits: [] } }, +}; + +type OrderBookExchangeOptions = { + fetchOrderBookResult?: unknown; + watchOrderBookResult?: unknown; + has?: Record; +}; + +function createOrderBookExchange(options: OrderBookExchangeOptions = {}) { + const calls: Record = { + fetchOrderBook: [], + watchOrderBook: [], + fetchTicker: [], + }; + const exchange: Record = { + has: { + fetchOrderBook: true, + watchOrderBook: true, + fetchTicker: true, + ...(options.has ?? {}), + }, + fetchOrderBook: async (...args: unknown[]) => { + calls.fetchOrderBook.push(args); + return ( + options.fetchOrderBookResult ?? { + bids: [ + [100, 1], + [99, 2], + ], + asks: [ + [101, 3], + [102, 4], + ], + timestamp: 1770000000000, + lastUpdateId: 42, + apiKey: "should-not-leak", + secret: "should-not-leak", + } + ); + }, + watchOrderBook: async (...args: unknown[]) => { + calls.watchOrderBook.push(args); + if (calls.watchOrderBook.length > 1) { + return new Promise(() => {}); + } + const watchOrderBookResult = + options.watchOrderBookResult ?? + ({ + bids: [ + [200, 1], + [199, 2], + ], + asks: [ + [201, 3], + [202, 4], + ], + timestamp: 1770000001000, + nonce: 77, + } as const); + if (watchOrderBookResult instanceof Error) { + throw watchOrderBookResult; + } + return watchOrderBookResult; + }, + fetchTicker: async (...args: unknown[]) => { + calls.fetchTicker.push(args); + return { symbol: args[0], last: 123 }; + }, + }; + return { exchange: exchange as Exchange, calls }; +} + +function createPool( + cex: string, + exchange: Exchange, +): Record { + return { + [cex]: { + primary: { exchange, label: "primary" }, + secondaryBrokers: [], + }, + }; +} + +function bindServer(server: grpc.Server) { + return new Promise((resolve, reject) => { + server.bindAsync( + "127.0.0.1:0", + grpc.ServerCredentials.createInsecure(), + (error, port) => { + if (error) { + reject(error); + return; + } + server.start(); + resolve(port); + }, + ); + }); +} + +function createClient(port: number) { + return new grpcObj.cex_broker.cex_service( + `127.0.0.1:${port}`, + grpc.credentials.createInsecure(), + ); +} + +function executeAction( + client: InstanceType, + request: Record, +) { + return new Promise<{ result: string; proof: string }>((resolve, reject) => { + client.ExecuteAction(request, (error, response) => { + if (error) { + reject(error); + return; + } + resolve(response as { result: string; proof: string }); + }); + }); +} + +function firstSubscribeFrame( + client: InstanceType, + request: Record, +) { + return new Promise<{ + data: string; + timestamp: string; + symbol: string; + type: string; + }>((resolve, reject) => { + let settled = false; + const stream = client.Subscribe(request); + const timeout = setTimeout(() => { + if (!settled) { + settled = true; + stream.cancel(); + reject(new Error("timed out waiting for Subscribe frame")); + } + }, 2000); + stream.on("data", (response) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + stream.cancel(); + resolve(response); + }); + stream.on("error", (error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + reject(error); + }); + }); +} + +describe("order-book RPC compatibility", () => { + let server: grpc.Server | undefined; + let client: InstanceType | undefined; + + afterEach(async () => { + client?.close(); + if (server) { + await server.forceShutdown(); + } + }); + + test("dispatches Maker capability method without invoking a fake CCXT method", async () => { + const { exchange, calls } = createOrderBookExchange(); + server = getServer( + testPolicy, + createPool("mexc", exchange), + ["*"], + false, + "", + ); + client = createClient(await bindServer(server)); + + const response = await executeAction(client, { + action: Action.Call, + cex: "mexc", + symbol: "ARB/USDT", + payload: { + method: "fetch_order_book_capability", + depthLimit: "100", + constructionMode: "sampled_top_n_snapshot", + }, + }); + + const payload = JSON.parse(response.result); + expect(payload).toMatchObject({ + exchange: "mexc", + symbol: "ARB/USDT", + provider: "ccxt_order_book", + maxDepth: 100, + supportsCurrentSnapshot: true, + supportsLiveStream: true, + supportsHistoricalSnapshots: false, + supportsSampledTopN: false, + supportsExactL2Reconstruction: false, + }); + expect(calls.fetchOrderBook).toHaveLength(0); + }); + + test("dispatches Maker current snapshot method and normalizes metadata", async () => { + const { exchange, calls } = createOrderBookExchange(); + server = getServer( + testPolicy, + createPool("binance", exchange), + ["*"], + false, + "", + ); + client = createClient(await bindServer(server)); + + const response = await executeAction(client, { + action: Action.Call, + cex: "binance", + symbol: "BTC/USDT", + payload: { + method: "fetch_order_book_snapshot", + depthLimit: "1", + }, + }); + + const payload = JSON.parse(response.result); + expect(payload).toMatchObject({ + bids: [[100, 1]], + asks: [[101, 3]], + timestamp: 1770000000000, + exchange: "binance", + symbol: "BTC/USDT", + sequence: 42, + depthLimit: 1, + }); + expect(typeof payload.receivedTimestamp).toBe("number"); + expect(JSON.stringify(payload)).not.toContain("should-not-leak"); + expect(calls.fetchOrderBook[0]).toEqual(["BTC/USDT", 1]); + }); + + test("returns typed historical unsupported including exact reconstruction", async () => { + const { exchange, calls } = createOrderBookExchange(); + server = getServer( + testPolicy, + createPool("binance", exchange), + ["*"], + false, + "", + ); + client = createClient(await bindServer(server)); + + const response = await executeAction(client, { + action: Action.Call, + cex: "binance", + symbol: "ARB/USDT", + payload: { + method: "fetch_historical_order_book_snapshots", + start: "2026-06-02T00:00:00Z", + end: "2026-06-02T00:01:00Z", + cadence: "1s", + depthLimit: "100", + constructionMode: "exact_l2_reconstruction", + }, + }); + + const payload = JSON.parse(response.result); + expect(payload).toMatchObject({ + exchange: "binance", + symbol: "ARB/USDT", + unsupported: true, + unsupportedReason: "historical_order_book_provider_unsupported", + constructionMode: "exact_l2_reconstruction", + }); + expect(calls.fetchOrderBook).toHaveLength(0); + expect(calls.watchOrderBook).toHaveLength(0); + }); + + test("rejects malformed order-book call input before provider access", async () => { + const { exchange, calls } = createOrderBookExchange(); + server = getServer( + testPolicy, + createPool("binance", exchange), + ["*"], + false, + "", + ); + client = createClient(await bindServer(server)); + + await expect( + executeAction(client, { + action: Action.Call, + cex: "binance", + symbol: "BTC/USDT", + payload: { + method: "fetch_order_book_snapshot", + depthLimit: "0", + }, + }), + ).rejects.toMatchObject({ + code: grpc.status.INVALID_ARGUMENT, + }); + expect(calls.fetchOrderBook).toHaveLength(0); + }); + + test("preserves generic non-order-book Call behavior", async () => { + const { exchange, calls } = createOrderBookExchange(); + server = getServer( + testPolicy, + createPool("binance", exchange), + ["*"], + false, + "", + ); + client = createClient(await bindServer(server)); + + const response = await executeAction(client, { + action: Action.Call, + cex: "binance", + symbol: "BTC/USDT", + payload: { + functionName: "fetchTicker", + args: JSON.stringify(["BTC/USDT"]), + }, + }); + + expect(JSON.parse(response.result)).toMatchObject({ + symbol: "BTC/USDT", + last: 123, + }); + expect(calls.fetchTicker[0]).toEqual(["BTC/USDT"]); + + await expect( + executeAction(client, { + action: Action.Call, + cex: "binance", + symbol: "BTC/USDT", + payload: { + functionName: "_privateMethod", + }, + }), + ).rejects.toMatchObject({ + code: grpc.status.INVALID_ARGUMENT, + }); + }); + + test("accepts functionName alias for order-book snapshot", async () => { + const { exchange, calls } = createOrderBookExchange(); + server = getServer( + testPolicy, + createPool("binance", exchange), + ["*"], + false, + "", + ); + client = createClient(await bindServer(server)); + + const response = await executeAction(client, { + action: Action.Call, + cex: "binance", + symbol: "BTC/USDT", + payload: { + functionName: "fetch_order_book_snapshot", + depthLimit: "2", + }, + }); + + expect(JSON.parse(response.result)).toMatchObject({ + bids: [ + [100, 1], + [99, 2], + ], + exchange: "binance", + symbol: "BTC/USDT", + depthLimit: 2, + }); + expect(calls.fetchOrderBook[0]).toEqual(["BTC/USDT", 2]); + }); + + test("returns explicit unsupported snapshot when fetchOrderBook is false", async () => { + const { exchange, calls } = createOrderBookExchange({ + has: { fetchOrderBook: false }, + }); + server = getServer( + testPolicy, + createPool("binance", exchange), + ["*"], + false, + "", + ); + client = createClient(await bindServer(server)); + + const capabilityResponse = await executeAction(client, { + action: Action.Call, + cex: "binance", + symbol: "BTC/USDT", + payload: { + method: "fetch_order_book_capability", + depthLimit: "5", + }, + }); + const capability = JSON.parse(capabilityResponse.result); + expect(capability).toMatchObject({ + supportsCurrentSnapshot: false, + supportsLiveStream: true, + }); + + await expect( + executeAction(client, { + action: Action.Call, + cex: "binance", + symbol: "BTC/USDT", + payload: { + method: "fetch_order_book_snapshot", + depthLimit: "1", + }, + }), + ).rejects.toMatchObject({ code: grpc.status.UNIMPLEMENTED }); + expect(calls.fetchOrderBook).toHaveLength(0); + }); + + test("enriches ORDERBOOK stream data while preserving omitted type compatibility", async () => { + const { exchange, calls } = createOrderBookExchange(); + server = getServer( + testPolicy, + createPool("binance", exchange), + ["*"], + false, + "", + ); + client = createClient(await bindServer(server)); + + const response = await firstSubscribeFrame(client, { + cex: "binance", + symbol: "BTC/USDT", + options: { depthLimit: "1" }, + }); + + const payload = JSON.parse(response.data); + expect(payload).toMatchObject({ + bids: [[200, 1]], + asks: [[201, 3]], + timestamp: 1770000001000, + exchange: "binance", + symbol: "BTC/USDT", + sequence: 77, + depthLimit: 1, + }); + expect(typeof payload.receivedTimestamp).toBe("number"); + expect(Number(response.timestamp)).toBe(payload.receivedTimestamp); + expect(calls.watchOrderBook[0]).toEqual(["BTC/USDT", 1]); + }); + + test("resolves explicit NO_ACTION subscription type to ORDERBOOK", async () => { + const { exchange, calls } = createOrderBookExchange(); + server = getServer( + testPolicy, + createPool("binance", exchange), + ["*"], + false, + "", + ); + client = createClient(await bindServer(server)); + + const response = await firstSubscribeFrame(client, { + cex: "binance", + symbol: "BTC/USDT", + type: 0, + }); + + const payload = JSON.parse(response.data); + expect(payload.bids).toEqual([ + [200, 1], + [199, 2], + ]); + expect(payload.asks).toEqual([ + [201, 3], + [202, 4], + ]); + expect(calls.watchOrderBook[0]).toEqual(["BTC/USDT"]); + }); + + test("resolves out-of-range subscription type to ORDERBOOK", async () => { + const { exchange, calls } = createOrderBookExchange(); + server = getServer( + testPolicy, + createPool("binance", exchange), + ["*"], + false, + "", + ); + client = createClient(await bindServer(server)); + + const response = await firstSubscribeFrame(client, { + cex: "binance", + symbol: "BTC/USDT", + type: 99, + }); + + const payload = JSON.parse(response.data); + expect(payload.bids).toEqual([ + [200, 1], + [199, 2], + ]); + expect(calls.watchOrderBook[0]).toEqual(["BTC/USDT"]); + }); + + test("publishes explicit orderbook stream error when watchOrderBook rejects", async () => { + const { exchange, calls } = createOrderBookExchange({ + watchOrderBookResult: new Error("stream boom"), + }); + server = getServer( + testPolicy, + createPool("binance", exchange), + ["*"], + false, + "", + ); + client = createClient(await bindServer(server)); + + const response = await firstSubscribeFrame(client, { + cex: "binance", + symbol: "BTC/USDT", + }); + + const payload = JSON.parse(response.data); + expect(payload.error).toContain("Failed to fetch orderbook: stream boom"); + expect(payload.error).toContain("Failed to fetch orderbook"); + expect(calls.watchOrderBook).toHaveLength(1); + }); + + test("publishes explicit orderbook stream error for malformed watchOrderBook payload", async () => { + const { exchange, calls } = createOrderBookExchange({ + watchOrderBookResult: "bad", + }); + server = getServer( + testPolicy, + createPool("binance", exchange), + ["*"], + false, + "", + ); + client = createClient(await bindServer(server)); + + const response = await firstSubscribeFrame(client, { + cex: "binance", + symbol: "BTC/USDT", + }); + + const payload = JSON.parse(response.data); + expect(payload.error).toContain("Malformed order book: expected object"); + expect(payload.error).toContain("Failed to fetch orderbook"); + expect(calls.watchOrderBook).toHaveLength(1); + }); +}); diff --git a/test/order-error-detail.test.ts b/test/order-error-detail.test.ts new file mode 100644 index 0000000..cdf0fe1 --- /dev/null +++ b/test/order-error-detail.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from "bun:test"; +import * as grpc from "@grpc/grpc-js"; +import type { Exchange } from "@usherlabs/ccxt"; +import type { ExecuteActionContext } from "../src/handlers/execute-action/context"; +import { handleOrders } from "../src/handlers/execute-action/orders"; +import { Action } from "../src/helpers/constants"; +import type { PolicyConfig } from "../src/types"; + +/** Stand-in for a ccxt venue error: a named subclass carrying the venue message, + * the exact shape the enclave logs today but the caller never sees. */ +class InsufficientFunds extends Error { + constructor(message: string) { + super(message); + this.name = "InsufficientFunds"; + } +} + +function createCallbackCapture() { + let error: { code?: number; message?: string } | null = null; + const callback = ( + callbackError: { code?: number; message?: string } | null, + ) => { + error = callbackError; + }; + return { callback, getError: () => error }; +} + +function createContext( + broker: Exchange, + action: (typeof Action)[keyof typeof Action], + payload: Record, +): { + ctx: ExecuteActionContext; + getError: () => { code?: number; message?: string } | null; +} { + const { callback, getError } = createCallbackCapture(); + const ctx = { + action, + call: { request: { payload } }, + wrappedCallback: callback, + cex: "binance", + normalizedCex: "binance", + symbol: "USDC/USDT", + broker, + verity: { proof: "" }, + // Allow-everything policy so resolveOrderExecution reaches broker.createOrder. + policy: { + order: { rule: { markets: ["*"], limits: [] } }, + } as unknown as PolicyConfig, + brokers: {}, + } as unknown as ExecuteActionContext; + return { ctx, getError }; +} + +describe("orders handler surfaces underlying error detail", () => { + test("CreateOrder appends class name + message after the stable prefix", async () => { + const venueError = new InsufficientFunds( + "binance Account has insufficient balance for requested action.", + ); + const broker = { + loadMarkets: async () => {}, + markets: { + "USDC/USDT": { + symbol: "USDC/USDT", + base: "USDC", + quote: "USDT", + spot: true, + type: "spot", + }, + }, + createOrder: async () => { + throw venueError; + }, + } as unknown as Exchange; + + const { ctx, getError } = createContext(broker, Action.CreateOrder, { + amount: "1", + fromToken: "USDC", + toToken: "USDT", + price: "1", + marketType: "spot", + }); + await handleOrders(ctx); + + const error = getError(); + expect(error?.code).toBe(grpc.status.INTERNAL); + expect(error?.message).toStartWith("Order Creation failed: "); + expect(error?.message).toContain("InsufficientFunds"); + expect(error?.message).toContain("insufficient balance"); + }); + + test("GetOrderDetails surfaces detail, keeps INTERNAL, single-line and capped", async () => { + const venueError = new InsufficientFunds( + `binance order lookup failed\nwith a newline and padding ${"x".repeat(1000)}`, + ); + const broker = { + fetchOrder: async () => { + throw venueError; + }, + } as unknown as Exchange; + + const { ctx, getError } = createContext(broker, Action.GetOrderDetails, { + orderId: "order-123", + }); + await handleOrders(ctx); + + const error = getError(); + expect(error?.code).toBe(grpc.status.INTERNAL); + expect(error?.message).toStartWith( + "Failed to fetch order details from binance: ", + ); + expect(error?.message).toContain("InsufficientFunds"); + // newline collapsed to a single line + expect(error?.message).not.toContain("\n"); + // only the detail portion is capped at 512; the fixed prefix is extra + const prefix = "Failed to fetch order details from binance: "; + const detail = error?.message?.slice(prefix.length) ?? ""; + expect(detail.length).toBe(512); + }); +}); diff --git a/test/order-telemetry-fixtures.ts b/test/order-telemetry-fixtures.ts new file mode 100644 index 0000000..d238721 --- /dev/null +++ b/test/order-telemetry-fixtures.ts @@ -0,0 +1,159 @@ +import * as grpc from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; +import type { Exchange } from "@usherlabs/ccxt"; +import type { BrokerPoolEntry } from "../src/helpers/index"; +import type { OtelMetrics } from "../src/helpers/otel"; +import { PROTO_LOADER_OPTIONS } from "../src/proto-loader-options"; + +const packageDef = protoLoader.loadSync( + "src/proto/node.proto", + PROTO_LOADER_OPTIONS, +); + +export const grpcObj = grpc.loadPackageDefinition(packageDef) as { + cex_broker: { + cex_service: new ( + address: string, + credentials: grpc.ChannelCredentials, + ) => { + ExecuteAction( + request: Record, + callback: grpc.requestCallback<{ result: string; proof: string }>, + ): void; + close(): void; + }; + }; +}; + +export type TelemetryMetricCall = { + name: string; + value: number; + labels: Record; +}; + +export class CapturingOtelMetrics { + readonly counters: TelemetryMetricCall[] = []; + readonly histograms: TelemetryMetricCall[] = []; + + async recordCounter( + name: string, + value: number, + labels: Record, + ) { + this.counters.push({ name, value, labels }); + } + + async recordHistogram( + name: string, + value: number, + labels: Record, + ) { + this.histograms.push({ name, value, labels }); + } + + asOtelMetrics(): OtelMetrics { + return this as unknown as OtelMetrics; + } +} + +export function createOrderExchangeFixture(options: { + createOrderResult?: unknown; + fetchOrderResult?: unknown; + fetchOrderBookResult?: unknown; + createOrderError?: Error; +}) { + const calls: Record = { + createOrder: [], + fetchOrder: [], + fetchOrderBook: [], + }; + const exchange: Record = { + markets: { + "ARB/USDT": { + symbol: "ARB/USDT", + base: "ARB", + quote: "USDT", + type: "spot", + spot: true, + swap: false, + }, + }, + loadMarkets: async function (this: { markets: Record }) { + return this.markets; + }, + market: (symbol: string) => { + if (symbol !== "ARB/USDT") { + throw new Error(`unsupported symbol ${symbol}`); + } + return { + symbol, + base: "ARB", + quote: "USDT", + type: "spot", + spot: true, + swap: false, + }; + }, + createOrder: async (...args: unknown[]) => { + calls.createOrder.push(args); + if (options.createOrderError) { + throw options.createOrderError; + } + return options.createOrderResult; + }, + fetchOrder: async (...args: unknown[]) => { + calls.fetchOrder.push(args); + return options.fetchOrderResult; + }, + }; + if (options.fetchOrderBookResult !== undefined) { + exchange.fetchOrderBook = async (...args: unknown[]) => { + calls.fetchOrderBook.push(args); + return options.fetchOrderBookResult; + }; + } + return { exchange: exchange as Exchange, calls }; +} + +export function createBinancePool( + exchange: Exchange, +): Record { + return { + binance: { + primary: { exchange, label: "primary" }, + secondaryBrokers: [], + }, + }; +} + +export function bindServer(server: grpc.Server) { + return new Promise((resolve, reject) => { + server.bindAsync( + "127.0.0.1:0", + grpc.ServerCredentials.createInsecure(), + (error, port) => { + if (error) { + reject(error); + return; + } + server.start(); + resolve(port); + }, + ); + }); +} + +export function executeAction( + client: InstanceType, + request: Record, +) { + return new Promise<{ result: string; proof: string }>((resolve, reject) => { + client.ExecuteAction(request, (error, response) => { + if (error) { + reject(error); + return; + } + resolve(response as { result: string; proof: string }); + }); + }); +} diff --git a/test/order-telemetry.test.ts b/test/order-telemetry.test.ts new file mode 100644 index 0000000..ac6284a --- /dev/null +++ b/test/order-telemetry.test.ts @@ -0,0 +1,834 @@ +import { afterAll, afterEach, describe, expect, spyOn, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as grpc from "@grpc/grpc-js"; +import { BrokerExecutionArchiver } from "../src/helpers/broker-execution-archive"; +import { Action } from "../src/helpers/constants"; +import { log } from "../src/helpers/logger"; +import { + buildOrderExecutionTelemetry, + extractOrderTelemetryIds, +} from "../src/helpers/order-telemetry"; +import { getServer } from "../src/server"; +import type { PolicyConfig } from "../src/types"; +import { startForwarderServer } from "./archive-forwarder-server"; +import { + bindServer, + CapturingOtelMetrics, + createBinancePool, + createOrderExchangeFixture, + executeAction, + grpcObj, +} from "./order-telemetry-fixtures"; + +const archiveTestDirectory = mkdtempSync( + join(tmpdir(), "cex-broker-order-archive-test-"), +); + +afterAll(() => { + rmSync(archiveTestDirectory, { recursive: true, force: true }); +}); + +class ExchangeOrderRejected extends Error { + readonly code = -2010; +} + +const testPolicy: PolicyConfig = { + withdraw: { rule: [] }, + deposit: {}, + order: { + rule: { + markets: ["BINANCE:ARB/USDT"], + limits: [{ from: "ARB", to: "USDT", min: 1, max: 100000 }], + }, + }, +}; + +function createClient(port: number) { + return new grpcObj.cex_broker.cex_service( + `127.0.0.1:${port}`, + grpc.credentials.createInsecure(), + ); +} + +function findHistogram(metrics: CapturingOtelMetrics, name: string) { + return metrics.histograms.find((metric) => metric.name === name); +} + +describe("order execution telemetry normalization", () => { + test("extracts accounting fields from CCXT and Binance fee shapes", () => { + const telemetry = buildOrderExecutionTelemetry( + { + action: "CreateOrder", + cex: "BINANCE", + accountLabel: "primary", + requestedQuantity: 10, + requestedNotional: 24, + brokerObservedTimestamp: "2026-05-14T00:00:00.000Z", + ...extractOrderTelemetryIds({ + newClientOrderId: "maker-hedge-1", + idempotencyKey: "idem-1", + action_id: "maker-action-1", + }), + }, + { + id: "123", + symbol: "ARB/USDT", + side: "sell", + type: "market", + status: "closed", + amount: 10, + filled: 10, + remaining: 0, + cost: 24.2, + average: 2.42, + timestamp: 1778716800000, + info: { + fills: [ + { commission: "0.01", commissionAsset: "ARB" }, + { commission: "0.02", commissionAsset: "ARB" }, + ], + }, + }, + ); + + expect(telemetry).toMatchObject({ + event: "cex_market_action_execution", + action: "CreateOrder", + cex: "binance", + accountLabel: "primary", + symbol: "ARB/USDT", + side: "sell", + orderType: "market", + orderId: "123", + clientOrderId: "maker-hedge-1", + idempotencyId: "idem-1", + makerActionId: "maker-action-1", + status: "closed", + requestedQuantity: 10, + requestedNotional: 24, + executedBaseQuantity: 10, + executedQuoteQuantity: 24.2, + averageExecutionPrice: 2.42, + filledAmount: 10, + remainingAmount: 0, + feeAmount: 0.03, + feeCurrency: "ARB", + exchangeTimestamp: "2026-05-14T00:00:00.000Z", + brokerObservedTimestamp: "2026-05-14T00:00:00.000Z", + }); + }); + + test("redacts upstream error messages from telemetry payloads", () => { + const telemetry = buildOrderExecutionTelemetry( + { + action: "CreateOrder", + cex: "binance", + accountLabel: "primary", + symbol: "ARB/USDT", + side: "sell", + orderType: "market", + brokerObservedTimestamp: "2026-05-14T00:00:00.000Z", + }, + undefined, + new Error("exchange rejected order because account abc123 is restricted"), + ); + + expect(telemetry).toMatchObject({ + status: "failed", + errorType: "Error", + errorMessage: "redacted_error", + }); + expect(JSON.stringify(telemetry)).not.toContain("account abc123"); + }); +}); + +describe("order execution telemetry RPC harness", () => { + let server: grpc.Server | undefined; + let client: InstanceType | undefined; + + afterEach(async () => { + client?.close(); + if (server) { + await server.forceShutdown(); + } + }); + + test("emits create-order price and fee metrics without changing response", async () => { + const metrics = new CapturingOtelMetrics(); + const { exchange } = createOrderExchangeFixture({ + createOrderResult: { + id: "order-1", + clientOrderId: "client-1", + symbol: "ARB/USDT", + side: "sell", + type: "market", + status: "closed", + amount: 10, + filled: 10, + remaining: 0, + cost: 21, + average: 2.1, + fee: { cost: 0.1, currency: "USDT", rate: 0.001 }, + timestamp: 1778716800000, + }, + }); + server = getServer( + testPolicy, + createBinancePool(exchange), + ["*"], + false, + "", + metrics.asOtelMetrics(), + ); + client = createClient(await bindServer(server)); + + const response = await executeAction(client, { + action: Action.CreateOrder, + cex: "binance", + payload: { + orderType: "market", + amount: "10", + fromToken: "ARB", + toToken: "USDT", + price: "2.1", + params: JSON.stringify({ + newClientOrderId: "client-1", + idempotencyKey: "idem-1", + actionId: "maker-action-1", + }), + }, + }); + + expect(JSON.parse(response.result)).toMatchObject({ + id: "order-1", + clientOrderId: "client-1", + fee: { cost: 0.1, currency: "USDT", rate: 0.001 }, + }); + expect( + findHistogram(metrics, "cex_market_action_average_execution_price"), + )?.toMatchObject({ + value: 2.1, + labels: { + action: "CreateOrder", + cex: "binance", + account: "primary", + symbol: "ARB/USDT", + side: "sell", + order_type: "market", + status: "closed", + }, + }); + expect(findHistogram(metrics, "cex_market_action_fee_amount")?.value).toBe( + 0.1, + ); + expect( + findHistogram(metrics, "cex_market_action_requested_notional")?.value, + ).toBe(21); + }); + + test("forwards and archives a top-level create-order client id", async () => { + const metrics = new CapturingOtelMetrics(); + const forwarder = await startForwarderServer(); + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: forwarder.url, + deadLetterPath: join(archiveTestDirectory, "client-order-id-loss.jsonl"), + deploymentId: "order-test", + batchSize: 100, + flushIntervalMs: 60_000, + }); + const { exchange, calls } = createOrderExchangeFixture({ + createOrderResult: { + id: "order-with-client-id", + symbol: "ARB/USDT", + side: "sell", + type: "limit", + status: "open", + amount: 10, + filled: 0, + remaining: 10, + }, + fetchOrderBookResult: { + bids: [[2.09, 100]], + asks: [[2.1, 100]], + timestamp: 1_788_000_000_000, + }, + }); + server = getServer( + testPolicy, + createBinancePool(exchange), + ["*"], + false, + "", + metrics.asOtelMetrics(), + archiver, + ); + try { + client = createClient(await bindServer(server)); + + await executeAction(client, { + action: Action.CreateOrder, + cex: "binance", + payload: { + orderType: "limit", + amount: "10", + fromToken: "ARB", + toToken: "USDT", + price: "2.1", + clientOrderId: "caller-order-1", + params: JSON.stringify({ + clientOrderId: "params-order-id", + idempotencyKey: "idem-1", + }), + }, + }); + + expect(calls.createOrder).toHaveLength(1); + expect(calls.createOrder[0]?.[5]).toEqual({ + clientOrderId: "caller-order-1", + idempotencyKey: "idem-1", + }); + + await Promise.resolve(); + await archiver.flush(); + const archivedRows = forwarder.requests.flatMap( + (request) => request.body.rows ?? [], + ) as Array<{ table?: string; row: Record }>; + const archivedOrder = archivedRows.find( + (entry) => entry.table === "broker_execution.order_events", + ); + const archivedMarketSnapshot = archivedRows.find( + (entry) => entry.table === "broker_execution.market_metadata_snapshots", + ); + + expect(archivedOrder?.row.client_order_id).toBe("caller-order-1"); + expect(JSON.parse(String(archivedOrder?.row.payload_json))).toMatchObject( + { clientOrderId: "caller-order-1" }, + ); + expect(archivedMarketSnapshot?.row.client_order_id).toBe( + "caller-order-1", + ); + } finally { + await archiver.close(); + await forwarder.close(); + } + }); + + test("archives Call createOrder with its client id and market snapshot", async () => { + const metrics = new CapturingOtelMetrics(); + const forwarder = await startForwarderServer(); + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: forwarder.url, + deadLetterPath: join( + archiveTestDirectory, + "call-create-order-loss.jsonl", + ), + deploymentId: "order-test", + batchSize: 100, + flushIntervalMs: 60_000, + }); + const order = { + id: "passthrough-order-1", + symbol: "ARB/USDT", + side: "buy", + type: "limit", + status: "open", + amount: 10, + filled: 0, + remaining: 10, + }; + const { exchange, calls } = createOrderExchangeFixture({ + createOrderResult: order, + fetchOrderBookResult: { + bids: [[2.09, 100]], + asks: [[2.1, 100]], + timestamp: 1_788_000_000_000, + }, + }); + server = getServer( + testPolicy, + createBinancePool(exchange), + ["*"], + false, + "", + metrics.asOtelMetrics(), + archiver, + ); + try { + client = createClient(await bindServer(server)); + + const response = await executeAction(client, { + action: Action.Call, + cex: "binance", + payload: { + functionName: "createOrder", + args: JSON.stringify(["ARB/USDT", "limit", "buy", 10, 2.1]), + params: JSON.stringify({ + postOnly: true, + clientOrderId: "FIET-call-order-1", + }), + }, + }); + + expect(JSON.parse(response.result)).toEqual(order); + expect(calls.createOrder).toEqual([ + [ + "ARB/USDT", + "limit", + "buy", + 10, + 2.1, + { postOnly: true, clientOrderId: "FIET-call-order-1" }, + ], + ]); + expect(calls.fetchOrderBook).toEqual([["ARB/USDT", 5]]); + + await Promise.resolve(); + await archiver.flush(); + const archivedRows = forwarder.requests.flatMap( + (request) => request.body.rows ?? [], + ) as Array<{ table?: string; row: Record }>; + const archivedOrder = archivedRows.find( + (entry) => entry.table === "broker_execution.order_events", + ); + const archivedMarketSnapshot = archivedRows.find( + (entry) => entry.table === "broker_execution.market_metadata_snapshots", + ); + + expect(archivedOrder?.row).toMatchObject({ + action: "CreateOrder", + client_order_id: "FIET-call-order-1", + symbol: "ARB/USDT", + side: "buy", + order_type: "limit", + requested_quantity: 10, + requested_notional: 21, + status: "open", + }); + expect(archivedMarketSnapshot?.row).toMatchObject({ + client_order_id: "FIET-call-order-1", + symbol: "ARB/USDT", + }); + expect(archivedOrder?.row.market_metadata_hash).toBe( + archivedMarketSnapshot?.row.market_metadata_hash, + ); + expect(metrics.counters).toContainEqual( + expect.objectContaining({ + name: "cex_market_action_executions_total", + labels: expect.objectContaining({ + action: "CreateOrder", + status: "open", + }), + }), + ); + } finally { + await archiver.close(); + await forwarder.close(); + } + }); + + test("archives a failed Call createOrder before preserving the RPC error", async () => { + const metrics = new CapturingOtelMetrics(); + const forwarder = await startForwarderServer(); + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: forwarder.url, + deadLetterPath: join( + archiveTestDirectory, + "failed-call-create-order-loss.jsonl", + ), + deploymentId: "order-test", + batchSize: 100, + flushIntervalMs: 60_000, + }); + const errorLog = spyOn(log, "error").mockImplementation(() => {}); + const { exchange, calls } = createOrderExchangeFixture({ + createOrderError: new ExchangeOrderRejected( + "post-only order would cross the book", + ), + fetchOrderBookResult: { + bids: [[2.09, 100]], + asks: [[2.1, 100]], + }, + }); + server = getServer( + testPolicy, + createBinancePool(exchange), + ["*"], + false, + "", + metrics.asOtelMetrics(), + archiver, + ); + try { + client = createClient(await bindServer(server)); + + await expect( + executeAction(client, { + action: Action.Call, + cex: "binance", + payload: { + functionName: "createOrder", + args: JSON.stringify(["ARB/USDT", "limit", "sell", 10, 2.1]), + params: JSON.stringify({ + postOnly: true, + clientOrderId: "FIET-rejected-order-1", + }), + }, + }), + ).rejects.toMatchObject({ + code: grpc.status.INTERNAL, + details: expect.stringContaining( + "post-only order would cross the book", + ), + }); + expect(calls.createOrder).toHaveLength(1); + + await Promise.resolve(); + await archiver.flush(); + const archivedRows = forwarder.requests.flatMap( + (request) => request.body.rows ?? [], + ) as Array<{ table?: string; row: Record }>; + const archivedOrder = archivedRows.find( + (entry) => entry.table === "broker_execution.order_events", + ); + const archivedMarketSnapshot = archivedRows.find( + (entry) => entry.table === "broker_execution.market_metadata_snapshots", + ); + expect(archivedOrder?.row).toMatchObject({ + action: "CreateOrder", + client_order_id: "FIET-rejected-order-1", + status: "failed", + symbol: "ARB/USDT", + side: "sell", + order_type: "limit", + requested_quantity: 10, + requested_notional: 21, + }); + expect(archivedMarketSnapshot?.row).toMatchObject({ + client_order_id: "FIET-rejected-order-1", + symbol: "ARB/USDT", + }); + expect(archivedOrder?.row.market_metadata_hash).toBe( + archivedMarketSnapshot?.row.market_metadata_hash, + ); + expect(metrics.counters).toContainEqual( + expect.objectContaining({ + name: "cex_market_action_executions_total", + labels: expect.objectContaining({ + action: "CreateOrder", + status: "failed", + }), + }), + ); + } finally { + errorLog.mockRestore(); + await archiver.close(); + await forwarder.close(); + } + }); + + test("does not emit order observability for other Call functions", async () => { + const metrics = new CapturingOtelMetrics(); + const forwarder = await startForwarderServer(); + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: forwarder.url, + deadLetterPath: join(archiveTestDirectory, "non-create-call-loss.jsonl"), + deploymentId: "order-test", + batchSize: 100, + flushIntervalMs: 60_000, + }); + const order = { id: "lookup-order-1", status: "open" }; + const { exchange, calls } = createOrderExchangeFixture({ + fetchOrderResult: order, + }); + server = getServer( + testPolicy, + createBinancePool(exchange), + ["*"], + false, + "", + metrics.asOtelMetrics(), + archiver, + ); + try { + client = createClient(await bindServer(server)); + + const response = await executeAction(client, { + action: Action.Call, + cex: "binance", + payload: { + functionName: "fetchOrder", + args: JSON.stringify(["lookup-order-1", "ARB/USDT"]), + params: "{}", + }, + }); + + expect(JSON.parse(response.result)).toEqual(order); + expect(calls.fetchOrder).toEqual([["lookup-order-1", "ARB/USDT"]]); + await Promise.resolve(); + await archiver.flush(); + expect(forwarder.requests).toHaveLength(0); + expect( + metrics.counters.filter((metric) => + metric.name.startsWith("cex_market_action_"), + ), + ).toHaveLength(0); + expect( + metrics.histograms.filter((metric) => + metric.name.startsWith("cex_market_action_"), + ), + ).toHaveLength(0); + } finally { + await archiver.close(); + await forwarder.close(); + } + }); + + test("handles create-order success without fee fields", async () => { + const metrics = new CapturingOtelMetrics(); + const { exchange } = createOrderExchangeFixture({ + createOrderResult: { + id: "order-no-fee", + symbol: "ARB/USDT", + side: "sell", + type: "limit", + status: "closed", + amount: 5, + filled: 5, + remaining: 0, + cost: 11, + }, + }); + server = getServer( + testPolicy, + createBinancePool(exchange), + ["*"], + false, + "", + metrics.asOtelMetrics(), + ); + client = createClient(await bindServer(server)); + + const response = await executeAction(client, { + action: Action.CreateOrder, + cex: "binance", + payload: { + orderType: "limit", + amount: "5", + fromToken: "ARB", + toToken: "USDT", + price: "2.2", + }, + }); + + expect(JSON.parse(response.result)).toMatchObject({ id: "order-no-fee" }); + expect( + findHistogram(metrics, "cex_market_action_fee_amount"), + ).toBeUndefined(); + expect( + findHistogram(metrics, "cex_market_action_executed_quote_quantity") + ?.value, + ).toBe(11); + }); + + test("emits partial fill telemetry from order-detail verification", async () => { + const metrics = new CapturingOtelMetrics(); + const { exchange } = createOrderExchangeFixture({ + fetchOrderResult: { + id: "partial-1", + symbol: "ARB/USDT", + side: "buy", + type: "limit", + status: "open", + amount: 10, + filled: 4, + remaining: 6, + cost: 8.4, + average: 2.1, + }, + }); + server = getServer( + testPolicy, + createBinancePool(exchange), + ["*"], + false, + "", + metrics.asOtelMetrics(), + ); + client = createClient(await bindServer(server)); + + const response = await executeAction(client, { + action: Action.GetOrderDetails, + cex: "binance", + symbol: "ARB/USDT", + payload: { orderId: "partial-1" }, + }); + + expect(JSON.parse(response.result)).toMatchObject({ + orderId: "partial-1", + status: "open", + filled: 4, + remaining: 6, + }); + expect( + findHistogram(metrics, "cex_market_action_filled_amount")?.value, + ).toBe(4); + expect( + findHistogram(metrics, "cex_market_action_remaining_amount")?.value, + ).toBe(6); + }); + + test("emits rejected order telemetry without converting it to an RPC error", async () => { + const metrics = new CapturingOtelMetrics(); + const { exchange } = createOrderExchangeFixture({ + createOrderResult: { + id: "rejected-1", + symbol: "ARB/USDT", + side: "sell", + type: "market", + status: "rejected", + amount: 10, + filled: 0, + remaining: 10, + }, + }); + server = getServer( + testPolicy, + createBinancePool(exchange), + ["*"], + false, + "", + metrics.asOtelMetrics(), + ); + client = createClient(await bindServer(server)); + + const response = await executeAction(client, { + action: Action.CreateOrder, + cex: "binance", + payload: { + orderType: "market", + amount: "10", + fromToken: "ARB", + toToken: "USDT", + price: "2", + }, + }); + + expect(JSON.parse(response.result)).toMatchObject({ + id: "rejected-1", + status: "rejected", + }); + expect(metrics.counters).toContainEqual( + expect.objectContaining({ + name: "cex_market_action_executions_total", + labels: expect.objectContaining({ status: "rejected", result: "ok" }), + }), + ); + }); + + test("emits failed order telemetry while preserving RPC error behavior", async () => { + const metrics = new CapturingOtelMetrics(); + const forwarder = await startForwarderServer(); + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: forwarder.url, + deadLetterPath: join(archiveTestDirectory, "failed-order-loss.jsonl"), + deploymentId: "order-test", + batchSize: 100, + flushIntervalMs: 60_000, + }); + const exchangeMessage = `exchange rejected order\n${"x".repeat(700)}`; + const errorLog = spyOn(log, "error").mockImplementation(() => {}); + const { exchange } = createOrderExchangeFixture({ + createOrderError: new ExchangeOrderRejected(exchangeMessage), + fetchOrderBookResult: { + bids: [[1.99, 100]], + asks: [[2.0, 100]], + }, + }); + server = getServer( + testPolicy, + createBinancePool(exchange), + ["*"], + false, + "", + metrics.asOtelMetrics(), + archiver, + ); + try { + client = createClient(await bindServer(server)); + + await expect( + executeAction(client, { + action: Action.CreateOrder, + cex: "binance", + payload: { + orderType: "market", + amount: "10", + fromToken: "ARB", + toToken: "USDT", + price: "2", + clientOrderId: "failed-client-order-1", + }, + }), + ).rejects.toMatchObject({ + code: grpc.status.INTERNAL, + details: expect.stringContaining( + "ExchangeOrderRejected: exchange rejected order", + ), + }); + expect(metrics.counters).toContainEqual( + expect.objectContaining({ + name: "cex_market_action_executions_total", + labels: expect.objectContaining({ + status: "failed", + result: "error", + }), + }), + ); + + await Promise.resolve(); + await archiver.flush(); + const archivedRows = forwarder.requests.flatMap( + (request) => request.body.rows ?? [], + ) as Array<{ table?: string; row: Record }>; + const archivedOrder = archivedRows.find( + (entry) => entry.table === "broker_execution.order_events", + ); + const archivedMarketSnapshot = archivedRows.find( + (entry) => entry.table === "broker_execution.market_metadata_snapshots", + ); + expect(archivedOrder?.row.status).toBe("failed"); + expect(archivedOrder?.row.client_order_id).toBe("failed-client-order-1"); + expect(archivedMarketSnapshot?.row.client_order_id).toBe( + "failed-client-order-1", + ); + expect(archivedOrder?.row.market_metadata_hash).toBe( + archivedMarketSnapshot?.row.market_metadata_hash, + ); + expect(archivedOrder?.row.error_message).toContain( + "ExchangeOrderRejected [code=-2010]: exchange rejected order", + ); + expect(String(archivedOrder?.row.error_message)).not.toContain("\n"); + expect(String(archivedOrder?.row.error_message)).toHaveLength(512); + const telemetryPayload = JSON.parse( + String(archivedOrder?.row.payload_json), + ) as Record; + expect(telemetryPayload).toMatchObject({ + status: "failed", + errorMessage: "redacted_error", + }); + expect(JSON.stringify(telemetryPayload)).not.toContain( + "exchange rejected order", + ); + expect(JSON.stringify(errorLog.mock.calls)).toContain("redacted_error"); + expect(JSON.stringify(errorLog.mock.calls)).not.toContain( + "exchange rejected order", + ); + } finally { + errorLog.mockRestore(); + await archiver.close(); + await forwarder.close(); + } + }); +}); diff --git a/test/otel.test.ts b/test/otel.test.ts index 4800c47..ac927ec 100644 --- a/test/otel.test.ts +++ b/test/otel.test.ts @@ -404,6 +404,7 @@ describe("OtelMetrics", () => { await metrics.recordHistogram("integration_histogram", 10, { test: "true", }); + await metrics.setObservableGauge("integration_heartbeat", 123, {}); await new Promise((r) => setTimeout(r, 5500)); diff --git a/test/passive-order.test.ts b/test/passive-order.test.ts new file mode 100644 index 0000000..ebd8658 --- /dev/null +++ b/test/passive-order.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, test } from "bun:test"; +import * as grpc from "@grpc/grpc-js"; +import ccxt, { type Exchange } from "@usherlabs/ccxt"; +import type { ExecuteActionContext } from "../src/handlers/execute-action/context"; +import { handleOrders } from "../src/handlers/execute-action/orders"; +import { Action } from "../src/helpers/constants"; +import type { PolicyConfig } from "../src/types"; + +type CallbackError = { code?: number; message?: string }; +type CallbackResponse = { result?: string }; + +function createFixture(createOrderResult: unknown = { id: "order-1" }) { + const createOrderCalls: unknown[][] = []; + const broker = { + loadMarkets: async () => {}, + markets: { + "USDC/USDT": { + symbol: "USDC/USDT", + base: "USDC", + quote: "USDT", + spot: true, + type: "spot", + }, + }, + createOrder: async (...args: unknown[]) => { + createOrderCalls.push(args); + if (createOrderResult instanceof Error) { + throw createOrderResult; + } + return createOrderResult; + }, + } as unknown as Exchange; + + let callbackError: CallbackError | null = null; + let callbackResponse: CallbackResponse | null = null; + const ctx = { + action: Action.CreateOrder, + call: { request: { payload: {} } }, + wrappedCallback: ( + error: CallbackError | null, + response: CallbackResponse | null, + ) => { + callbackError = error; + callbackResponse = response; + }, + cex: "binance", + normalizedCex: "binance", + symbol: "USDC/USDT", + broker, + verity: { proof: "" }, + policy: { + order: { rule: { markets: ["*"], limits: [] } }, + } as unknown as PolicyConfig, + brokers: {}, + } as unknown as ExecuteActionContext; + + return { + ctx, + createOrderCalls, + getError: () => callbackError, + getResponse: () => callbackResponse, + }; +} + +function createOrderPayload( + overrides: Record = {}, +): Record { + return { + orderType: "limit", + amount: "10", + fromToken: "USDC", + toToken: "USDT", + price: "1", + marketType: "spot", + ...overrides, + }; +} + +describe("passive CreateOrder", () => { + test("keeps the existing ccxt request and response byte-for-byte when intent is absent", async () => { + const order = { id: "ordinary-1", status: "open" }; + const fixture = createFixture(order); + fixture.ctx.call.request.payload = createOrderPayload({ + clientOrderId: "client-1", + params: JSON.stringify({ timeInForce: "IOC", strategyId: 7 }), + }); + + await handleOrders(fixture.ctx); + + expect(fixture.createOrderCalls).toEqual([ + [ + "USDC/USDT", + "limit", + "sell", + 10, + 1, + { + timeInForce: "IOC", + strategyId: 7, + clientOrderId: "client-1", + }, + ], + ]); + expect(fixture.getError()).toBeNull(); + expect(fixture.getResponse()?.result).toBe(JSON.stringify(order)); + }); + + test("adds postOnly without clobbering params and reports accepted passive placement", async () => { + const order = { id: "passive-1", status: "open" }; + const fixture = createFixture(order); + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "passive_only", + params: JSON.stringify({ timeInForce: "GTC", strategyId: 9 }), + }); + + await handleOrders(fixture.ctx); + + expect(fixture.createOrderCalls[0]?.[5]).toEqual({ + timeInForce: "GTC", + strategyId: 9, + postOnly: true, + }); + expect(JSON.parse(fixture.getResponse()?.result ?? "{}")).toEqual({ + ...order, + passivePlacementOutcome: "accepted_passive", + }); + }); + + test("overrides a conflicting caller postOnly value to preserve passive intent", async () => { + const fixture = createFixture(); + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "passive_only", + params: JSON.stringify({ postOnly: 0, strategyId: 9 }), + }); + + await handleOrders(fixture.ctx); + + expect(fixture.createOrderCalls[0]?.[5]).toEqual({ + postOnly: true, + strategyId: 9, + }); + }); + + test("rejects passive market orders as invalid before calling ccxt", async () => { + const fixture = createFixture(); + fixture.ctx.call.request.payload = createOrderPayload({ + orderType: "market", + orderIntent: "passive_only", + }); + + await handleOrders(fixture.ctx); + + expect(fixture.createOrderCalls).toHaveLength(0); + expect(fixture.getError()).toEqual({ + code: grpc.status.INVALID_ARGUMENT, + message: + "ValidationError: passive_only order intent requires a limit order", + }); + }); + + test("rejects unknown order intents in the payload schema", async () => { + const fixture = createFixture(); + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "maker_if_possible", + }); + + await handleOrders(fixture.ctx); + + expect(fixture.createOrderCalls).toHaveLength(0); + expect(fixture.getError()?.code).toBe(grpc.status.INVALID_ARGUMENT); + expect(fixture.getError()?.message).toContain("orderIntent"); + }); + + test("maps an immediately fillable venue rejection to would-cross", async () => { + const fixture = createFixture( + new ccxt.InvalidOrder("binance Order would immediately match and take."), + ); + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "passive_only", + }); + + await handleOrders(fixture.ctx); + + expect(fixture.getError()?.code).toBe(grpc.status.FAILED_PRECONDITION); + expect(fixture.getError()?.message).toStartWith( + "passive_order_would_cross:", + ); + }); + + test("maps missing ccxt post-only support to unsupported", async () => { + const fixture = createFixture( + new ccxt.NotSupported("binance post-only orders are not supported"), + ); + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "passive_only", + }); + + await handleOrders(fixture.ctx); + + expect(fixture.getError()?.code).toBe(grpc.status.UNIMPLEMENTED); + expect(fixture.getError()?.message).toStartWith( + "passive_order_unsupported:", + ); + }); + + test("preserves insufficient funds as the stable error code", async () => { + const fixture = createFixture( + new ccxt.InsufficientFunds("binance account has insufficient balance"), + ); + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "passive_only", + }); + + await handleOrders(fixture.ctx); + + expect(fixture.getError()?.code).toBe(grpc.status.FAILED_PRECONDITION); + expect(fixture.getError()?.message).toStartWith("InsufficientFunds:"); + expect(fixture.getError()?.message).not.toStartWith("passive_"); + }); + + test.each([ + [ + "authentication failure", + new ccxt.AuthenticationError("binance invalid api key"), + ], + [ + "permission failure", + new ccxt.PermissionDenied("binance key cannot create orders"), + ], + ])("preserves %s as the authentication stable error code", async (_, error) => { + const fixture = createFixture(error); + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "passive_only", + }); + + await handleOrders(fixture.ctx); + + expect(fixture.getError()?.code).toBe(grpc.status.UNAUTHENTICATED); + expect(fixture.getError()?.message).toStartWith("AuthenticationError:"); + expect(fixture.getError()?.message).not.toStartWith("passive_"); + }); + + test("maps any other passive venue rejection to rejected", async () => { + const fixture = createFixture( + new ccxt.InvalidOrder("binance passive order rejected: invalid price"), + ); + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "passive_only", + }); + + await handleOrders(fixture.ctx); + + expect(fixture.getError()?.code).toBe(grpc.status.FAILED_PRECONDITION); + expect(fixture.getError()?.message).toStartWith("passive_order_rejected:"); + }); + + test("does not classify a pre-submission failure as a passive venue rejection", async () => { + const fixture = createFixture(); + ( + fixture.ctx.broker as unknown as { loadMarkets: () => Promise } + ).loadMarkets = async () => { + throw new Error("market resolution unavailable"); + }; + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "passive_only", + }); + + await handleOrders(fixture.ctx); + + expect(fixture.createOrderCalls).toHaveLength(0); + expect(fixture.getError()?.message).not.toStartWith("passive_order_"); + }); + + test("does not classify a post-submission failure as a passive venue rejection", async () => { + const circularOrder: Record = { id: "order-1" }; + circularOrder.self = circularOrder; + const fixture = createFixture(circularOrder); + fixture.ctx.call.request.payload = createOrderPayload({ + orderIntent: "passive_only", + }); + + await handleOrders(fixture.ctx); + + // The order is resting on the venue; a passive code would tell the client + // it was never placed and invite a duplicate repost. + expect(fixture.createOrderCalls).toHaveLength(1); + expect(fixture.getError()?.code).toBe(grpc.status.INTERNAL); + expect(fixture.getError()?.message).not.toStartWith("passive_order_"); + }); +}); diff --git a/test/perp-config.test.ts b/test/perp-config.test.ts new file mode 100644 index 0000000..1a0b64f --- /dev/null +++ b/test/perp-config.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test"; +import * as grpc from "@grpc/grpc-js"; +import type { Exchange } from "@usherlabs/ccxt"; +import type { ExecuteActionContext } from "../src/handlers/execute-action/context"; +import { handlePerpConfig } from "../src/handlers/execute-action/perp-config"; +import { Action } from "../src/helpers/constants"; + +function createContext( + broker: Exchange | null, + action: (typeof Action)[keyof typeof Action], + payload: Record, +): { + ctx: ExecuteActionContext; + callback: ReturnType["callback"]; + getResponse: ReturnType["getResponse"]; +} { + const { callback, getResponse } = createCallbackCapture(); + const ctx = { + action, + call: { + request: { + payload, + }, + }, + wrappedCallback: callback, + cex: "hyperliquid", + normalizedCex: "hyperliquid", + broker, + } as unknown as ExecuteActionContext; + return { ctx, callback, getResponse }; +} + +function createCallbackCapture() { + let response: { code?: number; message?: string } | null = null; + let result: { result?: string } | null = null; + const callback = ( + error: { code?: number; message?: string } | null, + value: { result?: string } | null, + ) => { + response = error; + result = value; + }; + return { + callback, + getResponse: () => ({ error: response, result }), + }; +} + +describe("perp-config handler", () => { + test("GetPerpConfigState returns UNIMPLEMENTED when fetchPositions is unavailable", async () => { + const broker = { + has: { fetchPositions: false }, + } as unknown as Exchange; + const { ctx, getResponse } = createContext( + broker, + Action.GetPerpConfigState, + {}, + ); + await handlePerpConfig(ctx); + expect(getResponse().error?.code).toBe(grpc.status.UNIMPLEMENTED); + }); + + test("GetPerpConfigState returns normalized configs", async () => { + const broker = { + has: { fetchPositions: true }, + fetchPositions: async () => [ + { + symbol: "ETH/USDC:USDC", + leverage: 20, + marginMode: "cross", + }, + ], + } as unknown as Exchange; + const { ctx, getResponse } = createContext( + broker, + Action.GetPerpConfigState, + {}, + ); + await handlePerpConfig(ctx); + expect(getResponse().error).toBeNull(); + const parsed = JSON.parse(getResponse().result?.result ?? "{}"); + expect(parsed.configs).toEqual([ + { + symbol: "ETH/USDC:USDC", + leverage: 20, + marginMode: "cross", + }, + ]); + }); + + test("SetPerpConfigState calls setLeverage with marginMode default", async () => { + let calledWith: unknown; + const broker = { + has: { setLeverage: true }, + setLeverage: async ( + leverage: number, + symbol: string, + params: Record, + ) => { + calledWith = { leverage, symbol, params }; + return { status: "ok" }; + }, + } as unknown as Exchange; + const { ctx, getResponse } = createContext( + broker, + Action.SetPerpConfigState, + { + symbol: "ETH/USDC:USDC", + leverage: "10", + marginMode: "isolated", + }, + ); + await handlePerpConfig(ctx); + expect(getResponse().error).toBeNull(); + expect(calledWith).toEqual({ + leverage: 10, + symbol: "ETH/USDC:USDC", + params: { marginMode: "isolated" }, + }); + }); +}); diff --git a/test/proto-descriptor.test.ts b/test/proto-descriptor.test.ts new file mode 100644 index 0000000..59727e1 --- /dev/null +++ b/test/proto-descriptor.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test"; +import protobuf from "protobufjs"; +import descriptor from "../src/proto/node.descriptor.ts"; + +describe("Proto descriptor", () => { + test("matches src/proto/node.proto", async () => { + const root = await protobuf.load("src/proto/node.proto"); + expect(root.toJSON()).toEqual(descriptor); + }); +}); diff --git a/test/server-proto-loader-options.test.ts b/test/server-proto-loader-options.test.ts new file mode 100644 index 0000000..a6b50f8 --- /dev/null +++ b/test/server-proto-loader-options.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test"; +import { CEX_BROKER_PACKAGE_DEFINITION } from "../src/proto-package-definition"; + +const service = CEX_BROKER_PACKAGE_DEFINITION["cex_broker.cex_service"]; + +describe("server proto descriptor loading", () => { + test("uses the runtime proto-loader options for descriptor deserialization", () => { + const actionRequestBuffer = service.ExecuteAction.requestSerialize({ + action: 13, + cex: "binance", + symbol: "USDT", + payload: { amount: "1" }, + }); + + expect( + service.ExecuteAction.requestDeserialize(actionRequestBuffer), + ).toEqual({ + action: "InternalTransfer", + cex: "binance", + symbol: "USDT", + payload: { amount: "1" }, + }); + + const subscribeRequestBuffer = service.Subscribe.requestSerialize({ + cex: "kraken", + symbol: "ETH/USDT", + }); + + expect( + service.Subscribe.requestDeserialize(subscribeRequestBuffer), + ).toEqual({ + cex: "kraken", + symbol: "ETH/USDT", + type: "NO_ACTION", + options: {}, + }); + + const subscribeResponseBuffer = service.Subscribe.responseSerialize({ + data: "{}", + timestamp: "123", + symbol: "ETH/USDT", + type: 1, + }); + + expect( + service.Subscribe.responseDeserialize(subscribeResponseBuffer), + ).toEqual({ + data: "{}", + timestamp: "123", + symbol: "ETH/USDT", + type: "ORDERBOOK", + }); + }); +}); diff --git a/test/shared-errors.test.ts b/test/shared-errors.test.ts new file mode 100644 index 0000000..637d902 --- /dev/null +++ b/test/shared-errors.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test"; +import { + errorClassName, + getErrorMessage, + sanitizeErrorDetail, +} from "../src/helpers/shared/errors"; + +class InsufficientFunds extends Error { + constructor(message: string) { + super(message); + this.name = "InsufficientFunds"; + } +} + +describe("shared errors", () => { + test("getErrorMessage handles Error", () => { + expect(getErrorMessage(new Error("boom"))).toBe("boom"); + }); + + test("getErrorMessage handles string", () => { + expect(getErrorMessage("oops")).toBe("oops"); + }); + + test("getErrorMessage handles unknown", () => { + expect(getErrorMessage(42)).toBe("Unknown error"); + }); + + test("errorClassName returns the subclass name", () => { + expect(errorClassName(new InsufficientFunds("x"))).toBe( + "InsufficientFunds", + ); + }); + + test("errorClassName drops the generic Error name and non-Errors", () => { + expect(errorClassName(new Error("x"))).toBeUndefined(); + expect(errorClassName("nope")).toBeUndefined(); + }); + + test("sanitizeErrorDetail prefixes the class name to the message", () => { + expect( + sanitizeErrorDetail( + new InsufficientFunds("binance Account has insufficient balance."), + ), + ).toBe("InsufficientFunds: binance Account has insufficient balance."); + }); + + test("sanitizeErrorDetail collapses newlines into a single line", () => { + const detail = sanitizeErrorDetail( + new InsufficientFunds("line one\nline two\n\tindented"), + ); + expect(detail).not.toContain("\n"); + expect(detail).toBe("InsufficientFunds: line one line two indented"); + }); + + test("sanitizeErrorDetail caps the detail at 512 characters", () => { + const detail = sanitizeErrorDetail(new InsufficientFunds("a".repeat(1000))); + expect(detail.length).toBe(512); + }); + + test("sanitizeErrorDetail falls back to the message for non-Errors", () => { + expect(sanitizeErrorDetail("plain string")).toBe("plain string"); + expect(sanitizeErrorDetail(new Error("bare"))).toBe("bare"); + }); +}); diff --git a/test/shared-guards.test.ts b/test/shared-guards.test.ts new file mode 100644 index 0000000..a1c9847 --- /dev/null +++ b/test/shared-guards.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { asRecord, isRecord } from "../src/helpers/shared/guards"; + +describe("shared guards", () => { + test("isRecord accepts plain objects", () => { + expect(isRecord({ a: 1 })).toBe(true); + }); + + test("isRecord rejects null, arrays, and primitives", () => { + expect(isRecord(null)).toBe(false); + expect(isRecord([])).toBe(false); + expect(isRecord("x")).toBe(false); + }); + + test("asRecord returns object or undefined", () => { + expect(asRecord({ ok: true })).toEqual({ ok: true }); + expect(asRecord(null)).toBeUndefined(); + }); +}); diff --git a/test/subscribe-broker-lifecycle.test.ts b/test/subscribe-broker-lifecycle.test.ts new file mode 100644 index 0000000..f471b61 --- /dev/null +++ b/test/subscribe-broker-lifecycle.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import type { Exchange } from "@usherlabs/ccxt"; +import { SubscribeBrokerLifecycle } from "../src/handlers/subscribe"; + +type Deferred = { + promise: Promise; + resolve: () => void; + reject: (error: Error) => void; +}; + +function deferred(): Deferred { + let resolve!: () => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function fakeBroker(close: () => Promise): Exchange { + return { close } as unknown as Exchange; +} + +const context = { cex: "binance", symbol: "BTC/USDT" }; + +describe("SubscribeBrokerLifecycle", () => { + test("closeAll rejects when a broker close fails but still closes the rest", async () => { + const lifecycle = new SubscribeBrokerLifecycle(); + let healthyClosed = false; + lifecycle.register( + fakeBroker(async () => { + healthyClosed = true; + }), + context, + ); + lifecycle.register( + fakeBroker(async () => { + throw new Error("exchange refused to close"); + }), + context, + ); + + await expect(lifecycle.closeAll()).rejects.toThrow(/1 request-scoped/); + expect(healthyClosed).toBe(true); + }); + + test("closeAll waits for brokers registered while shutdown is in progress", async () => { + const lifecycle = new SubscribeBrokerLifecycle(); + const firstClose = deferred(); + let lateClosed = false; + lifecycle.register( + fakeBroker(() => firstClose.promise), + context, + ); + + let closeAllSettled = false; + const closing = lifecycle.closeAll().then(() => { + closeAllSettled = true; + }); + + // Registration racing shutdown: closeAll has already snapshotted its + // first drain round when this broker arrives. + const lateClose = deferred(); + lifecycle.register( + fakeBroker(async () => { + await lateClose.promise; + lateClosed = true; + }), + context, + ); + + firstClose.resolve(); + await Bun.sleep(10); + expect(closeAllSettled).toBe(false); + + lateClose.resolve(); + await closing; + expect(lateClosed).toBe(true); + }); +}); diff --git a/test/subscribe-handler.test.ts b/test/subscribe-handler.test.ts new file mode 100644 index 0000000..95a6c9a --- /dev/null +++ b/test/subscribe-handler.test.ts @@ -0,0 +1,534 @@ +import { afterAll, describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as grpc from "@grpc/grpc-js"; +import type { Exchange } from "@usherlabs/ccxt"; +import { createSubscribeHandler } from "../src/handlers/subscribe/handler"; +import type { + SubscribeRequest, + SubscribeResponse, +} from "../src/handlers/types"; +import type { BrokerPoolEntry } from "../src/helpers/broker"; +import { BrokerExecutionArchiver } from "../src/helpers/broker-execution-archive/writer"; +import { + SubscriptionType, + type SubscriptionType as SubscriptionTypeValue, +} from "../src/helpers/constants"; +import { startForwarderServer } from "./archive-forwarder-server"; + +const archiveTestDirectory = mkdtempSync( + join(tmpdir(), "cex-broker-subscribe-archive-test-"), +); +let deadLetterFileIndex = 0; + +function createDeadLetterPath(): string { + deadLetterFileIndex += 1; + return join(archiveTestDirectory, `loss-${deadLetterFileIndex}.jsonl`); +} + +afterAll(() => { + rmSync(archiveTestDirectory, { recursive: true, force: true }); +}); + +type MockCallState = { + writes: SubscribeResponse[]; + endCount: number; + destroyed: boolean; +}; + +function createSubscribeCall( + request: SubscribeRequest, + options: { writeResults?: boolean[] } = {}, +) { + const emitter = new EventEmitter(); + const state: MockCallState = { + writes: [], + endCount: 0, + destroyed: false, + }; + const writeResults = [...(options.writeResults ?? [])]; + const call = Object.assign(emitter, { + cancelled: false, + metadata: new grpc.Metadata(), + request, + getPeer: () => "127.0.0.1:1234", + write: (response: SubscribeResponse) => { + state.writes.push(response); + return writeResults.shift() ?? true; + }, + end: () => { + state.endCount += 1; + emitter.emit("end"); + }, + destroy: (error?: Error) => { + state.destroyed = true; + if (error) { + emitter.emit("error", error); + } + emitter.emit("close"); + }, + }); + Object.defineProperty(call, "destroyed", { + get: () => state.destroyed, + }); + + return { + call: call as unknown as grpc.ServerWritableStream< + SubscribeRequest, + SubscribeResponse + >, + state, + }; +} + +function cancelSubscribeCall( + call: grpc.ServerWritableStream, +): void { + call.cancelled = true; + call.emit("cancelled", "cancelled"); + call.destroy(); +} + +function nextTick(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function waitFor(condition: () => boolean): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (condition()) { + return; + } + await nextTick(); + } + throw new Error("Timed out waiting for test condition"); +} + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +function createControlledWatch() { + const calls: unknown[][] = []; + const resolvers: Array<(value: unknown) => void> = []; + return { + calls, + resolvers, + watch: (...args: unknown[]) => { + const deferred = createDeferred(); + calls.push(args); + resolvers.push(deferred.resolve); + return deferred.promise; + }, + }; +} + +async function expectBackpressureWaitsForDrain({ + type, + method, + firstValue, + secondValue, +}: { + type: SubscriptionTypeValue; + method: "watchOrderBook" | "watchTrades"; + firstValue: unknown; + secondValue: unknown; +}) { + const controlledWatch = createControlledWatch(); + const exchange = { + [method]: controlledWatch.watch, + } as unknown as Exchange; + const { call, state } = createSubscribeCall( + { + cex: "binance", + symbol: "BTC/USDT", + type, + }, + { writeResults: [false] }, + ); + const handler = createSubscribeHandler({ + brokers: createPool(exchange), + whitelistIps: ["*"], + }); + + const handlerPromise = handler(call); + await waitFor(() => controlledWatch.calls.length === 1); + controlledWatch.resolvers[0]?.(firstValue); + await waitFor(() => state.writes.length === 1); + + await nextTick(); + expect(controlledWatch.calls).toHaveLength(1); + + call.emit("drain"); + await waitFor(() => controlledWatch.calls.length === 2); + cancelSubscribeCall(call); + controlledWatch.resolvers[1]?.(secondValue); + await handlerPromise; + + expect(state.writes).toHaveLength(1); +} + +function createPool( + exchange: Exchange, + cex = "binance", +): Record { + return { + [cex]: { + primary: { exchange, label: "primary" }, + secondaryBrokers: [], + }, + }; +} + +type ThrowingSubscriptionMethod = + | "watchOrderBook" + | "watchTrades" + | "watchTicker" + | "watchOHLCV" + | "watchBalance" + | "watchOrders"; + +function createThrowingExchange( + method: ThrowingSubscriptionMethod, + errorMessage: string, +) { + return { + [method]: async () => { + throw new Error(errorMessage); + }, + } as unknown as Exchange; +} + +describe("subscribe handler", () => { + test("keeps a subscription active when close fires without cancellation", async () => { + const controlledWatch = createControlledWatch(); + const exchange = { + watchTrades: controlledWatch.watch, + } as unknown as Exchange; + const { call } = createSubscribeCall({ + cex: "binance", + symbol: "BTC/USDT", + type: SubscriptionType.TRADES, + }); + const handler = createSubscribeHandler({ + brokers: createPool(exchange), + whitelistIps: ["*"], + }); + const handlerPromise = handler(call); + + await waitFor(() => controlledWatch.calls.length === 1); + call.emit("close"); + controlledWatch.resolvers[0]?.([{ id: "trade-1" }]); + await waitFor(() => controlledWatch.calls.length === 2); + + cancelSubscribeCall(call); + controlledWatch.resolvers[1]?.([{ id: "trade-2" }]); + await handlerPromise; + }); + + test.each([ + { + type: SubscriptionType.TRADES, + method: "watchTrades", + firstValue: [{ id: "trade-1" }], + secondValue: [{ id: "trade-2" }], + }, + { + type: SubscriptionType.ORDERBOOK, + method: "watchOrderBook", + firstValue: { bids: [[1, 2]], asks: [[3, 4]] }, + secondValue: { bids: [[5, 6]], asks: [[7, 8]] }, + }, + ] satisfies Array<{ + type: SubscriptionTypeValue; + method: "watchOrderBook" | "watchTrades"; + firstValue: unknown; + secondValue: unknown; + }>)("waits for drain before consuming another $method event after write backpressure", async ({ + type, + method, + firstValue, + secondValue, + }) => { + await expectBackpressureWaitsForDrain({ + type, + method, + firstValue, + secondValue, + }); + }); + + test.each([ + { + type: SubscriptionType.ORDERBOOK, + method: "watchOrderBook", + errorMessage: "orderbook boom", + expectedError: "Failed to fetch orderbook: orderbook boom", + }, + { + type: SubscriptionType.TRADES, + method: "watchTrades", + errorMessage: "trades boom", + expectedError: "Failed to fetch trades: trades boom", + }, + { + type: SubscriptionType.TICKER, + method: "watchTicker", + errorMessage: "ticker boom", + expectedError: "Failed to fetch ticker: ticker boom", + }, + { + type: SubscriptionType.OHLCV, + method: "watchOHLCV", + errorMessage: "ohlcv boom", + expectedError: "Failed to fetch OHLCV: ohlcv boom", + }, + { + type: SubscriptionType.BALANCE, + cex: "mexc", + method: "watchBalance", + errorMessage: "balance boom", + expectedError: "Failed to fetch balance: balance boom", + }, + { + type: SubscriptionType.ORDERS, + cex: "mexc", + method: "watchOrders", + errorMessage: "orders boom", + expectedError: "Failed to fetch orders: orders boom", + }, + ] satisfies Array<{ + type: SubscriptionTypeValue; + cex?: string; + method: ThrowingSubscriptionMethod; + errorMessage: string; + expectedError: string; + }>)("closes $method stream after writing terminal error", async ({ + type, + cex = "binance", + method, + errorMessage, + expectedError, + }) => { + const exchange = createThrowingExchange(method, errorMessage); + const { call, state } = createSubscribeCall({ + cex, + symbol: "BTC/USDT", + type, + }); + const handler = createSubscribeHandler({ + brokers: createPool(exchange, cex), + whitelistIps: ["*"], + }); + + await handler(call); + + expect(state.writes).toHaveLength(1); + expect(JSON.parse(state.writes[0]?.data ?? "{}")).toEqual({ + error: expectedError, + }); + expect(state.writes[0]).toMatchObject({ + symbol: "BTC/USDT", + type, + }); + expect(state.endCount).toBe(1); + }); + + test("outer catch reports the resolved subscription type", async () => { + const exchange = { + loadMarkets: async () => { + throw new Error("markets unavailable"); + }, + } as unknown as Exchange; + const { call, state } = createSubscribeCall({ + cex: "binance", + symbol: "BTC/USDT", + type: SubscriptionType.TICKER, + options: { marketType: "swap" }, + }); + const handler = createSubscribeHandler({ + brokers: createPool(exchange), + whitelistIps: ["*"], + }); + + await handler(call); + + expect(state.writes).toHaveLength(1); + expect(JSON.parse(state.writes[0]?.data ?? "{}")).toEqual({ + error: "Internal server error: markets unavailable", + }); + expect(state.writes[0]).toMatchObject({ + symbol: "", + type: SubscriptionType.TICKER, + }); + expect(state.endCount).toBe(1); + }); + + test("archives orderbook snapshot rows to the forwarder", async () => { + const server = await startForwarderServer(); + const posts = server.requests; + const originalInterval = process.env.CEX_BROKER_ORDERBOOK_INTERVAL_MS; + const originalArchiveEnabled = + process.env.CEX_BROKER_MARKET_ARCHIVE_ENABLED; + process.env.CEX_BROKER_ORDERBOOK_INTERVAL_MS = "1"; + process.env.CEX_BROKER_MARKET_ARCHIVE_ENABLED = "true"; + + try { + const controlledWatch = createControlledWatch(); + const exchange = { + watchOrderBook: controlledWatch.watch, + } as unknown as Exchange; + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: server.url, + deadLetterPath: createDeadLetterPath(), + deploymentId: "test-deploy", + batchSize: 1, + flushIntervalMs: 60_000, + }); + const { call } = createSubscribeCall({ + cex: "binance", + symbol: "BTC/USDT", + type: SubscriptionType.ORDERBOOK, + }); + const handler = createSubscribeHandler({ + brokers: createPool(exchange), + whitelistIps: ["*"], + brokerArchiver: archiver, + }); + + const handlerPromise = handler(call); + await waitFor(() => controlledWatch.calls.length === 1); + controlledWatch.resolvers[0]?.({ + bids: [[100, 1.5]], + asks: [[101, 2]], + timestamp: 1_700_000_000_000, + }); + await waitFor(() => archiver.getStats().enqueued >= 1); + // batchSize 1 auto-flushes on enqueue; wait for that post to reach the + // forwarder over the real transport rather than racing the round trip. + await waitFor(() => archiver.getStats().flushed >= 1); + await waitFor(() => controlledWatch.calls.length >= 2); + cancelSubscribeCall(call); + controlledWatch.resolvers[1]?.({ + bids: [[100, 1.5]], + asks: [[101, 2]], + timestamp: 1_700_000_000_001, + }); + await handlerPromise; + + expect(posts.length).toBeGreaterThanOrEqual(1); + expect(posts[0]?.body).toMatchObject({ + source: "broker_write", + deployment_id: "test-deploy", + }); + const rows = posts.flatMap( + (post) => + (post.body.rows ?? []) as Array<{ + table: string; + row: Record; + }>, + ); + expect( + rows.some((entry) => entry.table === "market_data.orderbook_snapshots"), + ).toBe(true); + const snapshotRow = rows.find( + (entry) => entry.table === "market_data.orderbook_snapshots", + ); + expect(snapshotRow?.row).toMatchObject({ + exchange: "binance", + symbol: "BTC/USDT", + best_bid: 100, + best_ask: 101, + bids_price: [100], + asks_price: [101], + }); + + await archiver.close(); + } finally { + await server.close(); + if (originalInterval === undefined) { + delete process.env.CEX_BROKER_ORDERBOOK_INTERVAL_MS; + } else { + process.env.CEX_BROKER_ORDERBOOK_INTERVAL_MS = originalInterval; + } + if (originalArchiveEnabled === undefined) { + delete process.env.CEX_BROKER_MARKET_ARCHIVE_ENABLED; + } else { + process.env.CEX_BROKER_MARKET_ARCHIVE_ENABLED = originalArchiveEnabled; + } + } + }); + + test("archives OHLCV candle rows to the forwarder", async () => { + const server = await startForwarderServer(); + const posts = server.requests; + const originalArchiveEnabled = + process.env.CEX_BROKER_MARKET_ARCHIVE_ENABLED; + process.env.CEX_BROKER_MARKET_ARCHIVE_ENABLED = "true"; + + try { + const controlledWatch = createControlledWatch(); + const exchange = { + watchOHLCV: controlledWatch.watch, + } as unknown as Exchange; + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: server.url, + deadLetterPath: createDeadLetterPath(), + deploymentId: "test-deploy", + batchSize: 1, + flushIntervalMs: 60_000, + }); + const { call } = createSubscribeCall({ + cex: "binance", + symbol: "BTC/USDT", + type: SubscriptionType.OHLCV, + options: { timeframe: "1m" }, + }); + const handler = createSubscribeHandler({ + brokers: createPool(exchange), + whitelistIps: ["*"], + brokerArchiver: archiver, + }); + + const handlerPromise = handler(call); + await waitFor(() => controlledWatch.calls.length === 1); + controlledWatch.resolvers[0]?.([[1_700_000_000_000, 1, 2, 0.5, 1.5, 10]]); + await waitFor(() => archiver.getStats().enqueued >= 1); + // batchSize 1 auto-flushes on enqueue; wait for that post to reach the + // forwarder over the real transport rather than racing the round trip. + await waitFor(() => archiver.getStats().flushed >= 1); + await waitFor(() => controlledWatch.calls.length >= 2); + cancelSubscribeCall(call); + controlledWatch.resolvers[1]?.([[1_700_000_000_000, 1, 2, 0.5, 1.5, 10]]); + await handlerPromise; + + expect(posts.length).toBeGreaterThanOrEqual(1); + const rows = (posts[0]?.body.rows ?? []) as Array<{ + table: string; + row: Record; + }>; + expect(rows.some((entry) => entry.table === "market_data.candles")).toBe( + true, + ); + expect(rows[0]?.row).toMatchObject({ + timeframe: "1m", + open_time_ms: 1_700_000_000_000, + is_closed: 0, + }); + + await archiver.close(); + } finally { + await server.close(); + if (originalArchiveEnabled === undefined) { + delete process.env.CEX_BROKER_MARKET_ARCHIVE_ENABLED; + } else { + process.env.CEX_BROKER_MARKET_ARCHIVE_ENABLED = originalArchiveEnabled; + } + } + }); +}); diff --git a/test/subscribe-user-stream.test.ts b/test/subscribe-user-stream.test.ts new file mode 100644 index 0000000..ba6a3d3 --- /dev/null +++ b/test/subscribe-user-stream.test.ts @@ -0,0 +1,603 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Buffer } from "node:buffer"; +import * as grpc from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; +import type { Exchange } from "@usherlabs/ccxt"; +import { + BinanceSpotUserDataStream, + setBinanceUserDataWebSocketFactoryForTests, +} from "../src/helpers/binance-user-data-stream"; +import type { BrokerPoolEntry } from "../src/helpers/broker"; +import { SubscriptionType } from "../src/helpers/constants"; +import { PROTO_LOADER_OPTIONS } from "../src/proto-loader-options"; +import { getServer } from "../src/server"; +import type { PolicyConfig } from "../src/types"; + +const packageDef = protoLoader.loadSync( + "src/proto/node.proto", + PROTO_LOADER_OPTIONS, +); +const grpcObj = grpc.loadPackageDefinition(packageDef) as { + cex_broker: { + cex_service: new ( + address: string, + credentials: grpc.ChannelCredentials, + ) => { + Subscribe( + request: Record, + metadata?: grpc.Metadata, + ): grpc.ClientReadableStream<{ + data: string; + timestamp: number; + symbol: string; + type: string; + }>; + close(): void; + }; + }; +}; + +const testPolicy: PolicyConfig = { + withdraw: { rule: [] }, + deposit: {}, + order: { rule: { markets: [], limits: [] } }, +}; + +class FakeWebSocket { + static instances: FakeWebSocket[] = []; + readonly sent: string[] = []; + closed = false; + private readonly listeners: Record< + "open" | "message" | "error" | "close", + Array<(...args: unknown[]) => void> + > = { + open: [], + message: [], + error: [], + close: [], + }; + + constructor(readonly url: string) { + FakeWebSocket.instances.push(this); + queueMicrotask(() => this.emit("open")); + } + + on( + event: "open" | "message" | "error" | "close", + listener: (...args: unknown[]) => void, + ) { + this.listeners[event].push(listener); + return this; + } + + send(data: string) { + this.sent.push(data); + const request = JSON.parse(data) as { id: string }; + queueMicrotask(() => { + this.emit( + "message", + Buffer.from( + JSON.stringify({ + id: request.id, + status: 200, + result: { subscriptionId: FakeWebSocket.instances.length - 1 }, + }), + ), + ); + }); + } + + close() { + this.closed = true; + } + + emitEvent(event: Record) { + this.emitMessage({ + subscriptionId: FakeWebSocket.instances.indexOf(this), + event, + }); + } + + emitMessage(message: unknown) { + this.emit("message", Buffer.from(JSON.stringify(message))); + } + + emitError(error: unknown) { + this.emit("error", error); + } + + emitClose(code: number, reason: string | Buffer) { + this.emit("close", code, reason); + } + + private emit( + event: "open" | "message" | "error" | "close", + ...args: unknown[] + ) { + for (const listener of this.listeners[event]) { + listener(...args); + } + } +} + +function createBinanceExchange(apiKey: string, secret: string) { + const calls = { + watchBalance: 0, + watchOrders: 0, + loadMarkets: 0, + }; + const exchange = { + apiKey, + secret, + urls: { + api: { + ws: { + "ws-api": { + spot: "wss://ws-api.binance.com:443/ws-api/v3", + }, + }, + }, + }, + loadMarkets: async () => { + calls.loadMarkets += 1; + }, + market: (symbol: string) => ({ + id: symbol.replace("/", "").toUpperCase(), + symbol, + }), + watchBalance: async () => { + calls.watchBalance += 1; + throw new Error("legacy watchBalance should not be called"); + }, + watchOrders: async () => { + calls.watchOrders += 1; + throw new Error("legacy watchOrders should not be called"); + }, + } as unknown as Exchange; + return { exchange, calls }; +} + +function createBinancePool( + primaryExchange: Exchange, + secondaryExchange: Exchange, +): Record { + return { + binance: { + primary: { exchange: primaryExchange, label: "primary" }, + secondaryBrokers: [ + { exchange: secondaryExchange, label: "secondary:1", index: 1 }, + ], + }, + }; +} + +function bindServer(server: grpc.Server) { + return new Promise((resolve, reject) => { + server.bindAsync( + "127.0.0.1:0", + grpc.ServerCredentials.createInsecure(), + (error, port) => { + if (error) { + reject(error); + return; + } + server.start(); + resolve(port); + }, + ); + }); +} + +async function waitForSocket(): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + const socket = FakeWebSocket.instances.at(-1); + if (socket?.sent.length) { + return socket; + } + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("Fake Binance WebSocket was not opened"); +} + +function subscribeOnce( + client: InstanceType, + request: Record, + metadata?: grpc.Metadata, +) { + return new Promise<{ + data: string; + timestamp: number; + symbol: string; + type: string; + }>((resolve, reject) => { + const stream = client.Subscribe(request, metadata); + stream.once("data", (response) => { + stream.cancel(); + resolve(response); + }); + stream.once("error", (error) => { + if ((error as grpc.ServiceError).code === grpc.status.CANCELLED) { + return; + } + reject(error); + }); + }); +} + +function getSubscribeError(response: { data: string }): string { + const payload = JSON.parse(response.data) as { error?: unknown }; + expect(typeof payload.error).toBe("string"); + return payload.error as string; +} + +describe("Binance Subscribe user-data streams", () => { + const originalWebSocket = globalThis.WebSocket; + let resetWebSocketFactory: (() => void) | undefined; + let server: grpc.Server | undefined; + let client: InstanceType | undefined; + + beforeEach(() => { + resetWebSocketFactory = setBinanceUserDataWebSocketFactoryForTests( + (url) => new FakeWebSocket(url), + ); + (globalThis as { WebSocket?: typeof WebSocket }).WebSocket = undefined; + }); + + afterEach(async () => { + resetWebSocketFactory?.(); + resetWebSocketFactory = undefined; + globalThis.WebSocket = originalWebSocket; + FakeWebSocket.instances = []; + client?.close(); + if (server) { + await server.forceShutdown(); + } + server = undefined; + client = undefined; + }); + + async function startClient( + primaryExchange: Exchange, + secondaryExchange: Exchange, + ) { + server = getServer( + testPolicy, + createBinancePool(primaryExchange, secondaryExchange), + ["*"], + false, + "", + ); + const port = await bindServer(server); + client = new grpcObj.cex_broker.cex_service( + `127.0.0.1:${port}`, + grpc.credentials.createInsecure(), + ); + return client; + } + + test("closes Binance user-data stream when unread WebSocket events exceed the bounded buffer", async () => { + const primary = createBinanceExchange("primary-key", "primary-secret"); + const stream = new BinanceSpotUserDataStream(primary.exchange, { + maxBufferedEvents: 1, + }); + + try { + const iterator = stream[Symbol.asyncIterator](); + const socket = await waitForSocket(); + socket.emitEvent({ + e: "outboundAccountPosition", + E: 1, + B: [{ a: "BTC", f: "1.0", l: "0.0" }], + }); + socket.emitEvent({ + e: "outboundAccountPosition", + E: 2, + B: [{ a: "ETH", f: "2.0", l: "0.0" }], + }); + + let error: unknown; + try { + await iterator.next(); + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain( + "Binance user-data stream buffered event limit exceeded (1)", + ); + expect(socket.closed).toBe(true); + } finally { + stream.close(); + } + }); + + test("sends a Binance-compatible user-data request id", async () => { + const primary = createBinanceExchange("primary-key", "primary-secret"); + const stream = new BinanceSpotUserDataStream(primary.exchange); + + try { + const socket = await waitForSocket(); + const request = JSON.parse(socket.sent[0] ?? "{}") as { id?: string }; + + expect(request.id).toMatch(/^[a-zA-Z0-9-_]{1,36}$/); + } finally { + stream.close(); + } + }); + + test("uses different request ids for concurrent Binance user-data streams", async () => { + const primary = createBinanceExchange("primary-key", "primary-secret"); + const firstStream = new BinanceSpotUserDataStream(primary.exchange); + const secondStream = new BinanceSpotUserDataStream(primary.exchange); + + try { + await waitForSocket(); + const requestIds = FakeWebSocket.instances.map((socket) => { + const request = JSON.parse(socket.sent[0] ?? "{}") as { id?: string }; + return request.id; + }); + + expect(requestIds).toHaveLength(2); + expect(requestIds[0]).not.toBe(requestIds[1]); + } finally { + firstStream.close(); + secondStream.close(); + } + }); + + test("surfaces unmatched Binance user-data request errors", async () => { + const primary = createBinanceExchange("primary-key", "primary-secret"); + const stream = new BinanceSpotUserDataStream(primary.exchange); + + try { + const iterator = stream[Symbol.asyncIterator](); + const nextEvent = iterator.next(); + const socket = await waitForSocket(); + socket.emitMessage({ + id: null, + status: 400, + error: { + code: -1135, + msg: "Invalid 'id' in JSON request", + }, + }); + + let error: unknown; + try { + await nextEvent; + } catch (caught) { + error = caught; + } + + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toContain( + "Invalid 'id' in JSON request", + ); + expect((error as Error).message).toContain("code -1135"); + expect((error as Error).message).not.toContain("closed unexpectedly"); + } finally { + stream.close(); + } + }); + + test("streams Binance BALANCE frames through WebSocket API user-data subscription", async () => { + expect(globalThis.WebSocket).toBeUndefined(); + const primary = createBinanceExchange("primary-key", "primary-secret"); + const secondary = createBinanceExchange( + "secondary-key", + "secondary-secret", + ); + server = getServer( + testPolicy, + createBinancePool(primary.exchange, secondary.exchange), + ["*"], + false, + "", + ); + const port = await bindServer(server); + client = new grpcObj.cex_broker.cex_service( + `127.0.0.1:${port}`, + grpc.credentials.createInsecure(), + ); + + const responsePromise = subscribeOnce(client, { + cex: "BINANCE", + symbol: "BTC/USDT", + type: SubscriptionType.BALANCE, + }); + const socket = await waitForSocket(); + const subscribeRequest = JSON.parse(socket.sent[0] ?? "{}") as { + method?: string; + params?: { apiKey?: string }; + }; + socket.emitEvent({ + e: "outboundAccountPosition", + E: 1_564_031_571_105, + B: [{ a: "BTC", f: "1.0", l: "0.2" }], + }); + + const response = await responsePromise; + expect(subscribeRequest.method).toBe("userDataStream.subscribe.signature"); + expect(subscribeRequest.params?.apiKey).toBe("primary-key"); + expect(JSON.parse(response.data)).toMatchObject({ + subscriptionId: 0, + event: { e: "outboundAccountPosition" }, + }); + expect(response.symbol).toBe("BTC/USDT"); + expect(response.type).toBe("BALANCE"); + expect(primary.calls.watchBalance).toBe(0); + expect(primary.calls.watchOrders).toBe(0); + }); + + test("streams Binance ORDERS frames for the selected secondary account", async () => { + expect(globalThis.WebSocket).toBeUndefined(); + const primary = createBinanceExchange("primary-key", "primary-secret"); + const secondary = createBinanceExchange( + "secondary-key", + "secondary-secret", + ); + server = getServer( + testPolicy, + createBinancePool(primary.exchange, secondary.exchange), + ["*"], + false, + "", + ); + const port = await bindServer(server); + client = new grpcObj.cex_broker.cex_service( + `127.0.0.1:${port}`, + grpc.credentials.createInsecure(), + ); + const metadata = new grpc.Metadata(); + metadata.set("use-secondary-key", "1"); + + const responsePromise = subscribeOnce( + client, + { + cex: "binance", + symbol: "BTC/USDT", + type: SubscriptionType.ORDERS, + }, + metadata, + ); + const socket = await waitForSocket(); + const subscribeRequest = JSON.parse(socket.sent[0] ?? "{}") as { + method?: string; + params?: { apiKey?: string }; + }; + socket.emitEvent({ + e: "executionReport", + E: 1_499_405_658_658, + s: "ETHUSDT", + i: 1, + }); + socket.emitEvent({ + e: "executionReport", + E: 1_499_405_658_659, + s: "BTCUSDT", + i: 2, + }); + + const response = await responsePromise; + expect(subscribeRequest.method).toBe("userDataStream.subscribe.signature"); + expect(subscribeRequest.params?.apiKey).toBe("secondary-key"); + expect(JSON.parse(response.data)).toMatchObject({ + subscriptionId: 0, + event: { e: "executionReport", s: "BTCUSDT", i: 2 }, + }); + expect(response.type).toBe("ORDERS"); + expect(primary.calls.watchOrders).toBe(0); + expect(secondary.calls.watchOrders).toBe(0); + expect(secondary.calls.loadMarkets).toBe(1); + }); + + test("surfaces ws error event diagnostics without leaking signed params", async () => { + expect(globalThis.WebSocket).toBeUndefined(); + const primary = createBinanceExchange("primary-key", "primary-secret"); + const secondary = createBinanceExchange( + "secondary-key", + "secondary-secret", + ); + const subscribeClient = await startClient( + primary.exchange, + secondary.exchange, + ); + + const responsePromise = subscribeOnce(subscribeClient, { + cex: "binance", + symbol: "BTC/USDT", + type: SubscriptionType.BALANCE, + }); + const socket = await waitForSocket(); + socket.emitError({ + error: new Error( + "proxy refused event.error apiKey=primary-key secret=primary-secret signature=deadbeef", + ), + message: "fallback transport message", + }); + + const error = getSubscribeError(await responsePromise); + expect(error).toContain( + "Binance user-data WebSocket error: proxy refused event.error", + ); + expect(error).toContain("apiKey=[redacted]"); + expect(error).toContain("secret=[redacted]"); + expect(error).toContain("signature=[redacted]"); + expect(error).not.toContain("primary-key"); + expect(error).not.toContain("primary-secret"); + expect(error).not.toContain("deadbeef"); + }); + + test("surfaces ws error message diagnostics without leaking secrets", async () => { + expect(globalThis.WebSocket).toBeUndefined(); + const primary = createBinanceExchange("primary-key", "primary-secret"); + const secondary = createBinanceExchange( + "secondary-key", + "secondary-secret", + ); + const subscribeClient = await startClient( + primary.exchange, + secondary.exchange, + ); + + const responsePromise = subscribeOnce(subscribeClient, { + cex: "binance", + symbol: "BTC/USDT", + type: SubscriptionType.BALANCE, + }); + const socket = await waitForSocket(); + socket.emitError({ + message: + "transport refused event.message apiKey=primary-key secret=primary-secret signature=feedface", + }); + + const error = getSubscribeError(await responsePromise); + expect(error).toContain( + "Binance user-data WebSocket error: transport refused event.message", + ); + expect(error).toContain("apiKey=[redacted]"); + expect(error).toContain("secret=[redacted]"); + expect(error).toContain("signature=[redacted]"); + expect(error).not.toContain("primary-key"); + expect(error).not.toContain("primary-secret"); + expect(error).not.toContain("feedface"); + }); + + test("surfaces abnormal ws close code and reason without leaking secrets", async () => { + expect(globalThis.WebSocket).toBeUndefined(); + const primary = createBinanceExchange("primary-key", "primary-secret"); + const secondary = createBinanceExchange( + "secondary-key", + "secondary-secret", + ); + const subscribeClient = await startClient( + primary.exchange, + secondary.exchange, + ); + + const responsePromise = subscribeOnce(subscribeClient, { + cex: "binance", + symbol: "BTC/USDT", + type: SubscriptionType.BALANCE, + }); + const socket = await waitForSocket(); + socket.emitClose( + 1011, + Buffer.from( + "upstream closed apiKey=primary-key secret=primary-secret signature=abc123", + ), + ); + + const error = getSubscribeError(await responsePromise); + expect(error).toContain("Binance user-data WebSocket closed unexpectedly"); + expect(error).toContain("code=1011"); + expect(error).toContain("reason=upstream closed"); + expect(error).toContain("apiKey=[redacted]"); + expect(error).toContain("secret=[redacted]"); + expect(error).toContain("signature=[redacted]"); + expect(error).not.toContain("primary-key"); + expect(error).not.toContain("primary-secret"); + expect(error).not.toContain("abc123"); + }); +}); diff --git a/test/travel-rule-deposit.test.ts b/test/travel-rule-deposit.test.ts new file mode 100644 index 0000000..b69f496 --- /dev/null +++ b/test/travel-rule-deposit.test.ts @@ -0,0 +1,603 @@ +import { describe, expect, test } from "bun:test"; +import http from "node:http"; +import ccxt, { type Exchange } from "@usherlabs/ccxt"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { + australiaDepositQuestionnaireSchema, + getEnabledTravelRuleDepositConfig, + loadPolicy, + registerBinanceTravelRuleDepositEndpoints, + resolveDepositOriginatorQuestionnaire, +} from "../src/helpers"; +import { + createAccountState, + loadTravelRuleDepositReconcilerConfigFromEnv, + parseLocalEntityDeposit, + type ReconcileAccountDeps, + reconcileAccountOnce, + resolveOnChainSender, +} from "../src/helpers/travel-rule-deposit-reconciler"; +import type { PolicyConfig, TravelRuleDepositConfig } from "../src/types"; + +const SELF_OWNED_DEPOSIT = { + depositOriginator: 1, + receiveFrom: 1, + declaration: true, +}; +// Mixed-case in policy to exercise case-insensitive originator matching. +const ORIGINATOR = "0xE64B2f840b54C906e8dA26E96DBC9904b3B7f95a"; +const ORIGINATOR_LOWER = ORIGINATOR.toLowerCase(); +const WITHDRAW_DEST = "0xC8319213172c3a608Fb13f570fC7DF5cdA4F84d1"; + +function policyWithDeposit( + depositsEnabled: boolean, + withDeposits = true, +): PolicyConfig { + return { + withdraw: { + rule: [ + { + exchange: "BINANCE", + network: "ARBITRUM", + whitelist: [WITHDRAW_DEST], + }, + ], + }, + deposit: {}, + order: { rule: { markets: ["*"], limits: [] } }, + travelRule: { + rule: [ + { + exchange: "BINANCE", + enabled: true, + addresses: { + [WITHDRAW_DEST]: { + questionnaire: { + isAddressOwner: 1, + sendTo: 1, + declaration: true, + }, + }, + }, + ...(withDeposits && { + deposits: { + enabled: depositsEnabled, + originators: { + [ORIGINATOR]: { questionnaire: SELF_OWNED_DEPOSIT }, + }, + }, + }), + }, + ], + }, + }; +} + +describe("australiaDepositQuestionnaireSchema", () => { + test("accepts the self-owned deposit questionnaire", () => { + expect( + australiaDepositQuestionnaireSchema.validate(SELF_OWNED_DEPOSIT).error, + ).toBeUndefined(); + }); + + test("rejects a false declaration", () => { + const { error } = australiaDepositQuestionnaireSchema.validate({ + depositOriginator: 1, + receiveFrom: 1, + declaration: false, + }); + expect(error).toBeDefined(); + }); + + test("rejects a missing declaration", () => { + const { error } = australiaDepositQuestionnaireSchema.validate({ + depositOriginator: 1, + receiveFrom: 1, + }); + expect(error).toBeDefined(); + }); + + test("rejects a non-self depositOriginator (needs identity fields we lack)", () => { + const { error } = australiaDepositQuestionnaireSchema.validate({ + depositOriginator: 2, + receiveFrom: 1, + declaration: true, + }); + expect(error).toBeDefined(); + }); + + test("rejects the withdraw questionnaire shape (isAddressOwner/sendTo)", () => { + const { error } = australiaDepositQuestionnaireSchema.validate({ + isAddressOwner: 1, + sendTo: 1, + declaration: true, + }); + expect(error).toBeDefined(); + }); +}); + +describe("getEnabledTravelRuleDepositConfig", () => { + test("returns null when there is no travel-rule section", () => { + const policy: PolicyConfig = { + withdraw: { rule: [] }, + deposit: {}, + order: { rule: { markets: [], limits: [] } }, + }; + expect(getEnabledTravelRuleDepositConfig(policy, "BINANCE")).toBeNull(); + }); + + test("returns null when the deposits block is absent", () => { + expect( + getEnabledTravelRuleDepositConfig( + policyWithDeposit(true, false), + "BINANCE", + ), + ).toBeNull(); + }); + + test("returns null when deposits are disabled", () => { + expect( + getEnabledTravelRuleDepositConfig(policyWithDeposit(false), "BINANCE"), + ).toBeNull(); + }); + + test("returns the config when enabled (case-insensitive exchange)", () => { + const config = getEnabledTravelRuleDepositConfig( + policyWithDeposit(true), + "binance", + ); + expect(config?.enabled).toBe(true); + expect(config?.originators[ORIGINATOR]).toBeDefined(); + }); +}); + +describe("resolveDepositOriginatorQuestionnaire", () => { + const config: TravelRuleDepositConfig = { + enabled: true, + originators: { [ORIGINATOR]: { questionnaire: SELF_OWNED_DEPOSIT } }, + }; + + test("matches a declared originator case-insensitively", () => { + expect( + resolveDepositOriginatorQuestionnaire(config, ORIGINATOR_LOWER), + ).toEqual(SELF_OWNED_DEPOSIT); + }); + + test("returns null for an undeclared sender", () => { + expect( + resolveDepositOriginatorQuestionnaire( + config, + "0x0000000000000000000000000000000000000000", + ), + ).toBeNull(); + }); +}); + +describe("registerBinanceTravelRuleDepositEndpoints", () => { + test("registers the three localentity endpoints on Binance", () => { + const calls: unknown[][] = []; + const exchange = { + id: "binance", + defineRestApi: (...args: unknown[]) => calls.push(args), + } as unknown as Exchange; + registerBinanceTravelRuleDepositEndpoints(exchange); + expect(calls).toEqual([ + [ + { + sapi: { + get: { + "localentity/deposit/history": 1, + "localentity/questionnaire-requirements": 1, + }, + put: { "localentity/deposit/provide-info": 4.0002 }, + }, + }, + "request", + ], + ]); + }); + + test("is a no-op for non-Binance exchanges", () => { + const calls: unknown[][] = []; + const exchange = { + id: "bybit", + defineRestApi: (...args: unknown[]) => calls.push(args), + } as unknown as Exchange; + registerBinanceTravelRuleDepositEndpoints(exchange); + expect(calls).toEqual([]); + }); +}); + +describe("loadPolicy deposit travel-rule validation", () => { + function writeTempPolicy(policy: unknown): string { + const tempPath = path.join( + os.tmpdir(), + `policy-deposit-${Date.now()}-${Math.random()}.json`, + ); + fs.writeFileSync(tempPath, JSON.stringify(policy)); + return tempPath; + } + + test("loads a policy with a valid deposits block", () => { + const tempPath = writeTempPolicy(policyWithDeposit(true)); + try { + const policy = loadPolicy(tempPath); + const deposits = policy.travelRule?.rule[0]?.deposits; + expect(deposits?.enabled).toBe(true); + expect(deposits?.originators[ORIGINATOR]?.questionnaire).toEqual( + SELF_OWNED_DEPOSIT, + ); + } finally { + fs.unlinkSync(tempPath); + } + }); + + test("rejects a deposits block whose questionnaire is the withdraw shape", () => { + const bad = policyWithDeposit(true); + // biome-ignore lint/suspicious/noExplicitAny: intentionally invalid config + (bad.travelRule as any).rule[0].deposits.originators[ + ORIGINATOR + ].questionnaire = { isAddressOwner: 1, sendTo: 1, declaration: true }; + const tempPath = writeTempPolicy(bad); + try { + expect(() => loadPolicy(tempPath)).toThrow(); + } finally { + fs.unlinkSync(tempPath); + } + }); +}); + +describe("binance localentity deposit provide-info signing (ccxt patch)", () => { + // Guards the @usherlabs/ccxt patch: provide-info must sign the questionnaire + // with rawencode (raw JSON). urlencode percent-encodes the JSON and Binance + // rejects it with -1022. This uses the real ccxt so it fails if the patch is + // ever dropped on a version bump (edge case 11). + test("signs the questionnaire raw, not percent-encoded, on the PUT", () => { + const exchange = new ccxt.binance({ apiKey: "k", secret: "s" }); + const params = { + tranId: "387083631169", + questionnaire: JSON.stringify(SELF_OWNED_DEPOSIT), + }; + const signed = exchange.sign( + "localentity/deposit/provide-info", + "sapi", + "PUT", + params, + ); + const body = String(signed.body ?? ""); + expect(body).toContain('questionnaire={"depositOriginator":1'); + expect(body).not.toContain("questionnaire=%7B"); + }); +}); + +describe("parseLocalEntityDeposit", () => { + test("parses a frozen deposit row", () => { + const parsed = parseLocalEntityDeposit({ + tranId: 387083631169, + coin: "USDC", + amount: "1", + network: "ARBITRUM", + txId: `0x${"a".repeat(64)}`, + travelRuleStatusV2: "PENDING", + requireQuestionnaire: true, + }); + expect(parsed).toMatchObject({ + tranId: "387083631169", + coin: "USDC", + network: "ARBITRUM", + travelRuleStatus: "PENDING", + requireQuestionnaire: true, + }); + }); + + test("returns null when the row has no tranId (wrong id space)", () => { + expect(parseLocalEntityDeposit({ coin: "USDC", amount: "1" })).toBeNull(); + }); + + test("does NOT accept a generic `id` as the tranId", () => { + // `id` belongs to the capital/deposit/hisrec id space; provide-info rejects + // it. Only `tranId` is a valid provide-info identifier. + expect(parseLocalEntityDeposit({ id: "999", coin: "USDC" })).toBeNull(); + }); +}); + +describe("resolveOnChainSender", () => { + const HASH = `0x${"b".repeat(64)}`; + + // Exercise the real node:http transport (the enclave-safe path) against a + // local server, rather than mocking global fetch which the function no + // longer uses. + function startRpc(responder: () => { status?: number; body: string }) { + const state = { calls: 0 }; + const server = http.createServer((_req, res) => { + state.calls++; + const { status = 200, body } = responder(); + res.writeHead(status, { "content-type": "application/json" }); + res.end(body); + }); + return new Promise<{ + url: string; + state: { calls: number }; + close: () => Promise; + }>((resolve) => { + server.listen(0, "127.0.0.1", () => { + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + resolve({ + url: `http://127.0.0.1:${port}`, + state, + close: () => new Promise((r) => server.close(() => r())), + }); + }); + }); + } + + test("returns the lowercased tx.from", async () => { + const rpc = await startRpc(() => ({ + body: JSON.stringify({ result: { from: ORIGINATOR } }), + })); + try { + expect(await resolveOnChainSender(rpc.url, HASH)).toBe(ORIGINATOR_LOWER); + } finally { + await rpc.close(); + } + }); + + test("returns null when the tx is not found (result null)", async () => { + const rpc = await startRpc(() => ({ + body: JSON.stringify({ result: null }), + })); + try { + expect(await resolveOnChainSender(rpc.url, HASH)).toBeNull(); + } finally { + await rpc.close(); + } + }); + + test("throws on an RPC error payload (becomes the surfaced unproven reason)", async () => { + const rpc = await startRpc(() => ({ + body: JSON.stringify({ error: { code: -32000, message: "boom" } }), + })); + try { + await expect(resolveOnChainSender(rpc.url, HASH)).rejects.toThrow( + "travel_rule_rpc_error", + ); + } finally { + await rpc.close(); + } + }); + + test("returns null (no RPC call) for a malformed tx hash", async () => { + const rpc = await startRpc(() => ({ body: "{}" })); + try { + expect(await resolveOnChainSender(rpc.url, "not-a-hash")).toBeNull(); + expect(rpc.state.calls).toBe(0); + } finally { + await rpc.close(); + } + }); +}); + +describe("loadTravelRuleDepositReconcilerConfigFromEnv", () => { + test("parses per-network RPC URLs and applies defaults", () => { + const config = loadTravelRuleDepositReconcilerConfigFromEnv({ + TRAVEL_RULE_RPC_URL_ARBITRUM: "http://arb-rpc", + OTHER: "ignored", + }); + expect(config.rpcUrlsByNetwork).toEqual({ ARBITRUM: "http://arb-rpc" }); + expect(config.expectedQuestionnaireCountry).toBe("AU"); + expect(config.pollIntervalActiveMs).toBe(60_000); + }); + + test("honors cadence and country overrides", () => { + const config = loadTravelRuleDepositReconcilerConfigFromEnv({ + TRAVEL_RULE_DEPOSIT_POLL_ACTIVE_SECS: "30", + TRAVEL_RULE_QUESTIONNAIRE_COUNTRY: "au", + }); + expect(config.pollIntervalActiveMs).toBe(30_000); + expect(config.expectedQuestionnaireCountry).toBe("AU"); + }); +}); + +// --------------------------------------------------------------------------- +// reconcileAccountOnce — the compliance-critical core +// --------------------------------------------------------------------------- + +const FROZEN_DEPOSIT = { + tranId: "387083631169", + coin: "USDC", + amount: "1", + network: "ARBITRUM", + txId: `0x${"a".repeat(64)}`, + travelRuleStatusV2: "PENDING", + requireQuestionnaire: true, +}; + +function baseDeps( + overrides: Partial = {}, +): ReconcileAccountDeps { + return { + accountLabel: "binance:secondary:1", + depositConfig: { + enabled: true, + originators: { [ORIGINATOR]: { questionnaire: SELF_OWNED_DEPOSIT } }, + }, + expectedCountry: "AU", + failureBackoffMs: 900_000, + rateLimitCooldownMs: 300_000, + now: 1_000_000, + state: createAccountState(), + fetchDepositHistory: async () => [{ ...FROZEN_DEPOSIT }], + fetchQuestionnaireCountry: async () => "AU", + resolveSender: async () => ORIGINATOR_LOWER, + submitProvideInfo: async () => ({ accepted: true }), + resolveQuestionnaire: resolveDepositOriginatorQuestionnaire, + ...overrides, + }; +} + +describe("reconcileAccountOnce", () => { + test("submits provide-info for a declared-originator frozen deposit", async () => { + let submitCalls = 0; + const deps = baseDeps({ + submitProvideInfo: async () => { + submitCalls++; + return { accepted: true }; + }, + }); + const report = await reconcileAccountOnce(deps); + expect(submitCalls).toBe(1); + expect(report.outcomes.map((o) => o.kind)).toEqual(["submitted"]); + expect(deps.state.submittedTranIds.has(FROZEN_DEPOSIT.tranId)).toBe(true); + }); + + test("does not re-submit an already-submitted deposit on the next cycle", async () => { + let submitCalls = 0; + const deps = baseDeps({ + submitProvideInfo: async () => { + submitCalls++; + return { accepted: true }; + }, + }); + await reconcileAccountOnce(deps); + const second = await reconcileAccountOnce(deps); + expect(submitCalls).toBe(1); + expect(second.hadActionableWork).toBe(false); + // Still counted as frozen until Binance releases it asynchronously. + expect(second.frozenDeposits).toHaveLength(1); + }); + + test("NEVER submits when the origin is undeclared", async () => { + let submitCalls = 0; + const deps = baseDeps({ + resolveSender: async () => "0x000000000000000000000000000000000000dead", + submitProvideInfo: async () => { + submitCalls++; + return { accepted: true }; + }, + }); + const report = await reconcileAccountOnce(deps); + expect(submitCalls).toBe(0); + expect(report.outcomes.map((o) => o.kind)).toEqual(["undeclared-origin"]); + expect(deps.state.backoffUntil.has(FROZEN_DEPOSIT.tranId)).toBe(true); + }); + + test("NEVER submits when the on-chain sender is unresolved", async () => { + let submitCalls = 0; + const deps = baseDeps({ + resolveSender: async () => null, + submitProvideInfo: async () => { + submitCalls++; + return { accepted: true }; + }, + }); + const report = await reconcileAccountOnce(deps); + expect(submitCalls).toBe(0); + expect(report.outcomes.map((o) => o.kind)).toEqual(["unproven-origin"]); + }); + + test("NEVER submits when the entity country is not the expected one", async () => { + let submitCalls = 0; + const deps = baseDeps({ + fetchQuestionnaireCountry: async () => "DE", + submitProvideInfo: async () => { + submitCalls++; + return { accepted: true }; + }, + }); + const report = await reconcileAccountOnce(deps); + expect(submitCalls).toBe(0); + expect(report.outcomes.map((o) => o.kind)).toEqual(["entity-drift"]); + }); + + test("treats an 'already provided' error as idempotent success", async () => { + const deps = baseDeps({ + submitProvideInfo: async () => { + throw new Error("Questionnaire already provided for this deposit"); + }, + }); + const report = await reconcileAccountOnce(deps); + expect(report.outcomes.map((o) => o.kind)).toEqual(["already-provided"]); + expect(deps.state.submittedTranIds.has(FROZEN_DEPOSIT.tranId)).toBe(true); + }); + + test("backs off (does not mark submitted) on a content rejection", async () => { + const deps = baseDeps({ + submitProvideInfo: async () => ({ + accepted: false, + msg: "Questionnaire format not valid", + }), + }); + const report = await reconcileAccountOnce(deps); + expect(report.outcomes.map((o) => o.kind)).toEqual(["submit-error"]); + expect(deps.state.submittedTranIds.has(FROZEN_DEPOSIT.tranId)).toBe(false); + expect(deps.state.backoffUntil.get(FROZEN_DEPOSIT.tranId)).toBe( + deps.now + deps.failureBackoffMs, + ); + }); + + test("surfaces a FAILED deposit as terminal and never submits it", async () => { + let submitCalls = 0; + const deps = baseDeps({ + fetchDepositHistory: async () => [ + { ...FROZEN_DEPOSIT, travelRuleStatusV2: "FAILED" }, + ], + submitProvideInfo: async () => { + submitCalls++; + return { accepted: true }; + }, + }); + const report = await reconcileAccountOnce(deps); + expect(submitCalls).toBe(0); + expect(report.outcomes.map((o) => o.kind)).toEqual(["failed-terminal"]); + }); + + test("enters an account-wide cooldown on a rate-limit poll error", async () => { + const deps = baseDeps({ + fetchDepositHistory: async () => { + throw new Error("binance -1003 Too many requests"); + }, + }); + const report = await reconcileAccountOnce(deps); + expect(report.outcomes.map((o) => o.kind)).toEqual(["poll-error"]); + expect(deps.state.rateLimitedUntil).toBe( + deps.now + deps.rateLimitCooldownMs, + ); + }); + + test("skips entirely while inside the rate-limit cooldown", async () => { + let historyCalls = 0; + const state = createAccountState(); + state.rateLimitedUntil = 2_000_000; + const deps = baseDeps({ + state, + now: 1_500_000, + fetchDepositHistory: async () => { + historyCalls++; + return []; + }, + }); + await reconcileAccountOnce(deps); + expect(historyCalls).toBe(0); + }); + + test("stops the candidate loop after a mid-loop rate-limit signal", async () => { + const secondFrozen = { ...FROZEN_DEPOSIT, tranId: "999999999" }; + let submitCalls = 0; + const deps = baseDeps({ + fetchDepositHistory: async () => [{ ...FROZEN_DEPOSIT }, secondFrozen], + submitProvideInfo: async () => { + submitCalls++; + throw new Error("binance -1003 Too many requests"); + }, + }); + await reconcileAccountOnce(deps); + // First candidate trips the cooldown; the second must NOT be attempted. + expect(submitCalls).toBe(1); + expect(deps.state.rateLimitedUntil).toBe( + deps.now + deps.rateLimitCooldownMs, + ); + }); +}); diff --git a/test/travel-rule.test.ts b/test/travel-rule.test.ts new file mode 100644 index 0000000..cfee95e --- /dev/null +++ b/test/travel-rule.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, test } from "bun:test"; +import ccxt, { type Exchange } from "@usherlabs/ccxt"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { + australiaQuestionnaireSchema, + loadPolicy, + registerBinanceTravelRuleWithdrawEndpoint, + resolveTravelRuleDecision, + withdrawViaLocalEntity, +} from "../src/helpers"; +import type { PolicyConfig } from "../src/types"; + +const SELF_OWNED = { isAddressOwner: 1, sendTo: 1, declaration: true }; +const ADDRESS = "0xC8319213172c3a608Fb13f570fC7DF5cdA4F84d1"; + +function policyWithTravelRule(enabled: boolean): PolicyConfig { + return { + withdraw: { + rule: [ + { exchange: "BINANCE", network: "ARBITRUM", whitelist: [ADDRESS] }, + ], + }, + deposit: {}, + order: { rule: { markets: ["*"], limits: [] } }, + travelRule: { + rule: [ + { + exchange: "BINANCE", + enabled, + description: "Australia (AUSTRAC) travel-rule requirement", + addresses: { [ADDRESS]: { questionnaire: SELF_OWNED } }, + }, + ], + }, + }; +} + +describe("australiaQuestionnaireSchema", () => { + test("accepts a self-owned questionnaire", () => { + expect( + australiaQuestionnaireSchema.validate(SELF_OWNED).error, + ).toBeUndefined(); + }); + + test("rejects a false declaration", () => { + const { error } = australiaQuestionnaireSchema.validate({ + isAddressOwner: 1, + sendTo: 1, + declaration: false, + }); + expect(error).toBeDefined(); + }); + + test("rejects a missing declaration", () => { + const { error } = australiaQuestionnaireSchema.validate({ + isAddressOwner: 1, + sendTo: 1, + }); + expect(error).toBeDefined(); + }); + + test("requires bnfType when sending to another beneficiary", () => { + const { error } = australiaQuestionnaireSchema.validate({ + isAddressOwner: 2, + sendTo: 1, + declaration: true, + }); + expect(error).toBeDefined(); + }); + + test("requires individual name fields for bnfType individual", () => { + const { error } = australiaQuestionnaireSchema.validate({ + isAddressOwner: 2, + bnfType: 0, + sendTo: 1, + declaration: true, + }); + expect(error).toBeDefined(); + }); + + test("accepts a full individual-beneficiary questionnaire", () => { + const { error } = australiaQuestionnaireSchema.validate({ + isAddressOwner: 2, + bnfType: 0, + bnfFirstName: "Jane", + bnfLastName: "Doe", + country: "au", + city: "Sydney", + sendTo: 1, + declaration: true, + }); + expect(error).toBeUndefined(); + }); + + test("requires vasp when sending to another VASP", () => { + const { error } = australiaQuestionnaireSchema.validate({ + isAddressOwner: 1, + sendTo: 2, + declaration: true, + }); + expect(error).toBeDefined(); + }); + + test("requires vaspName when vasp is 'others'", () => { + const { error } = australiaQuestionnaireSchema.validate({ + isAddressOwner: 1, + sendTo: 2, + vasp: "others", + declaration: true, + }); + expect(error).toBeDefined(); + }); + + test("forbids beneficiary fields on a self-owned questionnaire", () => { + const { error } = australiaQuestionnaireSchema.validate({ + isAddressOwner: 1, + sendTo: 1, + declaration: true, + bnfType: 0, + }); + expect(error).toBeDefined(); + }); +}); + +describe("resolveTravelRuleDecision", () => { + test("returns standard when there is no travel-rule section", () => { + const policy: PolicyConfig = { + withdraw: { rule: [] }, + deposit: {}, + order: { rule: { markets: [], limits: [] } }, + }; + expect(resolveTravelRuleDecision(policy, "BINANCE", ADDRESS).mode).toBe( + "standard", + ); + }); + + test("returns standard when the exchange entry is disabled", () => { + expect( + resolveTravelRuleDecision(policyWithTravelRule(false), "BINANCE", ADDRESS) + .mode, + ).toBe("standard"); + }); + + test("returns localentity with the questionnaire when enabled (case-insensitive)", () => { + const decision = resolveTravelRuleDecision( + policyWithTravelRule(true), + "binance", + ADDRESS.toLowerCase(), + ); + expect(decision).toEqual({ + mode: "localentity", + questionnaire: SELF_OWNED, + }); + }); + + test("fails closed when enabled but the address has no questionnaire", () => { + const decision = resolveTravelRuleDecision( + policyWithTravelRule(true), + "BINANCE", + "0x0000000000000000000000000000000000000000", + ); + expect(decision.mode).toBe("denied"); + }); +}); + +describe("registerBinanceTravelRuleWithdrawEndpoint", () => { + test("registers the endpoint on a Binance instance", () => { + const calls: unknown[][] = []; + const exchange = { + id: "binance", + defineRestApi: (...args: unknown[]) => calls.push(args), + } as unknown as Exchange; + registerBinanceTravelRuleWithdrawEndpoint(exchange); + expect(calls).toEqual([ + [{ sapi: { post: { "localentity/withdraw/apply": 4.0002 } } }, "request"], + ]); + }); + + test("is a no-op for non-Binance exchanges", () => { + const calls: unknown[][] = []; + const exchange = { + id: "bybit", + defineRestApi: (...args: unknown[]) => calls.push(args), + } as unknown as Exchange; + registerBinanceTravelRuleWithdrawEndpoint(exchange); + expect(calls).toEqual([]); + }); +}); + +describe("withdrawViaLocalEntity", () => { + function createMockBinance() { + const captured: { request?: Record } = {}; + const exchange = { + options: { networks: { ARBITRUM: "ARBITRUM" } }, + checkAddress: (address: string) => address, + loadMarkets: async () => undefined, + currency: (code: string) => ({ id: code, code }), + currencyToPrecision: (_code: string, amount: number) => String(amount), + safeDict: (obj: Record, key: string) => obj?.[key] ?? {}, + safeString: ( + obj: Record, + key: string, + fallback: string, + ) => (typeof obj?.[key] === "string" ? (obj[key] as string) : fallback), + parseTransaction: (tx: Record) => ({ parsed: tx }), + sapiPostLocalentityWithdrawApply: async ( + request: Record, + ) => { + captured.request = request; + return { id: "withdrawal-123" }; + }, + } as unknown as Exchange; + return { exchange, captured }; + } + + test("calls the localentity endpoint with a URL-serializable questionnaire", async () => { + const { exchange, captured } = createMockBinance(); + const result = await withdrawViaLocalEntity(exchange, { + code: "USDC", + amount: 100, + address: ADDRESS, + network: "ARBITRUM", + questionnaire: SELF_OWNED, + }); + + expect(captured.request).toEqual({ + coin: "USDC", + address: ADDRESS, + amount: "100", + network: "ARBITRUM", + questionnaire: JSON.stringify(SELF_OWNED), + }); + expect(result).toEqual({ parsed: { id: "withdrawal-123" } }); + }); + + test("forwards caller params but never lets them override fixed fields", async () => { + const { exchange, captured } = createMockBinance(); + await withdrawViaLocalEntity(exchange, { + code: "USDC", + amount: 100, + address: ADDRESS, + network: "ARBITRUM", + questionnaire: SELF_OWNED, + params: { withdrawOrderId: "order-1", coin: "SHOULD_NOT_WIN" }, + }); + expect(captured.request?.withdrawOrderId).toBe("order-1"); + // Fixed fields take precedence over any colliding param. + expect(captured.request?.coin).toBe("USDC"); + }); + + test("throws when the endpoint is not registered", async () => { + const exchange = { + checkAddress: (address: string) => address, + loadMarkets: async () => undefined, + currency: (code: string) => ({ id: code }), + } as unknown as Exchange; + await expect( + withdrawViaLocalEntity(exchange, { + code: "USDC", + amount: 100, + address: ADDRESS, + network: "ARBITRUM", + questionnaire: SELF_OWNED, + }), + ).rejects.toThrow("binance_localentity_withdraw_unavailable"); + }); +}); + +describe("loadPolicy travel-rule validation", () => { + function writeTempPolicy(policy: unknown): string { + const tempPath = path.join( + os.tmpdir(), + `policy-travel-${Date.now()}-${Math.random()}.json`, + ); + fs.writeFileSync(tempPath, JSON.stringify(policy)); + return tempPath; + } + + test("loads a policy with a valid travel-rule section", () => { + const tempPath = writeTempPolicy(policyWithTravelRule(true)); + try { + const policy = loadPolicy(tempPath); + expect(policy.travelRule?.rule[0]?.enabled).toBe(true); + expect(policy.travelRule?.rule[0]?.description).toBe( + "Australia (AUSTRAC) travel-rule requirement", + ); + expect( + policy.travelRule?.rule[0]?.addresses[ADDRESS]?.questionnaire, + ).toEqual(SELF_OWNED); + } finally { + fs.unlinkSync(tempPath); + } + }); + + test("rejects a policy whose questionnaire is malformed", () => { + const bad = policyWithTravelRule(true); + // biome-ignore lint/suspicious/noExplicitAny: intentionally invalid config + (bad.travelRule as any).rule[0].addresses[ADDRESS].questionnaire = { + isAddressOwner: 1, + sendTo: 1, + declaration: false, + }; + const tempPath = writeTempPolicy(bad); + try { + expect(() => loadPolicy(tempPath)).toThrow(); + } finally { + fs.unlinkSync(tempPath); + } + }); + + test("rejects a travel-rule entry for a non-Binance exchange", () => { + const bad = policyWithTravelRule(true); + // biome-ignore lint/suspicious/noExplicitAny: intentionally invalid config + (bad.travelRule as any).rule[0].exchange = "BYBIT"; + const tempPath = writeTempPolicy(bad); + try { + expect(() => loadPolicy(tempPath)).toThrow(); + } finally { + fs.unlinkSync(tempPath); + } + }); +}); + +describe("binance localentity withdraw signing (ccxt patch)", () => { + // Guards the @usherlabs/ccxt patch: the localentity withdraw endpoint must be + // signed with rawencode (raw questionnaire JSON), exactly like + // capital/withdraw/apply. Signing with urlencode percent-encodes the JSON and + // Binance rejects it with -1022 "Signature for this request is not valid" — + // which is what a live withdrawal actually hit before the patch. Uses the real + // ccxt so this fails if the patch is ever dropped (e.g. on a version bump). + test("signs the questionnaire raw, not percent-encoded", () => { + const exchange = new ccxt.binance({ apiKey: "k", secret: "s" }); + const params = { + coin: "ARB", + address: "0x0000000000000000000000000000000000000000", + amount: "10", + network: "ARBITRUM", + questionnaire: JSON.stringify({ + isAddressOwner: 1, + sendTo: 1, + declaration: true, + }), + }; + const signed = exchange.sign( + "localentity/withdraw/apply", + "sapi", + "POST", + params, + ); + const body = String(signed.body ?? ""); + expect(body).toContain('questionnaire={"isAddressOwner":1'); + expect(body).not.toContain("questionnaire=%7B"); + }); +}); diff --git a/test/treasury-discovery-rpc.test.ts b/test/treasury-discovery-rpc.test.ts new file mode 100644 index 0000000..83e4a67 --- /dev/null +++ b/test/treasury-discovery-rpc.test.ts @@ -0,0 +1,896 @@ +import { afterAll, afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import * as grpc from "@grpc/grpc-js"; +import type { Exchange } from "@usherlabs/ccxt"; +import { + type BrokerArchiveRow, + BrokerExecutionArchiver, + WithdrawalObservationTracker, +} from "../src/helpers/broker-execution-archive"; +import { Action } from "../src/helpers/constants"; +import type { BrokerPoolEntry } from "../src/helpers/index"; +import { getServer } from "../src/server"; +import type { PolicyConfig } from "../src/types"; +import { startForwarderServer } from "./archive-forwarder-server"; +import { bindServer, executeAction, grpcObj } from "./order-telemetry-fixtures"; + +const archiveTestDirectory = mkdtempSync( + join(tmpdir(), "cex-broker-treasury-archive-test-"), +); + +afterAll(() => { + rmSync(archiveTestDirectory, { recursive: true, force: true }); +}); + +const testPolicy: PolicyConfig = { + withdraw: { rule: [] }, + deposit: {}, + order: { rule: { markets: [], limits: [] } }, +}; + +type TreasuryExchangeOptions = { + has?: Record; + markets?: Record; + currencies?: Record; + deposits?: Array>; + withdrawals?: Array>; + depositWithdrawFees?: Record; + balances?: Record; +}; + +function createTreasuryExchange(options: TreasuryExchangeOptions = {}) { + const calls: Record = { + fetchMarkets: [], + loadMarkets: [], + fetchCurrencies: [], + fetchDeposits: [], + fetchWithdrawals: [], + fetchDepositWithdrawFees: [], + fetchDepositAddress: [], + fetchTotalBalance: [], + withdraw: [], + }; + const exchange: Record = { + has: { + fetchMarkets: true, + fetchCurrencies: true, + fetchDeposits: true, + fetchWithdrawals: true, + fetchDepositAddress: true, + ...(options.has ?? {}), + }, + fees: { + trading: { + maker: 0, + taker: 0.001, + }, + }, + markets: options.markets ?? { + "ARB/USDC": { symbol: "ARB/USDC", base: "ARB", quote: "USDC" }, + }, + currencies: options.currencies ?? { + USDC: { + code: "USDC", + id: "USDC", + networks: { + BSC: { + id: "BSC", + network: "BSC", + deposit: true, + withdraw: true, + fee: "0", + }, + }, + }, + }, + fetchMarkets: async (...args: unknown[]) => { + calls.fetchMarkets.push(args); + return [{ symbol: "ARB/USDC", base: "ARB", quote: "USDC" }]; + }, + loadMarkets: async (...args: unknown[]) => { + calls.loadMarkets.push(args); + return options.markets ?? exchange.markets; + }, + fetchCurrencies: async (...args: unknown[]) => { + calls.fetchCurrencies.push(args); + return exchange.currencies; + }, + fetchDeposits: async (...args: unknown[]) => { + calls.fetchDeposits.push(args); + return options.deposits ?? []; + }, + fetchWithdrawals: async (...args: unknown[]) => { + calls.fetchWithdrawals.push(args); + return options.withdrawals ?? []; + }, + fetchDepositWithdrawFees: async (...args: unknown[]) => { + calls.fetchDepositWithdrawFees.push(args); + return options.depositWithdrawFees ?? {}; + }, + fetchDepositAddress: async (...args: unknown[]) => { + calls.fetchDepositAddress.push(args); + return { address: "0xdeposit" }; + }, + fetchTotalBalance: async (...args: unknown[]) => { + calls.fetchTotalBalance.push(args); + return options.balances ?? { USDC: 42 }; + }, + market: (symbol: string) => { + const market = (exchange.markets as Record)[symbol]; + if (!market) { + throw new Error(`unsupported symbol ${symbol}`); + } + return market; + }, + withdraw: async (...args: unknown[]) => { + calls.withdraw.push(args); + return { id: "withdraw-1", txid: "0xwithdraw" }; + }, + }; + return { exchange: exchange as Exchange, calls }; +} + +function createPool(exchange: Exchange): Record { + return { + binance: { + primary: { exchange, label: "primary" }, + secondaryBrokers: [], + }, + }; +} + +describe("Treasury discovery and transfer observation RPC", () => { + let server: grpc.Server | undefined; + let client: InstanceType | undefined; + + afterEach(async () => { + client?.close(); + if (server) { + await server.forceShutdown(); + } + }); + + async function start( + exchange: Exchange, + policy = testPolicy, + brokerArchiver?: BrokerExecutionArchiver, + withdrawalObservationTracker?: WithdrawalObservationTracker, + ) { + server = getServer( + policy, + createPool(exchange), + ["*"], + false, + "", + undefined, + brokerArchiver, + undefined, + withdrawalObservationTracker, + ); + const port = await bindServer(server); + client = new grpcObj.cex_broker.cex_service( + `127.0.0.1:${port}`, + grpc.credentials.createInsecure(), + ); + return client; + } + + test("archives evolving fetchWithdrawals venue observations without changing the RPC result", async () => { + const forwarder = await startForwarderServer(); + const withdrawals = [ + { + id: "wd-1", + txid: "tx-1", + currency: "USDC", + status: "pending", + amount: 12.5, + address: "0xrecipient", + network: "ARBITRUM", + fee: { cost: 0, currency: "USDC" }, + datetime: "2026-07-01T00:00:00.000Z", + info: { + amount: "12.50000000", + completeTime: "", + withdrawOrderId: "lane-withdrawal-1", + }, + }, + { + id: "wd-2", + txid: "tx-2", + currency: "ETH", + status: "ok", + amount: "1.25", + fee: { cost: "0.005", currency: "ETH" }, + }, + ]; + const options = { withdrawals }; + const { exchange, calls } = createTreasuryExchange(options); + ( + exchange as unknown as Record< + string, + (...args: unknown[]) => Promise + > + ).fetchWithdrawalsHistory = async () => [ + { id: "not-an-exact-fetch-withdrawals-call", currency: "USDC" }, + ]; + const archiver = BrokerExecutionArchiver.create({ + forwarderUrl: forwarder.url, + deadLetterPath: join(archiveTestDirectory, "loss.jsonl"), + deploymentId: "test-deploy", + batchSize: 100, + flushIntervalMs: 60_000, + }); + const tracker = new WithdrawalObservationTracker(); + + try { + const rpc = await start(exchange, testPolicy, archiver, tracker); + const request = { + action: Action.Call, + cex: "binance", + payload: { + functionName: "fetchWithdrawals", + args: '["USDC", 1700000000000]', + params: '{"limit": 50}', + }, + }; + + const initial = await executeAction(rpc, request); + expect(JSON.parse(initial.result)).toEqual(withdrawals); + expect(calls.fetchWithdrawals[0]).toEqual([ + "USDC", + 1700000000000, + { limit: 50 }, + ]); + await Promise.resolve(); + await archiver.flush(); + + const initialRows = forwarder.requests.flatMap( + (post) => post.body.rows ?? [], + ) as Array<{ table: string; row: Record }>; + expect(initialRows).toHaveLength(2); + expect(initialRows[0]).toMatchObject({ + table: "broker_execution.transfer_events", + row: { + schema_version: "1", + event_kind: "withdrawal", + lifecycle_action: "observe_withdrawal", + exchange: "binance", + account_selector: "primary", + asset_symbol: "USDC", + external_id: "wd-1", + // The submission that opened this movement is keyed on the same + // client id, so the observation is joinable back to it. + client_withdrawal_id: "lane-withdrawal-1", + txid: "tx-1", + status: "pending", + amount: "12.50000000", + fee_amount: "0", + fee_currency: "USDC", + address: "0xrecipient", + network: "ARBITRUM", + exchange_timestamp: "2026-07-01T00:00:00.000Z", + result_index: 0, + }, + }); + expect(initialRows[0]?.row.payload_json).toBe( + JSON.stringify(withdrawals[0]), + ); + expect(initialRows[1]?.row).toMatchObject({ + asset_symbol: "ETH", + external_id: "wd-2", + // A venue record with no echoed client id stays empty rather than + // borrowing one from a neighbouring observation. + client_withdrawal_id: "", + txid: "tx-2", + fee_amount: "0.005", + fee_currency: "ETH", + result_index: 1, + }); + + const nonExact = await executeAction(rpc, { + ...request, + payload: { + functionName: "fetchWithdrawalsHistory", + args: "[]", + params: "{}", + }, + }); + expect(JSON.parse(nonExact.result)).toEqual([ + { id: "not-an-exact-fetch-withdrawals-call", currency: "USDC" }, + ]); + expect(tracker.getSize()).toBe(2); + + await executeAction(rpc, request); + await Promise.resolve(); + expect(archiver.getQueueDepth()).toBe(0); + + withdrawals[0] = { + ...withdrawals[0], + status: "ok", + txid: "tx-1-final", + info: { + amount: "12.50000000", + completeTime: "2026-07-01T00:05:00.000Z", + }, + }; + await executeAction(rpc, request); + await Promise.resolve(); + await archiver.flush(); + + const allRows = forwarder.requests.flatMap( + (post) => post.body.rows ?? [], + ) as Array<{ row: Record }>; + expect(allRows).toHaveLength(3); + expect(allRows[2]?.row).toMatchObject({ + external_id: "wd-1", + txid: "tx-1-final", + status: "ok", + result_index: 0, + }); + } finally { + await archiver.close(); + await forwarder.close(); + } + }); + + test("keeps fetchWithdrawals successful and the tracker empty when archiving is disabled", async () => { + const withdrawals = [{ id: "wd-1", currency: "USDC", status: "pending" }]; + const { exchange } = createTreasuryExchange({ withdrawals }); + const tracker = new WithdrawalObservationTracker(); + const rpc = await start( + exchange, + testPolicy, + BrokerExecutionArchiver.disabled(), + tracker, + ); + + const response = await executeAction(rpc, { + action: Action.Call, + cex: "binance", + payload: { functionName: "fetchWithdrawals", args: "[]", params: "{}" }, + }); + + expect(JSON.parse(response.result)).toEqual(withdrawals); + expect(tracker.getSize()).toBe(0); + }); + + test("serves fetchMarkets and fetchCurrencies through the Call action", async () => { + const { exchange, calls } = createTreasuryExchange(); + const rpc = await start(exchange); + + const markets = await executeAction(rpc, { + action: Action.Call, + cex: "binance", + payload: { functionName: "fetchMarkets", args: "[]", params: "{}" }, + }); + expect(JSON.parse(markets.result)).toEqual([ + { symbol: "ARB/USDC", base: "ARB", quote: "USDC" }, + ]); + + const currencies = await executeAction(rpc, { + action: Action.Call, + cex: "binance", + payload: { functionName: "fetchCurrencies", args: "[]", params: "{}" }, + }); + expect(JSON.parse(currencies.result)).toHaveProperty("USDC.networks.BSC"); + expect(calls.fetchMarkets).toHaveLength(1); + expect(calls.fetchCurrencies).toHaveLength(1); + }); + + test("falls back to loaded markets when direct fetchMarkets is unavailable", async () => { + const { exchange, calls } = createTreasuryExchange({ + has: { fetchMarkets: false }, + }); + const rpc = await start(exchange); + + const response = await executeAction(rpc, { + action: Action.Call, + cex: "binance", + payload: { functionName: "fetchMarkets", args: "[]", params: "{}" }, + }); + + expect(JSON.parse(response.result)).toEqual([ + { symbol: "ARB/USDC", base: "ARB", quote: "USDC" }, + ]); + expect(calls.fetchMarkets).toHaveLength(0); + expect(calls.loadMarkets).toHaveLength(1); + }); + + test("allows callable treasury methods when capability metadata is missing", async () => { + const { exchange, calls } = createTreasuryExchange(); + const rpc = await start(exchange); + + const response = await executeAction(rpc, { + action: Action.Call, + cex: "binance", + payload: { functionName: "fetchTotalBalance", args: "[]", params: "{}" }, + }); + + expect(JSON.parse(response.result)).toEqual({ USDC: 42 }); + expect(calls.fetchTotalBalance).toHaveLength(1); + }); + + test("rejects callable treasury methods when capability is explicitly false", async () => { + const { exchange, calls } = createTreasuryExchange({ + has: { fetchTotalBalance: false }, + }); + const rpc = await start(exchange); + + await expect( + executeAction(rpc, { + action: Action.Call, + cex: "binance", + payload: { + functionName: "fetchTotalBalance", + args: "[]", + params: "{}", + }, + }), + ).rejects.toMatchObject({ + code: grpc.status.INVALID_ARGUMENT, + }); + expect(calls.fetchTotalBalance).toHaveLength(0); + }); + + test("serves transfer-network metadata through FetchCurrency", async () => { + const { exchange } = createTreasuryExchange(); + const rpc = await start(exchange); + + const response = await executeAction(rpc, { + action: Action.FetchCurrency, + cex: "binance", + symbol: "USDC", + }); + + expect(JSON.parse(response.result)).toMatchObject({ + exchange: "binance", + asset: "USDC", + code: "USDC", + networks: { + BSC: { + id: "BSC", + network: "BSC", + operatorAlias: "BSC", + brokerNetworkId: "BNB", + exchangeNetworkId: "BSC", + deposit: true, + withdraw: true, + fee: "0", + }, + }, + networkAliases: { + BNB: { + operatorAlias: "BNB", + brokerNetworkId: "BNB", + exchangeNetworkId: "BSC", + networkKey: "BSC", + }, + BEP20: { + operatorAlias: "BEP20", + brokerNetworkId: "BNB", + exchangeNetworkId: "BSC", + networkKey: "BSC", + }, + }, + }); + }); + + test("extracts transfer fee and limit metadata for funding discovery", async () => { + const { exchange, calls } = createTreasuryExchange({ + has: { fetchDepositWithdrawFees: true }, + depositWithdrawFees: { + USDC: { + withdraw: { fee: 0, percentage: false }, + networks: { + BSC: { + id: "BSC", + network: "BSC", + fee: 0, + limits: { withdraw: { min: 1, max: 50000 } }, + withdraw: true, + deposit: true, + }, + }, + }, + }, + }); + const rpc = await start(exchange); + + const response = await executeAction(rpc, { + action: Action.FetchFees, + cex: "binance", + symbol: "USDC", + payload: { includeAllFees: true }, + }); + + expect(JSON.parse(response.result)).toMatchObject({ + feeScope: "token", + symbol: "USDC", + fundingFeeSource: "fetchDepositWithdrawFees", + fundingFeesByCurrency: { + USDC: { + withdraw: { fee: 0, percentage: false }, + networks: { + BSC: { + fee: 0, + limits: { withdraw: { min: 1, max: 50000 } }, + withdraw: true, + deposit: true, + }, + }, + }, + }, + }); + expect(calls.fetchDepositWithdrawFees[0]).toEqual([["USDC"]]); + }); + + test("fails closed when a requested transfer-network alias is unsupported", async () => { + const { exchange } = createTreasuryExchange(); + const policy: PolicyConfig = { + withdraw: { rule: [] }, + deposit: { + rule: [{ exchange: "BINANCE", network: "TRC20", coins: ["USDC"] }], + }, + order: { rule: { markets: [], limits: [] } }, + }; + const rpc = await start(exchange, policy); + + await expect( + executeAction(rpc, { + action: Action.FetchDepositAddresses, + cex: "binance", + symbol: "USDC", + payload: { chain: "TRC20" }, + }), + ).rejects.toMatchObject({ + code: grpc.status.INVALID_ARGUMENT, + details: + "network_alias_unresolved: USDC/TRC20 is not available in discovered transfer networks", + }); + }); + + test("resolves BNB aliases for deposit address and withdrawal calls", async () => { + const { exchange, calls } = createTreasuryExchange(); + const policy: PolicyConfig = { + withdraw: { + rule: [ + { + exchange: "BINANCE", + network: "BNB", + whitelist: ["0xwithdraw"], + coins: ["USDC"], + }, + ], + }, + deposit: { + rule: [{ exchange: "BINANCE", network: "BEP20", coins: ["USDC"] }], + }, + order: { rule: { markets: [], limits: [] } }, + }; + const rpc = await start(exchange, policy); + + const depositAddress = await executeAction(rpc, { + action: Action.FetchDepositAddresses, + cex: "binance", + symbol: "USDC", + payload: { + chain: "BEP20", + }, + }); + expect(JSON.parse(depositAddress.result)).toEqual([ + { + address: "0xdeposit", + operatorAlias: "BEP20", + brokerNetworkId: "BNB", + exchangeNetworkId: "BSC", + }, + ]); + expect(calls.fetchDepositAddress[0]).toEqual(["USDC", { network: "BSC" }]); + + const withdraw = await executeAction(rpc, { + action: Action.Withdraw, + cex: "binance", + symbol: "USDC", + payload: { + recipientAddress: "0xwithdraw", + amount: "25.5", + chain: "BNB", + }, + }); + expect(JSON.parse(withdraw.result)).toMatchObject({ + id: "withdraw-1", + operatorAlias: "BNB", + brokerNetworkId: "BNB", + exchangeNetworkId: "BSC", + }); + expect(calls.withdraw[0]).toEqual([ + "USDC", + 25.5, + "0xwithdraw", + undefined, + { network: "BSC" }, + ]); + }); + + test("returns stable policy-denied errors for transfer actions", async () => { + const { exchange } = createTreasuryExchange(); + const policy: PolicyConfig = { + withdraw: { + rule: [ + { + exchange: "BINANCE", + network: "BEP20", + whitelist: ["0xwithdraw"], + coins: ["USDC"], + }, + ], + }, + deposit: { + rule: [{ exchange: "BINANCE", network: "BEP20", coins: ["USDC"] }], + }, + order: { rule: { markets: [], limits: [] } }, + }; + const rpc = await start(exchange, policy); + + await expect( + executeAction(rpc, { + action: Action.FetchDepositAddresses, + cex: "binance", + symbol: "USDT", + payload: { chain: "BEP20" }, + }), + ).rejects.toMatchObject({ + code: grpc.status.PERMISSION_DENIED, + details: expect.stringContaining("policy_deposit_denied:"), + }); + await expect( + executeAction(rpc, { + action: Action.Withdraw, + cex: "binance", + symbol: "ARB", + payload: { + recipientAddress: "0xwithdraw", + amount: "25.5", + chain: "BEP20", + }, + }), + ).rejects.toMatchObject({ + code: grpc.status.PERMISSION_DENIED, + details: expect.stringContaining("policy_withdrawal_denied:"), + }); + }); + + test("observes credited and missing deposits with stable statuses", async () => { + const { exchange, calls } = createTreasuryExchange({ + deposits: [ + { + id: "deposit-1", + txid: "0xtx", + amount: "25.5", + address: "0xdeposit", + status: "ok", + confirmations: 15, + datetime: "2026-06-04T00:00:00.000Z", + }, + ], + }); + const archivedRows: BrokerArchiveRow[] = []; + const archiver = { + isEnabled: () => true, + getDeploymentId: () => "test-deploy", + enqueue: (row: BrokerArchiveRow) => archivedRows.push(row), + } as unknown as BrokerExecutionArchiver; + const rpc = await start(exchange, testPolicy, archiver); + + const credited = await executeAction(rpc, { + action: Action.Deposit, + cex: "binance", + symbol: "USDC", + payload: { + recipientAddress: "0xdeposit", + amount: "25.5", + transactionHash: "0xtx", + since: "1710000000000", + params: JSON.stringify({ network: "BEP20" }), + }, + }); + expect(JSON.parse(credited.result)).toMatchObject({ + status: "credited", + exchange: "binance", + asset: "USDC", + operatorAlias: "BEP20", + brokerNetworkId: "BNB", + exchangeNetworkId: "BSC", + txid: "0xtx", + amount: "25.5", + observedAmount: "25.5", + expectedAmount: 25.5, + address: "0xdeposit", + confirmations: 15, + creditedAt: "2026-06-04T00:00:00.000Z", + }); + await Promise.resolve(); + expect(archivedRows).toHaveLength(1); + expect(archivedRows[0]).toMatchObject({ + table: "broker_execution.transfer_events", + row: { + event_kind: "deposit", + lifecycle_action: "observe_deposit", + status: "ok", + external_id: "0xtx", + }, + }); + expect(calls.fetchDeposits[0]).toEqual([ + "USDC", + 1710000000000, + 50, + { network: "BSC" }, + ]); + + const missing = await executeAction(rpc, { + action: Action.Deposit, + cex: "binance", + symbol: "USDC", + payload: { + recipientAddress: "0xdeposit", + amount: "25.5", + transactionHash: "0xmissing", + }, + }); + expect(JSON.parse(missing.result)).toMatchObject({ + status: "not_found", + txid: "0xmissing", + expectedAmount: 25.5, + }); + }); + + test("normalizes pending, failed, and timed-out deposit observation statuses", async () => { + const { exchange } = createTreasuryExchange({ + deposits: [ + { + txid: "0xpending", + amount: "1", + address: "0xdeposit", + status: "pending", + }, + { + txid: "0xfailed", + amount: "1", + address: "0xdeposit", + status: "rejected", + }, + { + txid: "0xtimeout", + amount: "1", + address: "0xdeposit", + status: "timeout", + }, + ], + }); + const rpc = await start(exchange); + + for (const [txid, status] of [ + ["0xpending", "pending"], + ["0xfailed", "failed"], + ["0xtimeout", "timed_out"], + ] as const) { + const response = await executeAction(rpc, { + action: Action.Deposit, + cex: "binance", + symbol: "USDC", + payload: { + recipientAddress: "0xdeposit", + amount: "1", + transactionHash: txid, + }, + }); + expect(JSON.parse(response.result)).toMatchObject({ txid, status }); + } + }); + + test("reports unsupported deposit observation without broker mutation", async () => { + const { exchange } = createTreasuryExchange({ + has: { fetchDeposits: false }, + }); + const rpc = await start(exchange); + + const response = await executeAction(rpc, { + action: Action.Deposit, + cex: "binance", + symbol: "USDC", + payload: { + recipientAddress: "0xdeposit", + amount: "25.5", + transactionHash: "0xtx", + }, + }); + + expect(JSON.parse(response.result)).toMatchObject({ + status: "unsupported", + txid: "0xtx", + expectedAmount: 25.5, + }); + }); + + test("rejects deposit observations with mismatched amount", async () => { + const { exchange } = createTreasuryExchange({ + deposits: [ + { + txid: "0xtx", + amount: "24", + address: "0xdeposit", + status: "ok", + }, + ], + }); + const rpc = await start(exchange); + + await expect( + executeAction(rpc, { + action: Action.Deposit, + cex: "binance", + symbol: "USDC", + payload: { + recipientAddress: "0xdeposit", + amount: "25.5", + transactionHash: "0xtx", + }, + }), + ).rejects.toMatchObject({ + code: grpc.status.FAILED_PRECONDITION, + details: "deposit_amount_mismatch: expected 25.5, observed 24", + }); + }); + + test("rejects deposit observations with mismatched address", async () => { + const { exchange } = createTreasuryExchange({ + deposits: [ + { + txid: "0xtx", + amount: "25.5", + address: "0xother", + status: "ok", + }, + ], + }); + const rpc = await start(exchange); + + await expect( + executeAction(rpc, { + action: Action.Deposit, + cex: "binance", + symbol: "USDC", + payload: { + recipientAddress: "0xdeposit", + amount: "25.5", + transactionHash: "0xtx", + }, + }), + ).rejects.toMatchObject({ + code: grpc.status.FAILED_PRECONDITION, + details: + "deposit_address_mismatch: expected address 0xdeposit, observed 0xother", + }); + }); + + test("keeps pre-existing balance fetch action backward compatible", async () => { + const { exchange, calls } = createTreasuryExchange({ + balances: { USDC: 42, ARB: 7 }, + }); + const rpc = await start(exchange); + + const response = await executeAction(rpc, { + action: Action.FetchBalances, + cex: "binance", + symbol: "USDC", + }); + + expect(JSON.parse(response.result)).toEqual({ + balances: { USDC: 42 }, + balanceType: "total", + }); + expect(calls.fetchTotalBalance[0]).toEqual([{ type: "spot" }]); + }); +});