├── .dockerignore ├── .env ├── .github ├── CODEOWNERS └── workflows │ ├── cargo-audit.yml │ ├── integration-test.yml │ ├── lint.yml │ └── unit-test.yml ├── .gitignore ├── .gitmodules ├── Cargo.lock ├── Cargo.toml ├── Makefile ├── README.md ├── config ├── default.yaml ├── development.yaml ├── restapi │ └── default.yaml └── staging.yaml ├── examples ├── js │ ├── .env.advise │ ├── .eslintrc.js │ ├── .gitignore │ ├── .nvmrc │ ├── .prettierrc │ ├── RESTClient.ts │ ├── accounts.jsonl │ ├── accounts.ts │ ├── bots │ │ ├── bot.ts │ │ ├── executor.ts │ │ ├── mm_external_price_bot.ts │ │ ├── run_bots.ts │ │ └── utils.ts │ ├── cli │ │ ├── deposit.ts │ │ ├── dumpdb.ts │ │ ├── monitor_kafka_message.ts │ │ ├── register_new_user.ts │ │ ├── reload_market.ts │ │ └── reset.ts │ ├── client.ts │ ├── config.ts │ ├── exchange_helper.ts │ ├── kafka_client.ts │ ├── ordersigner.proto │ ├── package-lock.json │ ├── package.json │ ├── signer_server.ts │ ├── tests │ │ ├── multi_market.ts │ │ ├── print_orders.ts │ │ ├── put_batch_orders.ts │ │ ├── set_markets.ts │ │ ├── stress.ts │ │ ├── tick.ts │ │ ├── tick_no_deploy.ts │ │ ├── trade.ts │ │ └── transfer.ts │ ├── tsconfig.json │ └── util.ts └── python │ ├── .gitignore │ ├── gen_grpc.sh │ ├── matchengine_pb2.py │ ├── matchengine_pb2_grpc.py │ ├── ordersigner_pb2.py │ ├── ordersigner_pb2_grpc.py │ ├── put_order.py │ └── requirements.txt ├── migrations ├── 20200123090047_trade_log.sql ├── 20200123090258_trade_history.sql ├── 20210114140803_kline.sql ├── 20210223025223_markets.sql ├── 20210223072038_markets_preset.sql ├── 20210310150412_market_constraint.sql ├── 20210514140412_account.sql ├── 20210607094808_internal_transfer.sql ├── reset │ ├── down.sql │ └── up.sql └── ts │ └── 20210114140803_kline.sql ├── release ├── Dockerfile └── release.sh ├── rust-toolchain ├── rustfmt.toml ├── scripts └── install_all_deps.sh └── src ├── bin ├── dump_unify_messages.rs ├── matchengine.rs ├── persistor.rs └── restapi.rs ├── config.rs ├── lib.rs ├── matchengine ├── asset │ ├── asset_manager.rs │ ├── balance_manager.rs │ ├── mod.rs │ └── update_controller.rs ├── controller.rs ├── dto.rs ├── eth_guard.rs ├── history.rs ├── market │ ├── mod.rs │ ├── order.rs │ └── trade.rs ├── mock.rs ├── mod.rs ├── persist │ ├── mod.rs │ ├── persistor.rs │ └── state_save_load.rs ├── sequencer.rs ├── server.rs └── user_manager.rs ├── message ├── consumer.rs ├── mod.rs ├── persist.rs └── producer.rs ├── restapi ├── config.rs ├── errors.rs ├── manage.rs ├── mock.rs ├── mod.rs ├── personal_history.rs ├── public_history.rs ├── state.rs ├── tradingview.rs ├── types.rs └── user.rs ├── storage ├── config.rs ├── database.rs ├── mod.rs ├── models.rs └── sqlxextend.rs ├── types └── mod.rs └── utils ├── mod.rs ├── serde.rs └── strings.rs /.dockerignore: -------------------------------------------------------------------------------- 1 | orchestra 2 | target/release 3 | target/debug 4 | examples 5 | logs 6 | src 7 | Cargo* 8 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | RUN_MODE=development # development or staging 2 | RUST_BACKTRACE=1 3 | RUST_LOG=info,matchengine=debug,restapi=debug 4 | # RUST_LOG=info 5 | # RUST_LOG=debug 6 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | @lispc @lispczz @noel2004 @HAOYUatHZ 2 | -------------------------------------------------------------------------------- /.github/workflows/cargo-audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | on: 3 | schedule: 4 | - cron: '0 0 * * *' 5 | jobs: 6 | audit: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: actions-rs/audit-check@v1 11 | with: 12 | token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.github/workflows/integration-test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - prod 8 | - release/* 9 | pull_request: 10 | branches: 11 | - master 12 | - prod 13 | - release/* 14 | 15 | env: 16 | SCCACHE_REGION: ap-northeast-1 17 | SCCACHE_BUCKET: ff-building 18 | SCCACHE_S3_USE_SSL: true 19 | SCCACHE_S3_KEY_PREFIX: sccache-gh-action 20 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 21 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 22 | CARGO_INCREMENTAL: false 23 | 24 | jobs: 25 | integration-test: 26 | runs-on: ubuntu-latest 27 | timeout-minutes: 15 28 | strategy: 29 | matrix: 30 | rust: 31 | - 1.56.0 32 | 33 | steps: 34 | - name: Checkout sources 35 | uses: actions/checkout@v2 36 | 37 | - name: Install libpq 38 | run: sudo apt-get install libpq-dev 39 | 40 | - name: Install rust 1.56.0 toolchain 41 | uses: actions-rs/toolchain@v1 42 | with: 43 | profile: minimal 44 | toolchain: 1.56.0 45 | override: true 46 | components: rustfmt, clippy 47 | 48 | # - name: Cache cargo registry 49 | # uses: actions/cache@v2 50 | # with: 51 | # path: ~/.cargo/registry 52 | # key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 53 | 54 | # - name: Cache cargo index 55 | # uses: actions/cache@v2 56 | # with: 57 | # path: ~/.cargo/git 58 | # key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 59 | 60 | # - name: Cache cargo target 61 | # uses: actions/cache@v2 62 | # with: 63 | # path: target 64 | # key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 65 | 66 | - name: Setup sccache 67 | run: | 68 | cd $RUNNER_TEMP 69 | export NAME="sccache-v0.2.15-x86_64-unknown-linux-musl" 70 | curl -fsSOL https://github.com/mozilla/sccache/releases/download/v0.2.15/$NAME.tar.gz 71 | tar xzf $NAME.tar.gz 72 | mkdir -p ~/.cargo/bin 73 | mv ./$NAME/sccache ~/.cargo/bin 74 | chmod +x ~/.cargo/bin/sccache 75 | printf "[build]\nrustc-wrapper = \"/home/runner/.cargo/bin/sccache\"" >> ~/.cargo/config 76 | ~/.cargo/bin/sccache -s 77 | 78 | - name: Install Node.js 16 79 | uses: actions/setup-node@v2 80 | with: 81 | node-version: '16' 82 | # cache: 'npm' 83 | # cache-dependency-path: examples/js/package-lock.json 84 | 85 | - name: Cache node_modules 86 | id: npm_cache 87 | uses: actions/cache@v2 88 | with: 89 | path: ./examples/js/node_modules 90 | key: node_modules-${{ hashFiles('examples/js/package-lock.json') }} 91 | 92 | - name: npm ci 93 | if: steps.npm_cache.outputs.cache-hit != 'true' 94 | run: | 95 | cd ./examples/js/ 96 | npm ci 97 | 98 | - name: Pull git submodule 99 | run: git submodule update --init --recursive 100 | 101 | - name: Up docker-compose 102 | run: docker-compose --file "./orchestra/docker/docker-compose.yaml" up --detach 103 | 104 | # 1. we build the binary after starting docker-compose, to ensure time for running services in docker-compose 105 | # 2. we avoid nohup cargo run directly, to make sure server is running before starting trading tests 106 | - name: Start exchange server daemon 107 | run: make startall 108 | 109 | - name: show sccache stats 110 | run: ~/.cargo/bin/sccache -s 111 | 112 | - name: Check services status 113 | run: | 114 | sleep 5 115 | make taillogs 116 | docker-compose --file "./orchestra/docker/docker-compose.yaml" logs --tail=20 117 | 118 | - name: Run trading tests 119 | run: | 120 | cd ./examples/js/ 121 | npx ts-node tests/trade.ts 122 | sleep 5 123 | npx ts-node tests/print_orders.ts 124 | npx ts-node tests/transfer.ts 125 | npx ts-node tests/put_batch_orders.ts 126 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - prod 8 | - release/* 9 | pull_request: 10 | branches: 11 | - master 12 | - prod 13 | - release/* 14 | 15 | env: 16 | SCCACHE_REGION: ap-northeast-1 17 | SCCACHE_BUCKET: ff-building 18 | SCCACHE_S3_USE_SSL: true 19 | SCCACHE_S3_KEY_PREFIX: sccache-gh-action 20 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 21 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 22 | CARGO_INCREMENTAL: false 23 | 24 | jobs: 25 | rust-lint: 26 | runs-on: ubuntu-latest 27 | strategy: 28 | matrix: 29 | rust: 30 | - 1.56.0 31 | 32 | steps: 33 | - name: Checkout sources 34 | uses: actions/checkout@v2 35 | 36 | - name: Install 1.56.0 toolchain 37 | uses: actions-rs/toolchain@v1 38 | with: 39 | profile: minimal 40 | toolchain: 1.56.0 41 | override: true 42 | components: rustfmt, clippy 43 | 44 | - name: Cache cargo registry 45 | uses: actions/cache@v2 46 | with: 47 | path: ~/.cargo/registry 48 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 49 | 50 | - name: Cache cargo index 51 | uses: actions/cache@v2 52 | with: 53 | path: ~/.cargo/git 54 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 55 | 56 | - name: Cache cargo target 57 | uses: actions/cache@v2 58 | with: 59 | path: target 60 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 61 | 62 | - name: Setup sccache 63 | run: | 64 | cd $RUNNER_TEMP 65 | export NAME="sccache-v0.2.15-x86_64-unknown-linux-musl" 66 | curl -fsSOL https://github.com/mozilla/sccache/releases/download/v0.2.15/$NAME.tar.gz 67 | tar xzf $NAME.tar.gz 68 | mkdir -p ~/.cargo/bin 69 | mv ./$NAME/sccache ~/.cargo/bin 70 | chmod +x ~/.cargo/bin/sccache 71 | printf "[build]\nrustc-wrapper = \"/home/runner/.cargo/bin/sccache\"" >> ~/.cargo/config 72 | ~/.cargo/bin/sccache -s 73 | 74 | - name: Run "cargo fmt & check" 75 | uses: actions-rs/cargo@v1 76 | with: 77 | command: fmt 78 | args: --all -- --check 79 | 80 | - name: Run "cargo clippy" 81 | uses: actions-rs/cargo@v1 82 | # continue-on-error: true 83 | with: 84 | command: clippy 85 | args: -- -D warnings 86 | 87 | - name: show sccache stats 88 | run: ~/.cargo/bin/sccache -s 89 | 90 | js-lint: 91 | runs-on: ubuntu-latest 92 | 93 | steps: 94 | - name: Checkout sources 95 | uses: actions/checkout@v2 96 | 97 | - name: Install Node.js 16 98 | uses: actions/setup-node@v2 99 | with: 100 | node-version: '16' 101 | # cache: 'npm' 102 | # cache-dependency-path: examples/js/package-lock.json 103 | 104 | - name: Cache node_modules 105 | id: npm_cache 106 | uses: actions/cache@v2 107 | with: 108 | path: examples/js/node_modules 109 | key: node_modules-${{ hashFiles('examples/js/package-lock.json') }} 110 | 111 | - name: npm ci 112 | if: steps.npm_cache.outputs.cache-hit != 'true' 113 | run: | 114 | cd examples/js/ 115 | npm ci 116 | 117 | - name: Prettify js/ts code 118 | run: | 119 | cd examples/js/ 120 | npx prettier --check "**/*.{js,ts}" 121 | 122 | - name: Run eslint 123 | run: | 124 | cd examples/js/ 125 | npx eslint . 126 | -------------------------------------------------------------------------------- /.github/workflows/unit-test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - prod 8 | - release/* 9 | pull_request: 10 | branches: 11 | - master 12 | - prod 13 | - release/* 14 | 15 | env: 16 | SCCACHE_REGION: ap-northeast-1 17 | SCCACHE_BUCKET: ff-building 18 | SCCACHE_S3_USE_SSL: true 19 | SCCACHE_S3_KEY_PREFIX: sccache-gh-action 20 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 21 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 22 | CARGO_INCREMENTAL: false 23 | 24 | jobs: 25 | unit-test: 26 | runs-on: ubuntu-latest 27 | strategy: 28 | matrix: 29 | rust: 30 | - 1.56.0 31 | 32 | steps: 33 | - name: Checkout sources 34 | uses: actions/checkout@v2 35 | 36 | - name: Install 1.56.0 toolchain 37 | uses: actions-rs/toolchain@v1 38 | with: 39 | profile: minimal 40 | toolchain: 1.56.0 41 | override: true 42 | components: rustfmt, clippy 43 | 44 | - name: Cache cargo registry 45 | uses: actions/cache@v2 46 | with: 47 | path: ~/.cargo/registry 48 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} 49 | 50 | - name: Cache cargo index 51 | uses: actions/cache@v2 52 | with: 53 | path: ~/.cargo/git 54 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }} 55 | 56 | - name: Cache cargo target 57 | uses: actions/cache@v2 58 | with: 59 | path: target 60 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} 61 | 62 | - name: Setup sccache 63 | run: | 64 | cd $RUNNER_TEMP 65 | export NAME="sccache-v0.2.15-x86_64-unknown-linux-musl" 66 | curl -fsSOL https://github.com/mozilla/sccache/releases/download/v0.2.15/$NAME.tar.gz 67 | tar xzf $NAME.tar.gz 68 | mkdir -p ~/.cargo/bin 69 | mv ./$NAME/sccache ~/.cargo/bin 70 | chmod +x ~/.cargo/bin/sccache 71 | printf "[build]\nrustc-wrapper = \"/home/runner/.cargo/bin/sccache\"" >> ~/.cargo/config 72 | ~/.cargo/bin/sccache -s 73 | 74 | - name: Run "cargo test" 75 | uses: actions-rs/cargo@v1 76 | with: 77 | command: test 78 | args: -- 79 | 80 | # - name: Run "cargo bench" 81 | # uses: actions-rs/cargo@v1 82 | # with: 83 | # command: bench 84 | # args: -- 85 | 86 | - name: show sccache stats 87 | run: ~/.cargo/bin/sccache -s 88 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /logs 3 | .idea 4 | /bak 5 | *.swp 6 | 7 | # unifymessenger output 8 | output.txt 9 | *.log 10 | persistor_output.txt 11 | unify_msgs_output.txt 12 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "orchestra"] 2 | path = orchestra 3 | url = https://github.com/fluidex/orchestra.git 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "dingir-exchange" 3 | version = "0.1.0" 4 | authors = [ "lispczz " ] 5 | edition = "2021" 6 | 7 | [dependencies] 8 | actix-rt = "2.1.0" 9 | actix-web = "4.0.0-beta.12" 10 | anyhow = "1.0.38" 11 | arrayref = "0.3.6" 12 | bytes = "1.0.1" 13 | chrono = { version = "0.4.19", features = [ "serde" ] } 14 | config_rs = { package = "config", version = "0.10.1" } 15 | const_format = "0.2.15" 16 | crossbeam-channel = "0.5.0" 17 | dotenv = "0.15.0" 18 | fluidex-common = { git = "https://github.com/fluidex/common-rs", branch = "master", features = [ "kafka", "non-blocking-tracing", "rust-decimal-dingir-exchange" ] } 19 | futures = "0.3.13" 20 | futures-channel = "0.3.13" 21 | futures-core = { version = "0.3.13", default-features = false } 22 | futures-util = { version = "0.3.13", default-features = false } 23 | hex = "0.4.3" 24 | humantime = "2.1.0" 25 | humantime-serde = "1.0.1" 26 | hyper = "0.14.4" 27 | itertools = "0.10.0" 28 | lazy_static = "1.4.0" 29 | log = "0.4.14" 30 | nix = "0.20.0" 31 | num_enum = "0.5.1" 32 | orchestra = { git = "https://github.com/fluidex/orchestra.git", branch = "master", features = [ "exchange" ] } 33 | paperclip = { git = "https://github.com/fluidex/paperclip.git", features = [ "actix", "chrono", "rust_decimal" ] } 34 | qstring = "0.7.2" 35 | rand = "0.8.3" 36 | serde = { version = "1.0.124", features = [ "derive" ] } 37 | serde_json = "1.0.64" 38 | sqlx = { version = "0.5.1", features = [ "runtime-tokio-rustls", "postgres", "chrono", "decimal", "migrate" ] } 39 | thiserror = "1.0.24" 40 | tokio = { version = "1.9.0", features = [ "full" ] } 41 | tonic = "0.5.2" 42 | tracing = "0.1" 43 | tracing-appender = "0.1" 44 | tracing-subscriber = "0.2" 45 | ttl_cache = "0.5.1" 46 | 47 | [[bin]] 48 | name = "restapi" 49 | path = "src/bin/restapi.rs" 50 | 51 | [[bin]] 52 | name = "matchengine" 53 | path = "src/bin/matchengine.rs" 54 | 55 | [features] 56 | windows_build = [ "fluidex-common/rdkafka-dynamic" ] 57 | emit_state_diff = [ ] 58 | default = [ "emit_state_diff" ] 59 | #default = ["windows_build"] 60 | #default = ["windows_build", "emit_state_diff"] 61 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | DB="postgres://exchange:exchange_AA9944@127.0.0.1/exchange" 2 | DB_RESET_DIR="migrations/reset" 3 | BUILD_MODE="debug" 4 | # tokio-runtime-worker does not work well, do not know why... 5 | PROCESSES="restapi|persistor|matchengine|tokio-runtime-w|ticker.ts" 6 | CURRENTDATE=`date +"%Y-%m-%d"` 7 | 8 | # code related 9 | lint: 10 | cargo fmt --all -- --check 11 | cargo clippy -- -D warnings 12 | fmtsql: 13 | find migrations -type f | xargs -L 1 pg_format --type-case 2 -i 14 | fmtrs: 15 | cargo fmt --all 16 | fmtjs: 17 | cd examples/js && yarn fmt 18 | fmt: fmtsql fmtrs fmtjs 19 | 20 | # docker related 21 | start-compose: 22 | # cd orchestra/docker; docker compose up -d exchange_db exchange_zookeeper exchange_kafka exchange_envoy 23 | cd orchestra/docker; docker-compose up -d exchange_db exchange_zookeeper exchange_kafka exchange_envoy 24 | stop-compose: 25 | # cd orchestra/docker; docker compose down exchange_db exchange_zookeeper exchange_kafka exchange_envoy 26 | cd orchestra/docker; docker-compose down 27 | clean-compose: stop-compose 28 | rm -rf orchestra/docker/volumes/exchange_* 29 | 30 | # process relared 31 | startall: 32 | cargo build 33 | mkdir -p logs 34 | `pwd`/target/$(BUILD_MODE)/persistor 1>logs/persistor.$(CURRENTDATE).log 2>&1 & 35 | `pwd`/target/$(BUILD_MODE)/matchengine 1>logs/matchengine.$(CURRENTDATE).log 2>&1 & 36 | sleep 3; 37 | `pwd`/target/$(BUILD_MODE)/restapi 1>logs/restapi.$(CURRENTDATE).log 2>&1 & 38 | list: 39 | pgrep -l $(PROCESSES) || true 40 | stopall: 41 | pkill -INT $(PROCESSES) || true 42 | (pgrep -l $(PROCESSES) && (echo '"pkill -INT" failed, force kill'; pkill $(PROCESSES))) || true 43 | 44 | # logs related 45 | taillogs: 46 | tail -n 15 logs/* 47 | viewlogs: 48 | watch -n 0.5 tail -n 4 logs/* 49 | rmlogs: 50 | rm -rf logs/* 51 | 52 | 53 | # db related 54 | conn: 55 | psql $(DB) 56 | cleardb: 57 | # https://stackoverflow.com/a/13823560/2078461 58 | psql $(DB) -X -a -f $(DB_RESET_DIR)/down.sql 59 | psql $(DB) -X -a -f $(DB_RESET_DIR)/up.sql 60 | dump-trades: 61 | psql $(DB) -c "select count(*) from user_trade where market = 'ETH_USDT' and user_id = 6" 62 | psql $(DB) -t -A -F"," -c "select time, side, role, price, amount, quote_amount from user_trade where market = 'ETH_USDT' and user_id = 6 order by time asc" > trades.csv 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dingir Exchange 2 | Dingir Exchange is a high performance exchange trading server. 3 | The core matching engine is a fully async, single threaded, memory based matching engine with thousands of TPS. 4 | 5 | * Features: order matching, order state change notification, user balance management, market data... 6 | * Non Features: user account system, cryptocurrency deposit/withdraw... 7 | 8 | ## Technical Details 9 | 10 | * Language: Rust 11 | * API Interface: GRPC 12 | * Server framework: Tokio/Hyper/Tonic 13 | * Storage: SQL Databases 14 | * Persistence: (a)Append operation log and (b)Redis-like fork-and-save persistence 15 | 16 | The architecture is heavily inspired by Redis and [Viabtc Exchange](https://github.com/viabtc/viabtc_exchange_server) 17 | 18 | ## Prerequisite 19 | 20 | * cmake 21 | * librdkafka 22 | 23 | ### MacOS 24 | 25 | ``` 26 | $ brew install cmake librdkafka 27 | ``` 28 | 29 | ### Ubuntu / Debian 30 | 31 | ``` 32 | # apt install cmake librdkafka-dev 33 | ``` 34 | 35 | ### RedHat / CentOS / Fedora 36 | 37 | ``` 38 | # dnf install cmake librdkafka-devel 39 | ``` 40 | 41 | 42 | ## Todos 43 | 44 | * push notifications using GRPC/websockets 45 | * API Documentation 46 | * Better test coverage 47 | 48 | ## Example 49 | 50 | ``` 51 | # Simple test 52 | $ cd $DingirExchangeDir 53 | # Lanuch the external dependency services like Postgresql and Kafka 54 | $ docker-compose --file "./orchestra/docker/docker-compose.yaml" up --detach 55 | $ make startall # or `cargo run --bin matchengine` to start only core service 56 | $ cd $DingirExchangeDir/examples/js ; npm i 57 | # This script will put orders into the exchange. 58 | # Then you will find some orders got matched, trades generated, 59 | # and users' balances updated accordingly. 60 | $ npx ts-node tests/trade.ts 61 | ``` 62 | 63 | ## Release 64 | 65 | We uses [cross](https://github.com/rust-embedded/cross) to generate release builds for Linux Distributions. 66 | For example, you could generate a static release build via the below command. 67 | 68 | ``` 69 | RUSTFLAGS="-C link-arg=-static -C target-feature=+crt-static" cross build --bin matchengine --target x86_64-unknown-linux-gnu --release 70 | ``` 71 | 72 | And a new Docker image could be generated by the `release` script. 73 | 74 | ``` 75 | # In root directory of this project 76 | ./release/release.sh YOUR_DOCKER_REGISTRY_DOMAIN.COM:YOUR_DOMAIN_PORT NEW_IMAGE_TAG 77 | ``` 78 | 79 | ## Related Projects 80 | 81 | [Peatio](https://github.com/openware/peatio): A full-featured crypto exchange backend, with user account system and crypto deposit/withdraw. Written in Ruby/Rails. It can process less than 200 orders per second. 82 | 83 | [viabtc exchange server](https://github.com/viabtc/viabtc_exchange_server): A high performance trading server written in C/libev. Most components of the project are written from scratch including network, RPC. It can process thousands of orders per second. 84 | -------------------------------------------------------------------------------- /config/default.yaml: -------------------------------------------------------------------------------- 1 | slice_interval: 3600 2 | slice_keeptime: 259200 3 | disable_self_trade: true 4 | disable_market_order: true 5 | check_eddsa_signatue: auto 6 | user_order_num_limit: 2000 7 | -------------------------------------------------------------------------------- /config/development.yaml: -------------------------------------------------------------------------------- 1 | debug: true 2 | db_log: postgres://exchange:exchange_AA9944@127.0.0.1/exchange 3 | db_history: postgres://exchange:exchange_AA9944@127.0.0.1/exchange 4 | brokers: '127.0.0.1:9092' 5 | -------------------------------------------------------------------------------- /config/restapi/default.yaml: -------------------------------------------------------------------------------- 1 | manage_endpoint: 'http://0.0.0.0:50051' 2 | -------------------------------------------------------------------------------- /config/staging.yaml: -------------------------------------------------------------------------------- 1 | debug: true 2 | db_log: postgres://exchange:exchange_AA9944@exchange-pq/exchange 3 | db_history: postgres://exchange:exchange_AA9944@exchange-pq/exchange 4 | brokers: 'exchange-kafka:9092' 5 | -------------------------------------------------------------------------------- /examples/js/.env.advise: -------------------------------------------------------------------------------- 1 | KAFKA_BROKERS=127.0.0.1:9092 2 | GRPC_SERVER=127.0.0.1:50051 3 | API_ENDPOINT=0.0.0.0:8765 4 | REST_API_SERVER=http://localhost:50053/api/exchange/panel 5 | 6 | VERBOSE=0 7 | GITHUB_ACTIONS=0 8 | TEST_MQ=0 9 | -------------------------------------------------------------------------------- /examples/js/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", // Specifies the ESLint parser 3 | extends: [ 4 | "plugin:@typescript-eslint/recommended", // Uses the recommended rules from the @typescript-eslint/eslint-plugin 5 | "prettier", // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 6 | "plugin:prettier/recommended", // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. Make sure this is always the last configuration in the extends array. 7 | ], 8 | parserOptions: { 9 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 10 | sourceType: "module", // Allows for the use of imports 11 | }, 12 | rules: { 13 | "@typescript-eslint/no-var-requires": "off", 14 | "@typescript-eslint/no-use-before-define": "off", 15 | "@typescript-eslint/explicit-function-return-type": "off", 16 | "@typescript-eslint/no-explicit-any": "off", 17 | "@typescript-eslint/explicit-module-boundary-types": "off", 18 | "@typescript-eslint/no-inferrable-types": "off", 19 | "prefer-const": "off", 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /examples/js/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | dist 4 | -------------------------------------------------------------------------------- /examples/js/.nvmrc: -------------------------------------------------------------------------------- 1 | 16.12.0 -------------------------------------------------------------------------------- /examples/js/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "printWidth": 140, 4 | "arrowParens": "avoid" 5 | } 6 | -------------------------------------------------------------------------------- /examples/js/RESTClient.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance } from "axios"; 2 | import * as _ from "lodash"; 3 | 4 | const REST_API_SERVER = "http://localhost:50053/api/exchange/panel"; 5 | 6 | class UserInfo { 7 | id: number; 8 | l1_address: string; 9 | l2_pubkey: string; 10 | } 11 | 12 | class RESTClient { 13 | client: AxiosInstance; 14 | 15 | constructor(server = process.env.REST_API_SERVER || REST_API_SERVER) { 16 | console.log("using REST API server: ", server); 17 | this.client = axios.create({ 18 | baseURL: server, 19 | timeout: 1000, 20 | }); 21 | } 22 | 23 | async get_user_by_addr(addr: string): Promise { 24 | let resp = await this.client.get(`/user/${addr}`); 25 | //console.log('user info', resp.data); 26 | if (resp.data.error) { 27 | console.log("error:", resp.data); 28 | return null; 29 | } 30 | let userInfo = resp.data as unknown as UserInfo; 31 | //console.log('raw', resp.data, 'result', userInfo); 32 | return userInfo; 33 | } 34 | 35 | async internal_txs( 36 | user_id: number | string, 37 | params?: { 38 | limit?: number; 39 | offset?: number; 40 | start_time?: number; 41 | end_time?: number; 42 | order?: "asc" | "desc"; 43 | side?: "from" | "to" | "both"; 44 | } 45 | ) { 46 | let resp = await this.client.get(`/internal_txs/${user_id}`, { 47 | params: _.pickBy(params, _.identity), 48 | }); 49 | if (resp.status === 200) { 50 | return resp.data; 51 | } else { 52 | throw new Error(`request failed with ${resp.status} ${resp.statusText}`); 53 | } 54 | } 55 | } 56 | 57 | let defaultRESTClient = new RESTClient(); 58 | export { defaultRESTClient, RESTClient }; 59 | -------------------------------------------------------------------------------- /examples/js/accounts.jsonl: -------------------------------------------------------------------------------- 1 | {"account_id":0,"mnemonic":"ready relief cabbage liar nation leisure genuine wolf juice logic scale bridge drill word leader leader gossip shrug enough kangaroo knock educate merry secret","priv_key":"0x46e2acccb36d276fd357b2928c95210ada30e551a0e7791302d8e9735dc4a779","eth_addr":"0x452aC94C662F4Bc2b29f3689E38D8E45884b35ee"} 2 | {"account_id":1,"mnemonic":"olympic comfort palm large heavy verb acid lion attract vast dash memory olympic syrup announce sure body cruise flip merge fabric frame question result","priv_key":"0x977e9bb2a7c13351c149b26784081882673741d8616c79feeb472b6378fed81d","eth_addr":"0x6286d0A2FC1d4C12a4ACc274018b401c68157Fdb"} 3 | {"account_id":2,"mnemonic":"mixture latin cage defense recipe mention rather pledge fix sea casino start fluid final place exhibit enroll clarify salad mobile recipe ask buyer assume","priv_key":"0xa2ef80f6d150a3f7e64a4f2fe3193a84fed24f932f0e59a50f990ece39679135","eth_addr":"0xf40e08F651f2f7f96f5602114E5A77F1A7BEeA5d"} 4 | {"account_id":3,"mnemonic":"zone steel market aunt chimney galaxy imitate sibling divide sister until front tail blush source maze equal globe include weather any reason fault awful","priv_key":"0x4434f4547080599945b6b171739e754f7a5ae93855ba933b991c4e7b3ef57339","eth_addr":"0xeD94298D30812327f22fA01cA9fAABAead2440Af"} 5 | {"account_id":4,"mnemonic":"also sausage kite amused mercy joke genius pioneer trash woman blush never afraid trim laptop maid bike museum reform spring pretty brand retreat shaft","priv_key":"0x940cdded1e7c8a2284c68fe75586564e956144dbb7cf0ee198ad120bbf997168","eth_addr":"0xc79030AFa00571BdA193846ff5B1F10bC1cf9a1E"} 6 | {"account_id":5,"mnemonic":"space boy neck venue govern flag prefer busy convince uncle gold figure chief degree park hand dose vintage tide scare exotic distance february wash","priv_key":"0x160276a92fce4c44039c24471f4c3ca7cacab358094ecd1b4863897eb2bcdba7","eth_addr":"0xec9C8B21a9f8e5Eb22373eDf7D0860Df0b98EBa5"} 7 | {"account_id":6,"mnemonic":"mention notable lady spin meadow fiber rare member comic drastic dwarf phone prevent dizzy lesson noise post butter pilot case virus animal entire safe","priv_key":"0xd33e3c7db96471ad4b3a34504a43cbed773dc5a6a21bd5acdcac7cba5aa06cce","eth_addr":"0x92f9a57b427Ee5D7599D12CeBae3f3F4f89C7392"} 8 | {"account_id":7,"mnemonic":"boost route aisle brother disorder squeeze tuna bridge purchase aunt acid liberty pen topple end lunch suffer favorite wear essence donkey scatter unaware taste","priv_key":"0x81946fe3c8c92e3f8b3b15ded2ec94bd227493c81240a773d2866d46ba34d79d","eth_addr":"0xB0660beF4DBF9e28eB422D46F3844CB41cc24717"} 9 | {"account_id":8,"mnemonic":"embark music govern stick clarify settle tent adapt dragon rubber keep few fashion casino eagle text denial mule survey fitness wonder wonder basic upset","priv_key":"0xce1489b4675348244d15e0369f7a848befb1257f6d51196d9e9d9b1209c19a7e","eth_addr":"0x94c8C37116Df235bF56A3F9C4cbdd1b6a43F3af0"} 10 | {"account_id":9,"mnemonic":"bread regret similar crack hero exercise day then corn verify vocal refuse spy endless second split canoe horse slice trip soccer bench sausage candy","priv_key":"0xbc66c297a75dba2e94790145fbc11b8aca5506ee6189f4733f50d8435a96e4e1","eth_addr":"0x3705EE1c0BcaEBd05c1FC971A2e3931a8D1BE77A"} 11 | {"account_id":10,"mnemonic":"artefact wood town zoo tiny isolate typical salad always choice mesh tool explain sign fence ghost peasant dragon rebel chef payment must real sadness","priv_key":"0x005c149d49d661b95c4e5ba042cdd8b8e9e5593842c3d317a2f46568b3afcf02","eth_addr":"0xe9206889b78E5b339853a1F08bE7E5295aAf9Db3"} 12 | {"account_id":11,"mnemonic":"whisper mountain upset upper tragic elegant grace veteran increase indoor copy teach antenna glue gauge fury concert cream swear clay fetch safe van hawk","priv_key":"0x709efe9f80cb5196cb0485c52b4df52d3467e03e1b1a1efd6d0c29d38b5fad95","eth_addr":"0x550df4b307884A751a6c60947126D3EB3D36D476"} 13 | {"account_id":12,"mnemonic":"worth program ostrich fame one track shoot eternal grunt transfer broccoli oval legal more member tenant cheap barrel idea tuna live kit detail husband","priv_key":"0x262ac634a06fe4bdf28093d9cd330efa661f0e323663923ab5464f66d127156f","eth_addr":"0xfD2F21Af21bd9901A55F95f53911F99B0f4d961D"} 14 | {"account_id":13,"mnemonic":"degree cabin section turtle history aspect credit subway vicious erase tip meadow remove salute fine welcome drum share speed era patrol stock right vicious","priv_key":"0x6477d1fbdc97591c2d2c54541ae3a09dd1b19ad20921c91c5ddcfbc0f53c9b8d","eth_addr":"0x9ddB77695AB6F5289f71889F8A1FcfF3Bb881769"} 15 | {"account_id":14,"mnemonic":"tiger base process wealth depend suggest horse above expose forget oak basic leave hope curious stuff engine praise moon road endless arch mesh interest","priv_key":"0x4e0c4cebc1456ef8cf20d93edc757c6d3c973cee924582b1580d698dcecc06dc","eth_addr":"0x616B71Cbcd8006F577673960700CB1811C2C4775"} 16 | {"account_id":15,"mnemonic":"verb anchor host hand actor pull patrol wear gossip enough bamboo horn cycle festival picture unfair target vault reduce eyebrow miss lucky orange guide","priv_key":"0x7105836e5d903f273075ab50429c36c08afb0b786986c3612b522bf59bcecc20","eth_addr":"0xfD4f6976e084CbBC8Dee5956B09bF32f94786eb9"} 17 | {"account_id":16,"mnemonic":"sound select report rug run cave provide index grief foster bar someone garage donate nominee crew once oil sausage flight tail holiday style afford","priv_key":"0xcd73077a9bb493ada626df70a3ebcd6a4df420be1870524b7ae4176596884aba","eth_addr":"0x7e99AE2709D761FBc3B3a23e0c0874Ba4aBBF229"} 18 | {"account_id":17,"mnemonic":"extra glove demise parade space april fashion mixture section barrel prize emerge flip sight pride swift beyond fresh check never scene ring anchor hazard","priv_key":"0x4976112050d262bfa62f56a12892590eb912f78c9b6a2df5098070940c72288e","eth_addr":"0x63C8C465dC4bb57723b2EC78Cb2aD50a2C804b1a"} 19 | {"account_id":18,"mnemonic":"camp awful sand include refuse cash reveal mystery pupil salad length plunge square admit vocal draft found side same clock hurt length say figure","priv_key":"0x8d3aa186bd7ff3a72dd7f7367b4f893dc7758c5154596a9b2a7b1c10bf8750b7","eth_addr":"0x09C4Ad711EfB0B9c4D3D9133b56c5Ab6DD1B4CD0"} 20 | {"account_id":19,"mnemonic":"chat cabin first fit zero avoid engine screen guitar young wool later occur element enroll amount brush melody seminar believe gossip alpha pool inch","priv_key":"0x5ee6b5badcda025167c8bccb0044d0df416dbae910b8bea5726145de3e9cf3ad","eth_addr":"0xd889eec15E43E9FfD49ae05fC131a8B374D3BDEb"} 21 | -------------------------------------------------------------------------------- /examples/js/accounts.ts: -------------------------------------------------------------------------------- 1 | let accounts = require("fs").readFileSync("./accounts.jsonl", "utf-8").split("\n").filter(Boolean).map(JSON.parse); 2 | 3 | export function getTestAccount(id) { 4 | let a = accounts[id]; 5 | return a; 6 | } 7 | -------------------------------------------------------------------------------- /examples/js/bots/bot.ts: -------------------------------------------------------------------------------- 1 | interface Bot { 2 | tick: (balance, oldOrders) => Promise<{ reset; orders }>; 3 | } 4 | export { Bot }; 5 | -------------------------------------------------------------------------------- /examples/js/bots/executor.ts: -------------------------------------------------------------------------------- 1 | async function executeOrders(client, market, uid, reset, orders, minAmount: number, verbose) { 2 | if (reset) { 3 | await client.orderCancelAll(uid, market); 4 | } 5 | for (const o of orders) { 6 | const { user_id, market, order_side, order_type, amount, price } = o; 7 | try { 8 | if (Number(amount) > minAmount) { 9 | let res = await client.orderPut(user_id, market, order_side, order_type, amount, price, "0", "0"); 10 | if (verbose) { 11 | console.log("put", res); 12 | } 13 | } 14 | } catch (e) { 15 | console.log("put error", o, e); 16 | } 17 | } 18 | } 19 | 20 | export { executeOrders }; 21 | -------------------------------------------------------------------------------- /examples/js/bots/mm_external_price_bot.ts: -------------------------------------------------------------------------------- 1 | import { Client } from "../client"; 2 | import { Bot } from "./bot"; 3 | import { ORDER_SIDE_BID, ORDER_SIDE_ASK, ORDER_TYPE_LIMIT, VERBOSE } from "../config"; 4 | class PriceBotParams {} 5 | class MMByPriceBot { 6 | client: Client; 7 | market: string; 8 | baseCoin: string; 9 | quoteCoin: string; 10 | params: PriceBotParams; 11 | latestPrice: number; 12 | verbose: boolean; 13 | name: string; 14 | user_id: number; 15 | priceFn: (coin: string) => Promise; 16 | init( 17 | user_id: number, 18 | bot_name: string, 19 | client: Client, 20 | baseCoin: string, 21 | quoteCoin: string, 22 | market: string, 23 | params: PriceBotParams, 24 | verbose: boolean 25 | ) { 26 | this.client = client; 27 | this.market = market; 28 | this.params = params; 29 | this.verbose = verbose; 30 | this.baseCoin = baseCoin; 31 | this.quoteCoin = quoteCoin; 32 | this.name = bot_name; 33 | this.user_id = user_id; 34 | } 35 | // TODO: remove async 36 | // run every second 37 | async tick(balance, oldOrders): Promise<{ reset; orders }> { 38 | const VERBOSE = this.verbose; 39 | const oldAskOrder = oldOrders.orders.find(elem => elem.order_side == "ASK"); 40 | const oldBidOrder = oldOrders.orders.find(elem => elem.order_side == "BID"); 41 | 42 | // put a big buy order and a big sell order 43 | //const price = await getPriceOfCoin(baseCoin, 5); 44 | 45 | const price = await this.priceFn(this.baseCoin); 46 | 47 | const allBase = Number(balance.get(this.baseCoin).available) + Number(balance.get(this.baseCoin).frozen); 48 | const allQuote = Number(balance.get(this.quoteCoin).available) + Number(balance.get(this.quoteCoin).frozen); 49 | //console.log({allBase, allQuote}); 50 | const ratio = 0.8; // use 80% of my assets to make market 51 | 52 | const spread = 0.0005; 53 | let askPriceRaw = price * (1 + spread); 54 | let bidPriceRaw = price * (1 - spread); 55 | let bidAmountRaw = (allQuote * ratio) / bidPriceRaw; 56 | let askAmountRaw = allBase * ratio; 57 | let { price: askPrice, amount: askAmount } = this.client.roundOrderInput(this.market, askAmountRaw, askPriceRaw); 58 | let { price: bidPrice, amount: bidAmount } = this.client.roundOrderInput(this.market, bidAmountRaw, bidPriceRaw); 59 | let minAmount = 0.001; 60 | if (askAmountRaw < minAmount) { 61 | askAmount = ""; 62 | askPrice = ""; 63 | } 64 | if (bidAmountRaw < minAmount) { 65 | bidAmount = ""; 66 | bidPrice = ""; 67 | } 68 | 69 | // const { user_id, market, order_side, order_type, amount, price, taker_fee, maker_fee } = o; 70 | if (VERBOSE) { 71 | console.log({ bidPrice, bidAmount, askAmount, askPrice }); 72 | //console.log({ bidPriceRaw, bidAmountRaw, askAmountRaw, askPriceRaw }); 73 | } 74 | let lastBidPrice = oldBidOrder?.price || ""; 75 | let lastBidAmount = oldBidOrder?.amount || ""; 76 | let lastAskPrice = oldAskOrder?.price || ""; 77 | let lastAskAmount = oldAskOrder?.amount || ""; 78 | //if(bidPrice == lastBidPrice && bidAmount == lastBidAmount && askPrice ==lastAskPrice && askAmount == lastAskAmount) { 79 | if (bidPrice == lastBidPrice && askPrice == lastAskPrice) { 80 | if (VERBOSE) { 81 | console.log("same order shape, skip"); 82 | } 83 | return { 84 | reset: false, 85 | orders: [], 86 | }; 87 | } 88 | //lastAskPrice = askPrice; 89 | //lastAskAmount = askAmount; 90 | //lastBidPrice = bidPrice; 91 | //lastBidAmount = bidAmount; 92 | const bid_order = { 93 | user_id: this.user_id, 94 | market: this.market, 95 | order_side: ORDER_SIDE_BID, 96 | order_type: ORDER_TYPE_LIMIT, 97 | amount: bidAmount, 98 | price: bidPrice, 99 | }; 100 | const ask_order = { 101 | user_id: this.user_id, 102 | market: this.market, 103 | order_side: ORDER_SIDE_ASK, 104 | order_type: ORDER_TYPE_LIMIT, 105 | amount: askAmount, 106 | price: askPrice, 107 | }; 108 | return { 109 | reset: true, 110 | orders: [bid_order, ask_order], 111 | }; 112 | } 113 | handleTrade(trade: any) { 114 | // console.log(trade); 115 | return; 116 | } 117 | handleOrderbookUpdate(orderbook: any) { 118 | // console.log(orderbook); 119 | return; 120 | } 121 | handleOrderEvent() { 122 | // console.log("log info"); 123 | return; 124 | } 125 | getLatestPrice(): number { 126 | return this.latestPrice; 127 | } 128 | estimatePrice(): number { 129 | return 3; 130 | } 131 | getMyBalance() { 132 | // console.log("log info"); 133 | return; 134 | } 135 | } 136 | 137 | export { MMByPriceBot }; 138 | -------------------------------------------------------------------------------- /examples/js/bots/run_bots.ts: -------------------------------------------------------------------------------- 1 | import { MMByPriceBot } from "./mm_external_price_bot"; 2 | import * as regression from "regression"; 3 | import { Account } from "fluidex.js"; 4 | import { defaultRESTClient, RESTClient } from "../RESTClient"; 5 | import { defaultClient as defaultGrpcClient, Client as grpcClient, defaultClient } from "../client"; 6 | import { sleep } from "../util"; 7 | import { ORDER_SIDE_BID, ORDER_SIDE_ASK, ORDER_TYPE_LIMIT, VERBOSE } from "../config"; 8 | import { 9 | estimateMarketOrderSell, 10 | estimateMarketOrderBuy, 11 | execMarketOrderAsLimit_Sell, 12 | execMarketOrderAsLimit_Buy, 13 | rebalance, 14 | printBalance, 15 | totalBalance, 16 | } from "./utils"; 17 | import { executeOrders } from "./executor"; 18 | import { depositAssets, getPriceOfCoin } from "../exchange_helper"; 19 | 20 | //const VERBOSE = false; 21 | console.log({ VERBOSE }); 22 | 23 | async function initUser(): Promise { 24 | const mnemonic1 = "split logic consider degree smile field term style opera dad believe indoor item type beyond"; 25 | const mnemonic2 = 26 | "camp awful sand include refuse cash reveal mystery pupil salad length plunge square admit vocal draft found side same clock hurt length say figure"; 27 | const mnemonic3 = 28 | "sound select report rug run cave provide index grief foster bar someone garage donate nominee crew once oil sausage flight tail holiday style afford"; 29 | const acc = Account.fromMnemonic(mnemonic3); 30 | //console.log('acc is', acc); 31 | const restClient = defaultRESTClient; 32 | let userInfo = await restClient.get_user_by_addr(acc.ethAddr); 33 | if (userInfo == null) { 34 | // register 35 | console.log("register new user"); 36 | let resp = await defaultGrpcClient.registerUser({ 37 | user_id: 0, // discard in server side 38 | l1_address: acc.ethAddr, 39 | l2_pubkey: acc.bjjPubKey, 40 | }); 41 | const t = Date.now(); 42 | console.log("register resp", resp); 43 | await sleep(2000); // FIXME 44 | userInfo = await restClient.get_user_by_addr(acc.ethAddr); 45 | await sleep(2000); // FIXME 46 | await depositAssets({ USDT: "10000.0" }, userInfo.id); 47 | } else { 48 | console.log("user", "already registered"); 49 | } 50 | console.log("user", userInfo); 51 | 52 | defaultClient.addAccount(userInfo.id, acc); 53 | return userInfo.id; 54 | } 55 | 56 | const market = "ETH_USDT"; 57 | const baseCoin = "ETH"; 58 | const quoteCoin = "USDT"; 59 | 60 | async function main() { 61 | const user_id = await initUser(); 62 | 63 | await defaultClient.connect(); 64 | 65 | await rebalance(user_id, baseCoin, quoteCoin, market); 66 | 67 | let bot = new MMByPriceBot(); 68 | bot.init(user_id, "bot1", defaultClient, baseCoin, quoteCoin, market, null, VERBOSE); 69 | bot.priceFn = async function (coin: string) { 70 | return await getPriceOfCoin(coin, 5, "coinstats"); 71 | }; 72 | let balanceStats = []; 73 | let count = 0; 74 | const startTime = Date.now() / 1000; 75 | const { totalValue: totalValueWhenStart } = await totalBalance(user_id, baseCoin, quoteCoin, market); 76 | while (true) { 77 | if (VERBOSE) { 78 | console.log("count:", count); 79 | } 80 | count += 1; 81 | if (VERBOSE) { 82 | console.log("sleep 500ms"); 83 | } 84 | await sleep(500); 85 | try { 86 | if (count % 100 == 1) { 87 | const t = Date.now() / 1000; // ms 88 | console.log("stats of", bot.name); 89 | console.log("orders:"); 90 | console.log(await defaultClient.orderQuery(user_id, market)); 91 | console.log("balances:"); 92 | await printBalance(user_id, baseCoin, quoteCoin, market); 93 | let { totalValue } = await totalBalance(user_id, baseCoin, quoteCoin, market); 94 | balanceStats.push([t, totalValue]); 95 | if (balanceStats.length >= 2) { 96 | const pastHour = (t - startTime) / 3600; 97 | const assetRatio = totalValue / totalValueWhenStart; 98 | console.log("time(hour)", pastHour, "asset ratio", assetRatio); 99 | console.log("current ROI per hour:", (assetRatio - 1) / pastHour); 100 | // we should use exp regression rather linear 101 | const hourDelta = 3600 * regression.linear(balanceStats).equation[0]; 102 | console.log("regression ROI per hour:", hourDelta / totalValueWhenStart); 103 | } 104 | } 105 | 106 | const oldOrders = await defaultClient.orderQuery(user_id, market); 107 | if (VERBOSE) { 108 | console.log("oldOrders", oldOrders); 109 | } 110 | 111 | const balance = await defaultClient.balanceQuery(user_id); 112 | const { reset, orders } = await bot.tick(balance, oldOrders); 113 | 114 | await executeOrders(defaultClient, market, user_id, reset, orders, 0.001, false); 115 | } catch (e) { 116 | console.log("err", e); 117 | } 118 | } 119 | } 120 | 121 | main(); 122 | -------------------------------------------------------------------------------- /examples/js/bots/utils.ts: -------------------------------------------------------------------------------- 1 | import { Account } from "fluidex.js"; 2 | import { defaultRESTClient, RESTClient } from "../RESTClient"; 3 | import { defaultClient as defaultGrpcClient, Client as grpcClient, defaultClient } from "../client"; 4 | import { sleep } from "../util"; 5 | import { depositAssets, getPriceOfCoin } from "../exchange_helper"; 6 | import { ORDER_SIDE_BID, ORDER_SIDE_ASK, ORDER_TYPE_LIMIT, VERBOSE } from "../config"; 7 | 8 | // TODO: add a similar function using quoteAmount. "i want to sell some eth to get 5000 usdt" 9 | // TODO: exclude my orders 10 | async function estimateMarketOrderSell(client: grpcClient, market, baseAmount: number) { 11 | const orderbook = await client.orderDepth(market, 20, "0.01"); 12 | //console.log('depth', orderbook); 13 | //console.log(client.markets); 14 | let quoteAcc = 0; 15 | let baseAcc = 0; 16 | let worstPrice = 0; // 17 | let bestPrice = Number(orderbook.bids[0].price); 18 | for (const elem of orderbook.bids) { 19 | let amount = Number(elem.amount); 20 | let price = Number(elem.price); 21 | if (baseAcc + amount > baseAmount) { 22 | amount = baseAmount - baseAcc; 23 | } 24 | baseAcc += amount; 25 | quoteAcc += amount * price; 26 | worstPrice = price; 27 | } 28 | let estimateResult = { 29 | base: baseAcc, 30 | quote: quoteAcc, 31 | avgPrice: quoteAcc / baseAcc, 32 | bestPrice, 33 | worstPrice, 34 | }; 35 | //console.log("estimateMarketOrderSell:", estimateResult); 36 | return estimateResult; 37 | } 38 | 39 | async function estimateMarketOrderBuy(client: grpcClient, market, quoteAmount: number) { 40 | //await client.connect(); 41 | const orderbook = await client.orderDepth(market, 20, "0.01"); 42 | //console.log('depth', orderbook); 43 | //console.log(client.markets); 44 | let quoteAcc = 0; 45 | let tradeAmount = 0; 46 | let worstPrice = 0; // 47 | let bestPrice = Number(orderbook.asks[0].price); 48 | for (const elem of orderbook.asks) { 49 | let amount = Number(elem.amount); 50 | let price = Number(elem.price); 51 | let quote = amount * price; 52 | if (quoteAcc + quote > quoteAmount) { 53 | amount = (quoteAmount - quoteAcc) / price; 54 | } 55 | tradeAmount += amount; 56 | quoteAcc += amount * price; 57 | worstPrice = price; 58 | } 59 | let estimateResult = { 60 | base: tradeAmount, 61 | quote: quoteAcc, 62 | avgPrice: quoteAcc / tradeAmount, 63 | bestPrice, 64 | worstPrice, 65 | }; 66 | //console.log("estimateMarketOrderBuy:", estimateResult); 67 | return estimateResult; 68 | } 69 | 70 | async function execMarketOrderAsLimit_Sell(client: grpcClient, market, baseAmount: string, uid) { 71 | /* 72 | let estimateResult = await estimateMarketOrderBuy( 73 | client, 74 | market, 75 | Number(amount) 76 | ); 77 | */ 78 | const price = "0.01"; // low enough as a market order... 79 | let order = await client.orderPut(uid, market, ORDER_SIDE_ASK, ORDER_TYPE_LIMIT, baseAmount, price, "0", "0"); 80 | //console.log("execMarketOrderAsLimit_Sell", order); 81 | } 82 | 83 | async function execMarketOrderAsLimit_Buy(client: grpcClient, market, quoteAmount: string, uid) { 84 | let estimateResult = await estimateMarketOrderBuy(client, market, Number(quoteAmount)); 85 | let order = await client.orderPut( 86 | uid, 87 | market, 88 | ORDER_SIDE_BID, 89 | ORDER_TYPE_LIMIT, 90 | estimateResult.base, 91 | estimateResult.worstPrice * 1.1, 92 | "0", 93 | "0" 94 | ); 95 | //console.log("execMarketOrderAsLimit_Buy", order); 96 | } 97 | 98 | async function rebalance(user_id, baseCoin, quoteCoin, market) { 99 | let rebalanced = false; 100 | const balance = await defaultGrpcClient.balanceQuery(user_id); 101 | const allBase = Number(balance.get(baseCoin).available) + Number(balance.get(baseCoin).frozen); 102 | const allQuote = Number(balance.get(quoteCoin).available) + Number(balance.get(quoteCoin).frozen); 103 | //onsole.log("balance when start", { balance, allBase, allQuote }); 104 | 105 | if (allBase < 0.1) { 106 | await defaultGrpcClient.orderCancelAll(user_id, market); 107 | 108 | await execMarketOrderAsLimit_Buy(defaultClient, market, "5000", user_id); 109 | rebalanced = true; 110 | } 111 | if (allQuote < 1000) { 112 | await defaultGrpcClient.orderCancelAll(user_id, market); 113 | 114 | // TODO: use quote amount rather than base amount 115 | await execMarketOrderAsLimit_Sell(defaultClient, market, "1.5" /*base*/, user_id); 116 | rebalanced = true; 117 | } 118 | return rebalanced; 119 | } 120 | 121 | async function totalBalance(user_id, baseCoin, quoteCoin, market, externalPrice = null) { 122 | if (externalPrice == null) { 123 | externalPrice = await getPriceOfCoin(baseCoin); 124 | } 125 | const balance = await defaultGrpcClient.balanceQuery(user_id); 126 | const allBase = Number(balance.get(baseCoin).available) + Number(balance.get(baseCoin).frozen); 127 | const allQuote = Number(balance.get(quoteCoin).available) + Number(balance.get(quoteCoin).frozen); 128 | return { 129 | quote: allQuote, 130 | base: allBase, 131 | quoteValue: allQuote, // stable coin 132 | baseValue: allBase * externalPrice, 133 | totalValue: allQuote + allBase * externalPrice, 134 | totalValueInBase: allQuote / externalPrice + allBase, 135 | }; 136 | } 137 | 138 | async function printBalance(user_id, baseCoin, quoteCoin, market) { 139 | const balance = await defaultGrpcClient.balanceQuery(user_id); 140 | const allBase = Number(balance.get(baseCoin).available) + Number(balance.get(baseCoin).frozen); 141 | const allQuote = Number(balance.get(quoteCoin).available) + Number(balance.get(quoteCoin).frozen); 142 | 143 | let res = await estimateMarketOrderSell(defaultGrpcClient, market, allBase); 144 | console.log("------- BALANCE1:", { 145 | quote: allQuote, 146 | base: res.quote, 147 | total: allQuote + res.quote, 148 | }); 149 | 150 | const externalPrice = await getPriceOfCoin(baseCoin); 151 | console.log("external base price", externalPrice); 152 | console.log("------- BALANCE2:", { 153 | quote: allQuote, 154 | base: allBase, 155 | quoteValue: allQuote, // stable coin 156 | baseValue: allBase * externalPrice, 157 | totalValue: allQuote + allBase * externalPrice, 158 | totalValueInBase: allQuote / externalPrice + allBase, 159 | }); 160 | } 161 | 162 | export { 163 | estimateMarketOrderSell, 164 | estimateMarketOrderBuy, 165 | execMarketOrderAsLimit_Sell, 166 | execMarketOrderAsLimit_Buy, 167 | rebalance, 168 | printBalance, 169 | totalBalance, 170 | }; 171 | -------------------------------------------------------------------------------- /examples/js/cli/deposit.ts: -------------------------------------------------------------------------------- 1 | //deposit a lot to engine, so we would not encounter "balance not enough" failure 2 | 3 | import { depositAssets } from "../exchange_helper"; 4 | 5 | async function main() { 6 | //if I really had so much money .... 7 | await depositAssets({ USDT: "10000000.0", ETH: "50000.0" }, 3); 8 | await depositAssets({ USDT: "10000.0", ETH: "50.0" }, 11); 9 | } 10 | 11 | main().catch(console.log); 12 | -------------------------------------------------------------------------------- /examples/js/cli/dumpdb.ts: -------------------------------------------------------------------------------- 1 | import { defaultClient as client } from "../client"; 2 | 3 | async function main() { 4 | // Dotenv.config() 5 | try { 6 | await client.debugDump(); 7 | } catch (error) { 8 | console.error("Caught error:", error); 9 | } 10 | } 11 | 12 | main(); 13 | -------------------------------------------------------------------------------- /examples/js/cli/monitor_kafka_message.ts: -------------------------------------------------------------------------------- 1 | import { KafkaConsumer } from "../kafka_client"; 2 | import * as Dotenv from "dotenv"; 3 | 4 | async function main() { 5 | Dotenv.config(); 6 | const consumer = new KafkaConsumer().Init(true); 7 | await consumer; 8 | } 9 | 10 | main().catch(console.error); 11 | -------------------------------------------------------------------------------- /examples/js/cli/register_new_user.ts: -------------------------------------------------------------------------------- 1 | import { getTestAccount } from "../accounts"; 2 | import { Account } from "fluidex.js"; 3 | import { defaultClient } from "../client"; 4 | async function main() { 5 | let acc = Account.fromPrivkey(getTestAccount(15).priv_key); 6 | console.log(getTestAccount(15).priv_key); 7 | let resp = await defaultClient.registerUser({ 8 | user_id: 0, // discard in server side 9 | l1_address: acc.ethAddr, 10 | l2_pubkey: acc.bjjPubKey, 11 | }); 12 | console.log(resp); 13 | } 14 | main(); 15 | -------------------------------------------------------------------------------- /examples/js/cli/reload_market.ts: -------------------------------------------------------------------------------- 1 | import { defaultClient as client } from "../client"; 2 | 3 | async function main() { 4 | // Dotenv.config() 5 | try { 6 | await client.reloadMarkets(); 7 | } catch (error) { 8 | console.error("Caught error:", error); 9 | } 10 | } 11 | 12 | main(); 13 | -------------------------------------------------------------------------------- /examples/js/cli/reset.ts: -------------------------------------------------------------------------------- 1 | import { defaultClient as client } from "../client"; 2 | 3 | async function main() { 4 | // Dotenv.config() 5 | try { 6 | await client.debugReset(); 7 | } catch (error) { 8 | console.error("Caught error:", error); 9 | } 10 | } 11 | 12 | main(); 13 | -------------------------------------------------------------------------------- /examples/js/config.ts: -------------------------------------------------------------------------------- 1 | import * as Dotenv from "dotenv"; 2 | Dotenv.config(); 3 | 4 | export const VERBOSE = !!process.env.VERBOSE; 5 | 6 | // constants 7 | export const ORDER_SIDE_ASK = 0; 8 | export const ORDER_SIDE_BID = 1; 9 | export const ORDER_TYPE_LIMIT = 0; 10 | export const ORDER_TYPE_MARKET = 1; 11 | 12 | // fake data 13 | export const userId = 3; 14 | export const base = "ETH"; 15 | export const quote = "USDT"; 16 | export const market = `${base}_${quote}`; 17 | export const fee = "0"; 18 | 19 | // global config 20 | import { inspect } from "util"; 21 | inspect.defaultOptions.depth = null; 22 | -------------------------------------------------------------------------------- /examples/js/exchange_helper.ts: -------------------------------------------------------------------------------- 1 | import { userId, fee, ORDER_SIDE_BID, ORDER_SIDE_ASK, ORDER_TYPE_MARKET, ORDER_TYPE_LIMIT, VERBOSE } from "./config"; // dotenv 2 | import { defaultClient as client } from "./client"; 3 | 4 | import Decimal from "decimal.js"; 5 | const gaussian = require("gaussian"); 6 | import { strict as assert } from "assert"; 7 | import axios from "axios"; 8 | import { getRandomFloat, getRandomInt } from "./util"; 9 | 10 | function depositId() { 11 | return Date.now(); 12 | } 13 | 14 | export async function printBalance(printList = ["USDT", "ETH"]) { 15 | const balances = await client.balanceQuery(userId); 16 | console.log("\nasset\tsum\tavaiable\tfrozen"); 17 | for (const asset of printList) { 18 | const balance = balances.get(asset); 19 | console.log( 20 | asset, 21 | "\t", 22 | new Decimal(balance.available).add(new Decimal(balance.frozen)), 23 | "\t", 24 | balance.available, 25 | "\t", 26 | balance.frozen 27 | ); 28 | } 29 | //console.log('\n'); 30 | } 31 | 32 | export async function depositAssets(assets: any, userId: number) { 33 | for (const [asset, amount] of Object.entries(assets)) { 34 | console.log("deposit", amount, asset); 35 | await client.balanceUpdate(userId, asset, "deposit", depositId(), amount, { 36 | key: "value", 37 | }); 38 | } 39 | } 40 | 41 | export async function putLimitOrder(userId, market, side, amount, price) { 42 | return await client.orderPut(userId, market, side, ORDER_TYPE_LIMIT, amount, price, fee, fee); 43 | } 44 | export async function putRandOrder(userId, market) { 45 | // TODO: market order? 46 | const side = [ORDER_SIDE_ASK, ORDER_SIDE_BID][getRandomInt(0, 10000) % 2]; 47 | const price = getRandomFloat(1350, 1450); 48 | const amount = getRandomFloat(0.5, 1.5); 49 | const order = await putLimitOrder(userId, market, side, amount, price); 50 | //console.log("order put", order.id.toString(), { side, price, amount }); 51 | } 52 | 53 | let pricesCache = new Map(); 54 | let pricesUpdatedTime = 0; 55 | export async function getPriceOfCoin( 56 | sym, 57 | timeout = 60, // default 1min 58 | backend = "coinstats" 59 | ): Promise { 60 | // limit query rate 61 | if (Date.now() > pricesUpdatedTime + timeout * 1000) { 62 | // update prices 63 | try { 64 | if (backend == "coinstats") { 65 | const url = "https://api.coinstats.app/public/v1/coins?skip=0&limit=100¤cy=USD"; 66 | const data = await axios.get(url); 67 | for (const elem of data.data.coins) { 68 | pricesCache.set(elem.symbol, elem.price); 69 | } 70 | } else if (backend == "cryptocompare") { 71 | const url = "https://min-api.cryptocompare.com/data/price?fsym=ETH&tsyms=USD"; 72 | // TODO 73 | } 74 | 75 | pricesUpdatedTime = Date.now(); 76 | } catch (e) { 77 | console.log("update prices err", e); 78 | } 79 | } 80 | 81 | return pricesCache.get(sym); 82 | } 83 | -------------------------------------------------------------------------------- /examples/js/kafka_client.ts: -------------------------------------------------------------------------------- 1 | import * as Kafka from "kafkajs"; 2 | 3 | export class KafkaConsumer { 4 | verbose: boolean; 5 | consumer: any; 6 | messages: Map>; 7 | async Init(verbose = false, topics = ["balances", "trades", "orders", "unifyevents"]) { 8 | this.verbose = verbose; 9 | const brokers = process.env.KAFKA_BROKERS; 10 | const kafka = new Kafka.Kafka({ 11 | brokers: (brokers || "127.0.0.1:9092").split(","), 12 | logLevel: Kafka.logLevel.WARN, 13 | }); 14 | const consumer = kafka.consumer({ groupId: "test-group" }); 15 | this.consumer = consumer; 16 | await consumer.connect(); 17 | const fromBeginning = false; 18 | this.messages = new Map(); 19 | for (const topic of topics) { 20 | this.messages.set(topic, []); 21 | await consumer.subscribe({ topic, fromBeginning }); 22 | } 23 | return consumer.run({ 24 | eachMessage: async ({ topic, partition, message }) => { 25 | if (this.verbose) { 26 | console.log("New message:", { 27 | topic, 28 | partition, 29 | offset: message.offset, 30 | key: message.key.toString(), 31 | value: message.value.toString(), 32 | }); 33 | } 34 | this.messages.get(topic).push(message.value.toString()); 35 | }, 36 | }); 37 | } 38 | Reset() { 39 | this.messages = new Map(); 40 | } 41 | GetAllMessages(): Map> { 42 | return this.messages; 43 | } 44 | async Stop() { 45 | await this.consumer.disconnect(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /examples/js/ordersigner.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package ordersigner; 4 | 5 | enum OrderSide { 6 | ASK = 0; 7 | BID = 1; 8 | } 9 | 10 | enum OrderType { 11 | LIMIT = 0; 12 | MARKET = 1; 13 | } 14 | 15 | service OrderSigner { 16 | rpc SignOrder(SignOrderRequest) returns (SignOrderResponse) { 17 | } 18 | 19 | // rpc AddAccount({uid, privkey}) returns (empty) {} 20 | } 21 | 22 | // 23 | message SignOrderRequest { 24 | // copied from https://github.com/fluidex/orchestra/blob/3938acc11e605cb381b62467a9b9cd23abed86b7/proto/exchange/matchengine.proto#L179 25 | uint32 user_id = 1; 26 | string market = 2; 27 | OrderSide order_side = 3; 28 | OrderType order_type = 4; 29 | string amount = 5; // always amount for base, even for market bid 30 | string price = 6; // should be empty or zero for market order 31 | string quote_limit = 7; // onyl valid for market bid order 32 | string taker_fee = 8; 33 | string maker_fee = 9; 34 | bool post_only = 10; // Ensures an Limit order is only subject to Maker Fees 35 | // (ignored for Market orders). 36 | string signature = 11; // bjj signature used in FluiDex 37 | } 38 | 39 | message SignOrderResponse { 40 | string signature = 1; 41 | } 42 | -------------------------------------------------------------------------------- /examples/js/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "fmt": "npx prettier --write \"**/*.ts\" package.json" 4 | }, 5 | "dependencies": { 6 | "@eeston/grpc-caller": "^0.20.2", 7 | "@grpc/grpc-js": "^1.2.5", 8 | "axios": "^0.21.2", 9 | "decimal.js": "^10.2.0", 10 | "dotenv": "^8.2.0", 11 | "gaussian": "^1.2.0", 12 | "kafkajs": "^1.11.0", 13 | "lodash": "^4.17.21", 14 | "regression": "^2.0.1" 15 | }, 16 | "devDependencies": { 17 | "@typescript-eslint/eslint-plugin": "^4.31.1", 18 | "@typescript-eslint/parser": "^4.31.0", 19 | "eslint": "^7.22.0", 20 | "eslint-config-prettier": "^8.1.0", 21 | "eslint-plugin-prettier": "^3.3.1", 22 | "fluidex.js": "github:fluidex/fluidex.js", 23 | "prettier": "^2.4.1", 24 | "ts-node": "^10.0.0", 25 | "typescript": "^4.3.2", 26 | "why-is-node-running": "^2.1.2" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/js/signer_server.ts: -------------------------------------------------------------------------------- 1 | import { Account } from "fluidex.js"; 2 | import { userId, base, quote, market, fee, ORDER_SIDE_BID, ORDER_SIDE_ASK, ORDER_TYPE_MARKET, ORDER_TYPE_LIMIT } from "./config"; // dotenv 3 | 4 | let PROTO_PATH = __dirname + "/ordersigner.proto"; 5 | 6 | let grpc = require("@grpc/grpc-js"); 7 | let protoLoader = require("@grpc/proto-loader"); 8 | let packageDefinition = protoLoader.loadSync(PROTO_PATH, { 9 | keepCase: true, 10 | longs: String, 11 | enums: String, 12 | defaults: true, 13 | oneofs: true, 14 | }); 15 | let ordersigner = grpc.loadPackageDefinition(packageDefinition).ordersigner; 16 | 17 | import { defaultClient as client } from "./client"; 18 | 19 | // FIXME 20 | // account11 21 | const ethPrivKey = "0x7105836e5d903f273075ab50429c36c08afb0b786986c3612b522bf59bcecc20"; 22 | const acc = Account.fromPrivkey(ethPrivKey); 23 | const uid = 3; 24 | client.addAccount(uid, acc); 25 | 26 | async function signOrder(call, callback) { 27 | let inputOrder = call.request; 28 | console.log({ inputOrder }); 29 | //let accountID = call.accountID; 30 | let { user_id, market, order_side, order_type, amount, price, taker_fee, maker_fee } = inputOrder; 31 | // FIXME 32 | if (uid != user_id) { 33 | throw new Error("set user key first!"); 34 | } 35 | //order_type = ORDER_TYPE_LIMIT; 36 | //order_side = ORDER_SIDE_BID; 37 | let signedOrder = await client.createOrder(user_id, market, order_side, order_type, amount, price, taker_fee, maker_fee); 38 | console.log({ signedOrder }); 39 | //console.log(await client.client.orderPut(signedOrder)); 40 | callback(null, { signature: signedOrder.signature }); 41 | } 42 | 43 | /** 44 | * Starts an RPC server that receives requests for the Greeter service at the 45 | * sample server port 46 | */ 47 | function main() { 48 | let server = new grpc.Server(); 49 | server.addService(ordersigner.OrderSigner.service, { signOrder: signOrder }); 50 | server.bindAsync("0.0.0.0:50061", grpc.ServerCredentials.createInsecure(), () => { 51 | server.start(); 52 | }); 53 | } 54 | 55 | main(); 56 | -------------------------------------------------------------------------------- /examples/js/tests/multi_market.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { Account } from "fluidex.js"; 3 | 4 | import { defaultClient as client } from "../client"; 5 | import { getTestAccount } from "../accounts"; 6 | import { fee, market, ORDER_SIDE_BID, ORDER_TYPE_LIMIT } from "../config"; 7 | import { depositAssets } from "../exchange_helper"; 8 | import { strict as assert } from "assert"; 9 | 10 | const userId = 1; 11 | const isCI = !!process.env.GITHUB_ACTIONS; 12 | const server = process.env.API_ENDPOINT || "0.0.0.0:8765"; 13 | 14 | async function initAccounts() { 15 | await client.debugReset(); 16 | await client.connect(); 17 | let acc = Account.fromMnemonic(getTestAccount(userId).mnemonic); 18 | client.addAccount(userId, acc); 19 | await client.client.RegisterUser({ 20 | userId, 21 | l1_address: acc.ethAddr, 22 | l2_pubkey: acc.bjjPubKey, 23 | }); 24 | } 25 | 26 | async function setupAsset() { 27 | await depositAssets({ USDT: "100.0", ETH: "50.0" }, userId); 28 | } 29 | 30 | async function orderTest() { 31 | const markets = Array.from(["ETH_USDT", "LINK_USDT", "MATIC_USDT", "UNI_USDT"]); 32 | let orders = await Promise.all( 33 | markets.map(market => 34 | client.orderPut(userId, market, ORDER_SIDE_BID, ORDER_TYPE_LIMIT, /*amount*/ "1", /*price*/ "1.1", fee, fee).then(o => [market, o.id]) 35 | ) 36 | ); 37 | console.log(orders); 38 | assert.equal(orders.length, 4); 39 | 40 | const openOrders = (await axios.get(`http://${server}/api/exchange/action/orders/all/1`)).data; 41 | console.log(openOrders); 42 | if (isCI) { 43 | assert.equal(openOrders.orders.length, orders.length); 44 | } 45 | 46 | await Promise.all(orders.map(([market, id]) => client.orderCancel(1, market, Number(id)))); 47 | 48 | const closedOrders = (await axios.get(`http://${server}/api/exchange/panel/closedorders/all/1`)).data; 49 | console.log(closedOrders); 50 | if (isCI) { 51 | assert.equal(closedOrders.orders.length, orders.length); 52 | } 53 | } 54 | 55 | async function main() { 56 | try { 57 | console.log("ci mode:", isCI); 58 | await initAccounts(); 59 | await setupAsset(); 60 | await orderTest(); 61 | } catch (error) { 62 | console.error("Caught error:", error); 63 | process.exit(1); 64 | } 65 | } 66 | main(); 67 | -------------------------------------------------------------------------------- /examples/js/tests/print_orders.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { strict as assert } from "assert"; 3 | import "../config"; 4 | 5 | const isCI = !!process.env.GITHUB_ACTIONS; 6 | 7 | async function main() { 8 | const server = process.env.API_ENDPOINT || "0.0.0.0:8765"; 9 | console.log("ci mode:", isCI); 10 | console.log("closed orders:"); 11 | const closedOrders = (await axios.get(`http://${server}/api/exchange/panel/closedorders/ETH_USDT/3`)).data; 12 | console.log(closedOrders); 13 | if (isCI) { 14 | assert.equal(closedOrders.orders.length, 2); 15 | } 16 | console.log("active orders:"); 17 | const openOrders = (await axios.get(`http://${server}/api/exchange/action/orders/ETH_USDT/4`)).data; 18 | console.log(openOrders); 19 | if (isCI) { 20 | assert.equal(openOrders.orders.length, 1); 21 | } 22 | console.log("market ticker:"); 23 | const ticker = (await axios.get(`http://${server}/api/exchange/panel/ticker_24h/ETH_USDT`)).data; 24 | console.log(ticker); 25 | if (isCI) { 26 | assert.equal(ticker.volume, 4); 27 | assert.equal(ticker.quote_volume, 4.4); 28 | } 29 | } 30 | main().catch(function (e) { 31 | console.log(e); 32 | process.exit(1); 33 | //throw e; 34 | }); 35 | -------------------------------------------------------------------------------- /examples/js/tests/put_batch_orders.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { Account } from "fluidex.js"; 3 | import { defaultClient as client } from "../client"; 4 | import { depositAssets } from "../exchange_helper"; 5 | import { fee, ORDER_SIDE_BID, ORDER_TYPE_LIMIT } from "../config"; 6 | import { getTestAccount } from "../accounts"; 7 | import { strict as assert } from "assert"; 8 | 9 | const botsIds = [1, 2, 3, 4, 5]; 10 | const apiServer = process.env.API_ENDPOINT || "0.0.0.0:8765"; 11 | 12 | async function loadAccounts() { 13 | for (const user_id of botsIds) { 14 | let acc = Account.fromMnemonic(getTestAccount(user_id).mnemonic); 15 | console.log("acc", user_id, acc); 16 | client.addAccount(user_id, acc); 17 | } 18 | } 19 | 20 | async function initClient() { 21 | await client.connect(); 22 | } 23 | 24 | async function registerAccounts() { 25 | for (const user_id of botsIds) { 26 | let acc = Account.fromMnemonic(getTestAccount(user_id).mnemonic); 27 | await client.client.RegisterUser({ 28 | user_id, 29 | l1_address: acc.ethAddr, 30 | l2_pubkey: acc.bjjPubKey, 31 | }); 32 | } 33 | } 34 | 35 | async function initAssets() { 36 | for (const user_id of botsIds) { 37 | await depositAssets({ USDT: "500000.0" }, user_id); 38 | for (const [name, info] of client.markets) { 39 | const base = info.base; 40 | const depositReq = {}; 41 | depositReq[base] = "10"; 42 | await depositAssets(depositReq, user_id); 43 | } 44 | } 45 | } 46 | 47 | async function mainTest() { 48 | await putOrdersTest(); 49 | await putAndResetOrdersTest(); 50 | } 51 | 52 | // Put multiple orders 53 | async function putOrdersTest() { 54 | console.log("putOrdersTest Begin"); 55 | 56 | const userId1 = botsIds[0]; 57 | const userId2 = botsIds[1]; 58 | const oldOrderNum1 = await openOrderNum(userId1); 59 | const oldOrderNum2 = await openOrderNum(userId2); 60 | 61 | const res = await client.batchOrderPut("ETH_USDT", false, [ 62 | { 63 | user_id: botsIds[0], 64 | market: "ETH_USDT", 65 | order_side: ORDER_SIDE_BID, 66 | order_type: ORDER_TYPE_LIMIT, 67 | amount: "1", 68 | price: "1", 69 | taker_fee: fee, 70 | maker_fee: fee, 71 | }, 72 | { 73 | user_id: botsIds[1], 74 | market: "ETH_USDT", 75 | order_side: ORDER_SIDE_BID, 76 | order_type: ORDER_TYPE_LIMIT, 77 | amount: "1", 78 | price: "1", 79 | taker_fee: fee, 80 | maker_fee: fee, 81 | }, 82 | ]); 83 | 84 | const newOrderNum1 = await openOrderNum(userId1); 85 | const newOrderNum2 = await openOrderNum(userId2); 86 | 87 | assert.equal(newOrderNum1 - oldOrderNum1, 1); 88 | assert.equal(newOrderNum2 - oldOrderNum2, 1); 89 | 90 | console.log("putOrdersTest End"); 91 | } 92 | 93 | // Put and reset multiple orders 94 | async function putAndResetOrdersTest() { 95 | console.log("putAndResetOrdersTest Begin"); 96 | 97 | const userId1 = botsIds[0]; 98 | const userId2 = botsIds[1]; 99 | const oldOrderNum1 = await openOrderNum(userId1); 100 | assert(oldOrderNum1 > 0); 101 | const oldOrderNum2 = await openOrderNum(userId2); 102 | assert(oldOrderNum2 > 0); 103 | 104 | const res = await client.batchOrderPut("ETH_USDT", true, [ 105 | { 106 | user_id: botsIds[0], 107 | market: "ETH_USDT", 108 | order_side: ORDER_SIDE_BID, 109 | order_type: ORDER_TYPE_LIMIT, 110 | amount: "1", 111 | price: "1", 112 | taker_fee: fee, 113 | maker_fee: fee, 114 | }, 115 | { 116 | user_id: botsIds[1], 117 | market: "ETH_USDT", 118 | order_side: ORDER_SIDE_BID, 119 | order_type: ORDER_TYPE_LIMIT, 120 | amount: "1", 121 | price: "1", 122 | taker_fee: fee, 123 | maker_fee: fee, 124 | }, 125 | ]); 126 | 127 | const newOrderNum1 = await openOrderNum(userId1); 128 | const newOrderNum2 = await openOrderNum(userId2); 129 | assert.equal(newOrderNum1, 1); 130 | assert.equal(newOrderNum2, 1); 131 | 132 | console.log("putAndResetOrdersTest End"); 133 | } 134 | 135 | async function openOrderNum(userId) { 136 | return (await axios.get(`http://${apiServer}/api/exchange/action/orders/ETH_USDT/${userId}`)).data.orders.length; 137 | } 138 | 139 | async function main() { 140 | try { 141 | await loadAccounts(); 142 | await initClient(); 143 | await client.debugReset(); 144 | await registerAccounts(); 145 | await initAssets(); 146 | await mainTest(); 147 | } catch (error) { 148 | console.error("Caught error:", error); 149 | process.exit(1); 150 | } 151 | } 152 | 153 | main(); 154 | -------------------------------------------------------------------------------- /examples/js/tests/set_markets.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { strict as assert } from "assert"; 3 | import "../config"; 4 | 5 | const isCI = !!process.env.GITHUB_ACTIONS; 6 | 7 | const new_asset = { 8 | assets: [{ name: "BTC", prec_save: 4, prec_show: 4 }], 9 | not_reload: true, 10 | }; 11 | 12 | const new_market1 = { 13 | market: { 14 | name: "BTC_USDC", 15 | base: { name: "BTC", prec: 2 }, 16 | quote: { name: "USDC", prec: 4 }, 17 | fee_prec: 2, 18 | min_amount: 0.01, 19 | }, 20 | asset_quote: { 21 | name: "USDC", 22 | prec_save: 6, 23 | prec_show: 6, 24 | }, 25 | }; 26 | 27 | const new_market2 = { 28 | market: { 29 | name: "BTC_USDT", 30 | base: { name: "BTC", prec: 2 }, 31 | quote: { name: "USDT", prec: 4 }, 32 | fee_prec: 2, 33 | min_amount: 0.01, 34 | }, 35 | }; 36 | async function main() { 37 | const server = process.env.API_ENDPOINT || "0.0.0.0:8765"; 38 | console.log("ci mode:", isCI); 39 | console.log("add asset"); 40 | const ret1 = (await axios.post(`http://${server}/api/exchange/panel/manage/market/assets`, new_asset)).data; 41 | console.log(ret1); 42 | if (isCI) { 43 | assert.equal(ret1, "done"); 44 | } 45 | console.log("add market 1"); 46 | const ret2 = (await axios.post(`http://${server}/api/exchange/panel/manage/market/tradepairs`, new_market1)).data; 47 | console.log(ret2); 48 | if (isCI) { 49 | assert.equal(ret2, "done"); 50 | } 51 | const { markets } = (await axios.get(`http://${server}/api/exchange/action/markets`)).data; 52 | console.log(markets); 53 | if (isCI) { 54 | assert.equal(markets.length, 2); 55 | } 56 | console.log("add market 2"); 57 | const ret3 = (await axios.post(`http://${server}/api/exchange/panel/manage/market/tradepairs`, new_market2)).data; 58 | console.log(ret3); 59 | if (isCI) { 60 | assert.equal(ret3, "done"); 61 | } 62 | const { markets: markets2 } = (await axios.get(`http://${server}/api/exchange/action/markets`)).data; 63 | console.log(markets2); 64 | if (isCI) { 65 | assert.equal(markets.length, 3); 66 | } 67 | } 68 | main().catch(function (e) { 69 | console.log(e); 70 | process.exit(1); 71 | //throw e; 72 | }); 73 | -------------------------------------------------------------------------------- /examples/js/tests/stress.ts: -------------------------------------------------------------------------------- 1 | import { market, userId } from "../config"; // dotenv 2 | import { defaultClient as client } from "../client"; 3 | 4 | import { sleep, decimalAdd, assertDecimalEqual } from "../util"; 5 | 6 | import { depositAssets, printBalance, putRandOrder } from "../exchange_helper"; 7 | 8 | async function stressTest({ parallel, interval, repeat }) { 9 | const tradeCountBefore = (await client.marketSummary(market)).trade_count; 10 | console.log("cancel", tradeCountBefore, "trades"); 11 | console.log(await client.orderCancelAll(userId, market)); 12 | await depositAssets({ USDT: "10000000", ETH: "10000" }, userId); 13 | const USDTBefore = await client.balanceQueryByAsset(userId, "USDT"); 14 | const ETHBefore = await client.balanceQueryByAsset(userId, "ETH"); 15 | await printBalance(); 16 | const startTime = Date.now(); 17 | function elapsedSecs() { 18 | return (Date.now() - startTime) / 1000; 19 | } 20 | let count = 0; 21 | for (;;) { 22 | let promises = []; 23 | for (let i = 0; i < parallel; i++) { 24 | promises.push(putRandOrder(userId, market)); 25 | } 26 | await Promise.all(promises); 27 | if (interval > 0) { 28 | await sleep(interval); 29 | } 30 | count += 1; 31 | console.log("avg orders/s:", (parallel * count) / elapsedSecs(), "orders", parallel * count, "secs", elapsedSecs()); 32 | if (repeat != 0 && count >= repeat) { 33 | break; 34 | } 35 | } 36 | const totalTime = elapsedSecs(); 37 | await printBalance(); 38 | const USDTAfter = await client.balanceQueryByAsset(userId, "USDT"); 39 | const ETHAfter = await client.balanceQueryByAsset(userId, "ETH"); 40 | assertDecimalEqual(USDTAfter, USDTBefore); 41 | assertDecimalEqual(ETHAfter, ETHBefore); 42 | const tradeCountAfter = (await client.marketSummary(market)).trade_count; 43 | console.log("avg orders/s:", (parallel * repeat) / totalTime); 44 | console.log("avg trades/s:", (tradeCountAfter - tradeCountBefore) / totalTime); 45 | console.log("stressTest done"); 46 | } 47 | 48 | async function main() { 49 | try { 50 | await stressTest({ parallel: 500, interval: 100, repeat: 100 }); 51 | // await stressTest({ parallel: 1, interval: 500, repeat: 0 }); 52 | } catch (error) { 53 | console.error("Caught error:", error); 54 | process.exit(1); 55 | } 56 | } 57 | main(); 58 | -------------------------------------------------------------------------------- /examples/js/tests/tick.ts: -------------------------------------------------------------------------------- 1 | import { ORDER_SIDE_BID, ORDER_SIDE_ASK } from "../config"; 2 | import { defaultClient as client } from "../client"; 3 | import { sleep, getRandomFloatAround, getRandomFloatAroundNormal, getRandomElem } from "../util"; 4 | import { Account } from "fluidex.js"; 5 | import { getTestAccount } from "../accounts"; 6 | import { strict as assert } from "assert"; 7 | import { depositAssets, getPriceOfCoin, putLimitOrder } from "../exchange_helper"; 8 | 9 | const verbose = true; 10 | const botsIds = [1, 2, 3, 4, 5]; 11 | let markets: Array = []; 12 | let prices = new Map(); 13 | 14 | function businessId() { 15 | return Date.now(); 16 | } 17 | 18 | async function initClient() { 19 | await client.connect(); 20 | markets = Array.from(client.markets.keys()); 21 | } 22 | async function loadAccounts() { 23 | for (const user_id of botsIds) { 24 | let acc = Account.fromMnemonic(getTestAccount(user_id).mnemonic); 25 | console.log("acc", user_id, acc); 26 | client.addAccount(user_id, acc); 27 | } 28 | } 29 | async function registerAccounts() { 30 | for (const user_id of botsIds) { 31 | // TODO: clean codes here 32 | let acc = Account.fromMnemonic(getTestAccount(user_id).mnemonic); 33 | await client.registerUser({ 34 | user_id, 35 | l1_address: acc.ethAddr, 36 | l2_pubkey: acc.bjjPubKey, 37 | }); 38 | } 39 | } 40 | async function initAssets() { 41 | for (const user_id of botsIds) { 42 | await depositAssets({ USDT: "500000.0" }, user_id); 43 | for (const [name, info] of client.markets) { 44 | const base = info.base; 45 | const depositReq = {}; 46 | depositReq[base] = "10"; 47 | await depositAssets(depositReq, user_id); 48 | } 49 | } 50 | } 51 | function randUser() { 52 | return getRandomElem(botsIds); 53 | } 54 | 55 | async function getPrice(token: string): Promise { 56 | const price = await getPriceOfCoin(token); 57 | if (verbose) { 58 | console.log("price", token, price); 59 | } 60 | return price; 61 | } 62 | 63 | async function cancelAllForUser(user_id) { 64 | for (const [market, _] of client.markets) { 65 | console.log("cancel all", user_id, market, await client.orderCancelAll(user_id, market)); 66 | } 67 | console.log("after cancel all, balance", user_id, await client.balanceQuery(user_id)); 68 | } 69 | 70 | async function cancelAll() { 71 | for (const user_id of botsIds) { 72 | await cancelAllForUser(user_id); 73 | } 74 | } 75 | 76 | async function transferTest() { 77 | console.log("successTransferTest BEGIN"); 78 | 79 | const res1 = await client.transfer(botsIds[0], botsIds[1], "USDT", 1000); 80 | assert.equal(res1.success, true); 81 | 82 | const res2 = await client.transfer(botsIds[1], botsIds[2], "USDT", 1000); 83 | assert.equal(res2.success, true); 84 | 85 | const res3 = await client.transfer(botsIds[2], botsIds[3], "USDT", 1000); 86 | assert.equal(res3.success, true); 87 | 88 | const res4 = await client.transfer(botsIds[3], botsIds[0], "USDT", 1000); 89 | assert.equal(res4.success, true); 90 | 91 | console.log("successTransferTest END"); 92 | } 93 | 94 | async function withdrawTest() { 95 | console.log("withdrawTest BEGIN"); 96 | 97 | await client.withdraw(botsIds[0], "USDT", "withdraw", businessId(), 100, { 98 | key0: "value0", 99 | }); 100 | 101 | await client.withdraw(botsIds[1], "USDT", "withdraw", businessId(), 100, { 102 | key1: "value1", 103 | }); 104 | 105 | await client.withdraw(botsIds[2], "USDT", "withdraw", businessId(), 100, { 106 | key2: "value2", 107 | }); 108 | 109 | await client.withdraw(botsIds[3], "USDT", "withdraw", businessId(), 100, { 110 | key3: "value3", 111 | }); 112 | 113 | console.log("withdrawTest END"); 114 | } 115 | 116 | async function run() { 117 | for (let cnt = 0; ; cnt++) { 118 | try { 119 | await sleep(1000); 120 | async function tickForUser(user) { 121 | if (Math.floor(cnt / botsIds.length) % 200 == 0) { 122 | await cancelAllForUser(user); 123 | } 124 | for (let market of markets) { 125 | const price = await getPrice(market.split("_")[0]); 126 | await putLimitOrder( 127 | user, 128 | market, 129 | getRandomElem([ORDER_SIDE_BID, ORDER_SIDE_ASK]), 130 | getRandomFloatAround(0.3, 0.3), 131 | getRandomFloatAroundNormal(price) 132 | ); 133 | } 134 | } 135 | const userId = botsIds[cnt % botsIds.length]; 136 | await tickForUser(userId); 137 | } catch (e) { 138 | console.log(e); 139 | } 140 | } 141 | } 142 | async function main() { 143 | const reset = true; 144 | await loadAccounts(); 145 | await initClient(); 146 | //await cancelAll(); 147 | if (reset) { 148 | await client.debugReset(); 149 | await registerAccounts(); 150 | await initAssets(); 151 | await transferTest(); 152 | await withdrawTest(); 153 | } 154 | await run(); 155 | } 156 | main().catch(console.log); 157 | -------------------------------------------------------------------------------- /examples/js/tests/tick_no_deploy.ts: -------------------------------------------------------------------------------- 1 | import { ORDER_SIDE_BID, ORDER_SIDE_ASK } from "../config"; 2 | import { defaultClient as client } from "../client"; 3 | import { sleep, getRandomFloatAround, getRandomFloatAroundNormal, getRandomElem } from "../util"; 4 | import { Account } from "fluidex.js"; 5 | import { getTestAccount } from "../accounts"; 6 | import { strict as assert } from "assert"; 7 | import { depositAssets, getPriceOfCoin, putLimitOrder } from "../exchange_helper"; 8 | 9 | const verbose = true; 10 | const botsIds = [1, 2, 3, 4, 5]; 11 | let markets: Array = []; 12 | let prices = new Map(); 13 | 14 | function businessId() { 15 | return Date.now(); 16 | } 17 | 18 | async function initClient() { 19 | await client.connect(); 20 | markets = Array.from(client.markets.keys()); 21 | } 22 | async function loadAccounts() { 23 | for (const user_id of botsIds) { 24 | let acc = Account.fromMnemonic(getTestAccount(user_id).mnemonic); 25 | console.log("acc", user_id, acc); 26 | client.addAccount(user_id, acc); 27 | } 28 | } 29 | async function registerAccounts() { 30 | for (const user_id of botsIds) { 31 | // TODO: clean codes here 32 | let acc = Account.fromMnemonic(getTestAccount(user_id).mnemonic); 33 | await client.registerUser({ 34 | user_id, 35 | l1_address: acc.ethAddr, 36 | l2_pubkey: acc.bjjPubKey, 37 | }); 38 | } 39 | } 40 | async function initAssets() { 41 | for (const user_id of botsIds) { 42 | await depositAssets({ USDT: "500000.0" }, user_id); 43 | for (const [name, info] of client.markets) { 44 | const base = info.base; 45 | const depositReq = {}; 46 | depositReq[base] = "10"; 47 | await depositAssets(depositReq, user_id); 48 | } 49 | } 50 | } 51 | function randUser() { 52 | return getRandomElem(botsIds); 53 | } 54 | 55 | async function getPrice(token: string): Promise { 56 | const price = await getPriceOfCoin(token); 57 | if (verbose) { 58 | console.log("price", token, price); 59 | } 60 | return price; 61 | } 62 | 63 | async function cancelAllForUser(user_id) { 64 | for (const [market, _] of client.markets) { 65 | console.log("cancel all", user_id, market, await client.orderCancelAll(user_id, market)); 66 | } 67 | console.log("after cancel all, balance", user_id, await client.balanceQuery(user_id)); 68 | } 69 | 70 | async function cancelAll() { 71 | for (const user_id of botsIds) { 72 | await cancelAllForUser(user_id); 73 | } 74 | } 75 | 76 | async function transferTest() { 77 | console.log("successTransferTest BEGIN"); 78 | 79 | const res1 = await client.transfer(botsIds[0], botsIds[1], "USDT", 1000); 80 | assert.equal(res1.success, true); 81 | 82 | const res2 = await client.transfer(botsIds[1], botsIds[2], "USDT", 1000); 83 | assert.equal(res2.success, true); 84 | 85 | const res3 = await client.transfer(botsIds[2], botsIds[3], "USDT", 1000); 86 | assert.equal(res3.success, true); 87 | 88 | const res4 = await client.transfer(botsIds[3], botsIds[0], "USDT", 1000); 89 | assert.equal(res4.success, true); 90 | 91 | console.log("successTransferTest END"); 92 | } 93 | 94 | async function withdrawTest() { 95 | console.log("withdrawTest BEGIN"); 96 | 97 | await client.withdraw(botsIds[0], "USDT", "withdraw", businessId(), 100, { 98 | key0: "value0", 99 | }); 100 | 101 | await client.withdraw(botsIds[1], "USDT", "withdraw", businessId(), 100, { 102 | key1: "value1", 103 | }); 104 | 105 | await client.withdraw(botsIds[2], "USDT", "withdraw", businessId(), 100, { 106 | key2: "value2", 107 | }); 108 | 109 | await client.withdraw(botsIds[3], "USDT", "withdraw", businessId(), 100, { 110 | key3: "value3", 111 | }); 112 | 113 | console.log("withdrawTest END"); 114 | } 115 | 116 | async function run() { 117 | for (let cnt = 0; ; cnt++) { 118 | try { 119 | await sleep(1000); 120 | async function tickForUser(user) { 121 | if (Math.floor(cnt / botsIds.length) % 200 == 0) { 122 | await cancelAllForUser(user); 123 | } 124 | for (let market of markets) { 125 | const price = await getPrice(market.split("_")[0]); 126 | await putLimitOrder( 127 | user, 128 | market, 129 | getRandomElem([ORDER_SIDE_BID, ORDER_SIDE_ASK]), 130 | getRandomFloatAround(0.3, 0.3), 131 | getRandomFloatAroundNormal(price) 132 | ); 133 | } 134 | } 135 | const userId = botsIds[cnt % botsIds.length]; 136 | await tickForUser(userId); 137 | } catch (e) { 138 | console.log(e); 139 | } 140 | } 141 | } 142 | async function main() { 143 | const reset = true; 144 | await loadAccounts(); 145 | await initClient(); 146 | //await cancelAll(); 147 | if (reset) { 148 | await client.debugReset(); 149 | // await registerAccounts(); 150 | // await initAssets(); 151 | await transferTest(); 152 | await withdrawTest(); 153 | } 154 | await run(); 155 | } 156 | main().catch(console.log); 157 | -------------------------------------------------------------------------------- /examples/js/tests/trade.ts: -------------------------------------------------------------------------------- 1 | import { userId, base, quote, market, fee, ORDER_SIDE_BID, ORDER_SIDE_ASK, ORDER_TYPE_MARKET, ORDER_TYPE_LIMIT } from "../config"; // dotenv 2 | import { getTestAccount } from "../accounts"; 3 | import { defaultClient as client } from "../client"; 4 | import { sleep, assertDecimalEqual } from "../util"; 5 | import { depositAssets } from "../exchange_helper"; 6 | import { KafkaConsumer } from "../kafka_client"; 7 | 8 | import { Account } from "fluidex.js"; 9 | import Decimal from "decimal.js"; 10 | import { strict as assert } from "assert"; 11 | import whynoderun from "why-is-node-running"; 12 | 13 | const askUser = userId; 14 | const bidUser = userId + 1; 15 | 16 | async function infoList() { 17 | console.log(await client.assetList()); 18 | console.log(await client.marketList()); 19 | console.log(await client.marketSummary(market)); 20 | } 21 | 22 | async function initAccounts() { 23 | await client.connect(); 24 | for (let user_id = 1; user_id <= bidUser; user_id++) { 25 | let acc = Account.fromMnemonic(getTestAccount(user_id).mnemonic); 26 | client.addAccount(user_id, acc); 27 | await client.client.RegisterUser({ 28 | user_id, 29 | l1_address: acc.ethAddr, 30 | l2_pubkey: acc.bjjPubKey, 31 | }); 32 | } 33 | } 34 | 35 | async function setupAsset() { 36 | // check balance is zero 37 | const balance1 = await client.balanceQuery(askUser); 38 | let usdtBalance = balance1.get("USDT"); 39 | let ethBalance = balance1.get("ETH"); 40 | assertDecimalEqual(usdtBalance.available, "0"); 41 | assertDecimalEqual(usdtBalance.frozen, "0"); 42 | assertDecimalEqual(ethBalance.available, "0"); 43 | assertDecimalEqual(ethBalance.frozen, "0"); 44 | 45 | await depositAssets({ USDT: "100.0", ETH: "50.0" }, askUser); 46 | 47 | // check deposit success 48 | const balance2 = await client.balanceQuery(askUser); 49 | usdtBalance = balance2.get("USDT"); 50 | ethBalance = balance2.get("ETH"); 51 | console.log(usdtBalance); 52 | assertDecimalEqual(usdtBalance.available, "100"); 53 | assertDecimalEqual(usdtBalance.frozen, "0"); 54 | assertDecimalEqual(ethBalance.available, "50"); 55 | assertDecimalEqual(ethBalance.frozen, "0"); 56 | 57 | await depositAssets({ USDT: "100.0", ETH: "50.0" }, bidUser); 58 | } 59 | 60 | // Test order put and cancel 61 | async function orderTest() { 62 | const order = await client.orderPut(askUser, market, ORDER_SIDE_BID, ORDER_TYPE_LIMIT, /*amount*/ "10", /*price*/ "1.1", fee, fee); 63 | console.log(order); 64 | const balance3 = await client.balanceQueryByAsset(askUser, "USDT"); 65 | assertDecimalEqual(balance3.available, "89"); 66 | assertDecimalEqual(balance3.frozen, "11"); 67 | 68 | const orderPending = await client.orderDetail(market, order.id); 69 | assert.deepEqual(orderPending, order); 70 | 71 | const summary = await client.marketSummary(market); 72 | assertDecimalEqual(summary.bid_amount, "10"); 73 | assert.equal(summary.bid_count, 1); 74 | 75 | const depth = await client.orderDepth(market, 100, /*not merge*/ "0"); 76 | assert.deepEqual(depth, { 77 | asks: [], 78 | bids: [{ price: "1.10", amount: "10.0000" }], 79 | }); 80 | 81 | await client.orderCancel(askUser, market, 1); 82 | const balance4 = await client.balanceQueryByAsset(askUser, "USDT"); 83 | assertDecimalEqual(balance4.available, "100"); 84 | assertDecimalEqual(balance4.frozen, "0"); 85 | 86 | console.log("orderTest passed"); 87 | } 88 | 89 | // Test order trading 90 | async function tradeTest() { 91 | const askOrder = await client.orderPut(askUser, market, ORDER_SIDE_ASK, ORDER_TYPE_LIMIT, /*amount*/ "4", /*price*/ "1.1", fee, fee); 92 | const bidOrder = await client.orderPut(bidUser, market, ORDER_SIDE_BID, ORDER_TYPE_LIMIT, /*amount*/ "10", /*price*/ "1.1", fee, fee); 93 | console.log("ask order id", askOrder.id); 94 | console.log("bid order id", bidOrder.id); 95 | await testStatusAfterTrade(askOrder.id, bidOrder.id); 96 | 97 | const testReload = false; 98 | if (testReload) { 99 | await client.debugReload(); 100 | await testStatusAfterTrade(askOrder.id, bidOrder.id); 101 | } 102 | 103 | console.log("tradeTest passed!"); 104 | return [askOrder.id, bidOrder.id]; 105 | } 106 | 107 | async function testStatusAfterTrade(askOrderId, bidOrderId) { 108 | const bidOrderPending = await client.orderDetail(market, bidOrderId); 109 | assertDecimalEqual(bidOrderPending.remain, "6"); 110 | 111 | // Now, the `askOrder` will be matched and traded 112 | // So it will not be kept by the match engine 113 | await assert.rejects(async () => { 114 | const askOrderPending = await client.orderDetail(market, askOrderId); 115 | console.log(askOrderPending); 116 | }, /invalid order_id/); 117 | 118 | // should check trade price is 1.1 rather than 1.0 here. 119 | const summary = await client.marketSummary(market); 120 | assertDecimalEqual(summary.bid_amount, "6"); 121 | assert.equal(summary.bid_count, 1); 122 | 123 | const depth = await client.orderDepth(market, 100, /*not merge*/ "0"); 124 | //assert.deepEqual(depth, { asks: [], bids: [{ price: "1.1", amount: "6" }] }); 125 | //assert.deepEqual(depth, { asks: [], bids: [{ price: "1.1", amount: "6" }] }); 126 | // 4 * 1.1 sell, filled 4 127 | const balance1 = await client.balanceQuery(askUser); 128 | let usdtBalance = balance1.get("USDT"); 129 | let ethBalance = balance1.get("ETH"); 130 | assertDecimalEqual(usdtBalance.available, "104.4"); 131 | assertDecimalEqual(usdtBalance.frozen, "0"); 132 | assertDecimalEqual(ethBalance.available, "46"); 133 | assertDecimalEqual(ethBalance.frozen, "0"); 134 | // 10 * 1.1 buy, filled 4 135 | const balance2 = await client.balanceQuery(bidUser); 136 | usdtBalance = balance2.get("USDT"); 137 | ethBalance = balance2.get("ETH"); 138 | assertDecimalEqual(usdtBalance.available, "89"); 139 | assertDecimalEqual(usdtBalance.frozen, "6.6"); 140 | assertDecimalEqual(ethBalance.available, "54"); 141 | assertDecimalEqual(ethBalance.frozen, "0"); 142 | } 143 | 144 | async function simpleTest() { 145 | await initAccounts(); 146 | await setupAsset(); 147 | await orderTest(); 148 | return await tradeTest(); 149 | } 150 | 151 | function checkMessages(messages) { 152 | // TODO: more careful check 153 | assert.equal(messages.get("orders").length, 5); 154 | assert.equal(messages.get("balances").length, 8); 155 | assert.equal(messages.get("trades").length, 1); 156 | } 157 | 158 | async function mainTest(withMQ) { 159 | await client.debugReset(); 160 | 161 | let kafkaConsumer: KafkaConsumer; 162 | if (withMQ) { 163 | kafkaConsumer = new KafkaConsumer(); 164 | kafkaConsumer.Init(); 165 | } 166 | const [askOrderId, bidOrderId] = await simpleTest(); 167 | if (withMQ) { 168 | await sleep(3 * 1000); 169 | const messages = kafkaConsumer.GetAllMessages(); 170 | console.log(messages); 171 | await kafkaConsumer.Stop(); 172 | checkMessages(messages); 173 | } 174 | } 175 | 176 | async function main() { 177 | try { 178 | await mainTest(!!process.env.TEST_MQ || false); 179 | } catch (error) { 180 | console.error("Caught error:", error); 181 | process.exit(1); 182 | } 183 | } 184 | main(); 185 | -------------------------------------------------------------------------------- /examples/js/tests/transfer.ts: -------------------------------------------------------------------------------- 1 | import { userId } from "../config"; // dotenv 2 | import { defaultClient as client } from "../client"; 3 | import { defaultRESTClient as rest_client } from "../RESTClient"; 4 | import { assertDecimalEqual, sleep } from "../util"; 5 | 6 | import { strict as assert } from "assert"; 7 | import { depositAssets } from "../exchange_helper"; 8 | 9 | const anotherUserId = userId + 10; 10 | 11 | async function setupAsset() { 12 | await depositAssets({ ETH: "100.0" }, userId); 13 | 14 | const balance1 = await client.balanceQueryByAsset(userId, "ETH"); 15 | assertDecimalEqual(balance1.available, "100"); 16 | const balance2 = await client.balanceQueryByAsset(anotherUserId, "ETH"); 17 | assertDecimalEqual(balance2.available, "0"); 18 | } 19 | 20 | async function registerUsers() { 21 | for (let i = 1; i <= anotherUserId; i++) { 22 | await client.registerUser({ 23 | id: i, 24 | l1_address: "l1_address_" + i, 25 | l2_pubkey: "l2_pubkey_" + i, 26 | }); 27 | console.log("register user", i); 28 | } 29 | } 30 | 31 | // Test failure with argument delta of value zero 32 | async function failureWithZeroDeltaTest() { 33 | const res = await client.transfer(userId, anotherUserId, "ETH", 0); 34 | 35 | assert.equal(res.success, false); 36 | assert.equal(res.asset, "ETH"); 37 | assertDecimalEqual(res.balance_from, "100"); 38 | 39 | const balance1 = await client.balanceQueryByAsset(userId, "ETH"); 40 | assertDecimalEqual(balance1.available, "100"); 41 | const balance2 = await client.balanceQueryByAsset(anotherUserId, "ETH"); 42 | assertDecimalEqual(balance2.available, "0"); 43 | 44 | console.log("failureWithZeroDeltaTest passed"); 45 | } 46 | 47 | // Test failure with insufficient balance of from user 48 | async function failureWithInsufficientFromBalanceTest() { 49 | const res = await client.transfer(userId, anotherUserId, "ETH", 101); 50 | 51 | assert.equal(res.success, false); 52 | assert.equal(res.asset, "ETH"); 53 | assertDecimalEqual(res.balance_from, "100"); 54 | 55 | const balance1 = await client.balanceQueryByAsset(userId, "ETH"); 56 | assertDecimalEqual(balance1.available, "100"); 57 | const balance2 = await client.balanceQueryByAsset(anotherUserId, "ETH"); 58 | assertDecimalEqual(balance2.available, "0"); 59 | 60 | console.log("failureWithInsufficientFromBalanceTest passed"); 61 | } 62 | 63 | // Test success transfer 64 | async function successTransferTest() { 65 | const res = await client.transfer(userId, anotherUserId, "ETH", 50); 66 | 67 | assert.equal(res.success, true); 68 | assert.equal(res.asset, "ETH"); 69 | assertDecimalEqual(res.balance_from, "50"); 70 | 71 | const balance1 = await client.balanceQueryByAsset(userId, "ETH"); 72 | assertDecimalEqual(balance1.available, "50"); 73 | const balance2 = await client.balanceQueryByAsset(anotherUserId, "ETH"); 74 | assertDecimalEqual(balance2.available, "50"); 75 | 76 | console.log("successTransferTest passed"); 77 | } 78 | 79 | async function listTxs() { 80 | const res1 = (await rest_client.internal_txs(userId))[0]; 81 | const res2 = (await rest_client.internal_txs(anotherUserId))[0]; 82 | console.log(res1, res2); 83 | assert.equal(res1.amount, res2.amount); 84 | assert.equal(res1.asset, res2.asset); 85 | assert.equal(res1.time, res2.time); 86 | assert.equal(res1.user_from, res2.user_from); 87 | assert.equal(res1.user_to, res2.user_to); 88 | } 89 | 90 | async function simpleTest() { 91 | await setupAsset(); 92 | await registerUsers(); 93 | await failureWithZeroDeltaTest(); 94 | await failureWithInsufficientFromBalanceTest(); 95 | await successTransferTest(); 96 | await sleep(3 * 1000); 97 | await listTxs(); 98 | } 99 | 100 | async function mainTest() { 101 | await client.debugReset(); 102 | await simpleTest(); 103 | } 104 | 105 | async function main() { 106 | try { 107 | await mainTest(); 108 | } catch (error) { 109 | console.error("Caught error:", error); 110 | process.exit(1); 111 | } 112 | } 113 | main(); 114 | -------------------------------------------------------------------------------- /examples/js/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "outDir": "./dist/", 5 | "sourceMap": true, 6 | "lib": ["ES2020", "DOM"], 7 | "target": "ES2020", 8 | "resolveJsonModule": true, 9 | "moduleResolution": "node", 10 | "downlevelIteration": true, 11 | "module": "commonjs" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /examples/js/util.ts: -------------------------------------------------------------------------------- 1 | import { userId, fee, ORDER_SIDE_BID, ORDER_SIDE_ASK, ORDER_TYPE_MARKET, ORDER_TYPE_LIMIT, VERBOSE } from "./config"; // dotenv 2 | 3 | import Decimal from "decimal.js"; 4 | let gaussian = require("gaussian"); 5 | import { strict as assert } from "assert"; 6 | import axios from "axios"; 7 | 8 | export function decimalEqual(a, b): boolean { 9 | return new Decimal(a).equals(new Decimal(b)); 10 | } 11 | 12 | export function assertDecimalEqual(result, gt) { 13 | assert(decimalEqual(result, gt), `${result} != ${gt}`); 14 | } 15 | 16 | export function decimalAdd(a, b) { 17 | return new Decimal(a).add(new Decimal(b)); 18 | } 19 | 20 | export function getRandomFloat(min, max) { 21 | return Math.random() * (max - min) + min; 22 | } 23 | export function getRandomFloatAroundNormal(value, stddev_ratio = 0.02) { 24 | let distribution = gaussian(value, value * stddev_ratio); 25 | // Take a random sample using inverse transform sampling method. 26 | let sample = distribution.ppf(Math.random()); 27 | return sample; 28 | } 29 | export function getRandomFloatAround(value, ratio = 0.05, abs = 0) { 30 | const eps1 = getRandomFloat(-abs, abs); 31 | const eps2 = getRandomFloat(-value * ratio, value * ratio); 32 | return value + eps1 + eps2; 33 | } 34 | export function getRandomInt(min, max) { 35 | min = Math.ceil(min); 36 | max = Math.floor(max); 37 | return Math.floor(Math.random() * (max - min)) + min; 38 | } 39 | export function getRandomElem(arr: Array): T { 40 | return arr[Math.floor(Math.random() * arr.length)]; 41 | } 42 | 43 | export function sleep(ms) { 44 | return new Promise(resolve => setTimeout(resolve, ms)); 45 | } 46 | -------------------------------------------------------------------------------- /examples/python/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | -------------------------------------------------------------------------------- /examples/python/gen_grpc.sh: -------------------------------------------------------------------------------- 1 | python3 -m grpc_tools.protoc -I../js --python_out=. --grpc_python_out=. ordersigner.proto 2 | python3 -m grpc_tools.protoc -I../../orchestra/proto/exchange -I../../orchestra/proto/third_party/googleapis/ --python_out=. --grpc_python_out=. matchengine.proto 3 | -------------------------------------------------------------------------------- /examples/python/ordersigner_pb2_grpc.py: -------------------------------------------------------------------------------- 1 | # Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! 2 | """Client and server classes corresponding to protobuf-defined services.""" 3 | import grpc 4 | 5 | import ordersigner_pb2 as ordersigner__pb2 6 | 7 | 8 | class OrderSignerStub(object): 9 | """Missing associated documentation comment in .proto file.""" 10 | 11 | def __init__(self, channel): 12 | """Constructor. 13 | 14 | Args: 15 | channel: A grpc.Channel. 16 | """ 17 | self.SignOrder = channel.unary_unary( 18 | '/ordersigner.OrderSigner/SignOrder', 19 | request_serializer=ordersigner__pb2.SignOrderRequest.SerializeToString, 20 | response_deserializer=ordersigner__pb2.SignOrderResponse.FromString, 21 | ) 22 | 23 | 24 | class OrderSignerServicer(object): 25 | """Missing associated documentation comment in .proto file.""" 26 | 27 | def SignOrder(self, request, context): 28 | """Missing associated documentation comment in .proto file.""" 29 | context.set_code(grpc.StatusCode.UNIMPLEMENTED) 30 | context.set_details('Method not implemented!') 31 | raise NotImplementedError('Method not implemented!') 32 | 33 | 34 | def add_OrderSignerServicer_to_server(servicer, server): 35 | rpc_method_handlers = { 36 | 'SignOrder': grpc.unary_unary_rpc_method_handler( 37 | servicer.SignOrder, 38 | request_deserializer=ordersigner__pb2.SignOrderRequest.FromString, 39 | response_serializer=ordersigner__pb2.SignOrderResponse.SerializeToString, 40 | ), 41 | } 42 | generic_handler = grpc.method_handlers_generic_handler( 43 | 'ordersigner.OrderSigner', rpc_method_handlers) 44 | server.add_generic_rpc_handlers((generic_handler,)) 45 | 46 | 47 | # This class is part of an EXPERIMENTAL API. 48 | class OrderSigner(object): 49 | """Missing associated documentation comment in .proto file.""" 50 | 51 | @staticmethod 52 | def SignOrder(request, 53 | target, 54 | options=(), 55 | channel_credentials=None, 56 | call_credentials=None, 57 | insecure=False, 58 | compression=None, 59 | wait_for_ready=None, 60 | timeout=None, 61 | metadata=None): 62 | return grpc.experimental.unary_unary(request, target, '/ordersigner.OrderSigner/SignOrder', 63 | ordersigner__pb2.SignOrderRequest.SerializeToString, 64 | ordersigner__pb2.SignOrderResponse.FromString, 65 | options, channel_credentials, 66 | insecure, call_credentials, compression, wait_for_ready, timeout, metadata) 67 | -------------------------------------------------------------------------------- /examples/python/put_order.py: -------------------------------------------------------------------------------- 1 | import matchengine_pb2_grpc 2 | import matchengine_pb2 3 | import ordersigner_pb2_grpc 4 | import ordersigner_pb2 5 | import grpc 6 | 7 | uid = 11 8 | 9 | 10 | signer_channel = grpc.insecure_channel('localhost:50061') 11 | signer_stub = ordersigner_pb2_grpc.OrderSignerStub(signer_channel) 12 | 13 | 14 | matchengine_channel = grpc.insecure_channel('localhost:50051') 15 | matchengine_stub = matchengine_pb2_grpc.MatchengineStub(matchengine_channel) 16 | 17 | 18 | def put_order(): 19 | uid = 3 20 | order = ordersigner_pb2.SignOrderRequest( 21 | user_id=uid, 22 | market='ETH_USDT', 23 | order_side=ordersigner_pb2.OrderSide.BID, 24 | order_type=ordersigner_pb2.OrderType.LIMIT, 25 | amount="1", 26 | price="1000", 27 | ) 28 | sig = signer_stub.SignOrder(order).signature 29 | order_final = matchengine_pb2.OrderPutRequest( 30 | 31 | user_id=uid, 32 | market='ETH_USDT', 33 | order_side=matchengine_pb2.OrderSide.BID, 34 | order_type=matchengine_pb2.OrderType.LIMIT, 35 | amount="1", 36 | price="1000", 37 | signature=sig 38 | ) 39 | res = matchengine_stub.OrderPut(order_final) 40 | print(res) 41 | 42 | 43 | put_order() 44 | -------------------------------------------------------------------------------- /examples/python/requirements.txt: -------------------------------------------------------------------------------- 1 | grpcio-tools 2 | #grpc 3 | grpcio 4 | google-api-python-client 5 | -------------------------------------------------------------------------------- /migrations/20200123090047_trade_log.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE balance_slice ( 2 | id SERIAL PRIMARY KEY, 3 | slice_id BIGINT NOT NULL, 4 | user_id INT CHECK (user_id >= 0) NOT NULL, 5 | asset VARCHAR(30) NOT NULL, 6 | t SMALLINT CHECK (t >= 0) NOT NULL, 7 | balance DECIMAL(30, 16) NOT NULL 8 | ); 9 | 10 | CREATE TABLE order_slice ( 11 | id BIGINT CHECK (id >= 0) NOT NULL, 12 | slice_id BIGINT NOT NULL, 13 | order_type VARCHAR(30) NOT NULL, 14 | order_side VARCHAR(30) NOT NULL, 15 | create_time TIMESTAMP(0) NOT NULL, 16 | update_time TIMESTAMP(0) NOT NULL, 17 | user_id INT CHECK (user_id >= 0) NOT NULL, 18 | market VARCHAR(30) NOT NULL, 19 | price DECIMAL(30, 8) NOT NULL, 20 | amount DECIMAL(30, 8) NOT NULL, 21 | taker_fee DECIMAL(30, 4) NOT NULL, 22 | maker_fee DECIMAL(30, 4) NOT NULL, 23 | remain DECIMAL(30, 8) NOT NULL, 24 | frozen DECIMAL(30, 8) NOT NULL, 25 | finished_base DECIMAL(30, 8) NOT NULL, 26 | finished_quote DECIMAL(30, 16) NOT NULL, 27 | finished_fee DECIMAL(30, 12) NOT NULL, 28 | post_only BOOL NOT NULL DEFAULT 'false', 29 | signature BYTEA NOT NULL, 30 | PRIMARY KEY (slice_id, id) 31 | ); 32 | 33 | CREATE TABLE slice_history ( 34 | id SERIAL PRIMARY KEY, 35 | time BIGINT NOT NULL, 36 | end_operation_log_id BIGINT CHECK (end_operation_log_id >= 0) NOT NULL, 37 | end_order_id BIGINT CHECK (end_order_id >= 0) NOT NULL, 38 | end_trade_id BIGINT CHECK (end_trade_id >= 0) NOT NULL 39 | ); 40 | 41 | CREATE TABLE operation_log ( 42 | id BIGINT CHECK (id >= 0) NOT NULL PRIMARY KEY, 43 | time TIMESTAMP(0) NOT NULL, 44 | method TEXT NOT NULL, 45 | params TEXT NOT NULL 46 | ); 47 | 48 | -------------------------------------------------------------------------------- /migrations/20200123090258_trade_history.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE balance_history ( 2 | id SERIAL PRIMARY KEY, 3 | time TIMESTAMP(0) NOT NULL, 4 | user_id INT CHECK (user_id >= 0) NOT NULL, 5 | business_id BIGINT CHECK (business_id >= 0) NOT NULL, 6 | asset VARCHAR(30) NOT NULL, 7 | business VARCHAR(30) NOT NULL, 8 | market_price DECIMAL(30, 8) NOT NULL, 9 | change DECIMAL(30, 8) NOT NULL, 10 | balance DECIMAL(30, 16) NOT NULL, 11 | balance_available DECIMAL(30, 16) NOT NULL, 12 | balance_frozen DECIMAL(30, 16) NOT NULL, 13 | detail TEXT NOT NULL, 14 | signature BYTEA NOT NULL 15 | ); 16 | 17 | CREATE INDEX balance_history_idx_user_asset ON balance_history (user_id, asset); 18 | 19 | CREATE INDEX balance_history_idx_user_business ON balance_history (business_id, business); 20 | 21 | CREATE INDEX balance_history_idx_user_asset_business ON balance_history (user_id, asset, business, business_id); 22 | 23 | CREATE TYPE order_status AS ENUM('active','filled','cancelled', 'expired'); 24 | 25 | CREATE TABLE order_history ( 26 | id BIGINT CHECK (id >= 0) NOT NULL PRIMARY KEY, 27 | create_time TIMESTAMP(0) NOT NULL, 28 | finish_time TIMESTAMP(0) NOT NULL, 29 | user_id INT CHECK (user_id >= 0) NOT NULL, 30 | market VARCHAR(30) NOT NULL, 31 | order_type VARCHAR(30) NOT NULL, 32 | order_side VARCHAR(30) NOT NULL, 33 | price DECIMAL(30, 8) NOT NULL, 34 | amount DECIMAL(30, 8) NOT NULL, 35 | taker_fee DECIMAL(30, 4) NOT NULL, 36 | maker_fee DECIMAL(30, 4) NOT NULL, 37 | finished_base DECIMAL(30, 8) NOT NULL, 38 | finished_quote DECIMAL(30, 16) NOT NULL, 39 | finished_fee DECIMAL(30, 16) NOT NULL, 40 | status order_status NOT NULL DEFAULT 'filled', 41 | post_only BOOL NOT NULL DEFAULT 'false', 42 | signature BYTEA NOT NULL 43 | ); 44 | 45 | CREATE INDEX order_history_idx_user_market ON order_history (user_id, market); 46 | 47 | CREATE TABLE user_trade ( 48 | id SERIAL PRIMARY KEY, 49 | time TIMESTAMP(0) NOT NULL, 50 | user_id INT CHECK (user_id >= 0) NOT NULL, 51 | market VARCHAR(30) NOT NULL, 52 | trade_id BIGINT CHECK (trade_id >= 0) NOT NULL, 53 | order_id BIGINT CHECK (order_id >= 0) NOT NULL, 54 | counter_order_id BIGINT CHECK (counter_order_id >= 0) NOT NULL, 55 | side SMALLINT CHECK (side >= 0) NOT NULL, 56 | role SMALLINT CHECK (ROLE >= 0) NOT NULL, 57 | price DECIMAL(30, 8) NOT NULL, 58 | amount DECIMAL(30, 8) NOT NULL, 59 | quote_amount DECIMAL(30, 16) NOT NULL, 60 | fee DECIMAL(30, 16) NOT NULL, 61 | counter_order_fee DECIMAL(30, 16) NOT NULL 62 | ); 63 | 64 | CREATE INDEX user_trade_idx_user_market ON user_trade (user_id, market); 65 | 66 | -------------------------------------------------------------------------------- /migrations/20210114140803_kline.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | --CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE; 3 | CREATE TABLE market_trade ( 4 | time TIMESTAMP(0) NOT NULL, 5 | market VARCHAR(30) NOT NULL, 6 | trade_id BIGINT CHECK (trade_id >= 0) NOT NULL, 7 | price DECIMAL(30, 8) NOT NULL, 8 | amount DECIMAL(30, 8) NOT NULL, 9 | quote_amount DECIMAL(30, 8) NOT NULL, 10 | taker_side VARCHAR(30) NOT NULL 11 | ); 12 | 13 | CREATE INDEX market_trade_idx_market ON market_trade (market, time DESC); 14 | 15 | SELECT create_hypertable('market_trade', 'time'); 16 | -------------------------------------------------------------------------------- /migrations/20210223025223_markets.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | CREATE TABLE asset ( 3 | id VARCHAR(64) NOT NULL PRIMARY KEY, 4 | symbol VARCHAR(30) NOT NULL DEFAULT '', 5 | name VARCHAR(30) NOT NULL DEFAULT '', 6 | chain_id SMALLINT CHECK (chain_id >= 0) NOT NULL DEFAULT 1, -- we actually only have one same chain_id for all records 7 | token_address VARCHAR(64) NOT NULL DEFAULT '', 8 | rollup_token_id integer CHECK (rollup_token_id >= 0) NOT NULL, 9 | -- token_address VARCHAR(64) DEFAULT NULL, 10 | -- UNIQUE (chain_id, token_address), 11 | UNIQUE (chain_id, rollup_token_id), 12 | precision_stor SMALLINT CHECK (precision_stor >= 0) NOT NULL, 13 | precision_show SMALLINT CHECK (precision_show >= 0) NOT NULL, 14 | logo_uri VARCHAR(256) NOT NULL DEFAULT '', 15 | create_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP 16 | ); 17 | 18 | CREATE TABLE market ( 19 | id SERIAL PRIMARY KEY, 20 | create_time TIMESTAMP(0) DEFAULT CURRENT_TIMESTAMP, 21 | base_asset VARCHAR(30) NOT NULL REFERENCES asset(id) ON DELETE RESTRICT, 22 | quote_asset VARCHAR(30) NOT NULL REFERENCES asset(id) ON DELETE RESTRICT, 23 | precision_amount SMALLINT CHECK (precision_amount >= 0) NOT NULL, 24 | precision_price SMALLINT CHECK (precision_price >= 0) NOT NULL, 25 | precision_fee SMALLINT CHECK (precision_fee >= 0) NOT NULL, 26 | min_amount DECIMAL(16, 16) NOT NULL, 27 | market_name VARCHAR(30) 28 | ); 29 | -------------------------------------------------------------------------------- /migrations/20210223072038_markets_preset.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | 3 | insert into asset (id, symbol, name, token_address, rollup_token_id, precision_stor, precision_show) values 4 | ('ETH', 'ETH', 'Ether', '', 0, 6, 6), 5 | ('USDT', 'USDT', 'Tether USD', '0xdAC17F958D2ee523a2206206994597C13D831ec7', 1, 6, 6), 6 | ('UNI', 'UNI', 'Uniswap', '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984', 2, 6, 6), 7 | ('LINK', 'LINK', 'ChainLink Token', '0x514910771af9ca656af840dff83e8264ecf986ca', 3, 6, 6), 8 | ('YFI', 'YFI', 'yearn.finance', '0x0bc529c00C6401aEF6D220BE8C6Ea1667F6Ad93e', 4, 6, 6), 9 | ('MATIC', 'MATIC', 'Matic Token', '0x7d1afa7b718fb893db30a3abc0cfc608aacfebb0', 5, 6, 6) 10 | ; 11 | 12 | -- Fee is disabled 13 | insert into market (base_asset, quote_asset, precision_amount, precision_price, precision_fee, min_amount) values 14 | ('ETH', 'USDT', 4, 2, 0, 0.001), 15 | ('UNI', 'USDT', 4, 2, 0, 0.001), 16 | ('LINK', 'USDT', 4, 2, 0, 0.001), 17 | ('MATIC', 'USDT', 4, 2, 0, 0.001) 18 | ; 19 | -------------------------------------------------------------------------------- /migrations/20210310150412_market_constraint.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | 3 | CREATE UNIQUE INDEX market_name_constraint ON market (market_name) WHERE market_name IS NOT NULL; 4 | CREATE UNIQUE INDEX market_pair_constraint_when_null ON market (base_asset, quote_asset) WHERE market_name IS NULL; 5 | -------------------------------------------------------------------------------- /migrations/20210514140412_account.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | 3 | CREATE TABLE account ( 4 | id INT CHECK (id >= 1) NOT NULL PRIMARY KEY, -- need to be consistent with rollup account_id 5 | l1_address VARCHAR(42) NOT NULL DEFAULT '', 6 | l2_pubkey VARCHAR(66) NOT NULL DEFAULT '' 7 | ); 8 | 9 | CREATE INDEX account_l1_address ON account (l1_address); 10 | CREATE INDEX account_l2_pubkey ON account (l2_pubkey); 11 | -------------------------------------------------------------------------------- /migrations/20210607094808_internal_transfer.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | CREATE TABLE internal_tx ( 3 | time TIMESTAMP(0) NOT NULL, 4 | user_from INT CHECK (user_from >= 0) NOT NULL, 5 | user_to INT CHECK (user_to >= 0) NOT NULL, 6 | asset VARCHAR(30) NOT NULL REFERENCES asset(id), 7 | amount DECIMAL(30, 8) CHECK (amount > 0) NOT NULL, 8 | signature BYTEA NOT NULL 9 | ); 10 | 11 | CREATE INDEX internal_tx_idx_to_time ON internal_tx (user_to, time DESC); 12 | CREATE INDEX internal_tx_idx_from_time ON internal_tx (user_from, time DESC); 13 | 14 | SELECT create_hypertable('internal_tx', 'time'); 15 | -------------------------------------------------------------------------------- /migrations/reset/down.sql: -------------------------------------------------------------------------------- 1 | DROP SCHEMA public CASCADE; -- will drop timescaledb 2 | DROP EXTENSION IF EXISTS timescaledb CASCADE; -------------------------------------------------------------------------------- /migrations/reset/up.sql: -------------------------------------------------------------------------------- 1 | CREATE SCHEMA public; 2 | GRANT ALL ON SCHEMA public TO public; 3 | CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE; 4 | -------------------------------------------------------------------------------- /migrations/ts/20210114140803_kline.sql: -------------------------------------------------------------------------------- 1 | -- Add migration script here 2 | --CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE; 3 | CREATE TABLE market_trade ( 4 | time TIMESTAMP(0) NOT NULL, 5 | market VARCHAR(30) NOT NULL, 6 | trade_id BIGINT CHECK (trade_id >= 0) NOT NULL, 7 | price DECIMAL(30, 8) NOT NULL, 8 | amount DECIMAL(30, 8) NOT NULL, 9 | quote_amount DECIMAL(30, 8) NOT NULL, 10 | taker_side VARCHAR(30) NOT NULL 11 | ); 12 | 13 | CREATE INDEX market_trade_idx_market ON market_trade (market, time DESC); 14 | 15 | SELECT create_hypertable('market_trade', 'time'); 16 | -------------------------------------------------------------------------------- /release/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:20.04 2 | 3 | COPY config /config 4 | COPY target/x86_64-unknown-linux-gnu/release/matchengine /usr/bin/ 5 | 6 | CMD 'matchengine' 7 | -------------------------------------------------------------------------------- /release/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -u 3 | 4 | if [ $# -ne 2 ] 5 | then 6 | echo "Usage: $0 docker-registry image-tag" 7 | exit 1 8 | fi 9 | 10 | DOCKER_IMAGE_NAME='dingir-exchange-matchengine' 11 | DOCKER_TARGET="$1/$DOCKER_IMAGE_NAME:$2" 12 | 13 | function run() { 14 | install_cross 15 | build_release 16 | docker_build 17 | help_info 18 | } 19 | 20 | function install_cross() { 21 | echo 'install cross - https://github.com/rust-embedded/cross' 22 | cargo install cross 23 | } 24 | 25 | function build_release() { 26 | echo 'build a release for target x86_64-unknown-linux-gnu' 27 | RUSTFLAGS="-C link-arg=-static -C target-feature=+crt-static" cross build --bin matchengine --target x86_64-unknown-linux-gnu --release 28 | } 29 | 30 | function docker_build() { 31 | echo "docker build a image $DOCKER_TARGET" 32 | docker build -t $DOCKER_TARGET -f release/Dockerfile . 33 | } 34 | 35 | function help_info() { 36 | echo "Push to Docker Registry: docker push $DOCKER_TARGET" 37 | echo "Run a new Docker Container: docker run $DOCKER_TARGET" 38 | } 39 | 40 | run 41 | -------------------------------------------------------------------------------- /rust-toolchain: -------------------------------------------------------------------------------- 1 | 1.56.0 2 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | max_width = 140 3 | -------------------------------------------------------------------------------- /scripts/install_all_deps.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xu 3 | 4 | function install_rust() { 5 | echo 'install rust' 6 | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh 7 | } 8 | 9 | function install_docker() { 10 | echo 'install docker' 11 | curl -fsSL https://get.docker.com | bash 12 | sudo groupadd docker 13 | sudo usermod -aG docker $USER 14 | newgrp docker 15 | sudo systemctl start docker 16 | sudo systemctl enable docker 17 | } 18 | 19 | function install_docker_compose() { 20 | echo 'install docker compose' 21 | sudo curl -L "https://github.com/docker/compose/releases/download/1.28.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose 22 | sudo chmod +x /usr/local/bin/docker-compose 23 | } 24 | 25 | function install_node() { 26 | echo 'install node' 27 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.37.2/install.sh | bash 28 | export NVM_DIR="$HOME/.nvm" 29 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm 30 | nvm install 'lts/*' 31 | nvm use 'lts/*' 32 | npm install --global yarn 33 | } 34 | 35 | function install_sys_deps() { 36 | echo 'install system deps' 37 | sudo apt install libpq-dev cmake gcc g++ postgresql-client-12 38 | } 39 | 40 | function install_dev_deps() { 41 | echo 'install some useful tools for development' 42 | sudo apt install pkg-config libssl-dev 43 | } 44 | 45 | function install_all() { 46 | install_sys_deps 47 | install_rust 48 | install_docker 49 | install_docker_compose 50 | install_node 51 | } 52 | 53 | install_all 54 | -------------------------------------------------------------------------------- /src/bin/dump_unify_messages.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | #![allow(clippy::collapsible_if)] 3 | #![allow(clippy::let_and_return)] 4 | #![allow(clippy::too_many_arguments)] 5 | #![allow(clippy::single_char_pattern)] 6 | 7 | use std::fs::File; 8 | use std::io::Write; 9 | use std::sync::Mutex; 10 | 11 | use dingir_exchange::{config, message}; 12 | use message::consumer::{Simple, SimpleConsumer, SimpleMessageHandler}; 13 | 14 | use fluidex_common::non_blocking_tracing; 15 | use fluidex_common::rdkafka::consumer::StreamConsumer; 16 | use fluidex_common::rdkafka::message::{BorrowedMessage, Message}; 17 | 18 | fn get_msg_tag_from_topic(t: &str) -> Option<&'static str> { 19 | Some(match t { 20 | "deposits" => "DepositMessage", 21 | "internaltransfer" => "TransferMessage", 22 | "orders" => "OrderMessage", 23 | "registeruser" => "UserMessage", 24 | "trades" => "TradeMessage", 25 | "withdraws" => "WithdrawMessage", 26 | _ => { 27 | println!("skip msg of type {}", t); 28 | return None; 29 | } 30 | }) 31 | } 32 | 33 | struct MessageWriter { 34 | out_file: Mutex, 35 | } 36 | 37 | impl SimpleMessageHandler for &MessageWriter { 38 | fn on_message(&self, msg: &BorrowedMessage<'_>) { 39 | let mut file = self.out_file.lock().unwrap(); 40 | let msg_key = std::str::from_utf8(msg.key().unwrap()).unwrap(); 41 | if let Some(msgtype) = get_msg_tag_from_topic(msg_key) { 42 | let payloadmsg = std::str::from_utf8(msg.payload().unwrap()).unwrap(); 43 | file.write_fmt(format_args!("{{\"type\":\"{}\",\"value\":{}}}\n", msgtype, payloadmsg)) 44 | .unwrap(); 45 | } 46 | } 47 | } 48 | 49 | fn main() { 50 | dotenv::dotenv().ok(); 51 | let _guard = non_blocking_tracing::setup(); 52 | 53 | let settings = config::Settings::new(); 54 | log::debug!("Settings: {:?}", settings); 55 | 56 | let rt: tokio::runtime::Runtime = tokio::runtime::Builder::new_multi_thread() 57 | .enable_all() 58 | .build() 59 | .expect("build runtime"); 60 | 61 | let writer = MessageWriter { 62 | out_file: Mutex::new(File::create("unify_msgs_output.txt").unwrap()), 63 | }; 64 | 65 | rt.block_on(async move { 66 | let consumer: StreamConsumer = fluidex_common::rdkafka::config::ClientConfig::new() 67 | .set("bootstrap.servers", &settings.brokers) 68 | .set("group.id", "unify_msg_dumper") 69 | .set("enable.partition.eof", "false") 70 | .set("session.timeout.ms", "6000") 71 | .set("enable.auto.commit", "false") 72 | .set("auto.offset.reset", "earliest") 73 | .create() 74 | .unwrap(); 75 | 76 | let consumer = std::sync::Arc::new(consumer); 77 | 78 | loop { 79 | let cr_main = SimpleConsumer::new(consumer.as_ref()) 80 | .add_topic(message::UNIFY_TOPIC, Simple::from(&writer)) 81 | .unwrap(); 82 | 83 | tokio::select! { 84 | _ = tokio::signal::ctrl_c() => { 85 | log::info!("Ctrl-c received, shutting down"); 86 | break; 87 | }, 88 | 89 | err = cr_main.run_stream(|cr|cr.stream()) => { 90 | log::error!("Kafka consumer error: {}", err); 91 | } 92 | } 93 | } 94 | }) 95 | } 96 | -------------------------------------------------------------------------------- /src/bin/matchengine.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | #![allow(clippy::collapsible_if)] 3 | #![allow(clippy::let_and_return)] 4 | #![allow(clippy::too_many_arguments)] 5 | #![allow(clippy::single_char_pattern)] 6 | //#![allow(clippy::await_holding_refcell_ref)] // FIXME 7 | 8 | use dingir_exchange::config; 9 | use dingir_exchange::controller::create_controller; 10 | use dingir_exchange::persist; 11 | use dingir_exchange::server::GrpcHandler; 12 | //use dingir_exchange::sqlxextend; 13 | 14 | use dingir_exchange::types::ConnectionType; 15 | use fluidex_common::non_blocking_tracing; 16 | use orchestra::rpc::exchange::matchengine_server::MatchengineServer; 17 | use sqlx::Connection; 18 | 19 | fn main() { 20 | dotenv::dotenv().ok(); 21 | let _guard = non_blocking_tracing::setup(); 22 | 23 | let rt: tokio::runtime::Runtime = tokio::runtime::Builder::new_multi_thread() 24 | .enable_all() 25 | .build() 26 | .expect("build runtime"); 27 | 28 | rt.block_on(async { 29 | let server = prepare().await.expect("Init state error"); 30 | grpc_run(server).await 31 | }) 32 | .unwrap(); 33 | } 34 | 35 | async fn prepare() -> anyhow::Result { 36 | let mut settings = config::Settings::new(); 37 | log::debug!("Settings: {:?}", settings); 38 | 39 | let mut conn = ConnectionType::connect(&settings.db_log) 40 | .await 41 | .expect(&*format!("cannot connect to db at {}", settings.db_log)); 42 | persist::MIGRATOR.run(&mut conn).await?; 43 | log::info!("MIGRATOR done"); 44 | 45 | let market_cfg = if settings.market_from_db { 46 | persist::init_config_from_db(&mut conn, &mut settings).await? 47 | } else { 48 | persist::MarketConfigs::new() 49 | }; 50 | 51 | let mut grpc_stub = create_controller((settings.clone(), market_cfg)); 52 | log::info!("grpc_stub created"); 53 | grpc_stub.user_manager.load_users_from_db(&mut conn).await?; 54 | persist::init_from_db(&mut conn, &mut grpc_stub).await?; 55 | log::info!("init from db done"); 56 | let grpc = GrpcHandler::new(grpc_stub, settings); 57 | Ok(grpc) 58 | } 59 | 60 | async fn grpc_run(mut grpc: GrpcHandler) -> Result<(), Box> { 61 | let addr = "0.0.0.0:50051".parse().unwrap(); 62 | log::info!("Starting gprc service"); 63 | 64 | let (tx, rx) = tokio::sync::oneshot::channel::<()>(); 65 | let on_leave = grpc.on_leave(); 66 | 67 | tokio::spawn(async move { 68 | tokio::signal::ctrl_c().await.ok(); 69 | log::info!("Ctrl-c received, shutting down"); 70 | tx.send(()).ok(); 71 | }); 72 | 73 | tonic::transport::Server::builder() 74 | .add_service(MatchengineServer::new(grpc)) 75 | .serve_with_shutdown(addr, async { 76 | rx.await.ok(); 77 | }) 78 | .await?; 79 | 80 | log::info!("Shutted down, wait for final clear"); 81 | on_leave.leave().await; 82 | log::info!("Shutted down"); 83 | Ok(()) 84 | } 85 | -------------------------------------------------------------------------------- /src/bin/persistor.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | #![allow(clippy::collapsible_if)] 3 | #![allow(clippy::let_and_return)] 4 | #![allow(clippy::too_many_arguments)] 5 | #![allow(clippy::single_char_pattern)] 6 | 7 | use database::{DatabaseWriter, DatabaseWriterConfig}; 8 | use dingir_exchange::{config, database, message, models, types}; 9 | use fluidex_common::non_blocking_tracing; 10 | use std::pin::Pin; 11 | use types::DbType; 12 | 13 | use fluidex_common::rdkafka::consumer::StreamConsumer; 14 | 15 | use message::persist::{self, TopicConfig}; 16 | 17 | fn main() { 18 | dotenv::dotenv().ok(); 19 | let _guard = non_blocking_tracing::setup(); 20 | 21 | let settings = config::Settings::new(); 22 | log::debug!("Settings: {:?}", settings); 23 | 24 | let rt: tokio::runtime::Runtime = tokio::runtime::Builder::new_multi_thread() 25 | .enable_all() 26 | .build() 27 | .expect("build runtime"); 28 | 29 | rt.block_on(async move { 30 | let consumer: StreamConsumer = fluidex_common::rdkafka::config::ClientConfig::new() 31 | .set("bootstrap.servers", &settings.brokers) 32 | .set("group.id", &settings.consumer_group) 33 | .set("enable.partition.eof", "false") 34 | .set("session.timeout.ms", "6000") 35 | .set("enable.auto.commit", "false") 36 | .set("auto.offset.reset", "earliest") 37 | .create() 38 | .unwrap(); 39 | 40 | let consumer = std::sync::Arc::new(consumer); 41 | 42 | let pool = sqlx::Pool::::connect(&settings.db_history).await.unwrap(); 43 | 44 | // migrate using `dingir_exchange::persist::MIGRATOR` with '/migrations' for db_history (state_changes) 45 | dingir_exchange::persist::MIGRATOR.run(&pool).await.ok(); 46 | // migrate using `message::persist::MIGRATOR` with '/migrations/ts' for kline additionally 47 | message::persist::MIGRATOR.run(&pool).await.ok(); 48 | 49 | let write_config = DatabaseWriterConfig { 50 | spawn_limit: 4, 51 | apply_benchmark: true, 52 | capability_limit: 8192, 53 | }; 54 | 55 | let persistor_kline: DatabaseWriter = DatabaseWriter::new(&write_config).start_schedule(&pool).unwrap(); 56 | 57 | //following is equal to writers in history.rs 58 | let persistor_trade: DatabaseWriter = DatabaseWriter::new(&write_config).start_schedule(&pool).unwrap(); 59 | 60 | let persistor_order: DatabaseWriter = DatabaseWriter::new(&write_config).start_schedule(&pool).unwrap(); 61 | 62 | let persistor_balance: DatabaseWriter = DatabaseWriter::new(&write_config).start_schedule(&pool).unwrap(); 63 | 64 | let persistor_transfer: DatabaseWriter = DatabaseWriter::new(&write_config).start_schedule(&pool).unwrap(); 65 | 66 | let persistor_user: DatabaseWriter = DatabaseWriter::new(&write_config).start_schedule(&pool).unwrap(); 67 | 68 | let trade_cfg = TopicConfig::::new(message::TRADES_TOPIC) 69 | .persist_to(&persistor_kline) 70 | .persist_to(&persistor_trade) 71 | .with_tr::() 72 | .persist_to(&persistor_trade) 73 | .with_tr::(); 74 | 75 | let order_cfg = TopicConfig::::new(message::ORDERS_TOPIC) 76 | .persist_to(&persistor_order) 77 | .with_tr::(); 78 | 79 | let balance_cfg = TopicConfig::::new(message::BALANCES_TOPIC).persist_to(&persistor_balance); 80 | 81 | let internaltx_cfg = TopicConfig::::new(message::INTERNALTX_TOPIC).persist_to(&persistor_transfer); 82 | 83 | let user_cfg = TopicConfig::::new(message::USER_TOPIC).persist_to(&persistor_user); 84 | 85 | let auto_commit = vec![ 86 | trade_cfg.auto_commit_start(consumer.clone()), 87 | order_cfg.auto_commit_start(consumer.clone()), 88 | balance_cfg.auto_commit_start(consumer.clone()), 89 | internaltx_cfg.auto_commit_start(consumer.clone()), 90 | user_cfg.auto_commit_start(consumer.clone()), 91 | ]; 92 | let consumer = consumer.as_ref(); 93 | 94 | loop { 95 | let cr_main = message::consumer::SimpleConsumer::new(consumer) 96 | .add_topic_config(&trade_cfg).unwrap() 97 | .add_topic_config(&order_cfg).unwrap() 98 | .add_topic_config(&balance_cfg).unwrap() 99 | .add_topic_config(&internaltx_cfg).unwrap() 100 | .add_topic_config(&user_cfg).unwrap() 101 | // .add_topic(message::TRADES_TOPIC, MsgDataPersistor::new(&persistor).handle_message::()) 102 | ; 103 | 104 | tokio::select! { 105 | _ = tokio::signal::ctrl_c() => { 106 | log::info!("Ctrl-c received, shutting down"); 107 | break; 108 | }, 109 | 110 | err = cr_main.run_stream(|cr|cr.stream()) => { 111 | log::error!("Kafka consumer error: {}", err); 112 | } 113 | } 114 | } 115 | 116 | tokio::try_join!( 117 | persistor_kline.finish(), 118 | persistor_trade.finish(), 119 | persistor_order.finish(), 120 | persistor_balance.finish(), 121 | persistor_transfer.finish(), 122 | persistor_user.finish(), 123 | ) 124 | .expect("all persistor should success finish"); 125 | let final_commits: Vec + Send>>> = auto_commit 126 | .into_iter() 127 | .map(|ac| -> Pin + Send>> { Box::pin(ac.final_commit(consumer)) }) 128 | .collect(); 129 | futures::future::join_all(final_commits).await; 130 | //auto_commit.final_commit(consumer).await; 131 | }) 132 | } 133 | -------------------------------------------------------------------------------- /src/bin/restapi.rs: -------------------------------------------------------------------------------- 1 | use actix_web::{App, HttpServer}; 2 | use dingir_exchange::restapi::manage::market; 3 | use dingir_exchange::restapi::personal_history::{my_internal_txs, my_orders}; 4 | use dingir_exchange::restapi::public_history::{order_trades, recent_trades}; 5 | use dingir_exchange::restapi::state::{AppCache, AppState}; 6 | use dingir_exchange::restapi::tradingview::{chart_config, history, search_symbols, symbols, ticker, unix_timestamp}; 7 | use dingir_exchange::restapi::user::get_user; 8 | use fluidex_common::non_blocking_tracing; 9 | use paperclip::actix::web::{self, HttpResponse}; 10 | use paperclip::actix::{api_v2_operation, OpenApiExt}; 11 | use sqlx::postgres::Postgres; 12 | use sqlx::Pool; 13 | use std::collections::HashMap; 14 | use std::convert::TryFrom; 15 | use std::sync::Mutex; 16 | 17 | #[actix_web::main] 18 | async fn main() -> std::io::Result<()> { 19 | dotenv::dotenv().ok(); 20 | let _guard = non_blocking_tracing::setup(); 21 | 22 | let db_url = dingir_exchange::config::Settings::new().db_history; 23 | log::debug!("Prepared DB connection: {}", &db_url); 24 | 25 | let config = dingir_exchange::restapi::config::Settings::new(); 26 | let manage_channel = if let Some(ep_str) = &config.manage_endpoint { 27 | log::info!("Connect to manage channel {}", ep_str); 28 | Some( 29 | tonic::transport::Endpoint::try_from(ep_str.clone()) 30 | .ok() 31 | .unwrap() 32 | .connect() 33 | .await 34 | .unwrap(), 35 | ) 36 | } else { 37 | None 38 | }; 39 | 40 | let user_map = web::Data::new(AppState { 41 | user_addr_map: Mutex::new(HashMap::new()), 42 | manage_channel, 43 | db: Pool::::connect(&db_url).await.unwrap(), 44 | config, 45 | }); 46 | 47 | let workers = user_map.config.workers; 48 | 49 | let server = HttpServer::new(move || { 50 | App::new() 51 | .app_data(user_map.clone()) 52 | .app_data(AppCache::new()) 53 | .wrap_api() 54 | .service( 55 | web::scope("/api/exchange/panel") 56 | .route("/ping", web::get().to(ping)) 57 | .route("/user/{l1addr_or_l2pubkey}", web::get().to(get_user)) 58 | .route("/recenttrades/{market}", web::get().to(recent_trades)) 59 | .route("/ordertrades/{market}/{order_id}", web::get().to(order_trades)) 60 | .route("/closedorders/{market}/{user_id}", web::get().to(my_orders)) 61 | .route("/internal_txs/{user_id}", web::get().to(my_internal_txs)) 62 | .route("/ticker_{ticker_inv}/{market}", web::get().to(ticker)) 63 | .service( 64 | web::scope("/tradingview") 65 | .route("/time", web::get().to(unix_timestamp)) 66 | .route("/config", web::get().to(chart_config)) 67 | .route("/search", web::get().to(search_symbols)) 68 | .route("/symbols", web::get().to(symbols)) 69 | .route("/history", web::get().to(history)), 70 | ) 71 | .service(if user_map.manage_channel.is_some() { 72 | web::scope("/manage").service( 73 | web::scope("/market") 74 | .route("/reload", web::post().to(market::reload)) 75 | .route("/tradepairs", web::post().to(market::add_pair)) 76 | .route("/assets", web::post().to(market::add_assets)), 77 | ) 78 | } else { 79 | web::scope("/manage") 80 | .service(web::resource("/").to(|| HttpResponse::Forbidden().body(String::from("No manage endpoint")))) 81 | }), 82 | ) 83 | .with_json_spec_at("/api/spec") 84 | .build() 85 | }); 86 | 87 | let server = match workers { 88 | Some(wr) => server.workers(wr), 89 | None => server, 90 | }; 91 | 92 | server.bind("0.0.0.0:50053")?.run().await 93 | } 94 | 95 | #[api_v2_operation] 96 | async fn ping() -> Result<&'static str, actix_web::Error> { 97 | Ok("pong") 98 | } 99 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use config_rs::{Config, File}; 2 | use fluidex_common::rust_decimal::Decimal; 3 | use paperclip::actix::Apiv2Schema; 4 | use serde::de; 5 | use serde::{Deserialize, Serialize}; 6 | use std::str::FromStr; 7 | 8 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default, Apiv2Schema)] 9 | #[serde(default)] 10 | pub struct Asset { 11 | pub id: String, 12 | pub symbol: String, 13 | pub name: String, 14 | pub chain_id: i16, 15 | pub token_address: String, 16 | pub rollup_token_id: i32, 17 | pub prec_save: u32, 18 | pub prec_show: u32, 19 | pub logo_uri: String, 20 | } 21 | 22 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 23 | #[serde(default)] 24 | pub struct MarketUnit { 25 | pub asset_id: String, 26 | pub prec: u32, 27 | } 28 | 29 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Apiv2Schema)] 30 | #[serde(default)] 31 | pub struct Market { 32 | pub name: String, 33 | pub base: String, 34 | pub quote: String, 35 | pub amount_prec: u32, 36 | pub price_prec: u32, 37 | pub fee_prec: u32, 38 | pub min_amount: Decimal, 39 | } 40 | 41 | impl Default for MarketUnit { 42 | fn default() -> Self { 43 | MarketUnit { 44 | asset_id: "".to_string(), 45 | prec: 0, 46 | } 47 | } 48 | } 49 | 50 | impl Default for Market { 51 | fn default() -> Self { 52 | Market { 53 | name: "".to_string(), 54 | fee_prec: 4, 55 | min_amount: Decimal::from_str("0.01").unwrap(), 56 | base: Default::default(), 57 | quote: Default::default(), 58 | amount_prec: 0, 59 | price_prec: 0, 60 | } 61 | } 62 | } 63 | 64 | #[derive(Debug, PartialEq, Copy, Clone)] 65 | pub enum PersistPolicy { 66 | Dummy, 67 | Both, 68 | ToDB, 69 | ToMessage, 70 | } 71 | 72 | impl<'de> de::Deserialize<'de> for PersistPolicy { 73 | fn deserialize>(deserializer: D) -> Result { 74 | let s = String::deserialize(deserializer)?; 75 | 76 | match s.as_ref() { 77 | "Both" | "both" => Ok(PersistPolicy::Both), 78 | "Db" | "db" | "DB" => Ok(PersistPolicy::ToDB), 79 | "Message" | "message" => Ok(PersistPolicy::ToMessage), 80 | "Dummy" | "dummy" => Ok(PersistPolicy::Dummy), 81 | _ => Err(serde::de::Error::custom("unexpected specification for persist policy")), 82 | } 83 | } 84 | } 85 | 86 | #[derive(Debug, PartialEq, Copy, Clone)] 87 | pub enum OrderSignatrueCheck { 88 | None, 89 | // auto means check sig only if sig != "" 90 | Auto, 91 | Needed, 92 | } 93 | 94 | impl<'de> de::Deserialize<'de> for OrderSignatrueCheck { 95 | fn deserialize>(deserializer: D) -> Result { 96 | let s = String::deserialize(deserializer)?; 97 | 98 | match s.as_ref() { 99 | "true" => Ok(OrderSignatrueCheck::Needed), 100 | "false" => Ok(OrderSignatrueCheck::None), 101 | "auto" => Ok(OrderSignatrueCheck::Auto), 102 | _ => Err(serde::de::Error::custom("unexpected specification for order sig check policy")), 103 | } 104 | } 105 | } 106 | 107 | #[derive(Clone, Debug, PartialEq, Deserialize)] 108 | #[serde(default)] 109 | pub struct Settings { 110 | pub debug: bool, 111 | pub db_log: String, 112 | pub db_history: String, 113 | pub history_persist_policy: PersistPolicy, 114 | pub market_from_db: bool, 115 | pub assets: Vec, 116 | pub markets: Vec, 117 | pub brokers: String, 118 | pub consumer_group: String, 119 | pub persist_interval: i32, 120 | pub slice_interval: i32, 121 | pub slice_keeptime: i32, 122 | pub history_thread: i32, 123 | pub cache_timeout: f64, 124 | pub disable_self_trade: bool, 125 | pub disable_market_order: bool, 126 | pub check_eddsa_signatue: OrderSignatrueCheck, 127 | pub user_order_num_limit: usize, 128 | } 129 | 130 | impl Default for Settings { 131 | fn default() -> Self { 132 | Settings { 133 | debug: false, 134 | db_log: Default::default(), 135 | db_history: Default::default(), 136 | history_persist_policy: PersistPolicy::ToMessage, 137 | market_from_db: true, 138 | assets: Vec::new(), 139 | markets: Vec::new(), 140 | consumer_group: "kline_data_fetcher".to_string(), 141 | brokers: "127.0.0.1:9092".to_string(), 142 | persist_interval: 3600, 143 | slice_interval: 86400, 144 | slice_keeptime: 86400 * 3, 145 | history_thread: 10, 146 | cache_timeout: 0.45, 147 | disable_self_trade: true, 148 | disable_market_order: false, 149 | check_eddsa_signatue: OrderSignatrueCheck::None, 150 | user_order_num_limit: 1000, 151 | } 152 | } 153 | } 154 | 155 | impl Settings { 156 | pub fn new() -> Self { 157 | // Initializes with `config/default.yaml`. 158 | let mut conf = Config::default(); 159 | conf.merge(File::with_name("config/default")).unwrap(); 160 | 161 | // Merges with `config/RUN_MODE.yaml` (development as default). 162 | let run_mode = dotenv::var("RUN_MODE").unwrap_or_else(|_| "development".into()); 163 | conf.merge(File::with_name(&format!("config/{}", run_mode)).required(false)) 164 | .unwrap(); 165 | 166 | conf.try_into().unwrap() 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | #![allow(clippy::collapsible_if)] 3 | #![allow(clippy::let_and_return)] 4 | #![allow(clippy::too_many_arguments)] 5 | #![allow(clippy::single_char_pattern)] 6 | 7 | pub mod matchengine; 8 | pub use matchengine::{asset, controller, dto, eth_guard, history, market, persist, sequencer, server, user_manager}; 9 | pub mod storage; 10 | pub use storage::{database, models, sqlxextend}; 11 | pub mod config; 12 | pub mod message; 13 | pub mod restapi; 14 | pub mod types; 15 | pub mod utils; 16 | -------------------------------------------------------------------------------- /src/matchengine/asset/asset_manager.rs: -------------------------------------------------------------------------------- 1 | use crate::config; 2 | use crate::market::{Market, OrderCommitment}; 3 | use anyhow::{bail, Result}; 4 | use fluidex_common::rust_decimal::{self, RoundingStrategy}; 5 | use fluidex_common::types::{DecimalExt, FrExt}; 6 | use fluidex_common::Fr; 7 | use orchestra::rpc::exchange::*; 8 | use serde::{Deserialize, Serialize}; 9 | use std::collections::HashMap; 10 | use std::str::FromStr; 11 | 12 | #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Eq, Hash)] 13 | pub struct AssetInfo { 14 | pub prec_save: u32, 15 | pub prec_show: u32, 16 | pub inner_id: u32, 17 | } 18 | 19 | #[derive(Clone)] 20 | pub struct AssetManager { 21 | pub assets: HashMap, 22 | } 23 | 24 | impl AssetManager { 25 | pub fn new(asset_config: &[config::Asset]) -> Result { 26 | log::info!("asset {:?}", asset_config); 27 | let mut assets = HashMap::new(); 28 | for item in asset_config.iter() { 29 | assets.insert( 30 | item.id.clone(), 31 | AssetInfo { 32 | prec_save: item.prec_save, 33 | prec_show: item.prec_show, 34 | inner_id: item.rollup_token_id as u32, 35 | }, 36 | ); 37 | } 38 | Ok(AssetManager { assets }) 39 | } 40 | 41 | pub fn append(&mut self, asset_config: &[config::Asset]) { 42 | //log::info() 43 | for item in asset_config.iter() { 44 | let ret = self.assets.insert( 45 | item.id.clone(), 46 | AssetInfo { 47 | prec_save: item.prec_save, 48 | prec_show: item.prec_show, 49 | inner_id: item.rollup_token_id as u32, 50 | }, 51 | ); 52 | if ret.is_some() { 53 | log::info!("Update asset {}", item.id); 54 | } else { 55 | log::info!("Append new asset {}", item.id); 56 | } 57 | } 58 | } 59 | 60 | pub fn asset_exist(&self, id: &str) -> bool { 61 | self.assets.contains_key(id) 62 | } 63 | pub fn asset_get(&self, id: &str) -> Option<&AssetInfo> { 64 | self.assets.get(id) 65 | } 66 | pub fn asset_prec(&self, id: &str) -> u32 { 67 | self.asset_get(id).unwrap().prec_save 68 | } 69 | pub fn asset_prec_show(&self, id: &str) -> u32 { 70 | self.asset_get(id).unwrap().prec_show 71 | } 72 | 73 | pub fn commit_order(&self, o: &OrderPutRequest, market: &Market) -> Result { 74 | let assets: Vec<&str> = o.market.split('_').collect(); 75 | if assets.len() != 2 { 76 | bail!("market error"); 77 | } 78 | let base_token = match self.asset_get(assets[0]) { 79 | Some(token) => token, 80 | None => bail!("market base_token error"), 81 | }; 82 | let quote_token = match self.asset_get(assets[1]) { 83 | Some(token) => token, 84 | None => bail!("market quote_token error"), 85 | }; 86 | let amount = match rust_decimal::Decimal::from_str(&o.amount) { 87 | Ok(d) => d.round_dp_with_strategy(market.amount_prec, RoundingStrategy::ToZero), 88 | _ => bail!("amount error"), 89 | }; 90 | let price = match rust_decimal::Decimal::from_str(&o.price) { 91 | Ok(d) => d.round_dp(market.price_prec), 92 | _ => bail!("price error"), 93 | }; 94 | 95 | match OrderSide::from_i32(o.order_side) { 96 | Some(OrderSide::Ask) => Ok(OrderCommitment { 97 | token_buy: Fr::from_u32(quote_token.inner_id), 98 | token_sell: Fr::from_u32(base_token.inner_id), 99 | total_buy: (amount * price).to_fr(market.amount_prec + market.price_prec), 100 | total_sell: amount.to_fr(market.amount_prec), 101 | }), 102 | Some(OrderSide::Bid) => Ok(OrderCommitment { 103 | token_buy: Fr::from_u32(base_token.inner_id), 104 | token_sell: Fr::from_u32(quote_token.inner_id), 105 | total_buy: amount.to_fr(market.amount_prec), 106 | total_sell: (amount * price).to_fr(market.amount_prec + market.price_prec), 107 | }), 108 | None => bail!("market error"), 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/matchengine/asset/balance_manager.rs: -------------------------------------------------------------------------------- 1 | use super::asset_manager::AssetManager; 2 | use crate::config; 3 | pub use crate::models::BalanceHistory; 4 | 5 | use anyhow::Result; 6 | use fluidex_common::rust_decimal::prelude::Zero; 7 | use fluidex_common::rust_decimal::Decimal; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | use num_enum::TryFromPrimitive; 11 | use std::collections::HashMap; 12 | 13 | #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Eq, Hash, Copy, TryFromPrimitive)] 14 | #[repr(i16)] 15 | pub enum BalanceType { 16 | AVAILABLE = 1, 17 | FREEZE = 2, 18 | } 19 | 20 | #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Eq, Hash)] 21 | pub struct BalanceMapKey { 22 | pub user_id: u32, 23 | pub balance_type: BalanceType, 24 | pub asset: String, 25 | } 26 | 27 | #[derive(Default)] 28 | pub struct BalanceStatus { 29 | pub total: Decimal, 30 | pub available_count: u32, 31 | pub available: Decimal, 32 | pub frozen_count: u32, 33 | pub frozen: Decimal, 34 | } 35 | 36 | //#[derive(default)] 37 | pub struct BalanceManager { 38 | pub asset_manager: AssetManager, 39 | pub balances: HashMap, 40 | } 41 | 42 | impl BalanceManager { 43 | pub fn new(asset_config: &[config::Asset]) -> Result { 44 | let asset_manager = AssetManager::new(asset_config)?; 45 | Ok(BalanceManager { 46 | asset_manager, 47 | balances: HashMap::new(), 48 | }) 49 | } 50 | 51 | pub fn reset(&mut self) { 52 | self.balances.clear() 53 | } 54 | pub fn get(&self, user_id: u32, balance_type: BalanceType, asset: &str) -> Decimal { 55 | self.get_by_key(&BalanceMapKey { 56 | user_id, 57 | balance_type, 58 | asset: asset.to_owned(), 59 | }) 60 | } 61 | pub fn get_with_round(&self, user_id: u32, balance_type: BalanceType, asset: &str) -> Decimal { 62 | let balance: Decimal = self.get(user_id, balance_type, asset); 63 | let prec_save = self.asset_manager.asset_prec(asset); 64 | let prec_show = self.asset_manager.asset_prec_show(asset); 65 | let balance_show = if prec_save == prec_show { 66 | balance 67 | } else { 68 | balance.round_dp(prec_show) 69 | }; 70 | balance_show 71 | } 72 | pub fn get_by_key(&self, key: &BalanceMapKey) -> Decimal { 73 | *self.balances.get(key).unwrap_or(&Decimal::zero()) 74 | } 75 | pub fn del(&mut self, user_id: u32, balance_type: BalanceType, asset: &str) { 76 | self.balances.remove(&BalanceMapKey { 77 | user_id, 78 | balance_type, 79 | asset: asset.to_owned(), 80 | }); 81 | } 82 | pub fn set(&mut self, user_id: u32, balance_type: BalanceType, asset: &str, amount: &Decimal) { 83 | let key = BalanceMapKey { 84 | user_id, 85 | balance_type, 86 | asset: asset.to_owned(), 87 | }; 88 | self.set_by_key(key, amount); 89 | } 90 | pub fn set_by_key(&mut self, key: BalanceMapKey, amount: &Decimal) { 91 | debug_assert!(amount.is_sign_positive()); 92 | let amount = amount.round_dp(self.asset_manager.asset_prec(&key.asset)); 93 | //log::debug!("set balance: {:?}, {}", key, amount); 94 | self.balances.insert(key, amount); 95 | } 96 | pub fn add(&mut self, user_id: u32, balance_type: BalanceType, asset: &str, amount: &Decimal) -> Decimal { 97 | debug_assert!(amount.is_sign_positive()); 98 | let amount = amount.round_dp(self.asset_manager.asset_prec(asset)); 99 | let key = BalanceMapKey { 100 | user_id, 101 | balance_type, 102 | asset: asset.to_owned(), 103 | }; 104 | let old_value = self.get_by_key(&key); 105 | let new_value = old_value + amount; 106 | self.set_by_key(key, &new_value); 107 | new_value 108 | } 109 | pub fn sub(&mut self, user_id: u32, balance_type: BalanceType, asset: &str, amount: &Decimal) -> Decimal { 110 | debug_assert!(amount.is_sign_positive()); 111 | let amount = amount.round_dp(self.asset_manager.asset_prec(asset)); 112 | let key = BalanceMapKey { 113 | user_id, 114 | balance_type, 115 | asset: asset.to_owned(), 116 | }; 117 | let old_value = self.get_by_key(&key); 118 | debug_assert!(old_value.ge(&amount)); 119 | let new_value = old_value - amount; 120 | debug_assert!(new_value.is_sign_positive()); 121 | // TODO don't remove it. Skip when sql insert 122 | /* 123 | if result.is_zero() { 124 | self.balances.remove(&key); 125 | } else { 126 | self.balances.insert(key, result); 127 | } 128 | */ 129 | self.set_by_key(key, &new_value); 130 | new_value 131 | } 132 | pub fn frozen(&mut self, user_id: u32, asset: &str, amount: &Decimal) { 133 | debug_assert!(amount.is_sign_positive()); 134 | let amount = amount.round_dp(self.asset_manager.asset_prec(asset)); 135 | let key = BalanceMapKey { 136 | user_id, 137 | balance_type: BalanceType::AVAILABLE, 138 | asset: asset.to_owned(), 139 | }; 140 | let old_available_value = self.get_by_key(&key); 141 | debug_assert!(old_available_value.ge(&amount)); 142 | self.sub(user_id, BalanceType::AVAILABLE, asset, &amount); 143 | self.add(user_id, BalanceType::FREEZE, asset, &amount); 144 | } 145 | pub fn unfrozen(&mut self, user_id: u32, asset: &str, amount: &Decimal) { 146 | debug_assert!(amount.is_sign_positive()); 147 | let amount = amount.round_dp(self.asset_manager.asset_prec(asset)); 148 | let key = BalanceMapKey { 149 | user_id, 150 | balance_type: BalanceType::FREEZE, 151 | asset: asset.to_owned(), 152 | }; 153 | let old_frozen_value = self.get_by_key(&key); 154 | debug_assert!( 155 | old_frozen_value.ge(&amount), 156 | "unfreeze larger than frozen {} > {}", 157 | amount, 158 | old_frozen_value 159 | ); 160 | self.add(user_id, BalanceType::AVAILABLE, asset, &amount); 161 | self.sub(user_id, BalanceType::FREEZE, asset, &amount); 162 | } 163 | pub fn total(&self, user_id: u32, asset: &str) -> Decimal { 164 | self.get(user_id, BalanceType::AVAILABLE, asset) + self.get(user_id, BalanceType::FREEZE, asset) 165 | } 166 | pub fn status(&self, asset: &str) -> BalanceStatus { 167 | let mut result = BalanceStatus::default(); 168 | for (k, amount) in self.balances.iter() { 169 | if k.asset.eq(asset) && !amount.is_zero() { 170 | result.total += amount; 171 | if k.balance_type == BalanceType::AVAILABLE { 172 | result.available_count += 1; 173 | result.available += amount; 174 | } else { 175 | result.frozen_count += 1; 176 | result.frozen += amount; 177 | } 178 | } 179 | } 180 | result 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/matchengine/asset/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod asset_manager; 2 | pub mod balance_manager; 3 | pub mod update_controller; 4 | pub use asset_manager::*; 5 | pub use balance_manager::*; 6 | pub use update_controller::*; 7 | -------------------------------------------------------------------------------- /src/matchengine/asset/update_controller.rs: -------------------------------------------------------------------------------- 1 | use super::balance_manager::{BalanceManager, BalanceType}; 2 | use crate::models; 3 | use crate::persist::PersistExector; 4 | use fluidex_common::utils::timeutil::{current_timestamp, FTimestamp}; 5 | pub use models::BalanceHistory; 6 | 7 | use anyhow::{bail, Result}; 8 | use fluidex_common::rust_decimal::Decimal; 9 | use ttl_cache::TtlCache; 10 | 11 | use std::time::Duration; 12 | 13 | const BALANCE_MAP_INIT_SIZE_ASSET: usize = 64; 14 | const PERSIST_ZERO_BALANCE_UPDATE: bool = false; 15 | 16 | pub struct BalanceUpdateParams { 17 | pub balance_type: BalanceType, 18 | pub business_type: BusinessType, 19 | pub user_id: u32, 20 | pub business_id: u64, 21 | pub asset: String, 22 | pub business: String, 23 | pub market_price: Decimal, 24 | pub change: Decimal, 25 | pub detail: serde_json::Value, 26 | pub signature: Vec, 27 | } 28 | 29 | #[derive(Clone, Copy, Eq, Hash, PartialEq)] 30 | pub enum BusinessType { 31 | Deposit, 32 | Trade, 33 | Transfer, 34 | Withdraw, 35 | } 36 | 37 | #[derive(PartialEq, Eq, Hash)] 38 | struct BalanceUpdateKey { 39 | pub balance_type: BalanceType, 40 | pub business_type: BusinessType, 41 | pub user_id: u32, 42 | pub asset: String, 43 | pub business: String, 44 | pub business_id: u64, 45 | } 46 | 47 | //pub trait BalanceUpdateValidator { 48 | // pub fn is_valid() 49 | //} 50 | 51 | // TODO: this class needs to be refactored 52 | // Currently it has two purpose: (1) filter duplicate (2) generate message 53 | pub struct BalanceUpdateController { 54 | cache: TtlCache, 55 | } 56 | 57 | impl BalanceUpdateController { 58 | pub fn new() -> BalanceUpdateController { 59 | let capacity = 1_000_000; 60 | BalanceUpdateController { 61 | cache: TtlCache::new(capacity), 62 | } 63 | } 64 | pub fn reset(&mut self) { 65 | self.cache.clear() 66 | } 67 | pub fn on_timer(&mut self) { 68 | self.cache.clear() 69 | } 70 | pub fn timer_interval(&self) -> Duration { 71 | Duration::from_secs(60) 72 | } 73 | // return false if duplicate 74 | pub fn update_user_balance( 75 | &mut self, 76 | balance_manager: &mut BalanceManager, 77 | persistor: &mut impl PersistExector, 78 | mut params: BalanceUpdateParams, 79 | ) -> Result<()> { 80 | let asset = params.asset; 81 | let balance_type = params.balance_type; 82 | let business = params.business; 83 | let business_type = params.business_type; 84 | let business_id = params.business_id; 85 | let user_id = params.user_id; 86 | let cache_key = BalanceUpdateKey { 87 | balance_type, 88 | business_type, 89 | user_id, 90 | asset: asset.clone(), 91 | business: business.clone(), 92 | business_id, 93 | }; 94 | if self.cache.contains_key(&cache_key) { 95 | bail!("duplicate request"); 96 | } 97 | let old_balance = balance_manager.get(user_id, balance_type, &asset); 98 | let change = params.change; 99 | let abs_change = change.abs(); 100 | if change.is_sign_positive() { 101 | balance_manager.add(user_id, balance_type, &asset, &abs_change); 102 | } else if change.is_sign_negative() { 103 | if old_balance < abs_change { 104 | bail!("balance not enough"); 105 | } 106 | balance_manager.sub(user_id, balance_type, &asset, &abs_change); 107 | } 108 | log::debug!("change user balance: {} {} {}", user_id, asset, change); 109 | self.cache.insert(cache_key, true, Duration::from_secs(3600)); 110 | if persistor.real_persist() && (PERSIST_ZERO_BALANCE_UPDATE || !change.is_zero()) { 111 | params.detail["id"] = serde_json::Value::from(business_id); 112 | let balance_available = balance_manager.get(user_id, BalanceType::AVAILABLE, &asset); 113 | let balance_frozen = balance_manager.get(user_id, BalanceType::FREEZE, &asset); 114 | let balance_history = BalanceHistory { 115 | time: FTimestamp(current_timestamp()).into(), 116 | user_id: user_id as i32, 117 | business_id: business_id as i64, 118 | asset, 119 | business, 120 | market_price: params.market_price, 121 | change, 122 | balance: balance_available + balance_frozen, 123 | balance_available, 124 | balance_frozen, 125 | detail: params.detail.to_string(), 126 | signature: params.signature, 127 | }; 128 | persistor.put_balance(&balance_history); 129 | match params.business_type { 130 | BusinessType::Deposit => persistor.put_deposit(&balance_history), 131 | BusinessType::Withdraw => persistor.put_withdraw(&balance_history), 132 | _ => {} 133 | } 134 | } 135 | Ok(()) 136 | } 137 | } 138 | 139 | impl Default for BalanceUpdateController { 140 | fn default() -> Self { 141 | Self::new() 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/matchengine/dto.rs: -------------------------------------------------------------------------------- 1 | use crate::market; 2 | 3 | use anyhow::{anyhow, bail, Result}; 4 | use arrayref::array_ref; 5 | use fluidex_common::rust_decimal::{self, prelude::Zero, Decimal}; 6 | use fluidex_common::utils::timeutil::FTimestamp; 7 | use orchestra::rpc::exchange::*; 8 | 9 | use std::convert::TryFrom; 10 | use std::str::FromStr; 11 | 12 | pub fn str_to_decimal(s: &str, allow_empty: bool) -> Result { 13 | if allow_empty && s.is_empty() { 14 | Ok(Decimal::zero()) 15 | } else { 16 | Ok(Decimal::from_str(s)?) 17 | } 18 | } 19 | 20 | impl From for OrderInfo { 21 | fn from(o: market::Order) -> Self { 22 | OrderInfo { 23 | id: o.id, 24 | market: String::from(&*o.market), 25 | order_type: if o.type_ == market::OrderType::LIMIT { 26 | OrderType::Limit as i32 27 | } else { 28 | OrderType::Market as i32 29 | }, 30 | order_side: if o.side == market::OrderSide::ASK { 31 | OrderSide::Ask as i32 32 | } else { 33 | OrderSide::Bid as i32 34 | }, 35 | user_id: o.user, 36 | create_time: FTimestamp::from(&o.create_time).as_milliseconds(), 37 | update_time: FTimestamp::from(&o.update_time).as_milliseconds(), 38 | price: o.price.to_string(), 39 | amount: o.amount.to_string(), 40 | taker_fee: o.taker_fee.to_string(), 41 | maker_fee: o.maker_fee.to_string(), 42 | remain: o.remain.to_string(), 43 | finished_base: o.finished_base.to_string(), 44 | finished_quote: o.finished_quote.to_string(), 45 | finished_fee: o.finished_fee.to_string(), 46 | post_only: o.post_only, 47 | } 48 | } 49 | } 50 | 51 | impl TryFrom for market::OrderInput { 52 | type Error = anyhow::Error; 53 | 54 | fn try_from(req: OrderPutRequest) -> std::result::Result { 55 | Ok(market::OrderInput { 56 | user_id: req.user_id, 57 | side: if req.order_side == OrderSide::Ask as i32 { 58 | market::OrderSide::ASK 59 | } else { 60 | market::OrderSide::BID 61 | }, 62 | type_: if req.order_type == OrderType::Limit as i32 { 63 | market::OrderType::LIMIT 64 | } else { 65 | market::OrderType::MARKET 66 | }, 67 | amount: str_to_decimal(&req.amount, false).map_err(|_| anyhow!("invalid amount"))?, 68 | price: str_to_decimal(&req.price, req.order_type == OrderType::Market as i32).map_err(|_| anyhow!("invalid price"))?, 69 | quote_limit: str_to_decimal(&req.quote_limit, true).map_err(|_| anyhow!("invalid quote limit"))?, 70 | taker_fee: str_to_decimal(&req.taker_fee, true).map_err(|_| anyhow!("invalid taker fee"))?, 71 | maker_fee: str_to_decimal(&req.maker_fee, true).map_err(|_| anyhow!("invalid maker fee"))?, 72 | market: req.market.clone(), 73 | post_only: req.post_only, 74 | signature: if req.signature.is_empty() { 75 | log::warn!("empty signature. should only happen in tests"); 76 | [0; 64] 77 | } else { 78 | let sig = req.signature.trim_start_matches("0x"); 79 | let v: Vec = hex::decode(sig)?; 80 | if v.len() != 64 { 81 | bail!("invalid signature length"); 82 | } 83 | *array_ref!(v[..64], 0, 64) 84 | }, 85 | }) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/matchengine/eth_guard.rs: -------------------------------------------------------------------------------- 1 | use orchestra::rpc::exchange; 2 | use std::collections::HashSet; 3 | 4 | #[derive(Debug, Clone)] 5 | pub struct EthLogGuard { 6 | block_number: u64, 7 | history: HashSet, 8 | } 9 | 10 | #[derive(Debug, Clone, Eq, PartialEq, Hash)] 11 | pub struct EthLogMetadata { 12 | pub block_number: u64, 13 | pub tx_hash: String, 14 | pub log_index: String, 15 | } 16 | 17 | impl EthLogGuard { 18 | pub fn new(block_number: u64) -> Self { 19 | Self { 20 | block_number, 21 | history: HashSet::new(), 22 | } 23 | } 24 | 25 | pub fn accept(&self, log_meta: &EthLogMetadata) -> bool { 26 | !(log_meta.block_number < self.block_number || (log_meta.block_number == self.block_number && self.history.contains(log_meta))) 27 | } 28 | 29 | pub fn accept_optional(&self, log_meta: &Option) -> bool { 30 | match log_meta { 31 | None => true, 32 | Some(meta) => self.accept(meta), 33 | } 34 | } 35 | 36 | pub fn update(&mut self, log_meta: EthLogMetadata) { 37 | assert!(self.accept(&log_meta)); 38 | 39 | if log_meta.block_number > self.block_number { 40 | self.block_number = log_meta.block_number; 41 | self.history.clear(); 42 | } 43 | 44 | assert!(self.history.insert(log_meta)); 45 | } 46 | 47 | pub fn update_optional(&mut self, log_meta: Option) { 48 | if let Some(meta) = log_meta { 49 | self.update(meta) 50 | } 51 | } 52 | } 53 | 54 | impl From<&exchange::EthLogMetadata> for EthLogMetadata { 55 | fn from(e: &exchange::EthLogMetadata) -> Self { 56 | Self { 57 | block_number: e.block_number, 58 | tx_hash: e.tx_hash.clone(), 59 | log_index: e.log_index.clone(), 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/matchengine/history.rs: -------------------------------------------------------------------------------- 1 | use crate::database::{DatabaseWriter, DatabaseWriterConfig}; 2 | use crate::market; 3 | use crate::models; 4 | use market::Trade; 5 | 6 | use anyhow::Result; 7 | use fluidex_common::utils::timeutil::FTimestamp; 8 | 9 | type BalanceWriter = DatabaseWriter; 10 | type TransferWriter = DatabaseWriter; 11 | type UserWriter = DatabaseWriter; 12 | type OrderWriter = DatabaseWriter; 13 | type TradeWriter = DatabaseWriter; 14 | 15 | pub trait HistoryWriter: Sync + Send { 16 | fn is_block(&self) -> bool; 17 | //TODO: don't take the ownership? 18 | fn append_balance_history(&mut self, data: models::BalanceHistory); 19 | fn append_internal_transfer(&mut self, data: models::InternalTx); 20 | fn append_user(&mut self, user: models::AccountDesc); 21 | fn append_order_history(&mut self, order: &market::Order); 22 | fn append_expired_order_history(&mut self, _order: &market::Order); 23 | fn append_pair_user_trade(&mut self, trade: &Trade); 24 | } 25 | 26 | pub struct DummyHistoryWriter; 27 | impl HistoryWriter for DummyHistoryWriter { 28 | fn append_balance_history(&mut self, _data: models::BalanceHistory) {} 29 | fn append_internal_transfer(&mut self, _data: models::InternalTx) {} 30 | fn append_user(&mut self, _user: models::AccountDesc) {} 31 | fn append_order_history(&mut self, _order: &market::Order) {} 32 | fn append_expired_order_history(&mut self, _order: &market::Order) {} 33 | fn append_pair_user_trade(&mut self, _trade: &Trade) {} 34 | fn is_block(&self) -> bool { 35 | false 36 | } 37 | } 38 | 39 | pub struct DatabaseHistoryWriter { 40 | pub balance_writer: BalanceWriter, 41 | pub transfer_writer: TransferWriter, 42 | pub user_writer: UserWriter, 43 | pub trade_writer: TradeWriter, 44 | pub order_writer: OrderWriter, 45 | } 46 | 47 | impl DatabaseHistoryWriter { 48 | pub fn new(config: &DatabaseWriterConfig, pool: &sqlx::Pool) -> Result { 49 | Ok(DatabaseHistoryWriter { 50 | balance_writer: BalanceWriter::new(config).start_schedule(pool)?, 51 | transfer_writer: TransferWriter::new(config).start_schedule(pool)?, 52 | user_writer: UserWriter::new(config).start_schedule(pool)?, 53 | trade_writer: TradeWriter::new(config).start_schedule(pool)?, 54 | order_writer: OrderWriter::new(config).start_schedule(pool)?, 55 | }) 56 | } 57 | } 58 | 59 | impl<'r> From<&'r market::Order> for models::OrderHistory { 60 | fn from(order: &'r market::Order) -> Self { 61 | let status = if order.remain.is_zero() { 62 | models::OrderStatus::Filled 63 | } else { 64 | models::OrderStatus::Cancelled 65 | }; 66 | 67 | models::OrderHistory { 68 | id: order.id as i64, 69 | create_time: FTimestamp(order.create_time).into(), 70 | finish_time: FTimestamp(order.update_time).into(), 71 | status, 72 | user_id: order.user as i32, 73 | market: order.market.to_string(), 74 | order_type: order.type_, 75 | order_side: order.side, 76 | price: order.price, 77 | amount: order.amount, 78 | taker_fee: order.taker_fee, 79 | maker_fee: order.maker_fee, 80 | finished_base: order.finished_base, 81 | finished_quote: order.finished_quote, 82 | finished_fee: order.finished_fee, 83 | post_only: order.post_only, 84 | signature: order.signature.to_vec(), 85 | } 86 | } 87 | } 88 | 89 | impl HistoryWriter for DatabaseHistoryWriter { 90 | fn is_block(&self) -> bool { 91 | self.balance_writer.is_block() || self.trade_writer.is_block() || self.order_writer.is_block() 92 | } 93 | fn append_balance_history(&mut self, data: models::BalanceHistory) { 94 | self.balance_writer.append(data).ok(); 95 | } 96 | fn append_internal_transfer(&mut self, data: models::InternalTx) { 97 | self.transfer_writer.append(data).ok(); 98 | } 99 | fn append_user(&mut self, user: models::AccountDesc) { 100 | self.user_writer.append(user).ok(); 101 | } 102 | fn append_order_history(&mut self, order: &market::Order) { 103 | self.order_writer.append(order.into()).ok(); 104 | } 105 | fn append_expired_order_history(&mut self, order: &market::Order) { 106 | let mut order_for_db: models::OrderHistory = From::from(order); 107 | order_for_db.status = models::OrderStatus::Expired; 108 | self.order_writer.append(order_for_db).ok(); 109 | } 110 | 111 | fn append_pair_user_trade(&mut self, trade: &Trade) { 112 | let ask_trade = models::UserTrade { 113 | time: FTimestamp(trade.timestamp).into(), 114 | user_id: trade.ask_user_id as i32, 115 | market: trade.market.clone(), 116 | trade_id: trade.id as i64, 117 | order_id: trade.ask_order_id as i64, 118 | counter_order_id: trade.bid_order_id as i64, // counter order 119 | side: market::OrderSide::ASK as i16, 120 | role: trade.ask_role as i16, 121 | price: trade.price, 122 | amount: trade.amount, 123 | quote_amount: trade.quote_amount, 124 | fee: trade.ask_fee, 125 | counter_order_fee: trade.bid_fee, // counter order 126 | }; 127 | let bid_trade = models::UserTrade { 128 | time: FTimestamp(trade.timestamp).into(), 129 | user_id: trade.bid_user_id as i32, 130 | market: trade.market.clone(), 131 | trade_id: trade.id as i64, 132 | order_id: trade.bid_order_id as i64, 133 | counter_order_id: trade.ask_order_id as i64, // counter order 134 | side: market::OrderSide::BID as i16, 135 | role: trade.bid_role as i16, 136 | price: trade.price, 137 | amount: trade.amount, 138 | quote_amount: trade.quote_amount, 139 | fee: trade.bid_fee, 140 | counter_order_fee: trade.ask_fee, // counter order 141 | }; 142 | self.trade_writer.append(ask_trade).ok(); 143 | self.trade_writer.append(bid_trade).ok(); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /src/matchengine/market/order.rs: -------------------------------------------------------------------------------- 1 | use crate::types::{OrderSide, OrderType}; 2 | use crate::utils::InternedString; 3 | use fluidex_common::types::{BigInt, Decimal, Fr, FrExt}; 4 | use serde::{Deserialize, Serialize}; 5 | use std::cmp::Ordering; 6 | use std::sync::Arc; 7 | use tokio::sync::{RwLock, RwLockReadGuard, RwLockWriteGuard}; 8 | 9 | #[derive(PartialEq, Eq, PartialOrd, Ord)] 10 | pub struct MarketKeyAsk { 11 | pub order_price: Decimal, 12 | pub order_id: u64, 13 | } 14 | 15 | #[derive(PartialEq, Eq)] 16 | pub struct MarketKeyBid { 17 | pub order_price: Decimal, 18 | pub order_id: u64, 19 | } 20 | 21 | impl Ord for MarketKeyBid { 22 | fn cmp(&self, other: &Self) -> Ordering { 23 | let price_order = self.order_price.cmp(&other.order_price).reverse(); 24 | if price_order != Ordering::Equal { 25 | price_order 26 | } else { 27 | self.order_id.cmp(&other.order_id) 28 | } 29 | } 30 | } 31 | 32 | #[cfg(test)] 33 | #[test] 34 | fn test_order_sort() { 35 | use fluidex_common::rust_decimal::prelude::One; 36 | use fluidex_common::rust_decimal::prelude::Zero; 37 | { 38 | let o1 = MarketKeyBid { 39 | order_price: Decimal::zero(), 40 | order_id: 5, 41 | }; 42 | let o2 = MarketKeyBid { 43 | order_price: Decimal::zero(), 44 | order_id: 6, 45 | }; 46 | let o3 = MarketKeyBid { 47 | order_price: Decimal::one(), 48 | order_id: 7, 49 | }; 50 | assert!(o1 < o2); 51 | assert!(o3 < o2); 52 | } 53 | { 54 | let o1 = MarketKeyAsk { 55 | order_price: Decimal::zero(), 56 | order_id: 5, 57 | }; 58 | let o2 = MarketKeyAsk { 59 | order_price: Decimal::zero(), 60 | order_id: 6, 61 | }; 62 | let o3 = MarketKeyAsk { 63 | order_price: Decimal::one(), 64 | order_id: 7, 65 | }; 66 | assert!(o1 < o2); 67 | assert!(o3 > o2); 68 | } 69 | } 70 | 71 | impl PartialOrd for MarketKeyBid { 72 | fn partial_cmp(&self, other: &MarketKeyBid) -> Option { 73 | Some(self.cmp(other)) 74 | } 75 | } 76 | 77 | #[derive(Serialize, Deserialize, Debug, Clone, Copy)] 78 | pub struct Order { 79 | // Order can be seen as two part: 80 | // first, const part, these fields cannot be updated 81 | // then, the updatable part, which changes whenever a trade occurs 82 | pub id: u64, 83 | pub base: InternedString, 84 | pub quote: InternedString, 85 | pub market: InternedString, 86 | #[serde(rename = "type")] 87 | pub type_: OrderType, // enum 88 | pub side: OrderSide, 89 | pub user: u32, 90 | pub post_only: bool, 91 | #[serde(with = "crate::utils::serde::HexArray")] 92 | pub signature: [u8; 64], 93 | pub price: Decimal, 94 | pub amount: Decimal, 95 | // fee rate when the order be treated as a taker 96 | pub maker_fee: Decimal, 97 | // fee rate when the order be treated as a taker, not useful when post_only 98 | pub taker_fee: Decimal, 99 | pub create_time: f64, 100 | 101 | // below are the changable parts 102 | // remain + finished_base == amount 103 | pub remain: Decimal, 104 | // frozen = if ask { amount (base) } else { amount * price (quote) } 105 | pub frozen: Decimal, 106 | pub finished_base: Decimal, 107 | pub finished_quote: Decimal, 108 | pub finished_fee: Decimal, 109 | pub update_time: f64, 110 | } 111 | 112 | /* 113 | fn de_market_string<'de, D: serde::de::Deserializer<'de>>(_deserializer: D) -> Result<&'static str, D::Error> { 114 | Ok("Test") 115 | } 116 | */ 117 | 118 | impl Order { 119 | pub fn get_ask_key(&self) -> MarketKeyAsk { 120 | MarketKeyAsk { 121 | order_price: self.price, 122 | order_id: self.id, 123 | } 124 | } 125 | pub fn get_bid_key(&self) -> MarketKeyBid { 126 | MarketKeyBid { 127 | order_price: self.price, 128 | order_id: self.id, 129 | } 130 | } 131 | pub fn is_ask(&self) -> bool { 132 | self.side == OrderSide::ASK 133 | } 134 | } 135 | 136 | #[derive(Clone, Debug)] 137 | pub struct OrderRc(Arc>); 138 | 139 | /* 140 | simulate behavior like RefCell, the syncing is ensured by locking in higher rank 141 | here we use RwLock only for avoiding unsafe tag, we can just use raw pointer 142 | casted from ARc rather than RwLock here if we do not care about unsafe 143 | */ 144 | impl OrderRc { 145 | pub(super) fn new(order: Order) -> Self { 146 | OrderRc(Arc::new(RwLock::new(order))) 147 | } 148 | 149 | pub fn borrow(&self) -> RwLockReadGuard<'_, Order> { 150 | self.0.try_read().expect("Lock for parent entry ensure it") 151 | } 152 | 153 | pub(super) fn borrow_mut(&mut self) -> RwLockWriteGuard<'_, Order> { 154 | self.0.try_write().expect("Lock for parent entry ensure it") 155 | } 156 | 157 | pub fn deep(&self) -> Order { 158 | *self.borrow() 159 | } 160 | } 161 | 162 | pub struct OrderInput { 163 | pub user_id: u32, 164 | pub side: OrderSide, 165 | pub type_: OrderType, 166 | pub amount: Decimal, 167 | pub price: Decimal, 168 | pub quote_limit: Decimal, 169 | pub taker_fee: Decimal, // FIXME fee should be determined inside engine rather than take from input 170 | pub maker_fee: Decimal, 171 | pub market: String, 172 | pub post_only: bool, 173 | pub signature: [u8; 64], 174 | } 175 | 176 | pub struct OrderCommitment { 177 | // order_id 178 | // account_id 179 | // nonce 180 | pub token_sell: Fr, 181 | pub token_buy: Fr, 182 | pub total_sell: Fr, 183 | pub total_buy: Fr, 184 | } 185 | 186 | impl OrderCommitment { 187 | pub fn hash(&self) -> BigInt { 188 | // consistent with https://github.com/fluidex/circuits/blob/d6e06e964b9d492f1fa5513bcc2295e7081c540d/helper.ts/state-utils.ts#L38 189 | // TxType::PlaceOrder 190 | let magic_head = Fr::from_u32(4); 191 | let data = Fr::hash(&[ 192 | magic_head, 193 | // TODO: sign nonce or order_id 194 | //u32_to_fr(self.order_id), 195 | self.token_sell, 196 | self.token_buy, 197 | self.total_sell, 198 | self.total_buy, 199 | ]); 200 | //data = hash([data, accountID, nonce]); 201 | // nonce and orderID seems redundant? 202 | 203 | // account_id is not needed if the hash is signed later? 204 | //data = hash(&[data, u32_to_fr(self.account_id)]); 205 | data.to_bigint() 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/matchengine/market/trade.rs: -------------------------------------------------------------------------------- 1 | use crate::market::Order; 2 | use crate::types::MarketRole; 3 | use crate::types::OrderSide; 4 | use crate::utils::InternedString; 5 | use fluidex_common::rust_decimal::Decimal; 6 | use serde::{Deserialize, Serialize}; 7 | 8 | #[derive(Debug, Serialize, Deserialize, Clone)] 9 | pub struct VerboseOrderState { 10 | pub user_id: u32, 11 | pub order_id: u64, 12 | pub order_side: OrderSide, 13 | pub finished_base: Decimal, 14 | pub finished_quote: Decimal, 15 | pub finished_fee: Decimal, 16 | //pub remain: Decimal, 17 | //pub frozen: Decimal, 18 | } 19 | 20 | #[derive(Debug, Serialize, Deserialize, Clone)] 21 | pub struct VerboseBalanceState { 22 | pub user_id: u32, 23 | pub asset: InternedString, 24 | // total = balance_available + balance_frozen 25 | pub balance: Decimal, 26 | //pub balance_available: Deimcal, 27 | //pub balance_frozen: Deimcal, 28 | } 29 | 30 | // TODO: rename this? 31 | #[derive(Debug, Serialize, Deserialize, Clone, Default)] 32 | pub struct VerboseTradeState { 33 | // emit all the related state 34 | pub order_states: Vec, 35 | pub balance_states: Vec, 36 | } 37 | 38 | #[derive(Debug, Serialize, Deserialize, Clone)] 39 | pub struct Trade { 40 | pub id: u64, 41 | pub timestamp: f64, // unix epoch timestamp, 42 | pub market: String, 43 | pub base: String, 44 | pub quote: String, 45 | pub price: Decimal, 46 | pub amount: Decimal, 47 | pub quote_amount: Decimal, 48 | 49 | pub ask_user_id: u32, 50 | pub ask_order_id: u64, 51 | pub ask_role: MarketRole, // take/make 52 | pub ask_fee: Decimal, 53 | 54 | pub bid_user_id: u32, 55 | pub bid_order_id: u64, 56 | pub bid_role: MarketRole, 57 | pub bid_fee: Decimal, 58 | 59 | // only not none when this is this order's first trade 60 | pub ask_order: Option, 61 | pub bid_order: Option, 62 | 63 | #[cfg(feature = "emit_state_diff")] 64 | pub state_before: VerboseTradeState, 65 | #[cfg(feature = "emit_state_diff")] 66 | pub state_after: VerboseTradeState, 67 | } 68 | -------------------------------------------------------------------------------- /src/matchengine/mock.rs: -------------------------------------------------------------------------------- 1 | use crate::asset::{AssetManager, BalanceManager}; 2 | use crate::config; 3 | use fluidex_common::rust_decimal::Decimal; 4 | use fluidex_common::rust_decimal_macros::*; 5 | 6 | pub fn get_simple_market_config() -> config::Market { 7 | config::Market { 8 | name: String::from("ETH_USDT"), 9 | base: MockAsset::ETH.id(), 10 | quote: MockAsset::USDT.id(), 11 | amount_prec: 4, 12 | price_prec: 2, 13 | fee_prec: 2, 14 | min_amount: dec!(0.01), 15 | } 16 | } 17 | pub fn get_integer_prec_market_config() -> config::Market { 18 | config::Market { 19 | name: String::from("ETH_USDT"), 20 | base: MockAsset::ETH.id(), 21 | quote: MockAsset::USDT.id(), 22 | amount_prec: 0, 23 | price_prec: 0, 24 | fee_prec: 0, 25 | min_amount: dec!(0), 26 | } 27 | } 28 | 29 | // TODO: implement and use Into for MockAsset 30 | pub fn get_simple_asset_config(prec: u32) -> Vec { 31 | vec![ 32 | config::Asset { 33 | id: MockAsset::USDT.id(), 34 | symbol: MockAsset::USDT.symbol(), 35 | name: MockAsset::USDT.name(), 36 | chain_id: 1, 37 | token_address: MockAsset::USDT.token_address(), 38 | rollup_token_id: MockAsset::USDT.rollup_token_id(), 39 | prec_save: prec, 40 | prec_show: prec, 41 | logo_uri: String::default(), 42 | }, 43 | config::Asset { 44 | id: MockAsset::ETH.id(), 45 | symbol: MockAsset::ETH.symbol(), 46 | name: MockAsset::ETH.name(), 47 | chain_id: 1, 48 | token_address: MockAsset::ETH.token_address(), 49 | rollup_token_id: MockAsset::ETH.rollup_token_id(), 50 | prec_save: prec, 51 | prec_show: prec, 52 | logo_uri: String::default(), 53 | }, 54 | ] 55 | } 56 | 57 | #[allow(clippy::upper_case_acronyms)] 58 | #[derive(Debug)] 59 | pub enum MockAsset { 60 | ETH, 61 | USDT, 62 | } 63 | impl MockAsset { 64 | pub fn id(self) -> String { 65 | match self { 66 | MockAsset::ETH => String::from("ETH"), 67 | MockAsset::USDT => String::from("USDT"), 68 | } 69 | } 70 | pub fn symbol(self) -> String { 71 | match self { 72 | MockAsset::ETH => String::from("ETH"), 73 | MockAsset::USDT => String::from("USDT"), 74 | } 75 | } 76 | pub fn name(self) -> String { 77 | match self { 78 | MockAsset::ETH => String::from("Ether"), 79 | MockAsset::USDT => String::from("Tether USD"), 80 | } 81 | } 82 | pub fn token_address(self) -> String { 83 | match self { 84 | MockAsset::ETH => String::from(""), 85 | MockAsset::USDT => String::from("0xdAC17F958D2ee523a2206206994597C13D831ec7"), 86 | } 87 | } 88 | pub fn rollup_token_id(self) -> i32 { 89 | match self { 90 | MockAsset::ETH => 0, 91 | MockAsset::USDT => 1, 92 | } 93 | } 94 | } 95 | 96 | pub fn get_simple_asset_manager(assets: Vec) -> AssetManager { 97 | AssetManager::new(&assets).unwrap() 98 | } 99 | pub fn get_simple_balance_manager(assets: Vec) -> BalanceManager { 100 | BalanceManager::new(&assets).unwrap() 101 | } 102 | 103 | fn get_market_base_and_quote(market: &str) -> (String, String) { 104 | let splits: Vec<&str> = market.split("_").collect(); 105 | (splits[0].to_owned(), splits[1].to_owned()) 106 | } 107 | -------------------------------------------------------------------------------- /src/matchengine/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod asset; 2 | pub mod controller; 3 | pub mod dto; 4 | pub mod eth_guard; 5 | pub mod history; 6 | pub mod market; 7 | pub mod persist; 8 | pub mod sequencer; 9 | pub mod server; 10 | pub mod user_manager; 11 | 12 | mod mock; 13 | -------------------------------------------------------------------------------- /src/matchengine/persist/mod.rs: -------------------------------------------------------------------------------- 1 | mod state_save_load; 2 | pub use state_save_load::*; 3 | mod persistor; 4 | pub use persistor::*; 5 | -------------------------------------------------------------------------------- /src/matchengine/sequencer.rs: -------------------------------------------------------------------------------- 1 | #[derive(Default)] 2 | pub struct Sequencer { 3 | order_id: u64, 4 | trade_id: u64, 5 | msg_id: u64, 6 | operation_log_id: u64, 7 | } 8 | 9 | impl Sequencer { 10 | pub fn reset(&mut self) { 11 | self.set_operation_log_id(0); 12 | self.set_order_id(0); 13 | self.set_trade_id(0); 14 | self.set_msg_id(0); 15 | } 16 | pub fn next_order_id(&mut self) -> u64 { 17 | self.order_id += 1; 18 | //log::debug!("next_order_id {}", self.order_id); 19 | self.order_id 20 | } 21 | pub fn next_trade_id(&mut self) -> u64 { 22 | self.trade_id += 1; 23 | self.trade_id 24 | } 25 | pub fn next_operation_log_id(&mut self) -> u64 { 26 | self.operation_log_id += 1; 27 | self.operation_log_id 28 | } 29 | pub fn next_msg_id(&mut self) -> u64 { 30 | self.msg_id += 1; 31 | self.msg_id 32 | } 33 | pub fn get_operation_log_id(&self) -> u64 { 34 | self.operation_log_id 35 | } 36 | pub fn get_trade_id(&self) -> u64 { 37 | self.trade_id 38 | } 39 | pub fn get_order_id(&self) -> u64 { 40 | self.order_id 41 | } 42 | pub fn get_msg_id(&self) -> u64 { 43 | self.msg_id 44 | } 45 | pub fn set_operation_log_id(&mut self, id: u64) { 46 | log::debug!("set operation_log id {}", id); 47 | self.operation_log_id = id; 48 | } 49 | pub fn set_trade_id(&mut self, id: u64) { 50 | log::debug!("set trade id {}", id); 51 | self.trade_id = id; 52 | } 53 | pub fn set_order_id(&mut self, id: u64) { 54 | log::debug!("set order id {}", id); 55 | self.order_id = id; 56 | } 57 | pub fn set_msg_id(&mut self, id: u64) { 58 | log::debug!("set msg id {}", id); 59 | self.msg_id = id; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/matchengine/user_manager.rs: -------------------------------------------------------------------------------- 1 | use crate::models::AccountDesc; 2 | use crate::types::ConnectionType; 3 | use fluidex_common::babyjubjub_rs; 4 | use fluidex_common::types::{BigInt, PubkeyExt, SignatureExt}; 5 | use serde::{Deserialize, Serialize}; 6 | use std::collections::HashMap; 7 | 8 | #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Eq, Hash)] 9 | pub struct UserInfo { 10 | pub l1_address: String, 11 | pub l2_pubkey: String, 12 | } 13 | 14 | #[derive(Clone)] 15 | pub struct UserManager { 16 | pub users: HashMap, 17 | } 18 | 19 | impl UserManager { 20 | pub fn new() -> Self { 21 | Self { users: HashMap::new() } 22 | } 23 | pub fn reset(&mut self) { 24 | self.users.clear(); 25 | } 26 | 27 | pub async fn load_users_from_db(&mut self, conn: &mut ConnectionType) -> anyhow::Result<()> { 28 | let users: Vec = sqlx::query_as::<_, AccountDesc>("SELECT * FROM account").fetch_all(conn).await?; 29 | // lock? 30 | for user in users { 31 | self.users.insert( 32 | user.id as u32, 33 | UserInfo { 34 | l1_address: user.l1_address, 35 | l2_pubkey: user.l2_pubkey, 36 | }, 37 | ); 38 | } 39 | Ok(()) 40 | } 41 | 42 | pub fn verify_signature(&self, user_id: u32, msg: BigInt, signature: &str) -> bool { 43 | match self.users.get(&user_id) { 44 | None => false, 45 | Some(user) => { 46 | let pubkey = match PubkeyExt::from_str(&user.l2_pubkey) { 47 | Ok(pubkey) => pubkey, 48 | Err(_) => { 49 | log::error!("invalid pubkey {:?}", user.l2_pubkey); 50 | return false; 51 | } 52 | }; 53 | let signature = match SignatureExt::from_str(signature) { 54 | Ok(signature) => signature, 55 | Err(_) => { 56 | log::error!("invalid signature {:?}", signature); 57 | return false; 58 | } 59 | }; 60 | babyjubjub_rs::verify(pubkey, signature, msg) 61 | } 62 | } 63 | } 64 | } 65 | 66 | impl Default for UserManager { 67 | fn default() -> Self { 68 | Self::new() 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/restapi/config.rs: -------------------------------------------------------------------------------- 1 | use config_rs::{Config, File}; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] 5 | #[serde(default)] 6 | pub struct Trading { 7 | #[serde(with = "humantime_serde")] 8 | pub ticker_update_interval: std::time::Duration, 9 | #[serde(with = "humantime_serde")] 10 | pub ticker_interval: std::time::Duration, 11 | } 12 | 13 | impl Default for Trading { 14 | fn default() -> Self { 15 | Trading { 16 | ticker_update_interval: std::time::Duration::from_secs(5), 17 | ticker_interval: std::time::Duration::from_secs(86_400), 18 | } 19 | } 20 | } 21 | 22 | #[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] 23 | #[serde(default)] 24 | pub struct Settings { 25 | pub workers: Option, 26 | pub manage_endpoint: Option, 27 | pub trading: Trading, 28 | } 29 | 30 | impl Default for Settings { 31 | fn default() -> Self { 32 | Settings { 33 | workers: None, 34 | manage_endpoint: None, 35 | trading: Default::default(), 36 | } 37 | } 38 | } 39 | 40 | impl Settings { 41 | pub fn new() -> Self { 42 | let mut conf = Config::default(); 43 | conf.merge(File::with_name("config/restapi/default.yaml")).unwrap(); 44 | conf.try_into().unwrap() 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/restapi/errors.rs: -------------------------------------------------------------------------------- 1 | use serde::Serialize; 2 | use std::fmt; 3 | use thiserror::Error; 4 | 5 | use actix_web::error::{QueryPayloadError, ResponseError}; 6 | use actix_web::{http::StatusCode, HttpResponse}; 7 | 8 | // It is better to use strong typed error for APIs. 9 | // Use thiserror rather than anyhow if you are a library or service that wants to design your own dedicated error type(s) 10 | // so that on failures the caller gets exactly the information that you choose. 11 | // Use Anyhow if you don't care what error type your functions return, you just want it to be easy. 12 | #[derive(Error, Debug, Clone, Serialize)] 13 | pub enum ErrorType { 14 | #[error("Requested resource was not found")] 15 | NotFound, 16 | #[error("You are forbidden to access")] 17 | Forbidden, 18 | #[error("Invalid request")] 19 | BadRequest, 20 | #[error("Unknown Internal Error")] 21 | Unknown, 22 | } 23 | 24 | #[derive(Serialize, Clone, Debug)] 25 | pub struct RpcError { 26 | pub error: ErrorType, 27 | pub message: String, 28 | } 29 | 30 | impl RpcError { 31 | pub fn new(error: ErrorType, message: String) -> RpcError { 32 | RpcError { error, message } 33 | } 34 | pub fn unknown(message: &str) -> RpcError { 35 | Self::new(ErrorType::Unknown, message.to_string()) 36 | } 37 | pub fn bad_request(message: &str) -> RpcError { 38 | Self::new(ErrorType::BadRequest, message.to_string()) 39 | } 40 | } 41 | impl fmt::Display for RpcError { 42 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 43 | write!(f, "({}, {})", self.error, self.message) 44 | } 45 | } 46 | 47 | impl ResponseError for RpcError { 48 | fn status_code(&self) -> StatusCode { 49 | StatusCode::OK 50 | } 51 | 52 | fn error_response(&self) -> HttpResponse { 53 | // all http response are 200. we handle the error inside json 54 | HttpResponse::build(StatusCode::OK).json(self) 55 | } 56 | } 57 | 58 | impl From for RpcError { 59 | fn from(original: QueryPayloadError) -> RpcError { 60 | RpcError::bad_request(&original.to_string()) 61 | } 62 | } 63 | 64 | impl From for RpcError { 65 | fn from(original: sqlx::Error) -> RpcError { 66 | match original { 67 | sqlx::error::Error::RowNotFound => RpcError::new(ErrorType::NotFound, "db query nothing".to_string()), 68 | sqlx::error::Error::Database(e) => RpcError::bad_request(&e.to_string()), 69 | _ => RpcError::unknown(&original.to_string()), 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/restapi/manage.rs: -------------------------------------------------------------------------------- 1 | use crate::restapi::{state, types}; 2 | use crate::storage; 3 | use actix_web::error::InternalError; 4 | use actix_web::http::StatusCode; 5 | use futures::future::OptionFuture; 6 | use orchestra::rpc::exchange::*; 7 | use paperclip::actix::api_v2_operation; 8 | use paperclip::actix::web; 9 | 10 | pub mod market { 11 | use super::*; 12 | 13 | async fn do_reload(app_state: &state::AppState) -> Result<&'static str, actix_web::Error> { 14 | let mut rpc_cli = matchengine_client::MatchengineClient::new(app_state.manage_channel.as_ref().unwrap().clone()); 15 | 16 | if let Err(e) = rpc_cli.reload_markets(ReloadMarketsRequest { from_scratch: false }).await { 17 | return Err(InternalError::new(e.to_string(), StatusCode::INTERNAL_SERVER_ERROR).into()); 18 | } 19 | 20 | Ok("done") 21 | } 22 | 23 | #[api_v2_operation] 24 | pub async fn add_assets( 25 | req: web::Json, 26 | app_state: web::Data, 27 | ) -> Result<&'static str, actix_web::Error> { 28 | let assets_req = req.into_inner(); 29 | 30 | for asset in &assets_req.assets { 31 | log::debug!("Add asset {:?}", asset); 32 | if let Err(e) = storage::config::persist_asset_to_db(&app_state.db, asset, false).await { 33 | return Err(InternalError::new(e.to_string(), StatusCode::INTERNAL_SERVER_ERROR).into()); 34 | } 35 | } 36 | 37 | if !assets_req.not_reload { 38 | do_reload(&app_state.into_inner()).await 39 | } else { 40 | Ok("done") 41 | } 42 | } 43 | 44 | #[api_v2_operation] 45 | pub async fn reload(app_state: web::Data) -> Result<&'static str, actix_web::Error> { 46 | do_reload(&app_state.into_inner()).await 47 | } 48 | 49 | #[api_v2_operation] 50 | pub async fn add_pair( 51 | req: web::Json, 52 | app_state: web::Data, 53 | ) -> Result<&'static str, actix_web::Error> { 54 | let trade_pair = req.into_inner(); 55 | 56 | if let Some(asset) = trade_pair.asset_base.as_ref() { 57 | if asset.id != trade_pair.market.base { 58 | return Err(InternalError::new("Base asset not match".to_owned(), StatusCode::BAD_REQUEST).into()); 59 | } 60 | } 61 | 62 | if let Some(asset) = trade_pair.asset_quote.as_ref() { 63 | if asset.id != trade_pair.market.quote { 64 | return Err(InternalError::new("Quote asset not match".to_owned(), StatusCode::BAD_REQUEST).into()); 65 | } 66 | } 67 | 68 | if let Some(Err(e)) = OptionFuture::from( 69 | trade_pair 70 | .asset_base 71 | .as_ref() 72 | .map(|base_asset| storage::config::persist_asset_to_db(&app_state.db, base_asset, false)), 73 | ) 74 | .await 75 | { 76 | return Err(InternalError::new(e.to_string(), StatusCode::INTERNAL_SERVER_ERROR).into()); 77 | } 78 | 79 | if let Some(Err(e)) = OptionFuture::from( 80 | trade_pair 81 | .asset_quote 82 | .as_ref() 83 | .map(|quote_asset| storage::config::persist_asset_to_db(&app_state.db, quote_asset, false)), 84 | ) 85 | .await 86 | { 87 | return Err(InternalError::new(e.to_string(), StatusCode::INTERNAL_SERVER_ERROR).into()); 88 | } 89 | 90 | if let Err(e) = storage::config::persist_market_to_db(&app_state.db, &trade_pair.market).await { 91 | return Err(InternalError::new(e.to_string(), StatusCode::INTERNAL_SERVER_ERROR).into()); 92 | } 93 | 94 | if !trade_pair.not_reload { 95 | do_reload(&app_state.into_inner()).await 96 | } else { 97 | Ok("done") 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/restapi/mock.rs: -------------------------------------------------------------------------------- 1 | use super::types::{KlineReq, KlineResult}; 2 | use core::cmp::max; 3 | use rand::Rng; 4 | 5 | pub fn fake_kline_result(req: &KlineReq) -> KlineResult { 6 | let mut r = KlineResult::default(); 7 | let from = req.from / req.resolution * req.resolution; 8 | let to = req.to / req.resolution * req.resolution; 9 | let resolution = req.resolution * 60; 10 | let kline_result_limit = 10; 11 | let mut t = max(from, to - kline_result_limit * resolution); 12 | 13 | r.s = "ok".to_owned(); 14 | let mut rnd = rand::thread_rng(); 15 | while t <= to { 16 | let mut fake_prices = Vec::new(); 17 | for _ in 0..4 { 18 | fake_prices.push(rnd.gen_range(5.0..100.0)); 19 | } 20 | fake_prices.sort_by(|a, b| a.partial_cmp(b).unwrap_or(core::cmp::Ordering::Equal)); 21 | 22 | r.t.push(t); 23 | if rand::random::() { 24 | r.o.push(fake_prices[1]); 25 | r.c.push(fake_prices[2]); 26 | } else { 27 | r.o.push(fake_prices[2]); 28 | r.c.push(fake_prices[1]); 29 | } 30 | r.h.push(fake_prices[3]); 31 | r.l.push(fake_prices[0]); 32 | r.v.push(rnd.gen_range(1.0..3.0)); 33 | 34 | t += resolution; 35 | } 36 | r 37 | } 38 | -------------------------------------------------------------------------------- /src/restapi/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod errors; 3 | pub mod manage; 4 | pub mod mock; 5 | pub mod personal_history; 6 | pub mod public_history; 7 | pub mod state; 8 | pub mod tradingview; 9 | pub mod types; 10 | pub mod user; 11 | -------------------------------------------------------------------------------- /src/restapi/personal_history.rs: -------------------------------------------------------------------------------- 1 | use crate::models::tablenames::{ACCOUNT, INTERNALTX, ORDERHISTORY}; 2 | use crate::models::{DateTimeMilliseconds, DecimalDbType, OrderHistory, TimestampDbType}; 3 | use crate::restapi::errors::RpcError; 4 | use crate::restapi::state::AppState; 5 | use core::cmp::min; 6 | use paperclip::actix::web::{self, HttpRequest, Json}; 7 | use paperclip::actix::{api_v2_operation, Apiv2Schema}; 8 | use serde::{Deserialize, Deserializer, Serialize}; 9 | 10 | #[derive(Serialize, Apiv2Schema)] 11 | pub struct OrderResponse { 12 | total: i64, 13 | orders: Vec, 14 | } 15 | 16 | #[api_v2_operation] 17 | pub async fn my_orders(req: HttpRequest, data: web::Data) -> Result, actix_web::Error> { 18 | let market = req.match_info().get("market").unwrap(); 19 | let user_id = req.match_info().get("user_id").unwrap_or_default().parse::(); 20 | let user_id = match user_id { 21 | Err(_) => { 22 | return Err(RpcError::bad_request("invalid user_id").into()); 23 | } 24 | _ => user_id.unwrap(), 25 | }; 26 | let qstring = qstring::QString::from(req.query_string()); 27 | let limit = min(100, qstring.get("limit").unwrap_or_default().parse::().unwrap_or(20)); 28 | let offset = qstring.get("offset").unwrap_or_default().parse::().unwrap_or(0); 29 | 30 | let table = ORDERHISTORY; 31 | let condition = if market == "all" { 32 | "user_id = $1".to_string() 33 | } else { 34 | "market = $1 and user_id = $2".to_string() 35 | }; 36 | let order_query = format!( 37 | "select * from {} where {} order by id desc limit {} offset {}", 38 | table, condition, limit, offset 39 | ); 40 | let orders: Vec = if market == "all" { 41 | sqlx::query_as(&order_query).bind(user_id) 42 | } else { 43 | sqlx::query_as(&order_query).bind(market).bind(user_id) 44 | } 45 | .fetch_all(&data.db) 46 | .await 47 | .map_err(|err| actix_web::Error::from(RpcError::from(err)))?; 48 | let count_query = format!("select count(*) from {} where {}", table, condition); 49 | let total: i64 = if market == "all" { 50 | sqlx::query_scalar(&count_query).bind(user_id) 51 | } else { 52 | sqlx::query_scalar(&count_query).bind(market).bind(user_id) 53 | } 54 | .fetch_one(&data.db) 55 | .await 56 | .map_err(|err| actix_web::Error::from(RpcError::from(err)))?; 57 | Ok(Json(OrderResponse { total, orders })) 58 | } 59 | 60 | #[derive(sqlx::FromRow, Serialize, Apiv2Schema)] 61 | pub struct InternalTxResponse { 62 | #[serde(with = "DateTimeMilliseconds")] 63 | time: TimestampDbType, 64 | user_from: String, 65 | user_to: String, 66 | asset: String, 67 | amount: DecimalDbType, 68 | } 69 | 70 | #[derive(Copy, Clone, Debug, Deserialize, Apiv2Schema)] 71 | pub enum Order { 72 | #[serde(rename = "lowercase")] 73 | Asc, 74 | #[serde(rename = "lowercase")] 75 | Desc, 76 | } 77 | 78 | impl Default for Order { 79 | fn default() -> Self { 80 | Self::Desc 81 | } 82 | } 83 | 84 | #[derive(Copy, Clone, Debug, Deserialize, Apiv2Schema)] 85 | pub enum Side { 86 | #[serde(rename = "lowercase")] 87 | From, 88 | #[serde(rename = "lowercase")] 89 | To, 90 | #[serde(rename = "lowercase")] 91 | Both, 92 | } 93 | 94 | impl Default for Side { 95 | fn default() -> Self { 96 | Self::Both 97 | } 98 | } 99 | 100 | #[derive(Debug, Deserialize, Apiv2Schema)] 101 | pub struct InternalTxQuery { 102 | /// limit with default value of 20 and max value of 100. 103 | #[serde(default = "default_limit")] 104 | limit: usize, 105 | /// offset with default value of 0. 106 | #[serde(default = "default_zero")] 107 | offset: usize, 108 | #[serde(default, deserialize_with = "u64_timestamp_deserializer")] 109 | start_time: Option, 110 | #[serde(default, deserialize_with = "u64_timestamp_deserializer")] 111 | end_time: Option, 112 | #[serde(default)] 113 | order: Order, 114 | #[serde(default)] 115 | side: Side, 116 | } 117 | 118 | fn u64_timestamp_deserializer<'de, D>(deserializer: D) -> Result, D::Error> 119 | where 120 | D: Deserializer<'de>, 121 | { 122 | let timestamp = Option::::deserialize(deserializer)?; 123 | Ok(timestamp.map(|ts| TimestampDbType::from_timestamp(ts as i64, 0))) 124 | } 125 | 126 | const fn default_limit() -> usize { 127 | 20 128 | } 129 | const fn default_zero() -> usize { 130 | 0 131 | } 132 | 133 | /// `/internal_txs/{user_id}` 134 | #[api_v2_operation] 135 | pub async fn my_internal_txs( 136 | user_id: web::Path, 137 | query: web::Query, 138 | data: web::Data, 139 | ) -> Result>, actix_web::Error> { 140 | let user_id = user_id.into_inner(); 141 | let limit = min(query.limit, 100); 142 | 143 | let base_query: &'static str = const_format::formatcp!( 144 | r#" 145 | select i.time as time, 146 | af.l2_pubkey as user_from, 147 | at.l2_pubkey as user_to, 148 | i.asset as asset, 149 | i.amount as amount 150 | from {} i 151 | inner join {} af on af.id = i.user_from 152 | inner join {} at on at.id = i.user_to 153 | where "#, 154 | INTERNALTX, 155 | ACCOUNT, 156 | ACCOUNT 157 | ); 158 | let (user_condition, args_n) = match query.side { 159 | Side::From => ("i.user_from = $1", 1), 160 | Side::To => ("i.user_to = $1", 1), 161 | Side::Both => ("i.user_from = $1 or i.user_to = $2", 2), 162 | }; 163 | 164 | let time_condition = match (query.start_time, query.end_time) { 165 | (Some(_), Some(_)) => Some(format!("i.time >= ${} and i.time <= ${}", args_n + 1, args_n + 2)), 166 | (Some(_), None) => Some(format!("i.time >= ${}", args_n + 1)), 167 | (None, Some(_)) => Some(format!("i.time <= ${}", args_n + 1)), 168 | (None, None) => None, 169 | }; 170 | 171 | let condition = match time_condition { 172 | Some(time_condition) => format!("({}) and {}", user_condition, time_condition), 173 | None => user_condition.to_string(), 174 | }; 175 | 176 | let constraint = format!("limit {} offset {}", limit, query.offset); 177 | let sql_query = format!("{}{}{}", base_query, condition, constraint); 178 | 179 | let query_as = sqlx::query_as(sql_query.as_str()); 180 | 181 | let query_as = match query.side { 182 | Side::To | Side::From => query_as.bind(user_id), 183 | Side::Both => query_as.bind(user_id).bind(user_id), 184 | }; 185 | 186 | let query_as = match (query.start_time, query.end_time) { 187 | (Some(start_time), Some(end_time)) => query_as.bind(start_time).bind(end_time), 188 | (Some(start_time), None) => query_as.bind(start_time), 189 | (None, Some(end_time)) => query_as.bind(end_time), 190 | (None, None) => query_as, 191 | }; 192 | 193 | let txs: Vec = query_as 194 | .fetch_all(&data.db) 195 | .await 196 | .map_err(|err| actix_web::Error::from(RpcError::from(err)))?; 197 | 198 | Ok(Json(txs)) 199 | } 200 | -------------------------------------------------------------------------------- /src/restapi/public_history.rs: -------------------------------------------------------------------------------- 1 | use crate::models::tablenames::{MARKETTRADE, USERTRADE}; 2 | use crate::models::{self, DecimalDbType, TimestampDbType}; 3 | use crate::restapi::errors::RpcError; 4 | use crate::restapi::state::AppState; 5 | use crate::restapi::types; 6 | use chrono::{DateTime, SecondsFormat, Utc}; 7 | use core::cmp::min; 8 | use paperclip::actix::api_v2_operation; 9 | use paperclip::actix::web::{self, HttpRequest, Json}; 10 | 11 | fn check_market_exists(_market: &str) -> bool { 12 | // TODO 13 | true 14 | } 15 | 16 | #[api_v2_operation] 17 | pub async fn recent_trades(req: HttpRequest, data: web::Data) -> Result>, actix_web::Error> { 18 | let market = req.match_info().get("market").unwrap(); 19 | let qstring = qstring::QString::from(req.query_string()); 20 | let limit = min(100, qstring.get("limit").unwrap_or_default().parse::().unwrap_or(20)); 21 | log::debug!("recent_trades market {} limit {}", market, limit); 22 | if !check_market_exists(market) { 23 | return Err(RpcError::bad_request("invalid market").into()); 24 | } 25 | 26 | // TODO: this API result should be cached, either in-memory or using redis 27 | 28 | // Here we use the kline trade table, which is more market-centric 29 | // and more suitable for fetching latest trades on a market. 30 | // models::UserTrade is designed for a user to fetch his trades. 31 | 32 | let sql_query = format!("select * from {} where market = $1 order by time desc limit {}", MARKETTRADE, limit); 33 | 34 | let trades: Vec = sqlx::query_as(&sql_query) 35 | .bind(market) 36 | .fetch_all(&data.db) 37 | .await 38 | .map_err(|err| actix_web::Error::from(RpcError::from(err)))?; 39 | 40 | log::debug!("query {} recent_trades records", trades.len()); 41 | Ok(Json(trades)) 42 | } 43 | 44 | #[derive(sqlx::FromRow, Debug, Clone)] 45 | struct QueriedUserTrade { 46 | pub time: TimestampDbType, 47 | pub user_id: i32, 48 | pub trade_id: i64, 49 | pub order_id: i64, 50 | pub price: DecimalDbType, 51 | pub amount: DecimalDbType, 52 | pub quote_amount: DecimalDbType, 53 | pub fee: DecimalDbType, 54 | } 55 | 56 | #[cfg(sqlxverf)] 57 | fn sqlverf_ticker() -> impl std::any::Any { 58 | sqlx::query_as!( 59 | QueriedUserTrade, 60 | "select time, user_id, trade_id, order_id, 61 | price, amount, quote_amount, fee 62 | from user_trade where market = $1 and order_id = $2 63 | order by trade_id, time asc", 64 | "USDT_ETH", 65 | 10000, 66 | ) 67 | } 68 | 69 | #[api_v2_operation] 70 | pub async fn order_trades( 71 | app_state: web::Data, 72 | path: web::Path<(String, i64)>, 73 | ) -> Result, actix_web::Error> { 74 | let (market_name, order_id): (String, i64) = path.into_inner(); 75 | log::debug!("order_trades market {} order_id {}", market_name, order_id); 76 | 77 | let sql_query = format!( 78 | " 79 | select time, user_id, trade_id, order_id, 80 | price, amount, quote_amount, fee 81 | from {} where market = $1 and order_id = $2 82 | order by trade_id, time asc", 83 | USERTRADE 84 | ); 85 | 86 | let trades: Vec = sqlx::query_as(&sql_query) 87 | .bind(market_name) 88 | .bind(order_id) 89 | .fetch_all(&app_state.db) 90 | .await 91 | .map_err(|err| actix_web::Error::from(RpcError::from(err)))?; 92 | 93 | Ok(Json(types::OrderTradeResult { 94 | trades: trades 95 | .into_iter() 96 | .map(|v| types::MarketTrade { 97 | trade_id: v.trade_id, 98 | time: DateTime::::from_utc(v.time, Utc).to_rfc3339_opts(SecondsFormat::Secs, true), 99 | amount: v.amount.to_string(), 100 | quote_amount: v.quote_amount.to_string(), 101 | price: v.price.to_string(), 102 | fee: v.fee.to_string(), 103 | }) 104 | .collect(), 105 | })) 106 | } 107 | -------------------------------------------------------------------------------- /src/restapi/state.rs: -------------------------------------------------------------------------------- 1 | use super::config::Settings; 2 | use super::types::TickerResult; 3 | use crate::models::AccountDesc; 4 | 5 | use sqlx::postgres::Postgres; 6 | use std::cell::RefCell; 7 | use std::collections::HashMap; 8 | use std::sync::Mutex; 9 | pub struct AppState { 10 | pub user_addr_map: Mutex>, 11 | pub db: sqlx::pool::Pool, 12 | pub manage_channel: Option, 13 | pub config: Settings, 14 | } 15 | 16 | #[derive(Debug)] 17 | pub struct TradingData { 18 | pub ticker_ret_cache: HashMap, 19 | } 20 | 21 | impl TradingData { 22 | pub fn new() -> Self { 23 | TradingData { 24 | ticker_ret_cache: HashMap::new(), 25 | } 26 | } 27 | } 28 | 29 | impl Default for TradingData { 30 | fn default() -> Self { 31 | Self::new() 32 | } 33 | } 34 | 35 | //TLS storage 36 | #[derive(Debug)] 37 | pub struct AppCache { 38 | pub trading: RefCell, 39 | } 40 | 41 | impl AppCache { 42 | pub fn new() -> Self { 43 | AppCache { 44 | trading: TradingData::new().into(), 45 | } 46 | } 47 | } 48 | 49 | impl Default for AppCache { 50 | fn default() -> Self { 51 | Self::new() 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/restapi/types.rs: -------------------------------------------------------------------------------- 1 | use crate::config::{Asset, Market}; 2 | use paperclip::actix::Apiv2Schema; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | #[derive(Deserialize, Debug)] 6 | pub struct KlineReq { 7 | pub from: i32, 8 | pub to: i32, 9 | pub symbol: String, 10 | pub resolution: i32, 11 | pub usemock: Option, 12 | } 13 | #[derive(Serialize, Deserialize, Default, Apiv2Schema)] 14 | pub struct KlineResult { 15 | pub s: String, // status, 'ok' or 'no_data' etc 16 | #[serde(skip_serializing_if = "Vec::is_empty")] 17 | pub t: Vec, // timestamp 18 | #[serde(skip_serializing_if = "Vec::is_empty")] 19 | pub c: Vec, // closing price 20 | #[serde(skip_serializing_if = "Vec::is_empty")] 21 | pub o: Vec, // opening price 22 | #[serde(skip_serializing_if = "Vec::is_empty")] 23 | pub h: Vec, // highest price 24 | #[serde(skip_serializing_if = "Vec::is_empty")] 25 | pub l: Vec, // lowest price 26 | #[serde(skip_serializing_if = "Vec::is_empty")] 27 | pub v: Vec, // trading volume 28 | #[serde(rename = "nextTime", skip_serializing_if = "Option::is_none")] 29 | pub nxt: Option, 30 | } 31 | 32 | #[derive(Serialize, Deserialize, Default, Debug, Clone, Apiv2Schema)] 33 | pub struct TickerResult { 34 | pub market: String, 35 | #[serde(rename = "price_change_percent")] 36 | pub change: f32, 37 | pub last: f32, 38 | pub high: f32, 39 | pub low: f32, 40 | pub volume: f32, 41 | pub quote_volume: f32, 42 | pub from: u64, 43 | pub to: u64, 44 | } 45 | 46 | #[derive(Serialize, Deserialize, Apiv2Schema)] 47 | pub struct MarketTrade { 48 | pub time: String, 49 | pub trade_id: i64, 50 | pub amount: String, 51 | pub quote_amount: String, 52 | pub price: String, 53 | pub fee: String, 54 | } 55 | 56 | #[derive(Serialize, Deserialize, Apiv2Schema)] 57 | pub struct OrderTradeResult { 58 | pub trades: Vec, 59 | } 60 | 61 | #[derive(Serialize, Deserialize, Apiv2Schema)] 62 | pub struct NewAssetReq { 63 | pub assets: Vec, 64 | #[serde(default)] 65 | pub not_reload: bool, 66 | } 67 | 68 | #[derive(Serialize, Deserialize, Default, Apiv2Schema)] 69 | pub struct NewTradePairReq { 70 | pub market: Market, 71 | #[serde(default)] 72 | pub asset_base: Option, 73 | #[serde(default)] 74 | pub asset_quote: Option, 75 | #[serde(default)] 76 | pub not_reload: bool, 77 | } 78 | -------------------------------------------------------------------------------- /src/restapi/user.rs: -------------------------------------------------------------------------------- 1 | use crate::models::{tablenames::ACCOUNT, AccountDesc}; 2 | use crate::restapi::errors::RpcError; 3 | use crate::restapi::state::AppState; 4 | use paperclip::actix::api_v2_operation; 5 | use paperclip::actix::web::{self, HttpRequest, Json}; 6 | use std::fmt::Display; 7 | 8 | #[api_v2_operation] 9 | pub async fn get_user(req: HttpRequest, data: web::Data) -> Result, actix_web::Error> { 10 | let user_id = req.match_info().get("l1addr_or_l2pubkey").unwrap().to_lowercase(); 11 | let mut user_map = data.user_addr_map.lock().unwrap(); 12 | 13 | if let Some(user_info) = user_map 14 | .get(&format_user_id_key(&user_id)) 15 | .or_else(|| user_map.get(&format_l1_address_key(&user_id))) 16 | .or_else(|| user_map.get(&format_l2_pubkey_key(&user_id))) 17 | { 18 | return Ok(Json(user_info.clone())); 19 | } 20 | 21 | let sql_query = format!("select * from {} where id = $1 OR l1_address = $2 OR l2_pubkey = $2", ACCOUNT); 22 | let user: AccountDesc = sqlx::query_as(&sql_query) 23 | .bind(user_id.parse::().unwrap_or(-1)) 24 | .bind(user_id) 25 | .fetch_one(&data.db) 26 | .await 27 | .map_err(|e| { 28 | log::error!("{:?}", e); 29 | RpcError::bad_request("invalid user ID, l1 address or l2 public key") 30 | })?; 31 | 32 | let user_info = AccountDesc { 33 | id: user.id, 34 | l1_address: user.l1_address.clone(), 35 | l2_pubkey: user.l2_pubkey.clone(), 36 | }; 37 | 38 | user_map.insert(format_user_id_key(&user.id), user_info.clone()); 39 | user_map.insert(format_l1_address_key(&user.l1_address), user_info.clone()); 40 | user_map.insert(format_l2_pubkey_key(&user.l2_pubkey), user_info); 41 | 42 | Ok(Json(user)) 43 | } 44 | 45 | fn format_user_id_key(val: T) -> String { 46 | format!("id:{}", val) 47 | } 48 | 49 | fn format_l1_address_key(val: T) -> String { 50 | format!("l1_addr:{}", val) 51 | } 52 | 53 | fn format_l2_pubkey_key(val: T) -> String { 54 | format!("l2_pubkey:{}", val) 55 | } 56 | -------------------------------------------------------------------------------- /src/storage/config.rs: -------------------------------------------------------------------------------- 1 | use super::models::{tablenames, AssetDesc, DbType, MarketDesc, TimestampDbType}; 2 | use crate::config; 3 | use anyhow::Result; 4 | 5 | impl From for config::Asset { 6 | fn from(origin: AssetDesc) -> Self { 7 | config::Asset { 8 | id: origin.id, 9 | symbol: origin.symbol, 10 | name: origin.name, 11 | chain_id: origin.chain_id, 12 | token_address: origin.token_address, 13 | rollup_token_id: origin.rollup_token_id, 14 | prec_show: origin.precision_show as u32, 15 | prec_save: origin.precision_stor as u32, 16 | logo_uri: origin.logo_uri, 17 | } 18 | } 19 | } 20 | 21 | impl From for config::Market { 22 | fn from(origin: MarketDesc) -> Self { 23 | let market_name = origin.market_name.unwrap_or(origin.base_asset.clone() + "_" + &origin.quote_asset); 24 | 25 | config::Market { 26 | base: origin.base_asset, 27 | quote: origin.quote_asset, 28 | price_prec: origin.precision_price as u32, 29 | amount_prec: origin.precision_amount as u32, 30 | fee_prec: origin.precision_fee as u32, 31 | name: market_name, 32 | min_amount: origin.min_amount, 33 | } 34 | } 35 | } 36 | 37 | pub struct MarketConfigs { 38 | assets_load_time: TimestampDbType, 39 | market_load_time: TimestampDbType, 40 | } 41 | 42 | // TODO: fix this 43 | #[cfg(sqlxverf)] 44 | fn sqlverf_loadasset_from_db() -> impl std::any::Any { 45 | let t = TimestampDbType::from_timestamp(0, 0); 46 | sqlx::query_as!( 47 | AssetDesc, 48 | "select asset_name, precision_stor, precision_show, create_time from asset where create_time > $1", 49 | t 50 | ) 51 | } 52 | 53 | impl Default for MarketConfigs { 54 | fn default() -> Self { 55 | Self::new() 56 | } 57 | } 58 | 59 | // TODO: fix this 60 | #[cfg(sqlxverf)] 61 | fn sqlverf_loadmarket_from_db() -> impl std::any::Any { 62 | let t = TimestampDbType::from_timestamp(0, 0); 63 | sqlx::query_as!( 64 | MarketDesc, 65 | "select id, create_time, base_asset, quote_asset, 66 | precision_amount, precision_price, precision_fee, 67 | min_amount, market_name from market where create_time > $1", 68 | t 69 | ) 70 | } 71 | 72 | use futures::TryStreamExt; 73 | 74 | impl MarketConfigs { 75 | pub fn new() -> Self { 76 | MarketConfigs { 77 | assets_load_time: TimestampDbType::from_timestamp(0, 0), 78 | market_load_time: TimestampDbType::from_timestamp(0, 0), 79 | } 80 | } 81 | 82 | pub fn reset_load_time(&mut self) { 83 | self.assets_load_time = TimestampDbType::from_timestamp(0, 0); 84 | self.market_load_time = TimestampDbType::from_timestamp(0, 0); 85 | } 86 | 87 | //this load market config from database, instead of loading them from the config 88 | //file 89 | pub async fn load_asset_from_db<'c, 'e, T>(&'c mut self, db_conn: T) -> Result> 90 | where 91 | T: sqlx::Executor<'e, Database = DbType> + Send, 92 | { 93 | let query = format!( 94 | "select id, symbol, name, chain_id, token_address, rollup_token_id, precision_stor, precision_show, 95 | logo_uri, create_time from {} where create_time > $1", 96 | tablenames::ASSET 97 | ); 98 | 99 | let mut ret: Vec = Vec::new(); 100 | let mut rows = sqlx::query_as::<_, AssetDesc>(&query).bind(self.market_load_time).fetch(db_conn); 101 | 102 | while let Some(item) = rows.try_next().await? { 103 | self.assets_load_time = item 104 | .create_time 105 | .and_then(|t| if self.assets_load_time < t { Some(t) } else { None }) 106 | .unwrap_or(self.assets_load_time); 107 | ret.push(item.into()); 108 | } 109 | 110 | log::info!("Load {} assets and update load time to {}", ret.len(), self.assets_load_time); 111 | Ok(ret) 112 | } 113 | 114 | pub async fn load_market_from_db<'c, 'e, T>(&'c mut self, db_conn: T) -> Result> 115 | where 116 | T: sqlx::Executor<'e, Database = DbType>, 117 | { 118 | let query = format!( 119 | "select id, create_time, base_asset, quote_asset, 120 | precision_amount, precision_price, precision_fee, 121 | min_amount, market_name from {} where create_time > $1", 122 | tablenames::MARKET 123 | ); 124 | 125 | let mut ret: Vec = Vec::new(); 126 | let mut rows = sqlx::query_as::<_, MarketDesc>(&query).bind(self.market_load_time).fetch(db_conn); 127 | 128 | while let Some(item) = rows.try_next().await? { 129 | self.market_load_time = item 130 | .create_time 131 | .and_then(|t| if self.market_load_time < t { Some(t) } else { None }) 132 | .unwrap_or(self.market_load_time); 133 | ret.push(item.into()); 134 | } 135 | 136 | log::info!("Load {} market and update load time to {}", ret.len(), self.market_load_time); 137 | Ok(ret) 138 | } 139 | } 140 | 141 | // TODO: fix this 142 | #[cfg(sqlxverf)] 143 | fn sqlverf_persist_asset_to_db() -> impl std::any::Any { 144 | let asset = config::Asset { 145 | name: String::from("test"), 146 | prec_save: 0, 147 | prec_show: 0, 148 | }; 149 | 150 | sqlx::query!( 151 | "insert into asset (asset_name, precision_stor, precision_show) values ($1, $2, $3) 152 | on conflict (asset_name) do update set precision_stor=EXCLUDED.precision_stor, precision_show=EXCLUDED.precision_show", 153 | &asset.name, 154 | asset.prec_save as i16, 155 | asset.prec_show as i16 156 | ) 157 | } 158 | 159 | // TODO: chain_id & logo_uri 160 | pub async fn persist_asset_to_db<'c, 'e, T>(db_conn: T, asset: &config::Asset, force: bool) -> Result<()> 161 | where 162 | T: sqlx::Executor<'e, Database = DbType>, 163 | { 164 | let query_template = if force { 165 | format!( 166 | "insert into {} (id, symbol, name, token_address, rollup_token_id, precision_stor, precision_show) values ($1, $2, $3, $4, $5, $6, $7) 167 | on conflict do update set precision_stor=EXCLUDED.precision_stor, precision_show=EXCLUDED.precision_show", 168 | tablenames::ASSET 169 | ) 170 | } else { 171 | format!( 172 | "insert into {} (id, symbol, name, token_address, rollup_token_id, precision_stor, precision_show) values ($1, $2, $3, $4, $5, $6, $7) on conflict do nothing", 173 | tablenames::ASSET 174 | ) 175 | }; 176 | 177 | sqlx::query(&query_template) 178 | .bind(&asset.id) 179 | .bind(&asset.symbol) 180 | .bind(&asset.name) 181 | .bind(&asset.token_address) 182 | .bind(&asset.rollup_token_id) 183 | .bind(asset.prec_save as i16) 184 | .bind(asset.prec_show as i16) 185 | .execute(db_conn) 186 | .await?; 187 | 188 | Ok(()) 189 | } 190 | 191 | pub async fn persist_market_to_db<'c, 'e, T>(db_conn: T, market: &config::Market) -> Result<()> 192 | where 193 | T: sqlx::Executor<'e, Database = DbType>, 194 | { 195 | sqlx::query(&format!( 196 | "insert into {} (base_asset, quote_asset, 197 | precision_amount, precision_price, precision_fee, 198 | min_amount, market_name) 199 | values ($1, $2, $3, $4, $5, $6, $7)", 200 | tablenames::MARKET 201 | )) 202 | .bind(&market.base) 203 | .bind(&market.quote) 204 | .bind(market.amount_prec as i16) 205 | .bind(market.price_prec as i16) 206 | .bind(market.fee_prec as i16) 207 | .bind(market.min_amount) 208 | .bind(&market.name) 209 | .execute(db_conn) 210 | .await?; 211 | 212 | Ok(()) 213 | } 214 | -------------------------------------------------------------------------------- /src/storage/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub mod database; 3 | pub mod models; 4 | pub mod sqlxextend; 5 | -------------------------------------------------------------------------------- /src/types/mod.rs: -------------------------------------------------------------------------------- 1 | use num_enum::TryFromPrimitive; 2 | use paperclip::actix::Apiv2Schema; 3 | use serde::{Deserialize, Serialize}; 4 | 5 | pub type SimpleResult = anyhow::Result<()>; 6 | 7 | #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy, TryFromPrimitive)] 8 | #[repr(i16)] 9 | pub enum MarketRole { 10 | MAKER = 1, 11 | TAKER = 2, 12 | } 13 | 14 | // https://stackoverflow.com/questions/4848964/difference-between-text-and-varchar-character-varying 15 | // It seems we don't need varchar(n), text is enough? 16 | // https://github.com/launchbadge/sqlx/issues/237#issuecomment-610696905 must use 'varchar'!!! 17 | // text is more readable than #[repr(i16)] and TryFromPrimitive 18 | #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy, sqlx::Type, Apiv2Schema)] 19 | #[sqlx(type_name = "varchar")] 20 | #[sqlx(rename_all = "lowercase")] 21 | pub enum OrderSide { 22 | ASK, 23 | BID, 24 | } 25 | // TryFromPrimitive 26 | #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy, sqlx::Type, Apiv2Schema)] 27 | #[sqlx(type_name = "varchar")] 28 | #[sqlx(rename_all = "lowercase")] 29 | pub enum OrderType { 30 | LIMIT, 31 | MARKET, 32 | } 33 | 34 | #[derive(Serialize, Deserialize, Debug, PartialEq, Clone, Copy)] 35 | pub enum OrderEventType { 36 | PUT = 1, 37 | UPDATE = 2, 38 | FINISH = 3, 39 | EXPIRED = 4, 40 | } 41 | 42 | //pub type DbType = diesel::mysql::Mysql; 43 | //pub type ConnectionType = diesel::mysql::MysqlConnection; 44 | pub type DbType = sqlx::Postgres; 45 | pub type ConnectionType = sqlx::postgres::PgConnection; 46 | pub type DBErrType = sqlx::Error; 47 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod serde; 2 | pub mod strings; 3 | 4 | pub use strings::*; 5 | -------------------------------------------------------------------------------- /src/utils/serde.rs: -------------------------------------------------------------------------------- 1 | use core::fmt; 2 | use core::marker::PhantomData; 3 | use serde::de::{Deserializer, Error, Unexpected, Visitor}; 4 | use serde::ser::Serializer; 5 | use std::convert::TryInto; 6 | 7 | pub trait HexArray<'de>: Sized { 8 | fn serialize(&self, serializer: S) -> Result 9 | where 10 | S: Serializer; 11 | fn deserialize(deserializer: D) -> Result 12 | where 13 | D: Deserializer<'de>; 14 | } 15 | 16 | impl<'de, const N: usize> HexArray<'de> for [u8; N] { 17 | fn serialize(&self, serializer: S) -> Result 18 | where 19 | S: Serializer, 20 | { 21 | serializer.serialize_str(hex::encode(&self).as_str()) 22 | } 23 | 24 | fn deserialize(deserializer: D) -> Result 25 | where 26 | D: Deserializer<'de>, 27 | { 28 | struct HexArrayVisitor { 29 | value: PhantomData, 30 | } 31 | 32 | impl<'de, const N: usize> Visitor<'de> for HexArrayVisitor<[u8; N]> { 33 | type Value = [u8; N]; 34 | 35 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 36 | write!(formatter, "an hex encoded array of length {}", N) 37 | } 38 | 39 | fn visit_str(self, v: &str) -> Result 40 | where 41 | E: Error, 42 | { 43 | hex::decode(v) 44 | .ok() 45 | .and_then(|v| v.try_into().ok()) 46 | .ok_or_else(|| Error::invalid_type(Unexpected::Str(v), &self)) 47 | } 48 | } 49 | 50 | let visitor = HexArrayVisitor { value: PhantomData }; 51 | deserializer.deserialize_str(visitor) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/utils/strings.rs: -------------------------------------------------------------------------------- 1 | use lazy_static::lazy_static; 2 | use std::collections::HashMap; 3 | use std::sync::Mutex; 4 | lazy_static! { 5 | pub static ref STRING_POOL: Mutex> = Default::default(); 6 | } 7 | 8 | // don't make this function From. We'd better call this explicitly 9 | // prevent any unintentional mem leak 10 | pub fn intern_string(s: &str) -> &'static str { 11 | *STRING_POOL 12 | .lock() 13 | .unwrap() 14 | .entry(s.to_owned()) 15 | .or_insert_with(|| Box::leak(s.to_string().into_boxed_str())) 16 | } 17 | 18 | #[derive(Debug, Clone, Copy, Default)] 19 | pub struct InternedString(&'static str); 20 | 21 | impl From<&'static str> for InternedString { 22 | fn from(str: &'static str) -> Self { 23 | InternedString(str) 24 | } 25 | } 26 | 27 | impl From for &'static str { 28 | fn from(str: InternedString) -> Self { 29 | str.0 30 | } 31 | } 32 | 33 | impl std::ops::Deref for InternedString { 34 | type Target = str; 35 | fn deref(&self) -> &Self::Target { 36 | self.0 37 | } 38 | } 39 | 40 | impl serde::ser::Serialize for InternedString { 41 | fn serialize(&self, serializer: S) -> Result { 42 | serializer.serialize_str(self.0) 43 | } 44 | } 45 | 46 | impl<'de> serde::de::Deserialize<'de> for InternedString { 47 | fn deserialize>(deserializer: D) -> Result { 48 | let s = String::deserialize(deserializer)?; 49 | Ok(intern_string(&s).into()) 50 | } 51 | } 52 | --------------------------------------------------------------------------------