diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..d50369a --- /dev/null +++ b/.env.sample @@ -0,0 +1,7 @@ + +# Required for CLI and Optional for the Broker Library + +CEX_BROKER_BYBIT_API_KEY=*********************** +CEX_BROKER_BINANCE_API_KEY=**************************************** +CEX_BROKER_BYBIT_API_SECRET=*********************************************** +CEX_BROKER_BINANCE_API_SECRET=************************************************************ \ No newline at end of file diff --git a/.github/workflows/bun-ci.yml b/.github/workflows/ci.yml similarity index 65% rename from .github/workflows/bun-ci.yml rename to .github/workflows/ci.yml index df0c57d..715c2a3 100644 --- a/.github/workflows/bun-ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,6 @@ -# .github/workflows/bun-ci.yml +# .github/workflows/ci.yml -name: Bun CI +name: CI on: push: @@ -24,5 +24,11 @@ jobs: - name: Install dependencies run: bun install + - name: Run Biome lint + run: bunx @biomejs/biome lint . + + - name: Run Biome format check + run: bunx @biomejs/biome format --write=false . + - name: Run tests - run: bun test + run: bun test \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ad3b91b --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,41 @@ +# .github/workflows/publish.yml + +name: Publish + +on: + push: + tags: + - "v*" + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout repo + uses: actions/checkout@v3 + + - name: Install Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Run tests + run: bun test + + - name: Run Biome lint + run: bunx @biomejs/biome lint . + + - name: Run Biome format check + run: bunx @biomejs/biome format --write=false . + + - name: Build project + run: bun run build + + - name: Publish to npm + run: bun publish + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9e539af..180bb1e 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store -build \ No newline at end of file +build +proto/** \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4ab8802 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright Β© 2024 Usher Labs Pty Ltd + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 524d3c8..c40a17f 100644 --- a/README.md +++ b/README.md @@ -1,64 +1,73 @@ -# FietCexBroker +# CEX Broker -A high-performance gRPC-based cryptocurrency exchange broker service that provides unified access to multiple centralized exchanges (CEX) including Binance and Bybit. Built with TypeScript, Bun, and CCXT for reliable trading operations. +A high-performance gRPC-based cryptocurrency exchange broker service that provides unified access to multiple centralized exchanges (CEX) through the CCXT library. Built with TypeScript, Bun, and designed for reliable trading operations with policy enforcement, real-time streaming, and zero-knowledge proof integration. -## Features +## πŸš€ Features -- **Multi-Exchange Support**: Unified API for Binance and Bybit -- **gRPC Interface**: High-performance RPC communication -- **Real-time Pricing**: Optimal price discovery across exchanges -- **Balance Management**: Real-time balance checking -- **Order Management**: Create, track, and cancel orders -- **Transfer Operations**: Withdraw funds to external addresses -- **Token Conversion**: Convert between different tokens -- **Policy Enforcement**: Configurable trading and withdrawal limits +- **Multi-Exchange Support**: Unified API to any CEX supported by [CCXT](https://github.com/ccxt/ccxt) (100+ exchanges) +- **gRPC Interface**: High-performance RPC communication with type safety +- **Real-time Streaming**: Live orderbook, trades, ticker, OHLCV, balance, and order updates +- **Policy Enforcement**: Configurable trading and withdrawal limits with real-time policy updates - **IP Authentication**: Security through IP whitelisting +- **Zero-Knowledge Proofs**: Optional Verity integration for privacy-preserving operations +- **Secondary Broker Support**: Multiple API keys per exchange for load balancing and redundancy +- **Real-time Policy Updates**: Hot-reload policy changes without server restart - **Type Safety**: Full TypeScript support with generated protobuf types +- **Comprehensive Logging**: Built-in logging with tslog +- **CLI Support**: Command-line interface for easy management +- **Deposit Address Management**: Fetch deposit addresses for supported networks +- **Advanced Order Management**: Create, fetch, and cancel orders with full details -## Prerequisites +## πŸ“‹ Prerequisites - [Bun](https://bun.sh) (v1.2.17 or higher) -- API keys for supported exchanges (Binance, Bybit) +- API keys for supported exchanges (e.g., Binance, Bybit, etc.) +- Optional: Verity prover URL for zero-knowledge proof integration -## Installation +## πŸ› οΈ Installation -1. Clone the repository: -```bash -git clone -cd fietCexBroker -``` +1. **Clone the repository:** + ```bash + git clone + cd fietCexBroker + ``` -2. Install dependencies: -```bash -bun install -``` +2. **Install dependencies:** + ```bash + bun install + ``` -3. Generate protobuf types: -```bash -bun run proto-gen -``` +3. **Generate protobuf types:** + ```bash + bun run proto-gen + ``` -## Configuration +## βš™οΈ Configuration ### Environment Variables -Create a `.env` file in the root directory with the following variables: +The broker loads configuration from environment variables with the `CEX_BROKER_` prefix: ```env # Server Configuration -PORT_NUM=8082 - -# Exchange API Keys -BINANCE_API_KEY=your_binance_api_key -BINANCE_API_SECRET=your_binance_api_secret -BYBIT_API_KEY=your_bybit_api_key -BYBIT_API_SECRET=your_bybit_api_secret - -# Supported Brokers (optional, defaults to BINANCE,BYBIT) -ROOCH_CHAIN_ID=BINANCE,BYBIT +PORT_NUM=8086 + +# Primary Exchange API Keys (format: CEX_BROKER__API_KEY/SECRET) +CEX_BROKER_BINANCE_API_KEY=your_binance_api_key +CEX_BROKER_BINANCE_API_SECRET=your_binance_api_secret +CEX_BROKER_BYBIT_API_KEY=your_bybit_api_key +CEX_BROKER_BYBIT_API_SECRET=your_bybit_api_secret +CEX_BROKER_KRAKEN_API_KEY=your_kraken_api_key +CEX_BROKER_KRAKEN_API_SECRET=your_kraken_api_secret + +# Secondary Exchange API Keys (for load balancing and redundancy) +CEX_BROKER_BINANCE_API_KEY_1=your_secondary_binance_api_key +CEX_BROKER_BINANCE_API_SECRET_1=your_secondary_binance_api_secret +CEX_BROKER_BINANCE_API_KEY_2=your_tertiary_binance_api_key +CEX_BROKER_BINANCE_API_SECRET_2=your_tertiary_binance_api_secret ``` -**Note**: API keys are only required for the exchanges you plan to use. The system will validate that required keys are provided based on the `ROOCH_CHAIN_ID` configuration. +**Note**: Only configure API keys for exchanges you plan to use. The system will automatically detect and initialize configured exchanges. ### Policy Configuration @@ -68,7 +77,7 @@ Configure trading policies in `policy/policy.json`: { "withdraw": { "rule": { - "networks": ["BEP20", "ARBITRUM"], + "networks": ["BEP20", "ARBITRUM", "ETHEREUM"], "whitelist": ["0x9d467fa9062b6e9b1a46e26007ad82db116c67cb"], "amounts": [ { @@ -104,19 +113,34 @@ Configure trading policies in `policy/policy.json`: } ``` -## Usage +## πŸš€ Usage ### Starting the Server ```bash -# Development +# Using the CLI (recommended) +bun run start-broker --policy policy/policy.json --port 8086 --whitelist 127.0.0.1 192.168.1.100 --verityProverUrl http://localhost:8080 + +# Development mode bun run start # Production build -bun run build +bun run build:ts bun run ./build/index.js ``` +### CLI Options + +```bash +cex-broker --help + +Options: + -p, --policy Policy JSON file (required) + --port Port number (default: 8086) + -w, --whitelist IPv4 address whitelist (space-separated list) + -vu, --verityProverUrl Verity Prover URL for zero-knowledge proofs +``` + ### Available Scripts ```bash @@ -124,7 +148,7 @@ bun run ./build/index.js bun run start # Build for production -bun run build +bun run build:ts # Run tests bun test @@ -142,195 +166,191 @@ bun run lint bun run check ``` -## API Reference +## πŸ“‘ API Reference -The service exposes a gRPC interface with the following methods: +The service exposes a gRPC interface with two main methods: -### GetOptimalPrice +### ExecuteAction -Get optimal buy/sell prices across supported exchanges. +Execute trading operations on supported exchanges. **Request:** ```protobuf -message OptimalPriceRequest { - string symbol = 1; // Trading pair symbol, e.g. "ARB/USDT" - double quantity = 2; // Quantity to buy or sell - OrderMode mode = 3; // Buy (0) or Sell (1) mode +message ActionRequest { + Action action = 1; // The action to perform + map payload = 2; // Parameters for the action + string cex = 3; // CEX identifier (e.g., "binance", "bybit") + string symbol = 4; // Trading pair symbol if needed } ``` **Response:** ```protobuf -message OptimalPriceResponse { - map results = 1; -} - -message PriceInfo { - double avgPrice = 1; // Volume-weighted average price - double fillPrice = 2; // Worst-case fill price +message ActionResponse { + string result = 2; // JSON string of the result data or ZK proof } ``` -**Example:** -```typescript -const request = { - symbol: "ARB/USDT", - quantity: 100, - mode: 0 // BUY -}; -``` - -### GetBalance +**Available Actions:** +- `NoAction` (0): No operation +- `Deposit` (1): Confirm deposit transaction +- `Transfer` (2): Transfer/withdraw funds +- `CreateOrder` (3): Create a new order +- `GetOrderDetails` (4): Get order information +- `CancelOrder` (5): Cancel an existing order +- `FetchBalance` (6): Get account balance +- `FetchDepositAddresses` (7): Get deposit addresses for a token/network -Get available balance for a specific currency on a specific exchange. +**Example Usage:** -**Request:** -```protobuf -message BalanceRequest { - string cex = 1; // CEX identifier (e.g., "BINANCE", "BYBIT") - string token = 2; // Token symbol, e.g. "USDT" -} -``` +```typescript +// Fetch balance +const balanceRequest = { + action: 6, // FetchBalance + payload: {}, + cex: "binance", + symbol: "USDT" +}; -**Response:** -```protobuf -message BalanceResponse { - double balance = 1; // Available balance for the token - string currency = 2; // Currency of the balance -} -``` +// Create order +const orderRequest = { + action: 3, // CreateOrder + payload: { + orderType: "limit", + amount: "0.001", + fromToken: "BTC", + toToken: "USDT", + price: "50000" + }, + cex: "binance", + symbol: "BTC/USDT" +}; -**Example:** -```typescript -const request = { - cex: "BINANCE", - token: "USDT" +// Fetch deposit addresses +const depositAddressRequest = { + action: 7, // FetchDepositAddresses + payload: { + chain: "BEP20" + }, + cex: "binance", + symbol: "USDT" }; ``` -### Deposit +### Subscribe (Streaming) -Confirm a deposit transaction. +Real-time streaming of market data and account updates. **Request:** ```protobuf -message DepositConfirmationRequest { - string chain = 1; - string recipient_address = 2; - double amount = 3; - string transaction_hash = 4; +message SubscribeRequest { + string cex = 1; // CEX identifier + string symbol = 2; // Trading pair symbol + SubscriptionType type = 3; // Type of subscription + map options = 4; // Additional options (e.g., timeframe) } ``` -**Response:** +**Response Stream:** ```protobuf -message DepositConfirmationResponse { - double newBalance = 1; +message SubscribeResponse { + string data = 1; // JSON string of the streaming data + int64 timestamp = 2; // Unix timestamp + string symbol = 3; // Trading pair symbol + SubscriptionType type = 4; // Type of subscription } ``` -### Transfer +**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 -Execute a transfer/withdrawal to an external address. +**Example Usage:** -**Request:** -```protobuf -message TransferRequest { - string chain = 1; // Network chain (e.g., "ARBITRUM", "BEP20") - string recipient_address = 2; // Destination address - double amount = 3; // Amount to transfer - string cex = 4; // CEX identifier - string token = 5; // Token symbol -} -``` +```typescript +// Subscribe to orderbook updates +const orderbookRequest = { + cex: "binance", + symbol: "BTC/USDT", + type: 0, // ORDERBOOK + options: {} +}; -**Response:** -```protobuf -message TransferResponse { - bool success = 1; - string transaction_id = 2; -} +// Subscribe to OHLCV with custom timeframe +const ohlcvRequest = { + cex: "binance", + symbol: "BTC/USDT", + type: 3, // OHLCV + options: { + timeframe: "1h" + } +}; ``` -### Convert +## πŸ”’ Security -Convert between different tokens using limit orders. +### IP Authentication -**Request:** -```protobuf -message ConvertRequest { - string from_token = 1; // Source token - string to_token = 2; // Destination token - double amount = 3; // Amount to convert - double price = 4; // Limit price - string cex = 5; // CEX identifier -} -``` +All API calls require IP authentication. Configure allowed IPs via CLI or broker initialization: -**Response:** -```protobuf -message ConvertResponse { - string order_id = 3; -} +```bash +# Via CLI +cex-broker --policy policy.json --whitelist 127.0.0.1 192.168.1.100 + +# Via code +const config = { + port: 8086, + whitelistIps: [ + "127.0.0.1", // localhost + "::1", // IPv6 localhost + "192.168.1.100", // Your allowed IP + ] +}; ``` -### GetOrderDetails +### Secondary Broker Support -Get details of a specific order. +For high-availability, load balancing and compartmentalized capital management, **you can configure multiple API keys per exchange**: -**Request:** -```protobuf -message OrderDetailsRequest { - string order_id = 1; // Unique order identifier - string cex = 2; // CEX identifier -} -``` - -**Response:** -```protobuf -message OrderDetailsResponse { - string order_id = 1; // Unique order identifier - string status = 2; // Current order status - double original_amount = 3; // Original order amount - double filled_amount = 4; // Amount that has been filled - string symbol = 5; // Trading pair symbol - string mode = 6; // Buy or Sell mode - double price = 7; // Order price -} +```env +# Primary keys +CEX_BROKER_BINANCE_API_KEY=primary_key +CEX_BROKER_BINANCE_API_SECRET=primary_secret + +# Secondary keys (numbered) +CEX_BROKER_BINANCE_API_KEY_1=secondary_key_1 +CEX_BROKER_BINANCE_API_SECRET_1=secondary_secret_1 +CEX_BROKER_BINANCE_API_KEY_2=secondary_key_2 +CEX_BROKER_BINANCE_API_SECRET_2=secondary_secret_2 ``` -### CancelOrder - -Cancel an existing order. +To use secondary brokers, include the `use-secondary-key` metadata in your gRPC calls: -**Request:** -```protobuf -message CancelOrderRequest { - string order_id = 1; // Unique order identifier - string cex = 2; // CEX identifier -} +```typescript +const metadata = new grpc.Metadata(); +metadata.set('use-secondary-key', '1'); // Use secondary broker 1 +metadata.set('use-secondary-key', '2'); // Use secondary broker 2 ``` -**Response:** -```protobuf -message CancelOrderResponse { - bool success = 1; // Whether cancellation was successful - string final_status = 2; // Final status of the order -} -``` +### Zero-Knowledge Proof Integration -## Security +**Enable privacy-preserving proof over CEX data** with [Verity zkTLS integration](https://github.com/usherlabs/verity-dp): -### IP Authentication +```bash +# Start with Verity integration +cex-broker --policy policy.json --verityProverUrl http://localhost:8080 +``` -All API calls require IP authentication. Configure allowed IPs in `helpers/index.ts`: +When Verity is enabled, responses include zero-knowledge proofs instead of raw data: ```typescript -const ALLOWED_IPS = [ - "127.0.0.1", // localhost - "::1", // IPv6 localhost - // Add your allowed IP addresses here -]; +// With Verity enabled +const response = await client.ExecuteAction(request, metadata); +// response.result contains ZK proof instead of raw data ``` ### API Key Management @@ -339,49 +359,141 @@ const ALLOWED_IPS = [ - Use read-only API keys when possible - Regularly rotate API keys - Monitor API usage and set appropriate rate limits +- Use secondary brokers for redundancy and load distribution -## Error Handling - -The service returns appropriate gRPC status codes: - -- `INVALID_ARGUMENT`: Missing or invalid parameters -- `PERMISSION_DENIED`: IP not allowed or policy violation -- `NOT_FOUND`: Resource not found (e.g., currency balance) -- `INTERNAL`: Server error - -## Development +## πŸ—οΈ Architecture ### Project Structure ``` fietCexBroker/ -β”œβ”€β”€ config/ # Configuration files -β”‚ β”œβ”€β”€ broker.ts # Exchange broker setup -β”‚ └── index.ts # Environment configuration -β”œβ”€β”€ helpers/ # Utility functions -β”‚ └── index.ts # Core helper functions -β”œβ”€β”€ policy/ # Policy configuration -β”‚ └── policy.json # Trading and withdrawal rules +β”œβ”€β”€ src/ # Source code +β”‚ β”œβ”€β”€ commands/ # CLI commands +β”‚ β”‚ └── start-broker.ts # Broker startup command +β”‚ β”œβ”€β”€ helpers/ # Utility functions +β”‚ β”‚ β”œβ”€β”€ index.ts # Policy validation helpers +β”‚ β”‚ └── logger.ts # Logging configuration +β”‚ β”œβ”€β”€ index.ts # Main broker class +β”‚ β”œβ”€β”€ server.ts # gRPC server implementation +β”‚ β”œβ”€β”€ cli.ts # CLI entry point +β”‚ └── types.ts # TypeScript type definitions β”œβ”€β”€ proto/ # Protocol buffer definitions -β”‚ β”œβ”€β”€ fietCexNode/ # Generated TypeScript types β”‚ β”œβ”€β”€ node.proto # Service definition β”‚ └── node.ts # Type exports +β”œβ”€β”€ policy/ # Policy configuration +β”‚ └── policy.json # Trading and withdrawal rules β”œβ”€β”€ scripts/ # Build scripts -β”‚ └── patch-protobufjs.js -β”œβ”€β”€ index.ts # Main server file -β”œβ”€β”€ types.ts # TypeScript type definitions -β”œβ”€β”€ proto-gen.sh # Protobuf generation script -β”œβ”€β”€ biome.json # Code formatting/linting config -β”œβ”€β”€ bunfig.toml # Bun configuration +β”œβ”€β”€ test/ # Test files +β”œβ”€β”€ patches/ # Dependency patches +β”œβ”€β”€ build.ts # Build configuration └── package.json # Dependencies and scripts ``` +### Core Components + +- **CEXBroker**: Main broker class that manages exchange connections and policy enforcement +- **Policy System**: Real-time policy validation and enforcement +- **gRPC Server**: High-performance RPC interface with streaming support +- **CCXT Integration**: Unified access to 100+ cryptocurrency exchanges +- **Verity Integration**: Zero-knowledge proof generation for privacy +- **Secondary Broker Management**: Load balancing and redundancy support + +## πŸ§ͺ Development + ### Adding New Exchanges -1. Add the exchange to `types.ts` in the `BrokerList` -2. Configure API keys in `config/index.ts` -3. Initialize the broker in `config/broker.ts` -4. Update policy configuration if needed +The broker automatically supports all exchanges available in CCXT. To add a new exchange: + +1. Add your API credentials to environment variables: + ```env + CEX_BROKER__API_KEY=your_api_key + CEX_BROKER__API_SECRET=your_api_secret + ``` + +2. Update policy configuration if needed for the new exchange + +3. The broker will automatically detect and initialize the exchange + +### Using Secondary Brokers + +Secondary brokers provide redundancy and load balancing: + +1. Configure secondary API keys: + ```env + CEX_BROKER_BINANCE_API_KEY_1=secondary_key_1 + CEX_BROKER_BINANCE_API_SECRET_1=secondary_secret_1 + ``` + +2. Use secondary brokers in your gRPC calls: + ```typescript + const metadata = new grpc.Metadata(); + metadata.set('use-secondary-key', '1'); // Use secondary broker + ``` + +### Querying Supported Networks + +To understand which networks each exchange supports for deposits and withdrawals, you can query the exchange's currency information: + +```typescript +import ccxt from 'ccxt'; + +// Initialize the exchange (no API keys needed for public data) +const exchange = new ccxt.binance(); // or any other exchange like ccxt.bybit() + +// Fetch all currencies and their network information +const currencies = await exchange.fetchCurrencies(); + +// Example: Check USDT networks on Binance +const usdtInfo = currencies['USDT']; +console.log("USDT Networks on Binance:"); +console.log(usdtInfo?.networks); + +// Example output: +// { +// 'BEP20': {id: 'BSC', network: 'BSC', active: true, deposit: true, withdraw: true, fee: 1.0}, +// 'ETH': {id: 'ETH', network: 'ETH', active: true, deposit: true, withdraw: true, fee: 15.0}, +// 'TRC20': {id: 'TRX', network: 'TRX', active: true, deposit: true, withdraw: true, fee: 1.0} +// } + +// Check all available currencies +for (const [currency, info] of Object.entries(currencies)) { + if ('networks' in info) { + console.log(`\n${currency} networks:`); + for (const [network, networkInfo] of Object.entries(info.networks)) { + console.log(` ${network}:`, networkInfo); + } + } +} +``` + +**Common Network Identifiers:** +- `BEP20` / `BSC`: Binance Smart Chain +- `ETH` / `ERC20`: Ethereum +- `TRC20`: Tron +- `ARBITRUM`: Arbitrum One +- `POLYGON`: Polygon +- `AVALANCHE`: Avalanche C-Chain +- `OPTIMISM`: Optimism + +**Using this information in your policy:** + +```json +{ + "withdraw": { + "rule": { + "networks": ["BEP20", "ARBITRUM", "ETH"], // Networks supported by your exchanges + "whitelist": ["0x9d467fa9062b6e9b1a46e26007ad82db116c67cb"], + "amounts": [ + { + "ticker": "USDT", + "max": 100000, + "min": 1 + } + ] + } + } +} +``` ### Testing @@ -396,35 +508,69 @@ bun test --watch bun test --coverage ``` -## Dependencies +### Code Quality + +```bash +# Format code +bun run format + +# Lint code +bun run lint + +# Check code (format + lint) +bun run check +``` + +## πŸ“¦ Dependencies ### Core Dependencies + - `@grpc/grpc-js`: gRPC server implementation - `@grpc/proto-loader`: Protocol buffer loading -- `ccxt`: Cryptocurrency exchange library -- `dotenv`: Environment variable management +- `@usherlabs/ccxt`: Enhanced CCXT library with Verity support +- `commander`: CLI framework - `joi`: Configuration validation +- `tslog`: TypeScript logging ### Development Dependencies + - `@biomejs/biome`: Code formatting and linting - `@types/bun`: Bun type definitions +- `bun-plugin-dts`: TypeScript declaration generation - `bun-types`: Additional Bun types - `husky`: Git hooks -## Contributing +## 🀝 Contributing 1. Fork the repository -2. Create a feature branch +2. Create a feature branch (`git checkout -b feature/amazing-feature`) 3. Make your changes 4. Add tests for new functionality -5. Ensure all tests pass -6. Run `bun run check` to format and lint code -7. Submit a pull request +5. Ensure all tests pass (`bun test`) +6. Run code quality checks (`bun run check`) +7. Commit your changes (`git commit -m 'Add amazing feature'`) +8. Push to the branch (`git push origin feature/amazing-feature`) +9. Open a Pull Request + +## πŸ“„ License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## πŸ†˜ Support + +For issues and questions: + +- Open an issue on the repository +- Contact the development team +- Check the [CCXT documentation](https://docs.ccxt.com/) for exchange-specific information -## License +## πŸ™ Acknowledgments -[Add your license information here] +- [CCXT](https://github.com/ccxt/ccxt) for providing unified access to cryptocurrency exchanges +- [Bun](https://bun.sh) for the fast JavaScript runtime +- [gRPC](https://grpc.io/) for high-performance RPC communication +- [Verity](https://usher.so/) for zero-knowledge proof integration -## Support +--- -For issues and questions, please open an issue on the repository or contact the development team. +**Built with ❀️ by Usher Labs** diff --git a/biome.json b/biome.json index c74a9f3..c444b31 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,7 @@ }, "files": { "ignoreUnknown": true, - "includes": ["proto/", "index.ts", "helpers/", "config/", "policy/"] + "includes": ["src/*", "config/*", "policy/*"] }, "formatter": { "enabled": true, @@ -16,7 +16,15 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "style": { + "useNodejsImportProtocol": "off", + "useTemplate": "off" + }, + "correctness": { + "noUnusedImports": "warn", + "noUnusedVariables": "warn" + } } }, "javascript": { diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..48eeeb4 --- /dev/null +++ b/build.ts @@ -0,0 +1,22 @@ +import dts from 'bun-plugin-dts' + + +await Bun.build({ + entrypoints: ["./src/cli.ts"], + outdir: './dist/commands', + target:"node", + plugins: [ + dts() + ], +}) + +await Bun.build({ + entrypoints: ['./src/index.ts'], + outdir: './dist', + target:"node", + plugins: [ + // dts() + ], +}) + +// Generates `dist/index.d.ts` and `dist/other/foo.d.ts` \ No newline at end of file diff --git a/bun.lock b/bun.lock index 9297db8..9b1748c 100644 --- a/bun.lock +++ b/bun.lock @@ -6,14 +6,17 @@ "dependencies": { "@grpc/grpc-js": "^1.13.4", "@grpc/proto-loader": "^0.7.15", - "ccxt": "^4.4.91", - "dotenv": "^17.0.0", + "@usherlabs/ccxt": "^0.0.4", + "commander": "^14.0.0", "joi": "^17.13.3", + "tslog": "^4.9.3", }, "devDependencies": { "@biomejs/biome": "2.0.6", "@types/bun": "latest", + "bun-plugin-dts": "latest", "bun-types": "latest", + "dotenv": "^17.2.0", "husky": "^9.1.7", }, "peerDependencies": { @@ -21,6 +24,9 @@ }, }, }, + "patchedDependencies": { + "@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=="], @@ -76,19 +82,31 @@ "@sideway/pinpoint": ["@sideway/pinpoint@2.0.0", "", {}, "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ=="], - "@types/bun": ["@types/bun@1.2.17", "", { "dependencies": { "bun-types": "1.2.17" } }, "sha512-l/BYs/JYt+cXA/0+wUhulYJB6a6p//GTPiJ7nV+QHa8iiId4HZmnu/3J/SowP5g0rTiERY2kfGKXEK5Ehltx4Q=="], + "@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="], + + "@types/eventsource": ["@types/eventsource@1.1.15", "", {}, "sha512-XQmGcbnxUNa06HR3VBVkc9+A2Vpi9ZyLJcdS5dwaQQ/4ZMWFO+5c90FnMUpbtMZwB/FChoYHwuVg8TvkECacTA=="], "@types/node": ["@types/node@24.0.4", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA=="], "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + "@usherlabs/ccxt": ["@usherlabs/ccxt@0.0.4", "", { "dependencies": { "@usherlabs/verity-client": "^0.0.24", "axios": "^1.10.0", "ws": "^8.8.1" } }, "sha512-lmBieByW4BgNOJjBRicOSUooMqS0SH3bOkKRPrJTZd/vOS2liFF7zamk2WxKhrZVeO2Azk4lOhrL0MH7H7l4sw=="], + + "@usherlabs/verity-client": ["@usherlabs/verity-client@0.0.24", "", { "dependencies": { "@types/eventsource": "^1.1.15", "axios": "^1.9.0", "eventsource": "^2.0.2", "uuid": "^11.1.0" } }, "sha512-TtKQUeIaKtWDTFIbFWfe/GKA3YixsOwko4vS8rjFuxQdg+FA6XUMUQolyZaAyqDr6QSzNtSu7FTYIluJZewFyQ=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "axios": ["axios@1.10.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", "proxy-from-env": "^1.1.0" } }, "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw=="], + + "bun-plugin-dts": ["bun-plugin-dts@0.3.0", "", { "dependencies": { "common-path-prefix": "^3.0.0", "dts-bundle-generator": "^9.5.1", "get-tsconfig": "^4.8.1" } }, "sha512-QpiAOKfPcdOToxySOqRY8FwL+brTvyXEHWzrSCRKt4Pv7Z4pnUrhK9tFtM7Ndm7ED09B/0cGXnHJKqmekr/ERw=="], + "bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="], - "ccxt": ["ccxt@4.4.91", "", { "dependencies": { "ws": "^8.8.1" } }, "sha512-IP7wZc1KfAojMfKyMeGwC/aJoXvAUFFUMtu3x9Wp5BAOISftcrcl/yt/gjxaPddrMT2/BcU3wHZzvqzr1FBFBw=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], @@ -96,16 +114,58 @@ "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "commander": ["commander@14.0.0", "", {}, "sha512-2uM9rYjPvyq39NwLRqaiLtWHyDC1FvryJDa2ATTVims5YAS4PupsEQsDvP14FqhFr0P49CYDugi59xaxJlTXRA=="], + + "common-path-prefix": ["common-path-prefix@3.0.0", "", {}, "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w=="], + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], - "dotenv": ["dotenv@17.0.0", "", {}, "sha512-A0BJ5lrpJVSfnMMXjmeO0xUnoxqsBHWCoqqTnGwGYVdnctqXXUEhJOO7LxmgxJon9tEZFGpe0xPRX0h2v3AANQ=="], + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "dotenv": ["dotenv@17.2.0", "", {}, "sha512-Q4sgBT60gzd0BB0lSyYD3xM4YxrXA9y4uBDof1JNYGzOXrQdQ6yX+7XIAqoFOGQFOTK1D3Hts5OllpxMDZFONQ=="], + + "dts-bundle-generator": ["dts-bundle-generator@9.5.1", "", { "dependencies": { "typescript": ">=5.0.2", "yargs": "^17.6.0" }, "bin": { "dts-bundle-generator": "dist/bin/dts-bundle-generator.js" } }, "sha512-DxpJOb2FNnEyOzMkG11sxO2dmxPjthoVWxfKqWYJ/bI/rT1rvTMktF5EKjAYrRZu6Z6t3NhOUZ0sZ5ZXevOfbA=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "eventsource": ["eventsource@2.0.2", "", {}, "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA=="], + + "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], + + "form-data": ["form-data@4.0.3", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-tsconfig": ["get-tsconfig@4.10.1", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], @@ -116,18 +176,32 @@ "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "protobufjs": ["protobufjs@7.5.3", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw=="], + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "tslog": ["tslog@4.9.3", "", {}, "sha512-oDWuGVONxhVEBtschLf2cs/Jy8i7h1T+CpdkTNWQgdAF7DhRo2G8vMCgILKe7ojdEkLhICWgI1LYSSKaJsRgcw=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "ws": ["ws@8.18.2", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ=="], @@ -137,7 +211,5 @@ "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], - - "@types/bun/bun-types": ["bun-types@1.2.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-ElC7ItwT3SCQwYZDYoAH+q6KT4Fxjl8DtZ6qDulUFBmXA8YB4xo+l54J9ZJN+k2pphfn9vk7kfubeSd5QfTVJQ=="], } } diff --git a/client.ts b/client.ts deleted file mode 100644 index 768d9f8..0000000 --- a/client.ts +++ /dev/null @@ -1,56 +0,0 @@ -import path from "bun:path"; -import * as grpc from "@grpc/grpc-js"; -import * as protoLoader from "@grpc/proto-loader"; -import type { ProtoGrpcType } from "./proto/node"; -import config from "./config"; - -const PROTO_FILE = "./proto/node.proto"; - -const packageDef = protoLoader.loadSync(path.resolve(__dirname, PROTO_FILE)); -const grpcObj = grpc.loadPackageDefinition( - packageDef, -) as unknown as ProtoGrpcType; - -const client = new grpcObj.fietCexNode.CexService( - `0.0.0.0:${config.port}`, - grpc.credentials.createInsecure(), -); - -const deadline = new Date(); -deadline.setSeconds(deadline.getSeconds() + 5); -client.waitForReady(deadline, (err) => { - if (err) { - console.error(err); - return; - } - onClientReady(); -}); - -function onClientReady() { - client.getOptimalPrice( - { mode: 0, symbol: "ARB/USDT", quantity: 200 }, - (err, result) => { - if (err) { - console.error({ err }); - return; - } - console.log({ x: result?.results }); - }, - ); - - client.getBalance({ cex: "BYBIT", token: "USDT" }, (err, result) => { - if (err) { - console.error({ err }); - return; - } - console.log({ x: result }); - }); - - client.Transfer({cex:"BINANCE",amount:1,token:"USDT",chain:"BEP20",recipientAddress:"0x9d467fa9062b6e9b1a46e26007ad82db116c67cb"},(err,result)=>{ - if (err) { - console.error({ err }); - return; - } - console.log({ x: result }); - }) -} diff --git a/config/broker.ts b/config/broker.ts deleted file mode 100644 index 22db1f1..0000000 --- a/config/broker.ts +++ /dev/null @@ -1,50 +0,0 @@ -import ccxt, { type bybit, type binance } from "ccxt"; -import { SupportedBroker } from "../types"; -import type { ISupportedBroker } from "../types"; -import config from "./index"; - -// Map each broker key to its specific CCXT class -type BrokerInstanceMap = { - [SupportedBroker.BYBIT]: bybit; - [SupportedBroker.BINANCE]: binance; -}; - -// Dynamic BrokerMap: each key maps to the correct broker type -export type BrokerMap = Partial<{ - [K in ISupportedBroker]: BrokerInstanceMap[K]; -}>; - -// Initialize brokers map -const brokers: BrokerMap = {}; - -// Conditionally initialize Bybit broker -if (config.brokers.includes(SupportedBroker.BYBIT as ISupportedBroker)) { - const bybitBroker = new ccxt.bybit({ - apiKey: config.bybitApiKey, - secret: config.bybitApiSecret, - defaultType: "spot", - }); - // Override Bybit API hostname - bybitBroker.options = { - ...bybitBroker.options, - hostname: "bytick.com", - }; - brokers[SupportedBroker.BYBIT as ISupportedBroker] = bybitBroker; -} - -// Conditionally initialize Binance broker -if (config.brokers.includes(SupportedBroker.BINANCE as ISupportedBroker)) { - const binanceBroker = new ccxt.binance({ - apiKey: config.binanceApiKey, - secret: config.binanceApiSecret, - defaultType: "spot", - }); - // Override Binance API hostname - binanceBroker.options = { - ...binanceBroker.options, - hostname: "binance.me", - }; - brokers[SupportedBroker.BINANCE as ISupportedBroker] = binanceBroker; -} - -export default brokers as Required; diff --git a/config/index.ts b/config/index.ts deleted file mode 100644 index 50eee08..0000000 --- a/config/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -import dotenv from "dotenv"; -import Joi from "joi"; -import { BrokerList, SupportedBroker } from "../types"; - -dotenv.config(); - -const baseConfig = { - port: process.env.PORT_NUM, - bybitApiKey: process.env.BYBIT_API_KEY, - bybitApiSecret: process.env.BYBIT_API_SECRET, - binanceApiKey: process.env.BINANCE_API_KEY, - binanceApiSecret: process.env.BINANCE_API_SECRET, - brokers: (process.env.ROOCH_CHAIN_ID - ? process.env.ROOCH_CHAIN_ID.split(",") - : BrokerList) as string[], -}; - -const isRequiredWhenBrokerInclude = ( - schema: Joi.StringSchema, - value: string, -) => - Joi.string().when("brokers", { - is: Joi.array().items(Joi.string().valid(value)).has(value), - // biome-ignore lint/suspicious/noThenProperty: Dynamic check - then: schema.required(), // 'details' is required if 'status' is 'active' - otherwise: Joi.string().optional().allow("", null).default(""), // 'details' is optional otherwise - }); - -const envVarsSchema = Joi.object({ - port: Joi.number().default(8082), - bybitApiKey: isRequiredWhenBrokerInclude(Joi.string(), SupportedBroker.BYBIT), - bybitApiSecret: isRequiredWhenBrokerInclude( - Joi.string(), - SupportedBroker.BYBIT, - ), - binanceApiKey: isRequiredWhenBrokerInclude( - Joi.string(), - SupportedBroker.BINANCE, - ), - binanceApiSecret: isRequiredWhenBrokerInclude( - Joi.string(), - SupportedBroker.BINANCE, - ), -}).unknown(); - -const { value: envVars, error } = envVarsSchema.validate({ - ...baseConfig, -}); - -if (error) { - throw new Error(`Config validation error: ${error.message}`); -} - -export default envVars as typeof baseConfig; diff --git a/helpers/index.ts b/helpers/index.ts deleted file mode 100644 index 818ec0e..0000000 --- a/helpers/index.ts +++ /dev/null @@ -1,255 +0,0 @@ -import type { Exchange } from "ccxt"; -import type { PolicyConfig } from "../types"; -/** - * Fetches the order book, computes the worst‐case fill price for `size`, - * and submits a single limit‐buy at that price. - */ - -// IP Whitelist configuration -const ALLOWED_IPS = [ - "127.0.0.1", // localhost - "::1", // IPv6 localhost - // Add your allowed IP addresses here -]; - -export function isIpAllowed(ip: string): boolean { - return ALLOWED_IPS.includes(ip); -} -export async function buyAtOptimalPrice( - exchange: Exchange, - symbol: string, - size: number, -) { - // 1) fetch the order book - const book = await exchange.fetchOrderBook(symbol, 500); - const bids = book.bids; - - // 2) walk bids until cumulative >= size - let remaining = size; - let cumCost = 0; - let fillPrice = 0; - - for (const [priceRaw, volumeRaw] of bids) { - const price = Number(priceRaw); - const volume = Number(volumeRaw); - const take = Math.min(volume, remaining); - cumCost += take * price; - remaining -= take; - - if (remaining <= 0) { - fillPrice = price; - break; - } - } - - if (remaining > 0) { - throw new Error( - `Insufficient depth: only filled ${size - remaining} of ${size}`, - ); - } - - const avgPrice = cumCost / size; - console.log( - `[${new Date().toISOString()}] ` + - `Will buy ${size} ${symbol.split("/")[0]} at limit ${fillPrice.toFixed(6)} ` + - `(VWAP ≃ ${avgPrice.toFixed(6)})`, - ); - return { avgPrice, fillPrice, size, symbol }; -} - -/** - * Fetches the order book, computes the worst‐case fill price on the ask side for `size`, - * and submits a single limit‐sell at that price. - */ -export async function sellAtOptimalPrice( - exchange: Exchange, - symbol: string, - size: number, -) { - // 1) fetch the order book - const book = await exchange.fetchOrderBook(symbol); - const asks = book.asks; - - // 2) walk asks until cumulative >= size - let remaining = size; - let cumProceeds = 0; - let fillPrice = 0; - - for (const entry of asks) { - const priceRaw = entry[0]; - const volumeRaw = entry[1]; - - if (priceRaw === undefined || volumeRaw === undefined) { - throw new Error("Orderbook entry had undefined price or volume"); - } - - const price = Number(priceRaw); - const volume = Number(volumeRaw); - - const take = Math.min(volume, remaining); - cumProceeds += take * price; - remaining -= take; - - if (remaining <= 0) { - fillPrice = price; - break; - } - } - - if (remaining > 0) { - throw new Error( - `Insufficient depth: only sold ${size - remaining} of ${size}`, - ); - } - - const avgPrice = cumProceeds / size; - console.log( - `[${new Date().toISOString()}] ` + - `Will sell ${size} ${symbol.split("/")[0]} at limit ${fillPrice.toFixed(6)} ` + - `(VWAP ≃ ${avgPrice.toFixed(6)})`, - ); - - return { avgPrice, fillPrice, size, symbol }; -} - -/** - * Loads and validates policy configuration - */ -export function loadPolicy(): PolicyConfig { - try { - const fs = require("bun:fs"); - const path = require("bun:path"); - const policyPath = path.join(__dirname, "../policy/policy.json"); - const policyData = fs.readFileSync(policyPath, "utf8"); - return JSON.parse(policyData) as PolicyConfig; - } catch (error) { - console.error("Failed to load policy:", error); - throw new Error("Policy configuration could not be loaded"); - } -} - -/** - * Validates withdraw request against policy rules - */ -export function validateWithdraw( - policy: PolicyConfig, - network: string, - recipientAddress: string, - amount: number, - ticker: string, -): { valid: boolean; error?: string } { - const withdrawRule = policy.withdraw.rule; - - // Check if network is allowed - if (!withdrawRule.networks.includes(network)) { - return { - valid: false, - error: `Network ${network} is not allowed. Allowed networks: ${withdrawRule.networks.join(", ")}`, - }; - } - - // Check if address is whitelisted - if (!withdrawRule.whitelist.includes(recipientAddress.toLowerCase())) { - return { - valid: false, - error: `Address ${recipientAddress} is not whitelisted for withdrawals`, - }; - } - - // Check amount limits - const amountRule = withdrawRule.amounts.find((a) => a.ticker === ticker); - - if (!amountRule) { - return { - valid: false, - error: `Ticker ${ticker} is not allowed. Supported tickers: ${withdrawRule.amounts.map((a) => a.ticker).join(", ")}`, - }; - } - - if (amount < amountRule.min) { - return { - valid: false, - error: `Amount ${amount} is below minimum ${amountRule.min}`, - }; - } - - if (amount > amountRule.max) { - return { - valid: false, - error: `Amount ${amount} exceeds maximum ${amountRule.max}`, - }; - } - - return { valid: true }; -} - -/** - * Validates order request against policy rules - */ -export function validateOrder( - policy: PolicyConfig, - fromToken: string, - toToken: string, - amount: number, - broker: string, -): { valid: boolean; error?: string } { - const orderRule = policy.order.rule; - - // Check if market is allowed - const marketKeys = [ - `${broker.toUpperCase()}:${toToken}/${fromToken}`, - `${broker.toUpperCase()}:${fromToken}/${toToken}`, - ]; - if ( - !( - orderRule.markets.includes(marketKeys[0] ?? "") || - orderRule.markets.includes(marketKeys[1] ?? "") - ) - ) { - return { - valid: false, - error: `Market ${marketKeys} is not allowed. Allowed markets: ${orderRule.markets.join(", ")}`, - }; - } - - // Check conversion limits - const limit = orderRule.limits.find( - (l) => l.from === fromToken && l.to === toToken, - ); - - if (!limit) { - return { - valid: false, - error: `Conversion from ${fromToken} to ${toToken} is not allowed`, - }; - } - - if (amount < limit.min) { - return { - valid: false, - error: `Amount ${amount} is below minimum ${limit.min} for ${fromToken} to ${toToken} conversion`, - }; - } - - if (amount > limit.max) { - return { - valid: false, - error: `Amount ${amount} exceeds maximum ${limit.max} for ${fromToken} to ${toToken} conversion`, - }; - } - - return { valid: true }; -} - -/** - * Validates deposit request (currently empty but can be extended) - */ -export function validateDeposit( - _policy: PolicyConfig, - _chain: string, - _amount: number, -): { valid: boolean; error?: string } { - // Currently deposit policy is empty, so all deposits are allowed - // This can be extended when deposit rules are added to the policy - return { valid: true }; -} diff --git a/index.ts b/index.ts deleted file mode 100644 index 72e7011..0000000 --- a/index.ts +++ /dev/null @@ -1,596 +0,0 @@ -import ccxt from "ccxt"; -import path from "bun:path"; -import * as grpc from "@grpc/grpc-js"; -import * as protoLoader from "@grpc/proto-loader"; -import type { ProtoGrpcType } from "./proto/node"; -import config from "./config"; -import brokers from "./config/broker"; -import type { OptimalPriceRequest } from "./proto/fietCexNode/OptimalPriceRequest"; -import { - buyAtOptimalPrice, - sellAtOptimalPrice, - loadPolicy, - validateOrder, - validateWithdraw, - validateDeposit, - isIpAllowed, -} from "./helpers"; -import type { OptimalPriceResponse } from "./proto/fietCexNode/OptimalPriceResponse"; -import type { BalanceRequest } from "./proto/fietCexNode/BalanceRequest"; -import type { BalanceResponse } from "./proto/fietCexNode/BalanceResponse"; -import type { PolicyConfig } from "./types"; -import type { TransferRequest } from "./proto/fietCexNode/TransferRequest"; -import type { TransferResponse } from "./proto/fietCexNode/TransferResponse"; -import type { DepositConfirmationRequest } from "./proto/fietCexNode/DepositConfirmationRequest"; -import type { DepositConfirmationResponse } from "./proto/fietCexNode/DepositConfirmationResponse"; -import type { ConvertRequest } from "./proto/fietCexNode/ConvertRequest"; -import type { ConvertResponse } from "./proto/fietCexNode/ConvertResponse"; -import type { OrderDetailsRequest } from "./proto/fietCexNode/OrderDetailsRequest"; -import type { OrderDetailsResponse } from "./proto/fietCexNode/OrderDetailsResponse"; -import type { CancelOrderRequest } from "./proto/fietCexNode/CancelOrderRequest"; -import type { CancelOrderResponse } from "./proto/fietCexNode/CancelOrderResponse"; - -const PROTO_FILE = "./proto/node.proto"; - -const packageDef = protoLoader.loadSync(path.resolve(__dirname, PROTO_FILE)); -const grpcObj = grpc.loadPackageDefinition( - packageDef, -) as unknown as ProtoGrpcType; -const fietCexNode = grpcObj.fietCexNode; - -console.log("CCXT Version:", ccxt.version); - -async function main() { - // Load policy configuration - const policy = loadPolicy(); - console.log("Policy loaded successfully"); - - const server = getServer(policy); - - console.log( - `BINANCE: Broker Balance ,${JSON.stringify(await brokers.BINANCE.fetchFreeBalance())}`, - ); - console.log( - `BYBIT: Broker Balance ,${JSON.stringify(await brokers.BYBIT.fetchFreeBalance())}`, - ); - - server.bindAsync( - `0.0.0.0:${config.port}`, - grpc.ServerCredentials.createInsecure(), - (err, port) => { - if (err) { - console.error(err); - return; - } - console.log(`Your server as started on port ${port}`); - }, - ); -} - -function authenticateRequest(call: grpc.ServerUnaryCall): boolean { - const clientIp = call.getPeer().split(":")[0]; - if (!clientIp || !isIpAllowed(clientIp)) { - console.warn( - `Blocked access from unauthorized IP: ${clientIp || "unknown"}`, - ); - return false; - } - return true; -} - -function getServer(policy: PolicyConfig) { - const server = new grpc.Server(); - server.addService(fietCexNode.CexService.service, { - GetOptimalPrice: async ( - call: grpc.ServerUnaryCall, - callback: grpc.sendUnaryData, - ) => { - // IP Authentication - if (!authenticateRequest(call)) { - return callback( - { - code: grpc.status.PERMISSION_DENIED, - message: "Access denied: Unauthorized IP", - }, - null, - ); - } - - const { mode, symbol, quantity } = - call.request as Required; - - // Validate required fields - if (mode === undefined || mode === null) { - // Return a gRPC error - const error = { - code: grpc.status.INVALID_ARGUMENT, - message: "Mode is required and must be BUY or SELL", - }; - return callback(error, null); - } - - if (!symbol) { - const error = { - code: grpc.status.INVALID_ARGUMENT, - message: "Symbol is required", - }; - return callback(error, null); - } - - if (!quantity || Number(quantity) <= 0) { - const error = { - code: grpc.status.INVALID_ARGUMENT, - message: "Quantity must be a positive number", - }; - return callback(error, null); - } - // Extract tokens from symbol (e.g., "ARB/USDT" -> fromToken: "ARB", toToken: "USDT") - const tokens = symbol.split("/"); - if (tokens.length !== 2) { - return callback( - { - code: grpc.status.INVALID_ARGUMENT, - message: "Invalid symbol format. Expected format: TOKEN1/TOKEN2", - }, - null, - ); - } - - if (mode === 1) { - const BINANCE = await sellAtOptimalPrice( - brokers.BINANCE, - symbol, - Number(quantity), - ); - const BYBIT = await sellAtOptimalPrice( - brokers.BYBIT, - symbol, - Number(quantity), - ); - return callback(null, { results: { BINANCE, BYBIT } }); - } else { - const BINANCE = await buyAtOptimalPrice( - brokers.BINANCE, - symbol, - Number(quantity), - ); - const BYBIT = await buyAtOptimalPrice( - brokers.BYBIT, - symbol, - Number(quantity), - ); - return callback(null, { results: { BINANCE, BYBIT } }); - } - }, - Deposit: async ( - call: grpc.ServerUnaryCall< - DepositConfirmationRequest, - DepositConfirmationResponse - >, - callback: grpc.sendUnaryData, - ) => { - // IP Authentication - if (!authenticateRequest(call)) { - return callback( - { - code: grpc.status.PERMISSION_DENIED, - message: "Access denied: Unauthorized IP", - }, - null, - ); - } - - // Implement deposit logic - const { chain, recipientAddress, amount, transactionHash } = call.request; - - // Validate required fields - if (!chain || !amount || !recipientAddress || !transactionHash) { - return callback( - { - code: grpc.status.INVALID_ARGUMENT, - message: - "chain, transactionHash, recipientAddress and amount are required", - }, - null, - ); - } - - // Validate against policy - const validation = validateDeposit(policy, chain, Number(amount)); - if (!validation.valid) { - return callback( - { - code: grpc.status.PERMISSION_DENIED, - message: validation.error, - }, - null, - ); - } - - try { - console.log( - `[${new Date().toISOString()}] ` + - `Amount ${amount} at ${transactionHash} on chain ${chain}. Paid to ${recipientAddress}`, - ); - callback(null, { newBalance: 0 }); - } catch (error) { - console.error({ error }); - callback( - { - code: grpc.status.INTERNAL, - message: "Deposit confirmation failed", - }, - null, - ); - } - }, - Transfer: async ( - call: grpc.ServerUnaryCall, - callback: grpc.sendUnaryData, - ) => { - // IP Authentication - if (!authenticateRequest(call)) { - return callback( - { - code: grpc.status.PERMISSION_DENIED, - message: "Access denied: Unauthorized IP", - }, - null, - ); - } - - // Implement transfer logic - const { chain, cex, amount, recipientAddress, token } = call.request; - - // Validate required fields - if (!chain || !recipientAddress || !amount || !cex || !token) { - return callback( - { - code: grpc.status.INVALID_ARGUMENT, - message: - "chain, recipient_address, amount, and ticker are required", - }, - null, - ); - } - - // Validate against policy - const validation = validateWithdraw( - policy, - chain, - recipientAddress, - Number(amount), - token, - ); - if (!validation.valid) { - return callback( - { - code: grpc.status.PERMISSION_DENIED, - message: validation.error, - }, - null, - ); - } - - try { - if (!Object.keys(brokers).includes(cex)) { - return callback( - { - code: grpc.status.INTERNAL, - message: `Broker ${cex} is not active. Allowed Broker: ${Object.keys(brokers).join(", ")}`, - }, - null, - ); - } - - // Validate CEX key - const broker = brokers[cex as keyof typeof brokers]; - const data = await broker.fetchCurrencies("USDT"); - const networks = Object.keys( - (data[token] ?? { networks: [] }).networks, - ); - - if (!networks.includes(chain)) { - return callback( - { - code: grpc.status.INTERNAL, - message: `Broker ${cex} doesnt support this ${chain} for token ${token}`, - }, - null, - ); - } - const transaction = await broker.withdraw( - token, - Number(amount), - recipientAddress, - undefined, - { network: chain }, - ); - - callback(null, { success: true, transactionId: transaction.id }); - } catch (error) { - console.error({ error }); - callback( - { - code: grpc.status.INTERNAL, - message: "Transfer failed", - }, - null, - ); - } - }, - Convert: async ( - call: grpc.ServerUnaryCall, - callback: grpc.sendUnaryData, - ) => { - // IP Authentication - if (!authenticateRequest(call)) { - return callback( - { - code: grpc.status.PERMISSION_DENIED, - message: "Access denied: Unauthorized IP", - }, - null, - ); - } - - // Implement convert logic - const { fromToken, toToken, amount, cex, price } = call.request; - - // Validate required fields - if (!fromToken || !toToken || !amount || !cex || !price) { - return callback( - { - code: grpc.status.INVALID_ARGUMENT, - message: "toToken, fromToken, amount, cex, and price are required", - }, - null, - ); - } - - const validation = validateOrder( - policy, - fromToken, - toToken, - Number(amount), - cex, - ); - if (!validation.valid) { - return callback( - { - code: grpc.status.INVALID_ARGUMENT, - message: validation.error, - }, - null, - ); - } - - try { - // Validate CEX key - const broker = brokers[cex as keyof typeof brokers]; - - const market = policy.order.rule.markets.find( - (market) => - market.includes(`${fromToken}/${toToken}`) || - market.includes(`${toToken}/${fromToken}`), - ); - const symbol = market?.split(":")[1] ?? ""; - const [from, _to] = symbol.split("/"); - - const order = await broker.createLimitOrder( - symbol, - from === fromToken ? "sell" : "buy", - Number(amount), - Number(price), - ); - - callback(null, { - orderId: order.id, - }); - } catch (error) { - console.error({ error }); - callback( - { - code: grpc.status.INTERNAL, - message: "Conversion failed", - }, - null, - ); - } - }, - GetBalance: async ( - call: grpc.ServerUnaryCall, - callback: grpc.sendUnaryData, - ) => { - // IP Authentication - if (!authenticateRequest(call)) { - return callback( - { - code: grpc.status.PERMISSION_DENIED, - message: "Access denied: Unauthorized IP", - }, - null, - ); - } - - const { cex, token } = call.request as Required; - - // Validate required fields - if (!cex) { - return callback( - { - code: grpc.status.INVALID_ARGUMENT, - message: "cex_key is required", - }, - null, - ); - } - - if (!token) { - return callback( - { - code: grpc.status.INVALID_ARGUMENT, - message: "token is required", - }, - null, - ); - } - - // Validate CEX key - const broker = brokers[cex as keyof typeof brokers]; - if (!broker) { - return callback( - { - code: grpc.status.INVALID_ARGUMENT, - message: `Invalid CEX key: ${cex}. Supported keys: ${Object.keys(brokers).join(", ")}`, - }, - null, - ); - } - - try { - // Fetch balance from the specified CEX - const balance = (await broker.fetchFreeBalance()) as any; - const currencyBalance = balance[token]; - - callback(null, { - balance: currencyBalance || 0, - currency: token, - }); - } catch (error) { - console.error(`Error fetching balance from ${cex}:`, error); - callback( - { - code: grpc.status.INTERNAL, - message: `Failed to fetch balance from ${cex}`, - }, - null, - ); - } - }, - GetOrderDetails: async ( - call: grpc.ServerUnaryCall, - callback: grpc.sendUnaryData, - ) => { - // IP Authentication - if (!authenticateRequest(call)) { - return callback( - { - code: grpc.status.PERMISSION_DENIED, - message: "Access denied: Unauthorized IP", - }, - null, - ); - } - - const { orderId, cex } = call.request; - - // Validate required fields - if (!orderId || !cex) { - return callback( - { - code: grpc.status.INVALID_ARGUMENT, - message: "order_id and cex are required", - }, - null, - ); - } - - try { - // Validate CEX key - const broker = brokers[cex as keyof typeof brokers]; - if (!broker) { - return callback( - { - code: grpc.status.INVALID_ARGUMENT, - message: `Invalid CEX key: ${cex}. Supported keys: ${Object.keys(brokers).join(", ")}`, - }, - null, - ); - } - - const orderDetails = await broker.fetchOrder(orderId); - - callback(null, { - orderId: orderDetails.id, - status: orderDetails.status, - originalAmount: orderDetails.amount, - filledAmount: orderDetails.filled, - symbol: orderDetails.symbol, - mode: orderDetails.side, - price: orderDetails.price, - }); - } catch (error) { - console.error(`Error fetching order details from ${cex}:`, error); - callback( - { - code: grpc.status.INTERNAL, - message: `Failed to fetch order details from ${cex}`, - }, - null, - ); - } - }, - CancelOrder: async ( - call: grpc.ServerUnaryCall, - callback: grpc.sendUnaryData, - ) => { - // IP Authentication - if (!authenticateRequest(call)) { - return callback( - { - code: grpc.status.PERMISSION_DENIED, - message: "Access denied: Unauthorized IP", - }, - null, - ); - } - - const { orderId, cex } = call.request; - - // Validate required fields - if (!orderId || !cex) { - return callback( - { - code: grpc.status.INVALID_ARGUMENT, - message: "order_id and cex are required", - }, - null, - ); - } - - try { - // Validate CEX key - const broker = brokers[cex as keyof typeof brokers]; - if (!broker) { - return callback( - { - code: grpc.status.INVALID_ARGUMENT, - message: `Invalid CEX key: ${cex}. Supported keys: ${Object.keys(brokers).join(", ")}`, - }, - null, - ); - } - - const cancelledOrder = await broker.cancelOrder(orderId); - - callback(null, { - success: cancelledOrder.status === "canceled", - finalStatus: cancelledOrder.status, - }); - } catch (error) { - console.error(`Error cancelling order from ${cex}:`, error); - callback( - { - code: grpc.status.INTERNAL, - message: `Failed to cancel order from ${cex}`, - }, - null, - ); - } - }, - }); - return server; -} - -main(); diff --git a/integration.test.ts b/integration.test.ts deleted file mode 100644 index 1bb4ae5..0000000 --- a/integration.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { describe, test, expect } from "bun:test"; - -describe("Integration Tests", () => { - describe("Policy Integration", () => { - test("should load and validate policy correctly", () => { - // Test that the policy file can be loaded - const fs = require("bun:fs"); - const path = require("bun:path"); - const policyPath = path.join(__dirname, "./policy/policy.json"); - - expect(() => { - const policyData = fs.readFileSync(policyPath, "utf8"); - const policy = JSON.parse(policyData); - return policy; - }).not.toThrow(); - }); - - test("should have correct policy structure", () => { - const fs = require("bun:fs"); - const path = require("bun:path"); - const policyPath = path.join(__dirname, "./policy/policy.json"); - const policyData = fs.readFileSync(policyPath, "utf8"); - const policy = JSON.parse(policyData); - - // Check withdraw policy - expect(policy.withdraw).toBeDefined(); - expect(policy.withdraw.rule).toBeDefined(); - expect(policy.withdraw.rule.networks).toContain("ARBITRUM"); - expect(policy.withdraw.rule.whitelist).toContain( - "0x9d467fa9062b6e9b1a46e26007ad82db116c67cb", - ); - - // Check order policy - expect(policy.order).toBeDefined(); - expect(policy.order.rule).toBeDefined(); - expect(policy.order.rule.markets).toContain("BINANCE:ARB/USDT"); - expect(policy.order.rule.limits).toBeDefined(); - expect(policy.order.rule.limits.length).toBeGreaterThan(0); - }); - }); - - describe("Helper Functions Integration", () => { - test("should validate withdraw policy correctly", () => { - const { validateWithdraw } = require("./helpers"); - const { loadPolicy } = require("./helpers"); - - const policy = loadPolicy(); - - // Test valid withdrawal - const validResult = validateWithdraw( - policy, - "ARBITRUM", - "0x9d467fa9062b6e9b1a46e26007ad82db116c67cb", - 1000, - "USDC", - ); - - expect(validResult.valid).toBe(true); - - // Test invalid withdrawal - const invalidResult = validateWithdraw( - policy, - "ETH", // Wrong network - "0x9d467fa9062b6e9b1a46e26007ad82db116c67cb", - 1000, - "USDC", - ); - - expect(invalidResult.valid).toBe(false); - }); - - test("should validate order policy correctly", () => { - const { validateOrder } = require("./helpers"); - const { loadPolicy } = require("./helpers"); - - const policy = loadPolicy(); - - // Test valid order - const validResult = validateOrder(policy, "USDT", "ETH", 1, "BINANCE"); - - expect(validResult.valid).toBe(true); - - // Test invalid order - const invalidResult = validateOrder(policy, "BTC", "ETH", 0.5, "BINANCE"); - - expect(invalidResult.valid).toBe(false); - }); - }); - - describe("Price Calculation Integration", () => { - test("should calculate optimal prices correctly", async () => { - const { buyAtOptimalPrice, sellAtOptimalPrice } = require("./helpers"); - - // Create a mock exchange with realistic order book data - const mockExchange = { - fetchOrderBook: async (_symbol: string) => ({ - bids: [ - [100, 10], - [99, 20], - [98, 30], - ], - asks: [ - [101, 10], - [102, 20], - [103, 30], - ], - }), - }; - - // Test buy calculation - const buyResult = await buyAtOptimalPrice(mockExchange, "ARB/USDT", 25); - expect(buyResult.avgPrice).toBeGreaterThan(0); - expect(buyResult.fillPrice).toBeGreaterThan(0); - expect(buyResult.size).toBe(25); - expect(buyResult.symbol).toBe("ARB/USDT"); - - // Test sell calculation - const sellResult = await sellAtOptimalPrice(mockExchange, "ARB/USDT", 25); - expect(sellResult.avgPrice).toBeGreaterThan(0); - expect(sellResult.fillPrice).toBeGreaterThan(0); - expect(sellResult.size).toBe(25); - expect(sellResult.symbol).toBe("ARB/USDT"); - }); - }); - - describe("Error Handling Integration", () => { - test("should handle insufficient depth correctly", async () => { - const { buyAtOptimalPrice } = require("./helpers"); - - const insufficientExchange = { - fetchOrderBook: async () => ({ - bids: [[100, 5]], // Only 5 volume available - }), - }; - - await expect( - buyAtOptimalPrice(insufficientExchange, "ARB/USDT", 10), - ).rejects.toThrow("Insufficient depth"); - }); - - test("should handle invalid symbol format", () => { - const { validateOrder } = require("./helpers"); - const { loadPolicy } = require("./helpers"); - - const policy = loadPolicy(); - - // Test with invalid symbol format - const result = validateOrder( - policy, - "USDT", - "ETH", - 0.5, - "BINANCE", - "ARB", // Invalid format - missing '/' - ); - - expect(result.valid).toBe(false); - }); - }); -}); diff --git a/package.json b/package.json index 877eeb9..12761f2 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,41 @@ { - "name": "fietCexBroker", - "module": "index.ts", + "name": "@usherlabs/cex-broker", + "version": "0.0.1", + "description": "Unified gRPC API to CEXs by Usher Labs", + "repository": "git@gitlab.com:usherlabs/cex-broker.git", + "homepage": "https://usher.so/", + "author": "Oki Ayobami ", + "module": "src/index.ts", "type": "module", - "private": true, + "license": "MIT", + "main": "dist/index.js", + "types": "dist/index.d.ts", "devDependencies": { "@biomejs/biome": "2.0.6", "@types/bun": "latest", + "bun-plugin-dts": "latest", "bun-types": "latest", + "dotenv": "^17.2.0", "husky": "^9.1.7" }, + "files": [ + "dist" + ], + "bin": { + "cex-broker": "dist/commands/cli.js" + }, "scripts": { "proto-gen": "./proto-gen.sh", - "start": "bun run index.ts", - "build": "bun build ./index.ts --outdir ./build --target bun", + "start": "bun run ./src/index.ts", + "build:ts": "bun run ./src/build.ts", "test": "bun test", "format": "bunx biome format --write", - "lint": "bunx biome lint --write", - "check": "bunx biome check --write", - "prepare": "husky" + "lint": "bunx biome lint", + "lint:fix": "bunx biome lint --write", + "check": "bunx biome check", + "check:fix": "bunx biome check --write", + "prepare": "bunx husky", + "postinstall": "./proto-gen.sh" }, "peerDependencies": { "typescript": "^5" @@ -25,8 +43,18 @@ "dependencies": { "@grpc/grpc-js": "^1.13.4", "@grpc/proto-loader": "^0.7.15", - "ccxt": "^4.4.91", - "dotenv": "^17.0.0", - "joi": "^17.13.3" + "@usherlabs/ccxt": "^0.0.4", + "commander": "^14.0.0", + "joi": "^17.13.3", + "tslog": "^4.9.3" + }, + "publishConfig": { + "registry": "https://registry.npmjs.org" + }, + "contributors": [ + "Oki Ayobami (https://github.com/xlassix)" + ], + "patchedDependencies": { + "@protobufjs/inquire@1.1.0": "patches/@protobufjs%2Finquire@1.1.0.patch" } } diff --git a/patches/@protobufjs%2Finquire@1.1.0.patch b/patches/@protobufjs%2Finquire@1.1.0.patch new file mode 100644 index 0000000..337d86e --- /dev/null +++ b/patches/@protobufjs%2Finquire@1.1.0.patch @@ -0,0 +1,16 @@ +diff --git a/index.js b/index.js +index 33778b5539b7fcd7a1e99474a4ecb1745fdfe508..4cc0a71670abd106fb29bc7f3d185c438039d91e 100644 +--- a/index.js ++++ b/index.js +@@ -9,7 +9,10 @@ module.exports = inquire; + */ + function inquire(moduleName) { + try { +- var mod = eval("quire".replace(/^/,"re"))(moduleName); // eslint-disable-line no-eval ++ // ζ³¨ι‡ŠζŽ‰δΈ‹ι’ηš„δ»£η  ++ // var mod = eval("quire".replace(/^/,"re"))(moduleName); // eslint-disable-line no-eval ++ // ζ–°ηš„δ»£η  ++ var mod = require(moduleName); + if (mod && (mod.length || Object.keys(mod).length)) + return mod; + } catch (e) {} // eslint-disable-line no-empty diff --git a/policy/policy.json b/policy/policy.json index 230aec2..f66efa6 100644 --- a/policy/policy.json +++ b/policy/policy.json @@ -25,7 +25,8 @@ "BYBIT:ARB/USDC", "UPBIT:ETH/USDC", "BINANCE:ETH/USDT", - "BINANCE:BTC/ETH" + "BINANCE:BTC/ETH", + "BINANCE:BTC/USDC" ], "limits": [ { "from": "USDT", "to": "ETH", "min": 1, "max": 100000 }, diff --git a/proto/fietCexNode/BalanceRequest.ts b/proto/fietCexNode/BalanceRequest.ts deleted file mode 100644 index b4a08c9..0000000 --- a/proto/fietCexNode/BalanceRequest.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Original file: proto/node.proto - - -export interface BalanceRequest { - 'cex'?: (string); - 'token'?: (string); -} - -export interface BalanceRequest__Output { - 'cex'?: (string); - 'token'?: (string); -} diff --git a/proto/fietCexNode/BalanceResponse.ts b/proto/fietCexNode/BalanceResponse.ts deleted file mode 100644 index 0c81f55..0000000 --- a/proto/fietCexNode/BalanceResponse.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Original file: proto/node.proto - - -export interface BalanceResponse { - 'balance'?: (number | string); - 'currency'?: (string); -} - -export interface BalanceResponse__Output { - 'balance'?: (number); - 'currency'?: (string); -} diff --git a/proto/fietCexNode/CancelOrderRequest.ts b/proto/fietCexNode/CancelOrderRequest.ts deleted file mode 100644 index f8d5c1c..0000000 --- a/proto/fietCexNode/CancelOrderRequest.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Original file: proto/node.proto - - -export interface CancelOrderRequest { - 'orderId'?: (string); - 'cex'?: (string); -} - -export interface CancelOrderRequest__Output { - 'orderId'?: (string); - 'cex'?: (string); -} diff --git a/proto/fietCexNode/CancelOrderResponse.ts b/proto/fietCexNode/CancelOrderResponse.ts deleted file mode 100644 index 72837e7..0000000 --- a/proto/fietCexNode/CancelOrderResponse.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Original file: proto/node.proto - - -export interface CancelOrderResponse { - 'success'?: (boolean); - 'finalStatus'?: (string); -} - -export interface CancelOrderResponse__Output { - 'success'?: (boolean); - 'finalStatus'?: (string); -} diff --git a/proto/fietCexNode/CexService.ts b/proto/fietCexNode/CexService.ts deleted file mode 100644 index 6443e27..0000000 --- a/proto/fietCexNode/CexService.ts +++ /dev/null @@ -1,111 +0,0 @@ -// Original file: proto/node.proto - -import type * as grpc from '@grpc/grpc-js' -import type { MethodDefinition } from '@grpc/proto-loader' -import type { BalanceRequest as _fietCexNode_BalanceRequest, BalanceRequest__Output as _fietCexNode_BalanceRequest__Output } from '../fietCexNode/BalanceRequest'; -import type { BalanceResponse as _fietCexNode_BalanceResponse, BalanceResponse__Output as _fietCexNode_BalanceResponse__Output } from '../fietCexNode/BalanceResponse'; -import type { CancelOrderRequest as _fietCexNode_CancelOrderRequest, CancelOrderRequest__Output as _fietCexNode_CancelOrderRequest__Output } from '../fietCexNode/CancelOrderRequest'; -import type { CancelOrderResponse as _fietCexNode_CancelOrderResponse, CancelOrderResponse__Output as _fietCexNode_CancelOrderResponse__Output } from '../fietCexNode/CancelOrderResponse'; -import type { ConvertRequest as _fietCexNode_ConvertRequest, ConvertRequest__Output as _fietCexNode_ConvertRequest__Output } from '../fietCexNode/ConvertRequest'; -import type { ConvertResponse as _fietCexNode_ConvertResponse, ConvertResponse__Output as _fietCexNode_ConvertResponse__Output } from '../fietCexNode/ConvertResponse'; -import type { DepositConfirmationRequest as _fietCexNode_DepositConfirmationRequest, DepositConfirmationRequest__Output as _fietCexNode_DepositConfirmationRequest__Output } from '../fietCexNode/DepositConfirmationRequest'; -import type { DepositConfirmationResponse as _fietCexNode_DepositConfirmationResponse, DepositConfirmationResponse__Output as _fietCexNode_DepositConfirmationResponse__Output } from '../fietCexNode/DepositConfirmationResponse'; -import type { OptimalPriceRequest as _fietCexNode_OptimalPriceRequest, OptimalPriceRequest__Output as _fietCexNode_OptimalPriceRequest__Output } from '../fietCexNode/OptimalPriceRequest'; -import type { OptimalPriceResponse as _fietCexNode_OptimalPriceResponse, OptimalPriceResponse__Output as _fietCexNode_OptimalPriceResponse__Output } from '../fietCexNode/OptimalPriceResponse'; -import type { OrderDetailsRequest as _fietCexNode_OrderDetailsRequest, OrderDetailsRequest__Output as _fietCexNode_OrderDetailsRequest__Output } from '../fietCexNode/OrderDetailsRequest'; -import type { OrderDetailsResponse as _fietCexNode_OrderDetailsResponse, OrderDetailsResponse__Output as _fietCexNode_OrderDetailsResponse__Output } from '../fietCexNode/OrderDetailsResponse'; -import type { TransferRequest as _fietCexNode_TransferRequest, TransferRequest__Output as _fietCexNode_TransferRequest__Output } from '../fietCexNode/TransferRequest'; -import type { TransferResponse as _fietCexNode_TransferResponse, TransferResponse__Output as _fietCexNode_TransferResponse__Output } from '../fietCexNode/TransferResponse'; - -export interface CexServiceClient extends grpc.Client { - CancelOrder(argument: _fietCexNode_CancelOrderRequest, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_CancelOrderResponse__Output>): grpc.ClientUnaryCall; - CancelOrder(argument: _fietCexNode_CancelOrderRequest, metadata: grpc.Metadata, callback: grpc.requestCallback<_fietCexNode_CancelOrderResponse__Output>): grpc.ClientUnaryCall; - CancelOrder(argument: _fietCexNode_CancelOrderRequest, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_CancelOrderResponse__Output>): grpc.ClientUnaryCall; - CancelOrder(argument: _fietCexNode_CancelOrderRequest, callback: grpc.requestCallback<_fietCexNode_CancelOrderResponse__Output>): grpc.ClientUnaryCall; - cancelOrder(argument: _fietCexNode_CancelOrderRequest, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_CancelOrderResponse__Output>): grpc.ClientUnaryCall; - cancelOrder(argument: _fietCexNode_CancelOrderRequest, metadata: grpc.Metadata, callback: grpc.requestCallback<_fietCexNode_CancelOrderResponse__Output>): grpc.ClientUnaryCall; - cancelOrder(argument: _fietCexNode_CancelOrderRequest, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_CancelOrderResponse__Output>): grpc.ClientUnaryCall; - cancelOrder(argument: _fietCexNode_CancelOrderRequest, callback: grpc.requestCallback<_fietCexNode_CancelOrderResponse__Output>): grpc.ClientUnaryCall; - - Convert(argument: _fietCexNode_ConvertRequest, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_ConvertResponse__Output>): grpc.ClientUnaryCall; - Convert(argument: _fietCexNode_ConvertRequest, metadata: grpc.Metadata, callback: grpc.requestCallback<_fietCexNode_ConvertResponse__Output>): grpc.ClientUnaryCall; - Convert(argument: _fietCexNode_ConvertRequest, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_ConvertResponse__Output>): grpc.ClientUnaryCall; - Convert(argument: _fietCexNode_ConvertRequest, callback: grpc.requestCallback<_fietCexNode_ConvertResponse__Output>): grpc.ClientUnaryCall; - convert(argument: _fietCexNode_ConvertRequest, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_ConvertResponse__Output>): grpc.ClientUnaryCall; - convert(argument: _fietCexNode_ConvertRequest, metadata: grpc.Metadata, callback: grpc.requestCallback<_fietCexNode_ConvertResponse__Output>): grpc.ClientUnaryCall; - convert(argument: _fietCexNode_ConvertRequest, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_ConvertResponse__Output>): grpc.ClientUnaryCall; - convert(argument: _fietCexNode_ConvertRequest, callback: grpc.requestCallback<_fietCexNode_ConvertResponse__Output>): grpc.ClientUnaryCall; - - Deposit(argument: _fietCexNode_DepositConfirmationRequest, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_DepositConfirmationResponse__Output>): grpc.ClientUnaryCall; - Deposit(argument: _fietCexNode_DepositConfirmationRequest, metadata: grpc.Metadata, callback: grpc.requestCallback<_fietCexNode_DepositConfirmationResponse__Output>): grpc.ClientUnaryCall; - Deposit(argument: _fietCexNode_DepositConfirmationRequest, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_DepositConfirmationResponse__Output>): grpc.ClientUnaryCall; - Deposit(argument: _fietCexNode_DepositConfirmationRequest, callback: grpc.requestCallback<_fietCexNode_DepositConfirmationResponse__Output>): grpc.ClientUnaryCall; - deposit(argument: _fietCexNode_DepositConfirmationRequest, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_DepositConfirmationResponse__Output>): grpc.ClientUnaryCall; - deposit(argument: _fietCexNode_DepositConfirmationRequest, metadata: grpc.Metadata, callback: grpc.requestCallback<_fietCexNode_DepositConfirmationResponse__Output>): grpc.ClientUnaryCall; - deposit(argument: _fietCexNode_DepositConfirmationRequest, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_DepositConfirmationResponse__Output>): grpc.ClientUnaryCall; - deposit(argument: _fietCexNode_DepositConfirmationRequest, callback: grpc.requestCallback<_fietCexNode_DepositConfirmationResponse__Output>): grpc.ClientUnaryCall; - - GetBalance(argument: _fietCexNode_BalanceRequest, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_BalanceResponse__Output>): grpc.ClientUnaryCall; - GetBalance(argument: _fietCexNode_BalanceRequest, metadata: grpc.Metadata, callback: grpc.requestCallback<_fietCexNode_BalanceResponse__Output>): grpc.ClientUnaryCall; - GetBalance(argument: _fietCexNode_BalanceRequest, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_BalanceResponse__Output>): grpc.ClientUnaryCall; - GetBalance(argument: _fietCexNode_BalanceRequest, callback: grpc.requestCallback<_fietCexNode_BalanceResponse__Output>): grpc.ClientUnaryCall; - getBalance(argument: _fietCexNode_BalanceRequest, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_BalanceResponse__Output>): grpc.ClientUnaryCall; - getBalance(argument: _fietCexNode_BalanceRequest, metadata: grpc.Metadata, callback: grpc.requestCallback<_fietCexNode_BalanceResponse__Output>): grpc.ClientUnaryCall; - getBalance(argument: _fietCexNode_BalanceRequest, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_BalanceResponse__Output>): grpc.ClientUnaryCall; - getBalance(argument: _fietCexNode_BalanceRequest, callback: grpc.requestCallback<_fietCexNode_BalanceResponse__Output>): grpc.ClientUnaryCall; - - GetOptimalPrice(argument: _fietCexNode_OptimalPriceRequest, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_OptimalPriceResponse__Output>): grpc.ClientUnaryCall; - GetOptimalPrice(argument: _fietCexNode_OptimalPriceRequest, metadata: grpc.Metadata, callback: grpc.requestCallback<_fietCexNode_OptimalPriceResponse__Output>): grpc.ClientUnaryCall; - GetOptimalPrice(argument: _fietCexNode_OptimalPriceRequest, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_OptimalPriceResponse__Output>): grpc.ClientUnaryCall; - GetOptimalPrice(argument: _fietCexNode_OptimalPriceRequest, callback: grpc.requestCallback<_fietCexNode_OptimalPriceResponse__Output>): grpc.ClientUnaryCall; - getOptimalPrice(argument: _fietCexNode_OptimalPriceRequest, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_OptimalPriceResponse__Output>): grpc.ClientUnaryCall; - getOptimalPrice(argument: _fietCexNode_OptimalPriceRequest, metadata: grpc.Metadata, callback: grpc.requestCallback<_fietCexNode_OptimalPriceResponse__Output>): grpc.ClientUnaryCall; - getOptimalPrice(argument: _fietCexNode_OptimalPriceRequest, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_OptimalPriceResponse__Output>): grpc.ClientUnaryCall; - getOptimalPrice(argument: _fietCexNode_OptimalPriceRequest, callback: grpc.requestCallback<_fietCexNode_OptimalPriceResponse__Output>): grpc.ClientUnaryCall; - - GetOrderDetails(argument: _fietCexNode_OrderDetailsRequest, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_OrderDetailsResponse__Output>): grpc.ClientUnaryCall; - GetOrderDetails(argument: _fietCexNode_OrderDetailsRequest, metadata: grpc.Metadata, callback: grpc.requestCallback<_fietCexNode_OrderDetailsResponse__Output>): grpc.ClientUnaryCall; - GetOrderDetails(argument: _fietCexNode_OrderDetailsRequest, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_OrderDetailsResponse__Output>): grpc.ClientUnaryCall; - GetOrderDetails(argument: _fietCexNode_OrderDetailsRequest, callback: grpc.requestCallback<_fietCexNode_OrderDetailsResponse__Output>): grpc.ClientUnaryCall; - getOrderDetails(argument: _fietCexNode_OrderDetailsRequest, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_OrderDetailsResponse__Output>): grpc.ClientUnaryCall; - getOrderDetails(argument: _fietCexNode_OrderDetailsRequest, metadata: grpc.Metadata, callback: grpc.requestCallback<_fietCexNode_OrderDetailsResponse__Output>): grpc.ClientUnaryCall; - getOrderDetails(argument: _fietCexNode_OrderDetailsRequest, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_OrderDetailsResponse__Output>): grpc.ClientUnaryCall; - getOrderDetails(argument: _fietCexNode_OrderDetailsRequest, callback: grpc.requestCallback<_fietCexNode_OrderDetailsResponse__Output>): grpc.ClientUnaryCall; - - Transfer(argument: _fietCexNode_TransferRequest, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_TransferResponse__Output>): grpc.ClientUnaryCall; - Transfer(argument: _fietCexNode_TransferRequest, metadata: grpc.Metadata, callback: grpc.requestCallback<_fietCexNode_TransferResponse__Output>): grpc.ClientUnaryCall; - Transfer(argument: _fietCexNode_TransferRequest, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_TransferResponse__Output>): grpc.ClientUnaryCall; - Transfer(argument: _fietCexNode_TransferRequest, callback: grpc.requestCallback<_fietCexNode_TransferResponse__Output>): grpc.ClientUnaryCall; - transfer(argument: _fietCexNode_TransferRequest, metadata: grpc.Metadata, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_TransferResponse__Output>): grpc.ClientUnaryCall; - transfer(argument: _fietCexNode_TransferRequest, metadata: grpc.Metadata, callback: grpc.requestCallback<_fietCexNode_TransferResponse__Output>): grpc.ClientUnaryCall; - transfer(argument: _fietCexNode_TransferRequest, options: grpc.CallOptions, callback: grpc.requestCallback<_fietCexNode_TransferResponse__Output>): grpc.ClientUnaryCall; - transfer(argument: _fietCexNode_TransferRequest, callback: grpc.requestCallback<_fietCexNode_TransferResponse__Output>): grpc.ClientUnaryCall; - -} - -export interface CexServiceHandlers extends grpc.UntypedServiceImplementation { - CancelOrder: grpc.handleUnaryCall<_fietCexNode_CancelOrderRequest__Output, _fietCexNode_CancelOrderResponse>; - - Convert: grpc.handleUnaryCall<_fietCexNode_ConvertRequest__Output, _fietCexNode_ConvertResponse>; - - Deposit: grpc.handleUnaryCall<_fietCexNode_DepositConfirmationRequest__Output, _fietCexNode_DepositConfirmationResponse>; - - GetBalance: grpc.handleUnaryCall<_fietCexNode_BalanceRequest__Output, _fietCexNode_BalanceResponse>; - - GetOptimalPrice: grpc.handleUnaryCall<_fietCexNode_OptimalPriceRequest__Output, _fietCexNode_OptimalPriceResponse>; - - GetOrderDetails: grpc.handleUnaryCall<_fietCexNode_OrderDetailsRequest__Output, _fietCexNode_OrderDetailsResponse>; - - Transfer: grpc.handleUnaryCall<_fietCexNode_TransferRequest__Output, _fietCexNode_TransferResponse>; - -} - -export interface CexServiceDefinition extends grpc.ServiceDefinition { - CancelOrder: MethodDefinition<_fietCexNode_CancelOrderRequest, _fietCexNode_CancelOrderResponse, _fietCexNode_CancelOrderRequest__Output, _fietCexNode_CancelOrderResponse__Output> - Convert: MethodDefinition<_fietCexNode_ConvertRequest, _fietCexNode_ConvertResponse, _fietCexNode_ConvertRequest__Output, _fietCexNode_ConvertResponse__Output> - Deposit: MethodDefinition<_fietCexNode_DepositConfirmationRequest, _fietCexNode_DepositConfirmationResponse, _fietCexNode_DepositConfirmationRequest__Output, _fietCexNode_DepositConfirmationResponse__Output> - GetBalance: MethodDefinition<_fietCexNode_BalanceRequest, _fietCexNode_BalanceResponse, _fietCexNode_BalanceRequest__Output, _fietCexNode_BalanceResponse__Output> - GetOptimalPrice: MethodDefinition<_fietCexNode_OptimalPriceRequest, _fietCexNode_OptimalPriceResponse, _fietCexNode_OptimalPriceRequest__Output, _fietCexNode_OptimalPriceResponse__Output> - GetOrderDetails: MethodDefinition<_fietCexNode_OrderDetailsRequest, _fietCexNode_OrderDetailsResponse, _fietCexNode_OrderDetailsRequest__Output, _fietCexNode_OrderDetailsResponse__Output> - Transfer: MethodDefinition<_fietCexNode_TransferRequest, _fietCexNode_TransferResponse, _fietCexNode_TransferRequest__Output, _fietCexNode_TransferResponse__Output> -} diff --git a/proto/fietCexNode/ConvertRequest.ts b/proto/fietCexNode/ConvertRequest.ts deleted file mode 100644 index 832385f..0000000 --- a/proto/fietCexNode/ConvertRequest.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Original file: proto/node.proto - - -export interface ConvertRequest { - 'fromToken'?: (string); - 'toToken'?: (string); - 'amount'?: (number | string); - 'price'?: (number | string); - 'cex'?: (string); -} - -export interface ConvertRequest__Output { - 'fromToken'?: (string); - 'toToken'?: (string); - 'amount'?: (number); - 'price'?: (number); - 'cex'?: (string); -} diff --git a/proto/fietCexNode/ConvertResponse.ts b/proto/fietCexNode/ConvertResponse.ts deleted file mode 100644 index 44b2b9e..0000000 --- a/proto/fietCexNode/ConvertResponse.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Original file: proto/node.proto - - -export interface ConvertResponse { - 'orderId'?: (string); -} - -export interface ConvertResponse__Output { - 'orderId'?: (string); -} diff --git a/proto/fietCexNode/DepositConfirmationRequest.ts b/proto/fietCexNode/DepositConfirmationRequest.ts deleted file mode 100644 index b5b3149..0000000 --- a/proto/fietCexNode/DepositConfirmationRequest.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Original file: proto/node.proto - - -export interface DepositConfirmationRequest { - 'chain'?: (string); - 'recipientAddress'?: (string); - 'amount'?: (number | string); - 'transactionHash'?: (string); -} - -export interface DepositConfirmationRequest__Output { - 'chain'?: (string); - 'recipientAddress'?: (string); - 'amount'?: (number); - 'transactionHash'?: (string); -} diff --git a/proto/fietCexNode/DepositConfirmationResponse.ts b/proto/fietCexNode/DepositConfirmationResponse.ts deleted file mode 100644 index b690b56..0000000 --- a/proto/fietCexNode/DepositConfirmationResponse.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Original file: proto/node.proto - - -export interface DepositConfirmationResponse { - 'newBalance'?: (number | string); -} - -export interface DepositConfirmationResponse__Output { - 'newBalance'?: (number); -} diff --git a/proto/fietCexNode/OptimalPriceRequest.ts b/proto/fietCexNode/OptimalPriceRequest.ts deleted file mode 100644 index 5f12b6a..0000000 --- a/proto/fietCexNode/OptimalPriceRequest.ts +++ /dev/null @@ -1,15 +0,0 @@ -// Original file: proto/node.proto - -import type { OrderMode as _fietCexNode_OrderMode, OrderMode__Output as _fietCexNode_OrderMode__Output } from '../fietCexNode/OrderMode'; - -export interface OptimalPriceRequest { - 'symbol'?: (string); - 'quantity'?: (number | string); - 'mode'?: (_fietCexNode_OrderMode); -} - -export interface OptimalPriceRequest__Output { - 'symbol'?: (string); - 'quantity'?: (number); - 'mode'?: (_fietCexNode_OrderMode__Output); -} diff --git a/proto/fietCexNode/OptimalPriceResponse.ts b/proto/fietCexNode/OptimalPriceResponse.ts deleted file mode 100644 index 4b2b97a..0000000 --- a/proto/fietCexNode/OptimalPriceResponse.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Original file: proto/node.proto - -import type { PriceInfo as _fietCexNode_PriceInfo, PriceInfo__Output as _fietCexNode_PriceInfo__Output } from '../fietCexNode/PriceInfo'; - -export interface OptimalPriceResponse { - 'results'?: ({[key: string]: _fietCexNode_PriceInfo}); -} - -export interface OptimalPriceResponse__Output { - 'results'?: ({[key: string]: _fietCexNode_PriceInfo__Output}); -} diff --git a/proto/fietCexNode/OrderDetailsRequest.ts b/proto/fietCexNode/OrderDetailsRequest.ts deleted file mode 100644 index ffd65d9..0000000 --- a/proto/fietCexNode/OrderDetailsRequest.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Original file: proto/node.proto - - -export interface OrderDetailsRequest { - 'orderId'?: (string); - 'cex'?: (string); -} - -export interface OrderDetailsRequest__Output { - 'orderId'?: (string); - 'cex'?: (string); -} diff --git a/proto/fietCexNode/OrderDetailsResponse.ts b/proto/fietCexNode/OrderDetailsResponse.ts deleted file mode 100644 index e80ad1a..0000000 --- a/proto/fietCexNode/OrderDetailsResponse.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Original file: proto/node.proto - - -export interface OrderDetailsResponse { - 'orderId'?: (string); - 'status'?: (string); - 'originalAmount'?: (number | string); - 'filledAmount'?: (number | string); - 'symbol'?: (string); - 'mode'?: (string); - 'price'?: (number | string); -} - -export interface OrderDetailsResponse__Output { - 'orderId'?: (string); - 'status'?: (string); - 'originalAmount'?: (number); - 'filledAmount'?: (number); - 'symbol'?: (string); - 'mode'?: (string); - 'price'?: (number); -} diff --git a/proto/fietCexNode/OrderMode.ts b/proto/fietCexNode/OrderMode.ts deleted file mode 100644 index 721639e..0000000 --- a/proto/fietCexNode/OrderMode.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Original file: proto/node.proto - -export const OrderMode = { - BUY: 0, - SELL: 1, -} as const; - -export type OrderMode = - | 'BUY' - | 0 - | 'SELL' - | 1 - -export type OrderMode__Output = typeof OrderMode[keyof typeof OrderMode] diff --git a/proto/fietCexNode/PriceInfo.ts b/proto/fietCexNode/PriceInfo.ts deleted file mode 100644 index 1215ad7..0000000 --- a/proto/fietCexNode/PriceInfo.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Original file: proto/node.proto - - -export interface PriceInfo { - 'avgPrice'?: (number | string); - 'fillPrice'?: (number | string); -} - -export interface PriceInfo__Output { - 'avgPrice'?: (number); - 'fillPrice'?: (number); -} diff --git a/proto/fietCexNode/TransferRequest.ts b/proto/fietCexNode/TransferRequest.ts deleted file mode 100644 index faf16c7..0000000 --- a/proto/fietCexNode/TransferRequest.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Original file: proto/node.proto - - -export interface TransferRequest { - 'chain'?: (string); - 'recipientAddress'?: (string); - 'amount'?: (number | string); - 'cex'?: (string); - 'token'?: (string); -} - -export interface TransferRequest__Output { - 'chain'?: (string); - 'recipientAddress'?: (string); - 'amount'?: (number); - 'cex'?: (string); - 'token'?: (string); -} diff --git a/proto/fietCexNode/TransferResponse.ts b/proto/fietCexNode/TransferResponse.ts deleted file mode 100644 index 5ab2b7c..0000000 --- a/proto/fietCexNode/TransferResponse.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Original file: proto/node.proto - - -export interface TransferResponse { - 'success'?: (boolean); - 'transactionId'?: (string); -} - -export interface TransferResponse__Output { - 'success'?: (boolean); - 'transactionId'?: (string); -} diff --git a/proto/node.proto b/proto/node.proto index 042667e..a378a68 100644 --- a/proto/node.proto +++ b/proto/node.proto @@ -1,97 +1,53 @@ syntax = "proto3"; -package fietCexNode; -// Mode enum for price direction -enum OrderMode { - BUY = 0; - SELL = 1; -} -// Optimal price query -message OptimalPriceRequest { - string symbol = 1; // Trading pair symbol, e.g. "ARB/USDT" - double quantity = 2; // Quantity to buy or sell - OrderMode mode = 3; // Buy or Sell mode -} -// Single‐symbol price info -message PriceInfo { - double avgPrice = 1; // Volume‑weighted average price - double fillPrice = 2; // Worst‑case fill price -} -// The new response: a map of symbolβ†’PriceInfo -message OptimalPriceResponse { - map results = 1; -} -// Order details query -message OrderDetailsRequest { - string order_id = 1; // Unique order identifier - string cex = 2; // CEX identifier -} -message OrderDetailsResponse { - string order_id = 1; // Unique order identifier - string status = 2; // Current order status - double original_amount = 3; // Original order amount - double filled_amount = 4; // Amount that has been filled - string symbol = 5; // Trading pair symbol - string mode = 6; // Buy or Sell mode - double price = 7; // Order price -} -// Cancel order request -message CancelOrderRequest { - string order_id = 1; // Unique order identifier - string cex = 2; // CEX identifier -} -message CancelOrderResponse { - bool success = 1; // Whether cancellation was successful - string final_status = 2; // Final status of the order -} -// Withdraw -message DepositConfirmationRequest { - string chain = 1; - string recipient_address = 2; - double amount = 3; - string transaction_hash = 4; -} -message DepositConfirmationResponse { - double new_balance = 1; -} -// Transfer -message TransferRequest { - string chain = 1; - string recipient_address = 2; - double amount = 3; - string cex=4; - string token = 5; -} -message TransferResponse { - bool success = 1; - string transaction_id= 2; -} -// Convert -message ConvertRequest { - string from_token = 1; - string to_token = 2; - double amount = 3; - double price= 4; - string cex=5; -} -message ConvertResponse { - string order_id= 3; -} -// Balance -message BalanceRequest { - string cex = 1; // CEX identifier (e.g., "BINANCE", "BYBIT") - string token = 2; // Trading pair symbol, e.g. "USDT" -} -message BalanceResponse { - double balance = 1; // Available balance for the symbol - string currency = 2; // Currency of the balance -} -// CEX service definition +package cexBroker; + +message ActionRequest { + Action action = 1; // The CCXT method to call (e.g., "fetchBalance", "createOrder") + map payload = 2; // Parameters to pass to the CCXT method + string cex = 3; // CEX identifier (e.g., "binance", "bybit") + string symbol = 4; // Optional: trading pair symbol if needed +} + +message ActionResponse { + string result = 2; // JSON string of the result data +} + +message SubscribeRequest { + string cex = 1; // CEX identifier (e.g., "binance", "bybit") + string symbol = 2; // Trading pair symbol (e.g., "BTC/USDT") + SubscriptionType type = 3; // Type of subscription (orderbook, trades, etc.) + map options = 4; // Additional subscription options +} + +message SubscribeResponse { + string data = 1; // JSON string of the streaming data + int64 timestamp = 2; // Unix timestamp of the data + string symbol = 3; // Trading pair symbol + SubscriptionType type = 4; // Type of subscription +} + +enum SubscriptionType { + ORDERBOOK = 0; // Order book updates + TRADES = 1; // Recent trades + TICKER = 2; // Ticker information + OHLCV = 3; // OHLCV candlestick data + BALANCE = 4; // Balance updates + ORDERS = 5; // Order updates +} + service CexService { - rpc Deposit(DepositConfirmationRequest) returns (DepositConfirmationResponse); - rpc Transfer(TransferRequest) returns (TransferResponse); - rpc Convert(ConvertRequest) returns (ConvertResponse); - rpc GetOptimalPrice(OptimalPriceRequest) returns (OptimalPriceResponse); - rpc GetBalance(BalanceRequest) returns (BalanceResponse); - rpc GetOrderDetails(OrderDetailsRequest) returns (OrderDetailsResponse); - rpc CancelOrder(CancelOrderRequest) returns (CancelOrderResponse); + rpc ExecuteAction(ActionRequest) returns (ActionResponse); + rpc Subscribe(SubscribeRequest) returns (stream SubscribeResponse); } + +// Mode enum for price direction +enum Action { + NoAction=0; + Deposit = 1; + Transfer = 2; + CreateOrder= 3; + GetOrderDetails=4; + CancelOrder=5; + FetchBalance=6; + FetchDepositAddresses=7; +} \ No newline at end of file diff --git a/proto/node.ts b/proto/node.ts index 4284ee8..947f80e 100644 --- a/proto/node.ts +++ b/proto/node.ts @@ -1,31 +1,34 @@ -import type * as grpc from '@grpc/grpc-js'; -import type { EnumTypeDefinition, MessageTypeDefinition } from '@grpc/proto-loader'; +import type * as grpc from "@grpc/grpc-js"; +import type { + EnumTypeDefinition, + MessageTypeDefinition, +} from "@grpc/proto-loader"; -import type { CexServiceClient as _fietCexNode_CexServiceClient, CexServiceDefinition as _fietCexNode_CexServiceDefinition } from './fietCexNode/CexService'; +import type { + CexServiceClient as _cexBroker_CexServiceClient, + CexServiceDefinition as _cexBroker_CexServiceDefinition, +} from "./cexBroker/CexService"; -type SubtypeConstructor any, Subtype> = { - new(...args: ConstructorParameters): Subtype; +type SubtypeConstructor< + Constructor extends new ( + ...args: any + ) => any, + Subtype, +> = { + new (...args: ConstructorParameters): Subtype; }; export interface ProtoGrpcType { - fietCexNode: { - BalanceRequest: MessageTypeDefinition - BalanceResponse: MessageTypeDefinition - CancelOrderRequest: MessageTypeDefinition - CancelOrderResponse: MessageTypeDefinition - CexService: SubtypeConstructor & { service: _fietCexNode_CexServiceDefinition } - ConvertRequest: MessageTypeDefinition - ConvertResponse: MessageTypeDefinition - DepositConfirmationRequest: MessageTypeDefinition - DepositConfirmationResponse: MessageTypeDefinition - OptimalPriceRequest: MessageTypeDefinition - OptimalPriceResponse: MessageTypeDefinition - OrderDetailsRequest: MessageTypeDefinition - OrderDetailsResponse: MessageTypeDefinition - OrderMode: EnumTypeDefinition - PriceInfo: MessageTypeDefinition - TransferRequest: MessageTypeDefinition - TransferResponse: MessageTypeDefinition - } + cexBroker: { + Action: EnumTypeDefinition; + ActionRequest: MessageTypeDefinition; + ActionResponse: MessageTypeDefinition; + CexService: SubtypeConstructor< + typeof grpc.Client, + _cexBroker_CexServiceClient + > & { service: _cexBroker_CexServiceDefinition }; + SubscribeRequest: MessageTypeDefinition; + SubscribeResponse: MessageTypeDefinition; + SubscriptionType: EnumTypeDefinition; + }; } - diff --git a/src/cli.ts b/src/cli.ts new file mode 100755 index 0000000..2629780 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,49 @@ +#!/usr/bin/env bun + +import { Command } from "commander"; +import { startBrokerCommand } from "./commands/start-broker"; + +const program = new Command(); + +program + .name("cex-broker") + .description("CLI to start the CEXBroker service") + .requiredOption("-p, --policy ", "Policy JSON file") + .option("--port ", "Port number (default: 8086)", "8086") + .option( + "-w", + "--whitelist ", + "IPv4 address whitelist (space-separated list)", + ) + .option("-vu", "--verityProverUrl ", "Verity Prover Url") + .action(async (options) => { + try { + // Optional: Validate IPv4 addresses + if (options.whitelist) { + const isValidIPv4 = (ip: string) => + /^(\d{1,3}\.){3}\d{1,3}$/.test(ip) && + ip + .split(".") + .every((part) => Number(part) >= 0 && Number(part) <= 255); + + for (const ip of options.whitelist) { + if (!isValidIPv4(ip)) { + console.error(`❌ Invalid IPv4 address: ${ip}`); + process.exit(1); + } + } + } + + await startBrokerCommand( + options.policy, + parseInt(options.port, 10), + options.whitelist ?? [], // Pass whitelist to your command, + options.verityProverUrl, + ); + } catch (err) { + console.error("❌ Failed to start broker:", err); + process.exit(1); + } + }); + +program.parse(process.argv); diff --git a/src/client.dev.ts b/src/client.dev.ts new file mode 100644 index 0000000..1d88158 --- /dev/null +++ b/src/client.dev.ts @@ -0,0 +1,111 @@ +import path from "path"; +import * as grpc from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; +import type { ProtoGrpcType } from "../proto/node"; +import { Action } from "../proto/cexBroker/Action"; +import { config } from "dotenv"; +import { log } from "./helpers/logger"; +import CEXBroker from "."; +import { loadPolicy } from "./helpers"; + +const PROTO_FILE = "../proto/node.proto"; +const port = 8086; + +const packageDef = protoLoader.loadSync(path.resolve(__dirname, PROTO_FILE)); +const grpcObj = grpc.loadPackageDefinition( + packageDef, +) as unknown as ProtoGrpcType; + +const client = new grpcObj.cexBroker.CexService( + `0.0.0.0:${port}`, + grpc.credentials.createInsecure(), +); + +config(); + +const broker = new CEXBroker({}, loadPolicy("./policy/policy.json"), { + useVerity: true, +}); +broker.loadEnvConfig(); +broker.run(); + +const metadata = new grpc.Metadata(); +metadata.add("api-key", process.env.BYBIT_API_KEY ?? ""); // Example header +metadata.add("api-secret", process.env.BYBIT_API_SECRET ?? ""); + +const deadline = new Date(); +deadline.setSeconds(deadline.getSeconds() + 5); +client.waitForReady(deadline, (err) => { + if (err) { + log.error(err); + return; + } + onClientReady(); +}); + +function onClientReady() { + // // Test ExecuteAction for balance + // client.executeAction({ cex: "bybit", symbol: "USDT",payload:{},action: Action.FetchBalance },metadata, (err, result) => { + // if (err) { + // log.error({ err }); + // return; + // } + // log.info("ExecuteAction Balance Result:", { result }); + // }); + + // Test ExecuteAction for balance + client.executeAction( + { + cex: "bybit", + symbol: "USDT", + payload: { chain: "TRC20" }, + action: Action.FetchDepositAddresses, + }, + metadata, + (err, result) => { + if (err) { + log.error({ err }); + return; + } + log.info("ExecuteAction Balance Result:", { result }); + }, + ); + + // // Test Subscribe for balance streaming + // log.info("Starting balance subscription test..."); + // const subscribeCall = client.subscribe( + // { + // cex: "bybit", + // symbol: "BTC/USDT", + // type: SubscriptionType.TICKER, + // options: {}, + // }, + // metadata, + // ); + + // // Handle incoming stream data + // subscribeCall.on("data", (response) => { + // log.info("Balance Subscription Update:", { + // symbol: response.symbol, + // type: response.type, + // data: JSON.parse(response.data), + // }); + // }); + + // // Handle stream end + // subscribeCall.on("end", () => { + // log.info("Balance subscription stream ended"); + // }); + + // // Handle stream errors + // subscribeCall.on("error", (error) => { + // log.error("Balance subscription stream error:", error); + // }); + + // // Keep the subscription alive for 30 seconds + // setTimeout(() => { + // log.info("Closing balance subscription after 30 seconds"); + // // For server-side streaming, we don't need to call end() on the client + // // The server will handle the stream lifecycle + // }, 3000000); +} diff --git a/src/commands/start-broker.ts b/src/commands/start-broker.ts new file mode 100644 index 0000000..4769e51 --- /dev/null +++ b/src/commands/start-broker.ts @@ -0,0 +1,10 @@ +import CEXBroker from '../index'; + +/** + * CLI Command wrapper to start the CEXBroker + */ +export async function startBrokerCommand(policyPath: string, port: number,whitelistIps: string[],url: string) { + const broker = new CEXBroker({}, policyPath, { port,whitelistIps,verityProverUrl: url }); + broker.loadEnvConfig(); + await broker.run(); +} diff --git a/helpers/index.test.ts b/src/helpers/index.test.ts similarity index 67% rename from helpers/index.test.ts rename to src/helpers/index.test.ts index 2d329c8..d6bee8f 100644 --- a/helpers/index.test.ts +++ b/src/helpers/index.test.ts @@ -1,37 +1,11 @@ -import { describe, test, expect, beforeEach, mock } from "bun:test"; -import { - buyAtOptimalPrice, - sellAtOptimalPrice, - validateWithdraw, - validateOrder, - validateDeposit, -} from "./index"; -import type { Exchange } from "ccxt"; +import { beforeEach, describe, expect, test } from "bun:test"; import type { PolicyConfig } from "../types"; +import { validateDeposit, validateOrder, validateWithdraw } from "./index"; describe("Helper Functions", () => { - let mockExchange: Exchange; let testPolicy: PolicyConfig; beforeEach(() => { - // Create mock exchange - mockExchange = { - fetchOrderBook: mock(async (symbol: string) => ({ - bids: [ - [100, 10], // price, volume - [99, 20], - [98, 30], - [97, 40], - ], - asks: [ - [101, 10], - [102, 20], - [103, 30], - [104, 40], - ], - })), - } as any; - // Test policy configuration testPolicy = { withdraw: { @@ -68,89 +42,11 @@ describe("Helper Functions", () => { }; }); - describe("buyAtOptimalPrice", () => { - test("should calculate optimal buy price correctly", async () => { - const result = await buyAtOptimalPrice(mockExchange, "ARB/USDT", 25); - - expect(result).toBeDefined(); - expect(result.avgPrice).toBeGreaterThan(0); - expect(result.fillPrice).toBeGreaterThan(0); - expect(result.size).toBe(25); - expect(result.symbol).toBe("ARB/USDT"); - expect(mockExchange.fetchOrderBook).toHaveBeenCalledWith("ARB/USDT", 500); - }); - - test("should handle insufficient depth", async () => { - // Mock exchange with insufficient depth - const insufficientExchange = { - fetchOrderBook: mock(async () => ({ - bids: [[100, 5]], // Only 5 volume available - })), - } as any; - - await expect( - buyAtOptimalPrice(insufficientExchange, "ARB/USDT", 10), - ).rejects.toThrow("Insufficient depth"); - }); - - test("should handle edge case with exact volume match", async () => { - const exactExchange = { - fetchOrderBook: mock(async () => ({ - bids: [[100, 25]], // Exact volume needed - })), - } as any; - - const result = await buyAtOptimalPrice(exactExchange, "ARB/USDT", 25); - expect(result.avgPrice).toBe(100); - expect(result.fillPrice).toBe(100); - }); - }); - - describe("sellAtOptimalPrice", () => { - test("should calculate optimal sell price correctly", async () => { - const result = await sellAtOptimalPrice(mockExchange, "ARB/USDT", 25); - - expect(result).toBeDefined(); - expect(result.avgPrice).toBeGreaterThan(0); - expect(result.fillPrice).toBeGreaterThan(0); - expect(result.size).toBe(25); - expect(result.symbol).toBe("ARB/USDT"); - expect(mockExchange.fetchOrderBook).toHaveBeenCalledWith("ARB/USDT"); - }); - - test("should handle insufficient depth for selling", async () => { - const insufficientExchange = { - fetchOrderBook: mock(async () => ({ - asks: [[101, 5]], // Only 5 volume available - })), - } as any; - - await expect( - sellAtOptimalPrice(insufficientExchange, "ARB/USDT", 10), - ).rejects.toThrow("Insufficient depth"); - }); - - test("should handle undefined orderbook entries", async () => { - const badExchange = { - fetchOrderBook: mock(async () => ({ - asks: [ - [undefined, 10], - [102, undefined], - ], - })), - } as any; - - await expect( - sellAtOptimalPrice(badExchange, "ARB/USDT", 5), - ).rejects.toThrow("Orderbook entry had undefined price or volume"); - }); - }); - describe("loadPolicy", () => { test("should load policy successfully", () => { // This test will use the actual policy file const { loadPolicy } = require("./index"); - const policy = loadPolicy(); + const policy = loadPolicy("./policy/policy.json"); expect(policy).toBeDefined(); expect(policy.withdraw.rule.networks).toContain("ARBITRUM"); diff --git a/src/helpers/index.ts b/src/helpers/index.ts new file mode 100644 index 0000000..3d32947 --- /dev/null +++ b/src/helpers/index.ts @@ -0,0 +1,271 @@ +import fs from "fs"; +import Joi from "joi"; +import type { PolicyConfig } from "../types"; +import { log } from "./logger"; +import type { Metadata, ServerUnaryCall } from "@grpc/grpc-js"; +import ccxt, { type Exchange } from "@usherlabs/ccxt"; + +export function authenticateRequest( + call: ServerUnaryCall, + whitelistIps: string[], +): boolean { + const clientIp = call.getPeer().split(":")[0]; + if (!clientIp || !whitelistIps.includes(clientIp)) { + log.warn(`Blocked access from unauthorized IP: ${clientIp || "unknown"}`); + return false; + } + return true; +} + +export function createBroker( + cex: string, + metadata: Metadata, + useVerity: boolean, + verityProverUrl: string, +): Exchange | null { + const api_key = metadata.get("api-key"); + const api_secret = metadata.get("api-secret"); + + const ExchangeClass = (ccxt.pro as Record)[cex]; + + metadata.remove("api-key"); + metadata.remove("api-secret"); + if (api_secret.length === 0 || api_key.length === 0 || !ExchangeClass) { + return null; + } + const exchange = new ExchangeClass({ + apiKey: api_key[0]?.toString(), + secret: api_secret[0]?.toString(), + enableRateLimit: true, + defaultType: "spot", + useVerity: useVerity, + verityProverUrl: verityProverUrl, + timeout: 150 * 1000, + options: { + adjustForTimeDifference: true, + recvWindow: 60000, + }, + }); + exchange.options.recvWindow = 60000; + return exchange; +} + +export function selectBroker( + brokers: + | { + primary: Exchange; + secondaryBrokers: Exchange[]; + } + | undefined, + metadata: Metadata, +): Exchange | null { + if (!brokers) { + return null; + } else { + const use_secondary_key = metadata.get("use-secondary-key"); + if (!use_secondary_key || use_secondary_key.length === 0) { + return brokers.primary; + } else if (use_secondary_key.length > 0) { + const keyIndex = Number.isInteger( + +(use_secondary_key[use_secondary_key.length - 1] ?? "0"), + ); + return brokers.secondaryBrokers[+keyIndex] ?? null; + } else { + return null; + } + } +} + +/** + * Loads and validates policy configuration + */ +export function loadPolicy(policyPath: string): PolicyConfig { + try { + const policyData = fs.readFileSync(policyPath, "utf8"); + + // Joi schema for WithdrawRule + const withdrawRuleSchema = Joi.object({ + networks: Joi.array().items(Joi.string()).required(), + whitelist: Joi.array().items(Joi.string()).required(), + amounts: Joi.array() + .items( + Joi.object({ + ticker: Joi.string().required(), + max: Joi.number().required(), + min: Joi.number().required(), + }), + ) + .required(), + }); + + // Joi schema for OrderRule + const orderRuleSchema = Joi.object({ + markets: Joi.array().items(Joi.string()).required(), + limits: Joi.array() + .items( + Joi.object({ + from: Joi.string().required(), + to: Joi.string().required(), + min: Joi.number().required(), + max: Joi.number().required(), + }), + ) + .required(), + }); + + // Full PolicyConfig schema + const policyConfigSchema = Joi.object({ + withdraw: Joi.object({ + rule: withdrawRuleSchema.required(), + }).required(), + + deposit: Joi.object() + .pattern(Joi.string(), Joi.valid(null)) // Record + .required(), + + order: Joi.object({ + rule: orderRuleSchema.required(), + }).required(), + }); + + const { error, value } = policyConfigSchema.validate( + JSON.parse(policyData), + ); + + if (error) { + console.error("Validation failed:", error.details); + } + + return value as PolicyConfig; + } catch (error) { + console.error("Failed to load policy:", error); + throw new Error("Policy configuration could not be loaded"); + } +} + +/** + * Validates withdraw request against policy rules + */ +// TODO: Nice work on the policy engine, however, we'll need incorporate a mapping between how the CEX Broker recognises networks, and how different CEXs recognise networks - eg. Binance might have "BSC", but another chain will have BNB" +export function validateWithdraw( + policy: PolicyConfig, + network: string, + recipientAddress: string, + amount: number, + ticker: string, +): { valid: boolean; error?: string } { + const withdrawRule = policy.withdraw.rule; + + // Check if network is allowed + if (!withdrawRule.networks.includes(network)) { + return { + valid: false, + error: `Network ${network} is not allowed. Allowed networks: ${withdrawRule.networks.join(", ")}`, + }; + } + + // Check if address is whitelisted + if (!withdrawRule.whitelist.includes(recipientAddress.toLowerCase())) { + return { + valid: false, + error: `Address ${recipientAddress} is not whitelisted for withdrawals`, + }; + } + + // Check amount limits + const amountRule = withdrawRule.amounts.find((a) => a.ticker === ticker); + + if (!amountRule) { + return { + valid: false, + error: `Ticker ${ticker} is not allowed. Supported tickers: ${withdrawRule.amounts.map((a) => a.ticker).join(", ")}`, + }; + } + + if (amount < amountRule.min) { + return { + valid: false, + error: `Amount ${amount} is below minimum ${amountRule.min}`, + }; + } + + if (amount > amountRule.max) { + return { + valid: false, + error: `Amount ${amount} exceeds maximum ${amountRule.max}`, + }; + } + + return { valid: true }; +} + +/** + * Validates order request against policy rules + */ +export function validateOrder( + policy: PolicyConfig, + fromToken: string, + toToken: string, + amount: number, + broker: string, +): { valid: boolean; error?: string } { + const orderRule = policy.order.rule; + + // Check if market is allowed + const marketKeys = [ + `${broker.toUpperCase()}:${toToken}/${fromToken}`, + `${broker.toUpperCase()}:${fromToken}/${toToken}`, + ]; + if ( + !( + orderRule.markets.includes(marketKeys[0] ?? "") || + orderRule.markets.includes(marketKeys[1] ?? "") + ) + ) { + return { + valid: false, + error: `Market ${marketKeys} is not allowed. Allowed markets: ${orderRule.markets.join(", ")}`, + }; + } + + // Check conversion limits + const limit = orderRule.limits.find( + (l) => l.from === fromToken && l.to === toToken, + ); + + if (!limit) { + return { + valid: false, + error: `Conversion from ${fromToken} to ${toToken} is not allowed`, + }; + } + + if (amount < limit.min) { + return { + valid: false, + error: `Amount ${amount} is below minimum ${limit.min} for ${fromToken} to ${toToken} conversion`, + }; + } + + if (amount > limit.max) { + return { + valid: false, + error: `Amount ${amount} exceeds maximum ${limit.max} for ${fromToken} to ${toToken} conversion`, + }; + } + + return { valid: true }; +} + +/** + * Validates deposit request (currently empty but can be extended) + */ +export function validateDeposit( + _policy: PolicyConfig, + _chain: string, + _amount: number, +): { valid: boolean; error?: string } { + // Currently deposit policy is empty, so all deposits are allowed + // This can be extended when deposit rules are added to the policy + return { valid: true }; +} diff --git a/src/helpers/logger.ts b/src/helpers/logger.ts new file mode 100644 index 0000000..8429e66 --- /dev/null +++ b/src/helpers/logger.ts @@ -0,0 +1,8 @@ +import { Logger } from "tslog"; + +const log = new Logger({ + type: process.env.NODE_ENV === "production" ? "json" : "pretty", + stylePrettyLogs: false, +}); + +export { log }; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..e32e76c --- /dev/null +++ b/src/index.ts @@ -0,0 +1,371 @@ +import * as grpc from "@grpc/grpc-js"; +import ccxt, { type Exchange } from "@usherlabs/ccxt"; +import { unwatchFile, watchFile } from "fs"; +import Joi from "joi"; +import { loadPolicy } from "./helpers"; +import { log } from "./helpers/logger"; +import { getServer } from "./server"; +import { + type BrokerCredentials, + BrokerList, + type ExchangeCredentials, + type PolicyConfig, +} from "./types"; + +log.info("CCXT Version:", ccxt.version); + +export default class CEXBroker { + #brokerConfig: ExchangeCredentials = {}; + #policyFilePath?: string; + #verityProverUrl: string = "http://localhost:8080"; + port = 8086; + private policy: PolicyConfig; + private brokers: Record< + string, + { primary: Exchange; secondaryBrokers: Exchange[] } + > = {}; + private whitelistIps: string[] = [ + "127.0.0.1", // localhost + "::1", // IPv6 localhost + ]; + + private server: grpc.Server | null = null; + private useVerity: boolean = false; + + /** + * Loads environment variables prefixed with CEX_BROKER_ + * Expected format: + * CEX_BROKER__API_KEY + * CEX_BROKER__API_SECRET + */ + public loadEnvConfig(): void { + log.info("πŸ”§ Loading CEX_BROKER_ environment variables:"); + const configMap: Record< + string, + Partial & { + _secondaryMap?: Record; + } + > = {}; + + for (const [key, value] of Object.entries(process.env)) { + if (!key.startsWith("CEX_BROKER_")) continue; + + // Match secondary keys like API_KEY_1, API_SECRET_1 + let match = key.match(/^CEX_BROKER_(\w+)_API_(KEY|SECRET)_(\d+)$/); + if (match) { + const broker = match[1]?.toLowerCase() ?? ""; + const type = match[2]?.toLowerCase(); + const index = Number(match[3]?.toLowerCase()); + + if (!configMap[broker]) configMap[broker] = {}; + if (!configMap[broker]._secondaryMap) + configMap[broker]._secondaryMap = {}; + if (!configMap[broker]._secondaryMap[index]) + configMap[broker]._secondaryMap[index] = {}; + + if (type === "key") { + configMap[broker]._secondaryMap[index].apiKey = value || ""; + } else if (type === "secret") { + configMap[broker]._secondaryMap[index].apiSecret = value || ""; + } + continue; + } + + match = key.match(/^CEX_BROKER_(\w+)_API_(KEY|SECRET)$/); + if (!match) { + log.warn(`⚠️ Skipping unrecognized env var: ${key}`); + continue; + } + + const broker = match[1]?.toLowerCase() ?? ""; // normalize to lowercase + const type = match[2]?.toLowerCase() ?? ""; // 'key' or 'secret' + + if (!configMap[broker]) { + configMap[broker] = {}; + } + + if (type === "key") { + configMap[broker].apiKey = value || ""; + } else if (type === "secret") { + configMap[broker].apiSecret = value || ""; + } + } + + if (Object.keys(configMap).length === 0) { + log.error(`❌ NO CEX Broker Key Found`); + } + + // Finalize config and print result per broker + for (const [broker, creds] of Object.entries(configMap)) { + const hasKey = !!creds.apiKey; + const hasSecret = !!creds.apiSecret; + const ExchangeClass = (ccxt.pro as Record)[ + broker + ]; + + if (!ExchangeClass) { + throw new Error(`Invalid Broker : ${broker}`); + } + + if (hasKey && hasSecret) { + const secondaryKeys: { apiKey: string; apiSecret: string }[] = []; + const secondaryBrokers: Exchange[] = []; + + if (creds._secondaryMap) { + for (const index of Object.keys(creds._secondaryMap)) { + const sec = creds._secondaryMap[+index]; + if (!!sec?.apiKey && !!sec?.apiSecret) { + secondaryKeys[+index] = { + apiKey: sec.apiKey, + apiSecret: sec.apiSecret, + }; + secondaryBrokers[+index] = new ExchangeClass({ + apiKey: sec.apiKey, + secret: sec.apiSecret, + enableRateLimit: true, + defaultType: "spot", + useVerity: this.useVerity, + verityProverUrl: this.#verityProverUrl, + timeout: 150 * 1000, + options: { + adjustForTimeDifference: true, + recvWindow: 60000, + }, + }); + } else { + log.warn( + `⚠️ Incomplete secondary credentials for broker "${broker}" at index ${index}`, + ); + } + } + } + + this.#brokerConfig[broker] = { + apiKey: creds.apiKey ?? "", + apiSecret: creds.apiSecret ?? "", + secondaryKeys: secondaryKeys, + }; + log.info(`βœ… Loaded credentials for broker "${broker}"`); + const client = new ExchangeClass({ + apiKey: creds.apiKey, + secret: creds.apiSecret, + enableRateLimit: true, + defaultType: "spot", + useVerity: this.useVerity, + verityProverUrl: this.#verityProverUrl, + timeout: 150 * 1000, + options: { + adjustForTimeDifference: true, + recvWindow: 60000, + }, + }); + this.brokers[broker] = { + primary: client, + secondaryBrokers: secondaryBrokers, + }; + } else { + const missing = []; + if (!hasKey) missing.push("API_KEY"); + if (!hasSecret) missing.push("API_SECRET"); + log.warn(`❌ Missing ${missing.join(" and ")} for broker "${broker}"`); + } + } + } + + /** + * Validates an exchange credential object structure. + */ + public loadExchangeCredentials( + creds: unknown, + ): asserts creds is ExchangeCredentials { + const schema = Joi.object< + Record + >() + .pattern( + Joi.string() + .allow(...BrokerList) + .required(), + Joi.object({ + apiKey: Joi.string().required(), + apiSecret: Joi.string().required(), + secondaryKeys: Joi.array() + .items( + Joi.object({ + apiKey: Joi.string().required(), + apiSecret: Joi.string().required(), + }), + ) + .default([]), + }), + ) + .required(); + + const { value, error } = schema.validate(creds); + if (error) { + throw new Error(`Invalid credentials format: ${error.message}`); + } + + // Finalize config and print result per broker + for (const [broker, creds] of Object.entries(value)) { + const ExchangeClass = (ccxt.pro as Record)[ + broker + ]; + + if (!ExchangeClass) { + throw new Error(`Invalid Broker : ${broker}`); + } + + log.info( + `βœ… Loaded credentials for broker "${broker}" (${1 + (creds.secondaryKeys?.length || 0)} key sets)`, + ); + const secondaryBroker: Exchange[] = []; + + for (const index of Object.keys(creds.secondaryKeys)) { + const sec = creds.secondaryKeys[+index]; + if (!!sec?.apiKey && !!sec?.apiSecret) { + secondaryBroker[+index] = new ExchangeClass({ + apiKey: sec.apiKey, + secret: sec.apiSecret, + enableRateLimit: true, + defaultType: "spot", + useVerity: this.useVerity, + verityProverUrl: this.#verityProverUrl, + timeout: 150 * 1000, + options: { + adjustForTimeDifference: true, + recvWindow: 60000, + }, + }); + } else { + log.warn( + `⚠️ Incomplete secondary credentials for broker "${broker}" at index ${index}`, + ); + } + } + + // Store full config, including secondary keys + this.#brokerConfig[broker] = { + apiKey: creds.apiKey, + apiSecret: creds.apiSecret, + secondaryKeys: creds.secondaryKeys ?? [], + }; + + const client = new ExchangeClass({ + apiKey: creds.apiKey, + secret: creds.apiSecret, + enableRateLimit: true, + defaultType: "spot", + useVerity: this.useVerity, + verityProverUrl: this.#verityProverUrl, + timeout: 150 * 1000, + options: { + adjustForTimeDifference: true, + recvWindow: 60000, + }, + }); + + this.brokers[broker] = { + primary: client, + secondaryBrokers: secondaryBroker, + }; + } + } + + constructor( + apiCredentials: ExchangeCredentials, + policies: string | PolicyConfig, + config?: { + port?: number; + whitelistIps?: string[]; + useVerity?: boolean; + verityProverUrl?: string; + }, + ) { + this.useVerity = config?.useVerity || false; + + if (typeof policies === "string") { + this.#policyFilePath = policies; + this.policy = loadPolicy(policies); + this.port = config?.port ?? 8086; + } else { + this.policy = policies; + } + + // If monitoring a file, start watcher + if (this.#policyFilePath) { + this.watchPolicyFile(this.#policyFilePath); + } + this.#verityProverUrl = config?.verityProverUrl || "http://localhost:8080"; + + this.loadExchangeCredentials(apiCredentials); + this.whitelistIps = [ + ...((config ?? { whitelistIps: [] }).whitelistIps ?? []), + ...this.whitelistIps, + ]; + } + + /** + * Watches the policy JSON file for changes, reloads policies, and reruns broker. + * @param filePath + */ + private watchPolicyFile(filePath: string): void { + watchFile(filePath, { interval: 1000 }, (curr, prev) => { + if (curr.mtime > prev.mtime) { + try { + const updated = loadPolicy(filePath); + this.policy = updated; + log.info( + `Policies reloaded from ${filePath} at ${new Date().toISOString()}`, + ); + // Rerun broker with updated policies + this.run(); + } catch (err) { + log.error(`Error reloading policies: ${err}`); + } + } + }); + } + + /** + * Stops Server and Stop watching the policy file, if applicable. + */ + public stop(): void { + if (this.#policyFilePath) { + unwatchFile(this.#policyFilePath); + log.info(`Stopped watching policy file: ${this.#policyFilePath}`); + } + if (this.server) { + this.server.forceShutdown(); + } + } + + /** + * Starts the broker, applying policies then running appropriate tasks. + */ + public async run(): Promise { + if (this.server) { + await this.server.forceShutdown(); + } + log.info(`Running CEXBroker at ${new Date().toISOString()}`); + this.server = getServer( + this.policy, + this.brokers, + this.whitelistIps, + this.useVerity, + this.#verityProverUrl, + ); + + this.server.bindAsync( + `0.0.0.0:${this.port}`, + grpc.ServerCredentials.createInsecure(), + (err, port) => { + if (err) { + log.error(err); + return; + } + log.info(`Your server as started on port ${port}`); + }, + ); + return this; + } +} diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..e156e0f --- /dev/null +++ b/src/server.ts @@ -0,0 +1,759 @@ +import { + authenticateRequest, + createBroker, + selectBroker, + validateOrder, + validateWithdraw, +} from "./helpers"; +import type { PolicyConfig } from "./types"; +import * as grpc from "@grpc/grpc-js"; +import * as protoLoader from "@grpc/proto-loader"; +import type { ProtoGrpcType } from "../proto/node"; +import path from "path"; +import type { Exchange } from "@usherlabs/ccxt"; +import type { ActionRequest } from "../proto/cexBroker/ActionRequest"; +import type { ActionResponse } from "../proto/cexBroker/ActionResponse"; +import { Action } from "../proto/cexBroker/Action"; +import type { SubscribeRequest } from "../proto/cexBroker/SubscribeRequest"; +import type { SubscribeResponse } from "../proto/cexBroker/SubscribeResponse"; +import { SubscriptionType } from "../proto/cexBroker/SubscriptionType"; +import Joi from "joi"; +import { log } from "./helpers/logger"; + +const PROTO_FILE = "../proto/node.proto"; + +const packageDef = protoLoader.loadSync(path.resolve(__dirname, PROTO_FILE)); +const grpcObj = grpc.loadPackageDefinition( + packageDef, +) as unknown as ProtoGrpcType; +const cexNode = grpcObj.cexBroker; + +export function getServer( + policy: PolicyConfig, + brokers: Record, + whitelistIps: string[], + useVerity: boolean, + verityProverUrl: string, +) { + const server = new grpc.Server(); + + server.addService(cexNode.CexService.service, { + ExecuteAction: async ( + call: grpc.ServerUnaryCall, + callback: grpc.sendUnaryData, + ) => { + // IP Authentication + if (!authenticateRequest(call, whitelistIps)) { + return callback( + { + code: grpc.status.PERMISSION_DENIED, + message: "Access denied: Unauthorized IP", + }, + null, + ); + } + // Read incoming metadata + const metadata = call.metadata; + const { action, cex, symbol } = call.request; + // Validate required fields + if (!action || !cex || !symbol) { + return callback( + { + code: grpc.status.INVALID_ARGUMENT, + message: "action, cex, symbol, and cex are required", + }, + null, + ); + } + + const broker = + selectBroker(brokers[cex as keyof typeof brokers], metadata) ?? + createBroker(cex, metadata, useVerity, verityProverUrl); + + if (!broker) { + return callback( + { + code: grpc.status.UNAUTHENTICATED, + message: `This Exchange is not registered and No API metadata ws found`, + }, + null, + ); + } + + switch (action) { + case Action.Deposit: { + const transactionSchema = Joi.object({ + recipientAddress: Joi.string().required(), + amount: Joi.number().positive().required(), // Must be a positive number + transactionHash: Joi.string().required(), + }); + const { value, error } = transactionSchema.validate( + call.request.payload ?? {}, + ); + if (error) { + return callback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `ValidationError: ${error.message}`, + }, + null, + ); + } + try { + const deposits = await broker.fetchDeposits(symbol, 50); + 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 callback(null, { + result: useVerity + ? broker.last_proof + : JSON.stringify({ ...deposit }), + }); + } + callback( + { + code: grpc.status.INTERNAL, + message: "Deposit confirmation failed", + }, + null, + ); + } catch (error) { + log.error({ error }); + callback( + { + code: grpc.status.INTERNAL, + message: "Deposit confirmation failed", + }, + null, + ); + } + break; + } + + case Action.FetchDepositAddresses: { + const fetchDepositAddressesSchema = Joi.object({ + chain: Joi.string().required(), + }); + const { + value: fetchDepositAddresses, + error: errorFetchDepositAddresses, + } = fetchDepositAddressesSchema.validate(call.request.payload ?? {}); + if (errorFetchDepositAddresses) { + return callback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `ValidationError: ${errorFetchDepositAddresses?.message}`, + }, + null, + ); + } + try { + const depositAddresses = + broker.has.fetchDepositAddress === true + ? await broker.fetchDepositAddress(symbol, { + network: fetchDepositAddresses.chain, + }) + : await broker.fetchDepositAddressesByNetwork(symbol, { + network: fetchDepositAddresses.chain, + }); + + if (depositAddresses) { + return callback(null, { + result: useVerity + ? broker.last_proof + : JSON.stringify({ ...depositAddresses }), + }); + } + callback( + { + code: grpc.status.INTERNAL, + message: "Deposit confirmation failed", + }, + null, + ); + } catch (error: unknown) { + log.error({ error }); + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Unknown error"; + callback( + { + code: grpc.status.INTERNAL, + message: + "Fetch Deposit Addresses confirmation failed: " + message, + }, + null, + ); + } + break; + } + case Action.Transfer: { + const transferSchema = Joi.object({ + recipientAddress: Joi.string().required(), + amount: Joi.number().positive().required(), // Must be a positive number + chain: Joi.string().required(), + }); + const { value: transferValue, error: transferError } = + transferSchema.validate(call.request.payload ?? {}); + if (transferError) { + return callback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `ValidationError:" ${transferError?.message}`, + }, + null, + ); + } + // Validate against policy + const transferValidation = validateWithdraw( + policy, + transferValue.chain, + transferValue.recipientAddress, + Number(transferValue.amount), + symbol, + ); + if (!transferValidation.valid) { + return callback( + { + code: grpc.status.PERMISSION_DENIED, + message: transferValidation.error, + }, + null, + ); + } + try { + const data = await broker.fetchCurrencies("USDT"); + const networks = Object.keys( + (data[symbol] ?? { networks: [] }).networks, + ); + + if (!networks.includes(transferValue.chain)) { + return callback( + { + code: grpc.status.INTERNAL, + message: `Broker ${cex} doesnt support this ${transferValue.chain} for token ${symbol}`, + }, + null, + ); + } + const transaction = await broker.withdraw( + symbol, + Number(transferValue.amount), + transferValue.recipientAddress, + undefined, + { network: transferValue.chain }, + ); + log.info(`Transfer Transfer: ${JSON.stringify(transaction)}`); + + callback(null, { + result: useVerity + ? broker.last_proof + : JSON.stringify({ ...transaction }), + }); + } catch (error) { + log.error({ error }); + callback( + { + code: grpc.status.INTERNAL, + message: "Transfer failed", + }, + null, + ); + } + break; + } + + case Action.CreateOrder: { + const createOrderSchema = Joi.object({ + orderType: Joi.string().valid("market", "limit").default("limit"), + amount: Joi.number().positive().required(), // Must be a positive number + fromToken: Joi.string().required(), + toToken: Joi.string().required(), + price: Joi.number().positive().required(), + }); + const { value: orderValue, error: orderError } = + createOrderSchema.validate(call.request.payload ?? {}); + if (orderError) { + return callback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `ValidationError:" ${orderError.message}`, + }, + null, + ); + } + const validation = validateOrder( + policy, + orderValue.fromToken, + orderValue.toToken, + Number(orderValue.amount), + cex, + ); + if (!validation.valid) { + return callback( + { + code: grpc.status.INVALID_ARGUMENT, + message: validation.error, + }, + null, + ); + } + + try { + const market = policy.order.rule.markets.find( + (market) => + market.includes( + `${orderValue.fromToken}/${orderValue.toToken}`, + ) || + market.includes( + `${orderValue.toToken}/${orderValue.fromToken}`, + ), + ); + const symbol = market?.split(":")[1] ?? ""; + const [from, _to] = symbol.split("/"); + + if (!broker) { + return callback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `Invalid CEX key: ${cex}. Supported keys: ${Object.keys(brokers).join(", ")}`, + }, + null, + ); + } + + const order = await broker.createOrder( + symbol, + orderValue.orderType, + from === orderValue.fromToken ? "sell" : "buy", + Number(orderValue.amount), + Number(orderValue.price), + ); + + callback(null, { result: JSON.stringify({ ...order }) }); + } catch (error) { + log.error({ error }); + callback( + { + code: grpc.status.INTERNAL, + message: "Order Creation failed", + }, + null, + ); + } + + break; + } + + case Action.GetOrderDetails: { + const getOrderSchema = Joi.object({ + orderId: Joi.string().required(), + }); + const { value: getOrderValue, error: getOrderError } = + getOrderSchema.validate(call.request.payload ?? {}); + // Validate required fields + if (getOrderError) { + return callback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `ValidationError: ${getOrderError.message}`, + }, + null, + ); + } + + try { + // Validate CEX key + if (!broker) { + return callback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `Invalid CEX key: ${cex}. Supported keys: ${Object.keys(brokers).join(", ")}`, + }, + null, + ); + } + + const orderDetails = await broker.fetchOrder(getOrderValue.orderId); + + callback(null, { + result: JSON.stringify({ + orderId: orderDetails.id, + status: orderDetails.status, + originalAmount: orderDetails.amount, + filledAmount: orderDetails.filled, + symbol: orderDetails.symbol, + mode: orderDetails.side, + price: orderDetails.price, + }), + }); + } catch (error) { + log.error(`Error fetching order details from ${cex}:`, error); + callback( + { + code: grpc.status.INTERNAL, + message: `Failed to fetch order details from ${cex}`, + }, + null, + ); + } + break; + } + case Action.CancelOrder: { + const cancelOrderSchema = Joi.object({ + orderId: Joi.string().required(), + }); + const { value: cancelOrderValue, error: cancelOrderError } = + cancelOrderSchema.validate(call.request.payload ?? {}); + // Validate required fields + if (cancelOrderError) { + return callback( + { + code: grpc.status.INVALID_ARGUMENT, + message: `ValidationError: ${cancelOrderError.message}`, + }, + null, + ); + } + + const cancelledOrder = await broker.cancelOrder( + cancelOrderValue.orderId, + ); + + callback(null, { + result: JSON.stringify({ ...cancelledOrder }), + }); + break; + } + case Action.FetchBalance: + try { + // Fetch balance from the specified CEX + // biome-ignore lint/suspicious/noExplicitAny: fetchFreeBalance + const balance = (await broker.fetchFreeBalance()) as any; + const currencyBalance = balance[symbol]; + + callback(null, { + result: useVerity + ? broker.last_proof + : JSON.stringify({ + balance: currencyBalance || 0, + currency: symbol, + }), + }); + } catch (error) { + log.error(`Error fetching balance from ${cex}:`, error); + callback( + { + code: grpc.status.INTERNAL, + message: `Failed to fetch balance from ${cex}`, + }, + null, + ); + } + break; + + default: + return callback({ + code: grpc.status.INVALID_ARGUMENT, + message: "Invalid Action", + }); + } + }, + + Subscribe: async ( + call: grpc.ServerWritableStream, + ) => { + // IP Authentication + if (!authenticateRequest(call, whitelistIps)) { + call.emit( + "error", + { + code: grpc.status.PERMISSION_DENIED, + message: "Access denied: Unauthorized IP", + }, + null, + ); + call.destroy(new Error("Access denied: Unauthorized IP")); + } + // 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; + + // Validate required fields + if (!cex || !symbol || type === undefined) { + call.write({ + data: JSON.stringify({ + error: "cex, symbol, and type are required", + }), + timestamp: Date.now(), + symbol: symbol || "", + type: type || SubscriptionType.ORDERBOOK, + }); + call.end(); + return; + } + + // Get or create broker + broker = + selectBroker(brokers[cex as keyof typeof brokers], metadata) ?? + createBroker(cex, metadata, useVerity, verityProverUrl); + + if (!broker) { + call.write({ + data: JSON.stringify({ + error: "Exchange not registered and no API metadata found", + }), + timestamp: Date.now(), + symbol, + type, + }); + call.end(); + return; + } + + // Handle different subscription types + switch (type) { + case SubscriptionType.ORDERBOOK: + try { + while (true) { + const orderbook = await broker.watchOrderBook(symbol); + call.write({ + data: JSON.stringify(orderbook), + timestamp: Date.now(), + symbol, + type, + }); + } + } 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, + }); + } + break; + + case SubscriptionType.TRADES: + try { + while (true) { + const trades = await broker.watchTrades(symbol); + call.write({ + data: JSON.stringify(trades), + timestamp: Date.now(), + symbol, + type, + }); + } + } 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, + }); + } + break; + + case SubscriptionType.TICKER: + try { + while (true) { + const ticker = await broker.watchTicker(symbol); + call.write({ + data: JSON.stringify(ticker), + timestamp: Date.now(), + symbol, + type, + }); + } + } 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, + }); + } + 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, + }); + } + } 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, + }); + } + break; + + case SubscriptionType.BALANCE: + try { + while (true) { + const balance = await broker.watchBalance(); + call.write({ + data: JSON.stringify(balance), + timestamp: Date.now(), + symbol, + type, + }); + } + } 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, + }); + } + break; + + case SubscriptionType.ORDERS: + try { + while (true) { + const orders = await broker.watchOrders(symbol); + call.write({ + data: JSON.stringify(orders), + timestamp: Date.now(), + symbol, + type, + }); + } + } 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, + }); + } + break; + + default: + call.write({ + data: JSON.stringify({ error: "Invalid subscription type" }), + timestamp: Date.now(), + symbol, + type, + }); + } + } 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"); + }); + + call.on("error", (error) => { + log.error("Subscribe stream error:", error); + }); + }, + }); + return server; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2fc5771 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,193 @@ +import type ccxt from "@usherlabs/ccxt"; + +// Policy types based on the policy.json structure +export type WithdrawRule = { + networks: string[]; + whitelist: string[]; + amounts: { + ticker: string; + max: number; + min: number; + }[]; +}; + +export type OrderRule = { + markets: string[]; + limits: Array<{ + from: string; + to: string; + min: number; + max: number; + }>; +}; + +export type PolicyConfig = { + withdraw: { + rule: WithdrawRule; + }; + deposit: Record; + order: { + rule: OrderRule; + }; +}; + +// Legacy types (keeping for backward compatibility) +export type Policy = { + isActive: boolean; + permissions: Array<"withdraw" | "transfer" | "convert">; + limits: { + dailyWithdrawLimit?: number; + dailyTransferredAmount?: number; + perTxTransferLimit?: number; + }; + networks: string[]; + conversionLimits: Array<{ + from: string; + to: string; + min: number; + max: number; + }>; +}; + +// Dynamic type mapping using CCXT's exchange classes +type BrokerInstanceMap = { + [K in ISupportedBroker]: InstanceType<(typeof ccxt)[K]>; +}; + +// Dynamic BrokerMap: each key maps to the correct broker type +export type BrokerMap = Partial<{ + [K in ISupportedBroker]: BrokerInstanceMap[K]; +}>; + +export const BrokerList = [ + "alpaca", + "apex", + "ascendex", + "bequant", + "bigone", + "binance", + "binancecoinm", + "binanceus", + "binanceusdm", + "bingx", + "bit2c", + "bitbank", + "bitbns", + "bitfinex", + "bitflyer", + "bitget", + "bithumb", + "bitmart", + "bitmex", + "bitopro", + "bitrue", + "bitso", + "bitstamp", + "bitteam", + "bittrade", + "bitvavo", + "blockchaincom", + "blofin", + "btcalpha", + "btcbox", + "btcmarkets", + "btcturk", + "bybit", + "cex", + "coinbase", + "coinbaseadvanced", + "coinbaseexchange", + "coinbaseinternational", + "coincatch", + "coincheck", + "coinex", + "coinmate", + "coinmetro", + "coinone", + "coinsph", + "coinspot", + "cryptocom", + "cryptomus", + "defx", + "delta", + "deribit", + "derive", + "digifinex", + "ellipx", + "exmo", + "fmfwio", + "gate", + "gateio", + "gemini", + "hashkey", + "hitbtc", + "hollaex", + "htx", + "huobi", + "hyperliquid", + "independentreserve", + "indodax", + "kraken", + "krakenfutures", + "kucoin", + "kucoinfutures", + "latoken", + "lbank", + "luno", + "mercado", + "mexc", + "modetrade", + "myokx", + "ndax", + "novadax", + "oceanex", + "okcoin", + "okx", + "okxus", + "onetrading", + "oxfun", + "p2b", + "paradex", + "paymium", + "phemex", + "poloniex", + "probit", + "timex", + "tokocrypto", + "tradeogre", + "upbit", + "vertex", + "wavesexchange", + "whitebit", + "woo", + "woofipro", + "xt", + "yobit", + "zaif", + "zonda", +] as const; + +export type brokers = Required; + +export type ISupportedBroker = (typeof BrokerList)[number]; +export type SupportedBrokers = (typeof BrokerList)[number]; + +export const SupportedBroker = BrokerList.reduce( + (acc, value) => { + acc[value] = value; + return acc; + }, + {} as Record<(typeof BrokerList)[number], string>, +); + +export type BrokerCredentials = { + apiKey: string; + apiSecret: string; +}; +export type SecondaryKeys = { + secondaryKeys: Array; +}; + +export interface ExchangeCredentials { + [exchange: string]: BrokerCredentials & SecondaryKeys; +} diff --git a/test/cex-broker.test.ts b/test/cex-broker.test.ts new file mode 100644 index 0000000..2e0a708 --- /dev/null +++ b/test/cex-broker.test.ts @@ -0,0 +1,369 @@ +import { describe, test, expect, beforeEach, mock, afterEach } from "bun:test"; +import CEXBroker from "../src/index"; +import type { PolicyConfig } from "../src/types"; +import * as grpc from "@grpc/grpc-js"; + +describe("CEXBroker", () => { + let broker: CEXBroker; + let testPolicy: PolicyConfig; + + beforeEach(() => { + // Test policy configuration + testPolicy = { + withdraw: { + rule: { + networks: ["BEP20", "ETH"], + whitelist: ["0x9d467fa9062b6e9b1a46e26007ad82db116c67cb"], + amounts: [ + { + ticker: "USDT", + max: 100000, + min: 1, + }, + ], + }, + }, + deposit: {}, + order: { + rule: { + markets: [ + "BINANCE:BTC/USDT", + "BINANCE:ETH/USDT", + ], + limits: [ + { from: "USDT", to: "BTC", min: 1, max: 100000 }, + { from: "BTC", to: "USDT", min: 0.001, max: 1 }, + ], + }, + }, + }; + + // Clear environment variables before each test + delete process.env.CEX_BROKER_BINANCE_API_KEY; + delete process.env.CEX_BROKER_BINANCE_API_SECRET; + delete process.env.CEX_BROKER_BINANCE_API_KEY_1; + delete process.env.CEX_BROKER_BINANCE_API_SECRET_1; + }); + + afterEach(() => { + if (broker) { + broker.stop(); + } + }); + + describe("Environment Configuration", () => { + test("should load primary API keys from environment", () => { + process.env.CEX_BROKER_BINANCE_API_KEY = "test_key"; + process.env.CEX_BROKER_BINANCE_API_SECRET = "test_secret"; + + broker = new CEXBroker({}, testPolicy); + broker.loadEnvConfig(); + + // Test that environment variables are loaded + expect(process.env.CEX_BROKER_BINANCE_API_KEY).toBe("test_key"); + expect(process.env.CEX_BROKER_BINANCE_API_SECRET).toBe("test_secret"); + }); + + test("should load secondary API keys from environment", () => { + process.env.CEX_BROKER_BINANCE_API_KEY_1 = "secondary_key_1"; + process.env.CEX_BROKER_BINANCE_API_SECRET_1 = "secondary_secret_1"; + process.env.CEX_BROKER_BINANCE_API_KEY_2 = "secondary_key_2"; + process.env.CEX_BROKER_BINANCE_API_SECRET_2 = "secondary_secret_2"; + + broker = new CEXBroker({}, testPolicy); + broker.loadEnvConfig(); + + // Test that secondary environment variables are loaded + expect(process.env.CEX_BROKER_BINANCE_API_KEY_1).toBe("secondary_key_1"); + expect(process.env.CEX_BROKER_BINANCE_API_SECRET_1).toBe("secondary_secret_1"); + expect(process.env.CEX_BROKER_BINANCE_API_KEY_2).toBe("secondary_key_2"); + expect(process.env.CEX_BROKER_BINANCE_API_SECRET_2).toBe("secondary_secret_2"); + }); + + test("should handle case-insensitive broker names", () => { + process.env.CEX_BROKER_BINANCE_API_KEY = "test_key"; + process.env.CEX_BROKER_BINANCE_API_SECRET = "test_secret"; + + broker = new CEXBroker({}, testPolicy); + broker.loadEnvConfig(); + + // Test that broker names are normalized to lowercase + expect(process.env.CEX_BROKER_BINANCE_API_KEY).toBe("test_key"); + }); + + test("should skip unrecognized environment variables", () => { + process.env.CEX_BROKER_INVALID_VAR = "invalid_value"; + process.env.CEX_BROKER_BINANCE_API_KEY = "test_key"; + + broker = new CEXBroker({}, testPolicy); + broker.loadEnvConfig(); + + // Test that only valid variables are processed + expect(process.env.CEX_BROKER_BINANCE_API_KEY).toBe("test_key"); + }); + + test("should handle empty API keys", () => { + process.env.CEX_BROKER_BINANCE_API_KEY = ""; + process.env.CEX_BROKER_BINANCE_API_SECRET = ""; + + broker = new CEXBroker({}, testPolicy); + broker.loadEnvConfig(); + + // Test that empty keys are handled + expect(process.env.CEX_BROKER_BINANCE_API_KEY).toBe(""); + }); + }); + + describe("Broker Initialization", () => { + test("should initialize with empty credentials", () => { + broker = new CEXBroker({}, testPolicy); + expect(broker).toBeDefined(); + }); + + test("should initialize with custom port", () => { + broker = new CEXBroker({}, testPolicy, { port: 9090 }); + expect(broker).toBeDefined(); + // Note: The port property might not be directly accessible due to private implementation + }); + + test("should initialize with custom whitelist IPs", () => { + const whitelistIps = ["192.168.1.100", "10.0.0.1"]; + broker = new CEXBroker({}, testPolicy, { whitelistIps }); + expect(broker).toBeDefined(); + }); + + test("should initialize with Verity integration", () => { + broker = new CEXBroker({}, testPolicy, { + useVerity: true, + verityProverUrl: "http://localhost:8080", + }); + expect(broker).toBeDefined(); + }); + + test("should use default port when not specified", () => { + broker = new CEXBroker({}, testPolicy); + expect(broker.port).toBe(8086); + }); + + test("should use default whitelist IPs when not specified", () => { + broker = new CEXBroker({}, testPolicy); + expect(broker).toBeDefined(); + }); + }); + + describe("Policy Management", () => { + test("should load policy from file path", () => { + broker = new CEXBroker({}, "./policy/policy.json"); + expect(broker).toBeDefined(); + }); + + test("should load policy from object", () => { + broker = new CEXBroker({}, testPolicy); + expect(broker).toBeDefined(); + }); + + test("should validate policy structure", () => { + const invalidPolicy = { + withdraw: {}, + // Missing order policy + }; + + // Test that invalid policy is handled + expect(invalidPolicy.order).toBeUndefined(); + }); + + test("should handle policy file watching", () => { + broker = new CEXBroker({}, "./policy/policy.json"); + expect(broker).toBeDefined(); + }); + }); + + describe("Exchange Credentials", () => { + test("should validate exchange credentials structure", () => { + const validCredentials = { + binance: { + apiKey: "test_key", + apiSecret: "test_secret", + }, + }; + + const invalidCredentials = { + binance: { + apiKey: "test_key", + // Missing apiSecret + }, + }; + + // Test validation logic + expect(validCredentials.binance.apiSecret).toBeDefined(); + expect(invalidCredentials.binance.apiSecret).toBeUndefined(); + }); + + test("should handle multiple exchanges", () => { + const credentials = { + binance: { + apiKey: "binance_key", + apiSecret: "binance_secret", + }, + bybit: { + apiKey: "bybit_key", + apiSecret: "bybit_secret", + }, + }; + + broker = new CEXBroker(credentials, testPolicy); + expect(broker).toBeDefined(); + }); + + test("should handle secondary broker credentials", () => { + const credentials = { + binance: { + apiKey: "primary_key", + apiSecret: "primary_secret", + }, + }; + + broker = new CEXBroker(credentials, testPolicy); + expect(broker).toBeDefined(); + }); + }); + + describe("Server Management", () => { + test("should start server successfully", async () => { + broker = new CEXBroker({}, testPolicy, { port: 0 }); // Use random port + const startedBroker = await broker.run(); + expect(startedBroker).toBe(broker); + }); + + test("should stop server successfully", () => { + broker = new CEXBroker({}, testPolicy); + broker.stop(); + expect(broker).toBeDefined(); + }); + + test("should handle server startup errors", async () => { + // Test with invalid port + broker = new CEXBroker({}, testPolicy, { port: -1 }); + + try { + await broker.run(); + expect(false).toBe(true); // Should not reach here + } catch (error) { + expect(error).toBeDefined(); + } + }); + }); + + describe("Configuration Validation", () => { + test("should validate port number", () => { + const validPort = 8086; + const invalidPort = -1; + + expect(validPort > 0 && validPort <= 65535).toBe(true); + expect(invalidPort > 0 && invalidPort <= 65535).toBe(false); + }); + + test("should validate IP addresses", () => { + const validIPs = ["127.0.0.1", "192.168.1.100"]; + const invalidIPs = ["invalid_ip", "256.256.256.256"]; + + const isValidIPv4 = (ip: string) => + /^(\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)); + }); + + test("should validate Verity URL", () => { + const validURL = "http://localhost:8080"; + const invalidURL = "not_a_url"; + + const isValidURL = (url: string) => { + try { + new URL(url); + return true; + } catch { + return false; + } + }; + + expect(isValidURL(validURL)).toBe(true); + expect(isValidURL(invalidURL)).toBe(false); + }); + }); + + describe("Error Handling", () => { + test("should handle missing policy file", () => { + try { + broker = new CEXBroker({}, "./nonexistent/policy.json"); + expect(false).toBe(true); // Should not reach here + } catch (error) { + expect(error).toBeDefined(); + } + }); + + test("should handle invalid policy JSON", () => { + const invalidPolicy = "invalid json"; + + try { + JSON.parse(invalidPolicy); + expect(false).toBe(true); // Should not reach here + } catch (error) { + expect(error).toBeDefined(); + } + }); + + test("should handle network errors", () => { + const networkError = new Error("Network timeout"); + expect(networkError.message).toBe("Network timeout"); + }); + + test("should handle exchange initialization errors", () => { + const invalidCredentials = { + nonexistent: { + apiKey: "invalid_key", + apiSecret: "invalid_secret", + }, + }; + + // Test that invalid exchange is handled + expect(invalidCredentials.nonexistent).toBeDefined(); + }); + }); + + describe("Integration Tests", () => { + test("should initialize with all components", () => { + process.env.CEX_BROKER_BINANCE_API_KEY = "test_key"; + process.env.CEX_BROKER_BINANCE_API_SECRET = "test_secret"; + + broker = new CEXBroker({}, testPolicy, { + port: 8086, + whitelistIps: ["127.0.0.1"], + useVerity: false, + verityProverUrl: "http://localhost:8080", + }); + + expect(broker).toBeDefined(); + expect(broker.port).toBe(8086); + }); + + test("should handle complex configuration", () => { + const credentials = { + binance: { + apiKey: "primary_key", + apiSecret: "primary_secret", + }, + }; + + broker = new CEXBroker(credentials, testPolicy, { + port: 9090, + whitelistIps: ["192.168.1.100", "10.0.0.1"], + useVerity: true, + verityProverUrl: "http://verity:8080", + }); + + expect(broker).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/index.test.ts b/test/index.test.ts similarity index 80% rename from index.test.ts rename to test/index.test.ts index b2b1ca0..da8aa32 100644 --- a/index.test.ts +++ b/test/index.test.ts @@ -1,33 +1,6 @@ import { describe, test, expect } from "bun:test"; -import { isIpAllowed } from "./helpers"; describe("RPC Server Logic Tests", () => { - describe("IP Authentication", () => { - test("should allow requests from whitelisted IPs", () => { - const allowedIPs = ["127.0.0.1"]; - - allowedIPs.forEach((ip) => { - const clientIp = ip; - const isAllowed = allowedIPs.includes(clientIp); - - expect(isAllowed).toBe(true); - }); - }); - - test("should block requests from unauthorized IPs", () => { - const clientIp = "192.168.1.100"; - const isAllowed = isIpAllowed(clientIp); - - expect(isAllowed).toBe(false); - }); - - test("should handle undefined peer information", () => { - const clientIp = ""; - const isAllowed = isIpAllowed(clientIp); - - expect(isAllowed).toBe(false); - }); - }); describe("GetOptimalPrice Validation", () => { test("should validate required fields correctly", () => { diff --git a/test/integration.test.ts b/test/integration.test.ts new file mode 100644 index 0000000..a9badfb --- /dev/null +++ b/test/integration.test.ts @@ -0,0 +1,90 @@ +import { describe, test, expect } from "bun:test"; + +describe("Integration Tests", () => { + describe("Policy Integration", () => { + test("should load and validate policy correctly", () => { + // Test that the policy file can be loaded + const fs = require("bun:fs"); + const path = require("bun:path"); + const policyPath = path.join(__dirname, "../policy/policy.json"); + + expect(() => { + const policyData = fs.readFileSync(policyPath, "utf8"); + const policy = JSON.parse(policyData); + return policy; + }).not.toThrow(); + }); + + test("should have correct policy structure", () => { + const fs = require("bun:fs"); + const path = require("bun:path"); + const policyPath = path.join(__dirname, "../policy/policy.json"); + const policyData = fs.readFileSync(policyPath, "utf8"); + const policy = JSON.parse(policyData); + + // Check withdraw policy + expect(policy.withdraw).toBeDefined(); + expect(policy.withdraw.rule).toBeDefined(); + expect(policy.withdraw.rule.networks).toContain("ARBITRUM"); + expect(policy.withdraw.rule.whitelist).toContain( + "0x9d467fa9062b6e9b1a46e26007ad82db116c67cb", + ); + + // Check order policy + expect(policy.order).toBeDefined(); + expect(policy.order.rule).toBeDefined(); + expect(policy.order.rule.markets).toContain("BINANCE:ARB/USDT"); + expect(policy.order.rule.limits).toBeDefined(); + expect(policy.order.rule.limits.length).toBeGreaterThan(0); + }); + }); + + describe("Helper Functions Integration", () => { + test("should validate withdraw policy correctly", () => { + const { validateWithdraw } = require("../src/helpers"); + const { loadPolicy } = require("../src/helpers"); + + const policy = loadPolicy("./policy/policy.json"); + + // Test valid withdrawal + const validResult = validateWithdraw( + policy, + "ARBITRUM", + "0x9d467fa9062b6e9b1a46e26007ad82db116c67cb", + 1000, + "USDC", + ); + + expect(validResult.valid).toBe(true); + + // Test invalid withdrawal + const invalidResult = validateWithdraw( + policy, + "ETH", // Wrong network + "0x9d467fa9062b6e9b1a46e26007ad82db116c67cb", + 1000, + "USDC", + ); + + expect(invalidResult.valid).toBe(false); + }); + + test("should validate order policy correctly", () => { + const { validateOrder } = require("../src/helpers"); + const { loadPolicy } = require("../src/helpers"); + + const policy = loadPolicy("./policy/policy.json"); + + // Test valid order + const validResult = validateOrder(policy, "USDT", "ETH", 1, "BINANCE"); + + expect(validResult.valid).toBe(true); + + // Test invalid order + const invalidResult = validateOrder(policy, "BTC", "ETH", 0.5, "BINANCE"); + + expect(invalidResult.valid).toBe(false); + }); + }); + +}); diff --git a/test/server.test.ts b/test/server.test.ts new file mode 100644 index 0000000..89763c4 --- /dev/null +++ b/test/server.test.ts @@ -0,0 +1,535 @@ +import { describe, test, expect, beforeEach, mock, afterEach } from "bun:test"; +import * as grpc from "@grpc/grpc-js"; +import { getServer } from "../src/server"; +import type { PolicyConfig } from "../src/types"; +import type { Exchange } from "@usherlabs/ccxt"; +import { Action } from "../proto/cexBroker/Action"; +import { SubscriptionType } from "../proto/cexBroker/SubscriptionType"; + +describe("gRPC Server", () => { + let mockExchange: Exchange; + let testPolicy: PolicyConfig; + let server: grpc.Server; + let brokers: Record; + + beforeEach(() => { + // Create comprehensive mock exchange + mockExchange = { + fetchDeposits: mock(async (symbol: string, limit: number) => [ + { + id: "tx123", + txid: "tx123", + amount: 100, + currency: symbol, + status: "ok", + timestamp: Date.now(), + }, + ]), + fetchDepositAddress: mock(async (symbol: string, params: any) => ({ + address: "0x1234567890123456789012345678901234567890", + tag: null, + network: params.network, + })), + fetchDepositAddressesByNetwork: mock(async (symbol: string, params: any) => ({ + address: "0x1234567890123456789012345678901234567890", + tag: null, + network: params.network, + })), + has: { + fetchDepositAddress: true, + }, + fetchCurrencies: mock(async (symbol: string) => ({ + [symbol]: { + networks: { + BEP20: { id: "BSC", network: "BSC", active: true, deposit: true, withdraw: true, fee: 1.0 }, + ETH: { id: "ETH", network: "ETH", active: true, deposit: true, withdraw: true, fee: 15.0 }, + }, + }, + })), + withdraw: mock(async (symbol: string, amount: number, address: string, tag: string, params: any) => ({ + id: "withdraw123", + amount, + address, + currency: symbol, + status: "ok", + timestamp: Date.now(), + })), + createOrder: mock(async (symbol: string, type: string, side: string, amount: number, price: number) => ({ + id: "order123", + symbol, + type, + side, + amount, + price, + status: "open", + timestamp: Date.now(), + })), + fetchOrder: mock(async (orderId: string) => ({ + id: orderId, + symbol: "BTC/USDT", + status: "closed", + amount: 0.001, + filled: 0.001, + side: "buy", + price: 50000, + })), + cancelOrder: mock(async (orderId: string) => ({ + id: orderId, + status: "canceled", + symbol: "BTC/USDT", + })), + fetchFreeBalance: mock(async () => ({ + USDT: 1000, + BTC: 0.1, + })), + watchOrderBook: mock(async (symbol: string) => ({ + symbol, + bids: [[50000, 1]], + asks: [[50001, 1]], + timestamp: Date.now(), + })), + watchTrades: mock(async (symbol: string) => [ + { + id: "trade123", + symbol, + amount: 0.001, + price: 50000, + side: "buy", + timestamp: Date.now(), + }, + ]), + watchTicker: mock(async (symbol: string) => ({ + symbol, + last: 50000, + bid: 49999, + ask: 50001, + volume: 100, + timestamp: Date.now(), + })), + fetchOHLCVWs: mock(async (symbol: string, timeframe: string) => [ + [Date.now(), 50000, 50001, 49999, 50000, 100], + ]), + watchBalance: mock(async () => ({ + free: { USDT: 1000, BTC: 0.1 }, + total: { USDT: 1000, BTC: 0.1 }, + })), + watchOrders: mock(async (symbol: string) => [ + { + id: "order123", + symbol, + status: "open", + amount: 0.001, + filled: 0, + side: "buy", + price: 50000, + }, + ]), + last_proof: "zk_proof_123", + } as any; + + // Test policy configuration + testPolicy = { + withdraw: { + rule: { + networks: ["BEP20", "ETH"], + whitelist: ["0x9d467fa9062b6e9b1a46e26007ad82db116c67cb"], + amounts: [ + { + ticker: "USDT", + max: 100000, + min: 1, + }, + ], + }, + }, + deposit: {}, + order: { + rule: { + markets: [ + "BINANCE:BTC/USDT", + "BINANCE:ETH/USDT", + ], + limits: [ + { from: "USDT", to: "BTC", min: 1, max: 100000 }, + { from: "BTC", to: "USDT", min: 0.001, max: 1 }, + ], + }, + }, + }; + + brokers = { + binance: { + primary: mockExchange, + secondaryBrokers: [mockExchange, mockExchange], + }, + }; + + server = getServer(testPolicy, brokers, ["127.0.0.1"], false, "http://localhost:8080"); + }); + + afterEach(() => { + if (server) { + server.tryShutdown(() => {}); + } + }); + + describe("ExecuteAction", () => { + test("should authenticate IP correctly", () => { + const call = { + getPeer: () => "127.0.0.1:12345", + metadata: new grpc.Metadata(), + request: { + action: Action.FetchBalance, + payload: {}, + cex: "binance", + symbol: "USDT", + }, + } as any; + + const callback = mock((error: any, response: any) => {}); + + // This would require more complex mocking of the gRPC service + // For now, we test the authentication logic separately + expect(true).toBe(true); + }); + + test("should reject unauthorized IP", () => { + const call = { + getPeer: () => "192.168.1.100:12345", + metadata: new grpc.Metadata(), + request: { + action: Action.FetchBalance, + payload: {}, + cex: "binance", + symbol: "USDT", + }, + } as any; + + const callback = mock((error: any, response: any) => {}); + + // This would require more complex mocking of the gRPC service + // For now, we test the authentication logic separately + expect(true).toBe(true); + }); + + test("should validate required fields", () => { + const call = { + getPeer: () => "127.0.0.1:12345", + metadata: new grpc.Metadata(), + request: { + action: Action.FetchBalance, + payload: {}, + cex: "", // Missing cex + symbol: "USDT", + }, + } as any; + + const callback = mock((error: any, response: any) => {}); + + // This would require more complex mocking of the gRPC service + // For now, we test the validation logic separately + expect(true).toBe(true); + }); + }); + + describe("Action Handlers", () => { + describe("Deposit Action", () => { + test("should validate deposit payload correctly", () => { + const validPayload = { + recipientAddress: "0x1234567890123456789012345678901234567890", + amount: 100, + transactionHash: "tx123", + }; + + const invalidPayload = { + recipientAddress: "0x1234567890123456789012345678901234567890", + amount: -100, // Invalid amount + transactionHash: "tx123", + }; + + // Test validation logic + expect(validPayload.amount > 0).toBe(true); + expect(invalidPayload.amount > 0).toBe(false); + }); + + test("should find deposit by transaction hash", async () => { + const deposits = await mockExchange.fetchDeposits("USDT", 50); + const deposit = deposits.find((d: any) => d.id === "tx123" || d.txid === "tx123"); + + expect(deposit).toBeDefined(); + expect(deposit.id).toBe("tx123"); + }); + }); + + describe("FetchDepositAddresses Action", () => { + test("should validate chain parameter", () => { + const validPayload = { chain: "BEP20" }; + const invalidPayload = { chain: "" }; + + expect(validPayload.chain).toBeTruthy(); + expect(invalidPayload.chain).toBeFalsy(); + }); + + test("should fetch deposit address with network parameter", async () => { + const address = await mockExchange.fetchDepositAddress("USDT", { network: "BEP20" }); + expect(address.address).toBe("0x1234567890123456789012345678901234567890"); + expect(address.network).toBe("BEP20"); + }); + }); + + describe("Transfer Action", () => { + test("should validate transfer payload", () => { + const validPayload = { + recipientAddress: "0x9d467fa9062b6e9b1a46e26007ad82db116c67cb", + amount: 100, + chain: "BEP20", + }; + + const invalidPayload = { + recipientAddress: "0x1234567890123456789012345678901234567890", // Not whitelisted + amount: 100, + chain: "BEP20", + }; + + // Test validation logic + expect(validPayload.amount > 0).toBe(true); + expect(validPayload.chain).toBeTruthy(); + expect(validPayload.recipientAddress).toBeTruthy(); + }); + + test("should validate network support", async () => { + const currencies = await mockExchange.fetchCurrencies("USDT"); + const networks = Object.keys(currencies["USDT"].networks); + + expect(networks).toContain("BEP20"); + expect(networks).toContain("ETH"); + }); + }); + + describe("CreateOrder Action", () => { + test("should validate order payload", () => { + const validPayload = { + orderType: "limit", + amount: 0.001, + fromToken: "BTC", + toToken: "USDT", + price: 50000, + }; + + const invalidPayload = { + orderType: "invalid", + amount: -0.001, + fromToken: "BTC", + toToken: "USDT", + price: -50000, + }; + + // Test validation logic + expect(["market", "limit"].includes(validPayload.orderType)).toBe(true); + expect(validPayload.amount > 0).toBe(true); + expect(validPayload.price > 0).toBe(true); + }); + + test("should determine correct order side", () => { + const symbol = "BTC/USDT"; + const [from, to] = symbol.split("/"); + const fromToken = "BTC"; + const side = from === fromToken ? "sell" : "buy"; + + expect(side).toBe("sell"); + }); + }); + + describe("GetOrderDetails Action", () => { + test("should validate order ID", () => { + const validPayload = { orderId: "order123" }; + const invalidPayload = { orderId: "" }; + + expect(validPayload.orderId).toBeTruthy(); + expect(invalidPayload.orderId).toBeFalsy(); + }); + + test("should format order details correctly", async () => { + const orderDetails = await mockExchange.fetchOrder("order123"); + const formatted = { + orderId: orderDetails.id, + status: orderDetails.status, + originalAmount: orderDetails.amount, + filledAmount: orderDetails.filled, + symbol: orderDetails.symbol, + mode: orderDetails.side, + price: orderDetails.price, + }; + + expect(formatted.orderId).toBe("order123"); + expect(formatted.status).toBe("closed"); + expect(formatted.symbol).toBe("BTC/USDT"); + }); + }); + + describe("CancelOrder Action", () => { + test("should validate order ID for cancellation", () => { + const validPayload = { orderId: "order123" }; + const invalidPayload = { orderId: "" }; + + expect(validPayload.orderId).toBeTruthy(); + expect(invalidPayload.orderId).toBeFalsy(); + }); + + test("should cancel order successfully", async () => { + const cancelledOrder = await mockExchange.cancelOrder("order123"); + expect(cancelledOrder.status).toBe("canceled"); + expect(cancelledOrder.id).toBe("order123"); + }); + }); + + describe("FetchBalance Action", () => { + test("should fetch balance for specific symbol", async () => { + const balance = await mockExchange.fetchFreeBalance(); + const currencyBalance = balance["USDT"]; + + expect(currencyBalance).toBe(1000); + }); + + test("should handle missing currency balance", async () => { + const balance = await mockExchange.fetchFreeBalance(); + const currencyBalance = balance["INVALID"]; + + expect(currencyBalance).toBeUndefined(); + }); + }); + }); + + describe("Subscribe Stream", () => { + describe("Orderbook Subscription", () => { + test("should stream orderbook data", async () => { + const orderbook = await mockExchange.watchOrderBook("BTC/USDT"); + expect(orderbook.symbol).toBe("BTC/USDT"); + expect(orderbook.bids).toBeDefined(); + expect(orderbook.asks).toBeDefined(); + }); + }); + + describe("Trades Subscription", () => { + test("should stream trades data", async () => { + const trades = await mockExchange.watchTrades("BTC/USDT"); + expect(Array.isArray(trades)).toBe(true); + expect(trades.length).toBeGreaterThan(0); + expect(trades[0].symbol).toBe("BTC/USDT"); + }); + }); + + describe("Ticker Subscription", () => { + test("should stream ticker data", async () => { + const ticker = await mockExchange.watchTicker("BTC/USDT"); + expect(ticker.symbol).toBe("BTC/USDT"); + expect(ticker.last).toBeDefined(); + expect(ticker.bid).toBeDefined(); + expect(ticker.ask).toBeDefined(); + }); + }); + + describe("OHLCV Subscription", () => { + test("should stream OHLCV data with default timeframe", async () => { + const ohlcv = await mockExchange.fetchOHLCVWs("BTC/USDT", "1m"); + expect(Array.isArray(ohlcv)).toBe(true); + expect(ohlcv.length).toBeGreaterThan(0); + }); + + test("should stream OHLCV data with custom timeframe", async () => { + const ohlcv = await mockExchange.fetchOHLCVWs("BTC/USDT", "1h"); + expect(Array.isArray(ohlcv)).toBe(true); + expect(ohlcv.length).toBeGreaterThan(0); + }); + }); + + describe("Balance Subscription", () => { + test("should stream balance updates", async () => { + const balance = await mockExchange.watchBalance(); + expect(balance.free).toBeDefined(); + expect(balance.total).toBeDefined(); + expect(balance.free.USDT).toBe(1000); + }); + }); + + describe("Orders Subscription", () => { + test("should stream order updates", async () => { + const orders = await mockExchange.watchOrders("BTC/USDT"); + expect(Array.isArray(orders)).toBe(true); + expect(orders.length).toBeGreaterThan(0); + expect(orders[0].symbol).toBe("BTC/USDT"); + }); + }); + }); + + describe("Secondary Broker Support", () => { + test("should create broker with secondary keys", () => { + const metadata = new grpc.Metadata(); + metadata.set("api-key", "secondary_key"); + metadata.set("api-secret", "secondary_secret"); + metadata.set("use-secondary-key", "1"); + + // Test that secondary broker selection works + expect(metadata.get("use-secondary-key").length).toBe(1); + }); + + test("should fallback to primary broker when secondary not available", () => { + const metadata = new grpc.Metadata(); + metadata.set("use-secondary-key", "999"); // Non-existent secondary + + // Test fallback logic + expect(metadata.get("use-secondary-key").length).toBe(1); + }); + }); + + describe("Verity Integration", () => { + test("should return ZK proof when Verity is enabled", () => { + const serverWithVerity = getServer(testPolicy, brokers, ["127.0.0.1"], true, "http://localhost:8080"); + + // Test that Verity integration is configured + expect(serverWithVerity).toBeDefined(); + }); + + test("should return raw data when Verity is disabled", () => { + const serverWithoutVerity = getServer(testPolicy, brokers, ["127.0.0.1"], false, "http://localhost:8080"); + + // Test that Verity integration is not configured + expect(serverWithoutVerity).toBeDefined(); + }); + }); + + describe("Error Handling", () => { + test("should handle exchange errors gracefully", async () => { + // Mock exchange with error + const errorExchange = { + ...mockExchange, + fetchBalance: mock(async () => { + throw new Error("Exchange error"); + }), + } as any; + + // Test error handling + expect(errorExchange).toBeDefined(); + }); + + test("should handle network errors", async () => { + // Mock network error + const networkError = new Error("Network timeout"); + expect(networkError.message).toBe("Network timeout"); + }); + + test("should handle validation errors", () => { + const invalidRequest = { + action: Action.CreateOrder, + payload: { + amount: -1, // Invalid amount + }, + cex: "binance", + symbol: "BTC/USDT", + }; + + // Test validation error handling + expect(invalidRequest.payload.amount <= 0).toBe(true); + }); + }); +}); \ No newline at end of file diff --git a/test/start-broker.test.ts b/test/start-broker.test.ts new file mode 100644 index 0000000..1f86f2e --- /dev/null +++ b/test/start-broker.test.ts @@ -0,0 +1,133 @@ +import { describe, test, expect, beforeEach } from "bun:test"; +import { startBrokerCommand } from "../src/commands/start-broker"; + +describe("Start Broker Command", () => { + describe("Function Signature", () => { + test("should have correct function signature", () => { + expect(typeof startBrokerCommand).toBe("function"); + }); + }); + + describe("Parameter Validation", () => { + test("should validate policy path", () => { + const policyPath = "./policy/policy.json"; + expect(policyPath).toBeTruthy(); + expect(typeof policyPath).toBe("string"); + }); + + test("should validate port number", () => { + const port = 8086; + expect(port > 0 && port <= 65535).toBe(true); + }); + + test("should validate whitelist IPs", () => { + const whitelistIps = ["127.0.0.1", "192.168.1.100"]; + expect(Array.isArray(whitelistIps)).toBe(true); + whitelistIps.forEach(ip => { + expect(typeof ip).toBe("string"); + }); + }); + + test("should validate Verity prover URL", () => { + const verityProverUrl = "http://localhost:8080"; + if (verityProverUrl) { + expect(() => new URL(verityProverUrl)).not.toThrow(); + } + }); + }); + + describe("Configuration Options", () => { + test("should handle custom port configuration", () => { + const port = 9090; + expect(port > 0 && port <= 65535).toBe(true); + }); + + test("should handle multiple whitelist IPs", () => { + const whitelistIps = ["127.0.0.1", "192.168.1.100", "10.0.0.1"]; + expect(Array.isArray(whitelistIps)).toBe(true); + expect(whitelistIps.length).toBe(3); + }); + + test("should handle Verity integration configuration", () => { + const verityProverUrl = "https://verity.usher.so"; + expect(() => new URL(verityProverUrl)).not.toThrow(); + }); + + test("should handle empty whitelist", () => { + const whitelistIps: string[] = []; + expect(Array.isArray(whitelistIps)).toBe(true); + expect(whitelistIps.length).toBe(0); + }); + }); + + describe("Integration Tests", () => { + test("should validate complete configuration", () => { + const policyPath = "./policy/policy.json"; + const port = 8086; + const whitelistIps = ["127.0.0.1"]; + const verityProverUrl = "http://localhost:8080"; + + // Validate all parameters + expect(policyPath).toBeTruthy(); + expect(port > 0 && port <= 65535).toBe(true); + expect(Array.isArray(whitelistIps)).toBe(true); + expect(() => new URL(verityProverUrl)).not.toThrow(); + }); + + test("should handle complex configuration", () => { + const policyPath = "./policy/policy.json"; + const port = 9090; + const whitelistIps = [ + "127.0.0.1", + "192.168.1.100", + "10.0.0.1", + "172.16.0.1", + ]; + const verityProverUrl = "https://verity.usher.so/api/v1"; + + // Validate all parameters + expect(policyPath).toBeTruthy(); + expect(port > 0 && port <= 65535).toBe(true); + expect(Array.isArray(whitelistIps)).toBe(true); + expect(whitelistIps.length).toBe(4); + expect(() => new URL(verityProverUrl)).not.toThrow(); + }); + + test("should handle production-like configuration", () => { + const policyPath = "./policy/policy.json"; + const port = 443; + const whitelistIps = ["192.168.1.100", "10.0.0.1"]; + const verityProverUrl = "https://verity.production.usher.so"; + + // Validate all parameters + expect(policyPath).toBeTruthy(); + expect(port > 0 && port <= 65535).toBe(true); + expect(Array.isArray(whitelistIps)).toBe(true); + expect(whitelistIps.length).toBe(2); + expect(() => new URL(verityProverUrl)).not.toThrow(); + }); + }); + + describe("Edge Cases", () => { + test("should handle maximum port number", () => { + const port = 65535; + expect(port > 0 && port <= 65535).toBe(true); + }); + + test("should handle minimum port number", () => { + const port = 1; + expect(port > 0 && port <= 65535).toBe(true); + }); + + test("should handle large number of whitelist IPs", () => { + const whitelistIps = Array.from({ length: 100 }, (_, i) => `192.168.1.${i + 1}`); + expect(Array.isArray(whitelistIps)).toBe(true); + expect(whitelistIps.length).toBe(100); + }); + + test("should handle long Verity URL", () => { + const verityProverUrl = "https://very-long-verity-url.example.com/api/v1/prover/endpoint"; + expect(() => new URL(verityProverUrl)).not.toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 146fe4e..d2346de 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,8 +5,9 @@ "target": "ESNext", "module": "Preserve", "moduleDetection": "force", - "jsx": "react-jsx", "allowJs": true, + "types": ["@types/bun", "bun-types" ], + // Bundler mode "moduleResolution": "bundler", diff --git a/types.ts b/types.ts deleted file mode 100644 index df0995c..0000000 --- a/types.ts +++ /dev/null @@ -1,64 +0,0 @@ -// Policy types based on the policy.json structure -export type WithdrawRule = { - networks: string[]; - whitelist: string[]; - amounts: { - ticker: string; - max: number; - min: number; - }[]; -}; - -export type OrderRule = { - markets: string[]; - limits: Array<{ - from: string; - to: string; - min: number; - max: number; - }>; -}; - -export type PolicyConfig = { - withdraw: { - rule: WithdrawRule; - }; - deposit: Record; - order: { - rule: OrderRule; - }; -}; - -// Legacy types (keeping for backward compatibility) -export type Policy = { - isActive: boolean; - permissions: Array<"withdraw" | "transfer" | "convert">; - limits: { - dailyWithdrawLimit?: number; - dailyTransferredAmount?: number; - perTxTransferLimit?: number; - }; - networks: string[]; - conversionLimits: Array<{ - from: string; - to: string; - min: number; - max: number; - }>; -}; - -export type Policies = { - [apiKey: string]: Policy; // key is an Ethereum-style address like '0x...' -}; - -export const BrokerList = ["BINANCE", "BYBIT"] as const; - -export type ISupportedBroker = (typeof BrokerList)[number]; - -export const SupportedBroker = BrokerList.reduce( - (acc, value) => { - acc[value] = value; - return acc; - }, - {} as Record<(typeof BrokerList)[number], string>, -);