├── .env.dev ├── .env.prod ├── .github ├── issue_template.md ├── pull_request_template.md └── workflows │ ├── build_push_docker.yml │ ├── ci.yml │ └── docker_push_oidc.yaml ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .vscode ├── extensions.json └── settings.json ├── Dockerfile ├── LICENSE ├── README-config.md ├── README-dev.md ├── README-metrics.md ├── README.md ├── SECURITY.md ├── __mocks__ ├── @celo │ └── contractkit │ │ └── index.ts └── bunyan │ └── index.ts ├── build_and_publish.sh ├── devApiKeys.txt ├── devPriceSourcesConfig.txt ├── jest.config.js ├── package.json ├── pnpm-lock.yaml ├── readinessProbe.sh ├── renovate.json ├── scripts └── get_cert_fingerprint.sh ├── src ├── aggregator_functions.ts ├── app.ts ├── data_aggregator.ts ├── default_config.ts ├── envvar_utils.ts ├── exchange_adapters │ ├── alphavantage.ts │ ├── base.ts │ ├── binance.ts │ ├── binance_us.ts │ ├── bitget.ts │ ├── bitmart.ts │ ├── bitso.ts │ ├── bitstamp.ts │ ├── bittrex.ts │ ├── coinbase.ts │ ├── currencyapi.ts │ ├── gemini.ts │ ├── kraken.ts │ ├── kucoin.ts │ ├── mercado.ts │ ├── novadax.ts │ ├── okcoin.ts │ ├── okx.ts │ ├── whitebit.ts │ └── xignite.ts ├── exchange_price_source.ts ├── index.ts ├── metric_collector.ts ├── price_source.ts ├── reporters │ ├── base.ts │ ├── block_based_reporter.ts │ └── transaction_manager │ │ ├── index.ts │ │ ├── send.ts │ │ └── send_with_retries.ts ├── run_app.ts ├── services │ └── SSLFingerprintService.ts └── utils.ts ├── test ├── aggregator_functions.test.ts ├── app.test.ts ├── data_aggregator.test.ts ├── data_aggregator_testdata_utils.ts ├── envvar_utils.test.ts ├── exchange_adapters │ ├── alphavantage.test.ts │ ├── base.test.ts │ ├── binance.test.ts │ ├── binance_us.test.ts │ ├── bitget.test.ts │ ├── bitmart.test.ts │ ├── bitso.test.ts │ ├── bitstamp.test.ts │ ├── bittrex.test.ts │ ├── coinbase.test.ts │ ├── currencyapi.test.ts │ ├── gemini.test.ts │ ├── kraken.test.ts │ ├── kucoin.test.ts │ ├── mercado.test.ts │ ├── novadax.ts │ ├── okcoin.test.ts │ ├── okx.test.ts │ ├── whitebit.test.ts │ └── xignite.test.ts ├── exchange_price_source.test.ts ├── reporters │ ├── base.test.ts │ ├── block_based_reporter.test.ts │ └── transaction_manager.test.ts ├── run_app.test.ts ├── services │ ├── mock_ssl_fingerprint_service.ts │ └── ssl_fingerprint_service.test.ts └── utils.test.ts ├── tsconfig.json └── tslint.json /.env.dev: -------------------------------------------------------------------------------- 1 | export DEVMODE=true 2 | export HTTP_RPC_PROVIDER_URL=https://forno.celo.org 3 | export WS_RPC_PROVIDER_URL=wss://forno.celo.org/ws 4 | export PRICE_SOURCES=$(cat devPriceSourcesConfig.txt) 5 | export OVERRIDE_INDEX=1 6 | export OVERRIDE_ORACLE_COUNT=3 7 | export SSL_REGISTRY_ADDRESS=0x72d96b39d207c231a3b803f569f05118514673ac 8 | export API_KEYS=$(cat devApiKeys.txt) 9 | export MID_AGGREGATION_MAX_EXCHANGE_VOLUME_SHARE=1 10 | -------------------------------------------------------------------------------- /.env.prod: -------------------------------------------------------------------------------- 1 | # This file is for reference only, each oracle provider should make their own 2 | # Documentation on what variables can be configured can be found here: https://github.com/celo-org/celo-oracle/blob/main/README-config.md 3 | 4 | MID_AGGREGATION_MAX_EXCHANGE_VOLUME_SHARE=1 5 | MID_AGGREGATION_MAX_PERCENTAGE_DEVIATION=0.05 6 | MID_AGGREGATION_MAX_PERCENTAGE_BID_ASK_SPREAD=0.025 7 | 8 | METRICS=true 9 | PROMETHEUS_PORT=9090 10 | 11 | API_REQUEST_TIMEOUT=5000 12 | CIRCUIT_BREAKER_PRICE_CHANGE_THRESHOLD=0.25 13 | MINIMUM_PRICE_SOURCES=2 14 | REPORT_STRATEGY=BLOCK_BASED 15 | MIN_REPORT_PRICE_CHANGE_THRESHOLD=0.005 16 | 17 | # Replace with your RPC 18 | HTTP_RPC_PROVIDER_URL= 19 | # Replace with your RPC WS 20 | WS_RPC_PROVIDER_URL= 21 | 22 | 23 | # Replace with your address (at least your using private key) 24 | ADDRESS= 25 | 26 | DEVMODE=true 27 | OVERRIDE_INDEX=1 28 | OVERRIDE_ORACLE_COUNT=3 29 | CURRENCY_PAIR=CELOUSD 30 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | ### Expected Behavior 2 | 3 | _Please describe the behavior you are expecting, and any context helpful in understanding the purpose of that expected behavior._ 4 | 5 | ### Current Behavior 6 | 7 | _What is the current behavior and why is it a problem?_ 8 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | _A few sentences describing the overall effects and goals of the pull request's commits. 4 | What is the current behavior, and what is the updated/expected behavior with this PR?_ 5 | 6 | ## Other changes 7 | 8 | _Describe any minor or "drive-by" changes here._ 9 | 10 | ## Tested 11 | 12 | _An explanation of how the changes were tested or an explanation as to why they don't need to be._ 13 | 14 | ## Related issues 15 | 16 | - Fixes #[issue number here] 17 | 18 | ## Backwards compatibility 19 | 20 | _Brief explanation of why these changes are/are not backwards compatible._ 21 | -------------------------------------------------------------------------------- /.github/workflows/build_push_docker.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Build and Push Image 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | build_env: 7 | description: 'Build environment (staging|production)' 8 | required: true 9 | default: 'staging' 10 | type: string 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | env: 15 | BUILD_ENV: ${{ inputs.build_env }} 16 | steps: 17 | 18 | - name: Checkout 19 | uses: actions/checkout@v4 20 | 21 | - name: 'Authenticate to Google Cloud' 22 | uses: 'google-github-actions/auth@v1' 23 | with: 24 | credentials_json: '${{ secrets.GCP_SERVICE_ACCOUNT }}' 25 | 26 | - name: 'Set up Cloud SDK' 27 | uses: 'google-github-actions/setup-gcloud@v1' 28 | 29 | - name: Configure Docker Client 30 | run: |- 31 | gcloud auth configure-docker us-west1-docker.pkg.dev --quiet 32 | 33 | - name: Build and publish docker image 34 | run: |- 35 | ./build_and_publish.sh -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: 'CI' 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: 7 | - 'main' 8 | - 'feature/**' 9 | push: 10 | branches: 11 | - 'main' 12 | 13 | jobs: 14 | ci: 15 | name: Build & Test 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: 'Check out the repo' 20 | uses: 'actions/checkout@v4' 21 | 22 | - uses: pnpm/action-setup@v3 23 | with: 24 | version: 9 25 | 26 | - name: Use Node.js 20.x 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: 20.x 30 | cache: 'pnpm' 31 | 32 | - name: Install dependencies 33 | run: pnpm install --frozen-lockfile 34 | 35 | - name: Build packages 36 | run: pnpm run build 37 | 38 | - name: Run lint checks 39 | run: | 40 | pnpm run prettify:diff 41 | pnpm run lint 42 | pnpm run lint:tests 43 | 44 | - name: Run tests 45 | run: pnpm run test --maxWorkers 2 46 | -------------------------------------------------------------------------------- /.github/workflows/docker_push_oidc.yaml: -------------------------------------------------------------------------------- 1 | name: Docker Push OIDC 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Release Version' 8 | required: true 9 | default: '0.1.0' 10 | type: string 11 | 12 | jobs: 13 | Build-Celo-Oracle: 14 | uses: celo-org/reusable-workflows/.github/workflows/container-cicd.yaml@v2.0.4 15 | with: 16 | workload-id-provider: projects/1094498259535/locations/global/workloadIdentityPools/gh-celo-oracle/providers/github-by-repos 17 | service-account: 'celo-oracle-images@devopsre.iam.gserviceaccount.com' 18 | artifact-registry: us-west1-docker.pkg.dev/devopsre/celo-oracle/celo-oracle 19 | tag: ${{ inputs.version }} 20 | context: . 21 | provenance: false 22 | trivy: true 23 | trivy-timeout: 40m 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # node.js 2 | node_modules/ 3 | npm-debug.log 4 | yarn-error.log 5 | 6 | lib/ 7 | tsconfig.tsbuildinfo 8 | 9 | # OSX 10 | .DS_Store 11 | 12 | # IDEs 13 | .idea/ 14 | 15 | # User specific 16 | .env.dev 17 | devPriceSourcesConfig.txt 18 | .tool-versions 19 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | **/node_modules 3 | .git/ 4 | 5 | lib/ 6 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | semi: false, 3 | singleQuote: true, 4 | trailingComma: 'es5', 5 | arrowParens: 'always', 6 | printWidth: 100, 7 | tabWidth: 2, 8 | bracketSpacing: true, 9 | overrides: [ 10 | { 11 | files: '**/*.sol', 12 | options: { 13 | singleQuote: false, 14 | }, 15 | }, 16 | ], 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | "eamodio.gitlens", 8 | "ms-vscode.vscode-typescript-tslint-plugin", 9 | "esbenp.prettier-vscode", 10 | "juanblanco.solidity", 11 | "redhat.vscode-yaml", 12 | "smkamranqadri.vscode-bolt-language" 13 | ], 14 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 15 | "unwantedRecommendations": [] 16 | } 17 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "search.exclude": { 3 | "**/node_modules": false 4 | }, 5 | "files.exclude": { 6 | "**/*.js": { "when": "$(basename).ts" }, 7 | "**/*.js.map": true 8 | }, 9 | "files.watcherExclude": { 10 | "**/.git/objects/**": true, 11 | "**/.git/subtree-cache/**": true, 12 | "**/node_modules/*/**": true 13 | }, 14 | "typescript.preferences.importModuleSpecifier": "non-relative", 15 | "typescript.updateImportsOnFileMove.enabled": "always", 16 | "editor.codeActionsOnSave": { 17 | "source.organizeImports": "never" 18 | }, 19 | "[javascript]": { 20 | "editor.formatOnSave": true, 21 | "editor.codeActionsOnSave": { 22 | "source.organizeImports": "never" 23 | } 24 | }, 25 | "[javascriptreact]": { 26 | "editor.formatOnSave": true, 27 | "editor.codeActionsOnSave": { 28 | "source.organizeImports": "explicit" 29 | } 30 | }, 31 | "[typescript]": { 32 | "editor.formatOnSave": false, 33 | "editor.codeActionsOnSave": { 34 | "source.organizeImports": "never" 35 | } 36 | }, 37 | "javascript.format.enable": false, 38 | "editor.tabSize": 2, 39 | "editor.detectIndentation": false, 40 | "tslint.jsEnable": true, 41 | "typescript.tsdk": "node_modules/typescript/lib" 42 | } 43 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # How to build: 2 | # export COMMIT_SHA=$(git rev-parse HEAD) 3 | # docker build -f Dockerfile --build-arg COMMIT_SHA=$COMMIT_SHA -t oracletest.azurecr.io/test/oracle:$COMMIT_SHA . 4 | 5 | # How to push to registry 6 | # az acr login -n oracletest 7 | # docker push oracletest.azurecr.io/test/oracle:$COMMIT_SHA 8 | 9 | # First stage, builder to install devDependencies to build TypeScript 10 | FROM node:20.11 as BUILDER 11 | 12 | ENV PNPM_HOME="/pnpm" 13 | ENV PATH="$PNPM_HOME:$PATH" 14 | RUN corepack enable 15 | 16 | RUN apt-get update 17 | RUN apt-get install -y libusb-1.0-0-dev 18 | 19 | WORKDIR /celo-oracle 20 | 21 | # ensure pnpm-lock.yaml is evaluated by kaniko cache diff 22 | COPY package.json pnpm-lock.yaml ./ 23 | 24 | RUN pnpm install --frozen-lockfile 25 | 26 | COPY tsconfig.json ./ 27 | 28 | # copy contents 29 | COPY src src 30 | 31 | # build contents 32 | RUN pnpm run build 33 | 34 | # Second stage, create slimmed down production-ready image 35 | FROM node:20.11 36 | ARG COMMIT_SHA 37 | ENV NODE_ENV production 38 | RUN corepack enable 39 | WORKDIR /celo-oracle 40 | 41 | COPY package.json package.json pnpm-lock.yaml tsconfig.json readinessProbe.sh ./ 42 | 43 | COPY --from=BUILDER /celo-oracle/lib ./lib 44 | 45 | RUN pnpm install --frozen-lockfile 46 | RUN echo $COMMIT_SHA > /version 47 | RUN ["chmod", "+x", "/celo-oracle/readinessProbe.sh"] 48 | 49 | USER 1000:1000 50 | 51 | CMD pnpm run start 52 | -------------------------------------------------------------------------------- /README-dev.md: -------------------------------------------------------------------------------- 1 | # Development Guide 2 | 3 | ## Prerequisites 4 | 5 | Running the Celo Oracle locally is the easiest way to get started contributing to the code base. We leverage a few existing Celo specific tools so make sure to have the [celo-monorepo](https://github.com/celo-org/celo-monorepo) and [celo-blockchain](https://github.com/celo-org/celo-blockchain) cloned locally and follow their respective setup guides before continuing. The folder structure most commonly used is: 6 | 7 | ```shell 8 | ~/celo/celo-monorepo 9 | ~/celo/celo-blockchain 10 | ~/celo/celo-oracle 11 | ``` 12 | 13 | For more information on how the Oracles work on Celo take a look at the [Oracles](https://docs.celo.org/celo-codebase/protocol/stability/oracles) section of the [docs](https://docs.celo.org). 14 | 15 | 16 | 17 | 18 | 19 | ## How to develop the oracle locally 20 | 21 | To run the oracle we need a couple of things: 22 | 23 | 1. A private key for your Oracle to sign transactions 24 | 2. A running Celo blockchain with relevant migrations applied 25 | - this means we need to register your Oracle's address in the migration overrides 26 | 27 | ### Generate private key and address 28 | 29 | To accomplish we'll use the celocli package from the monorepo, run the following in your terminal from the monorepo directory: 30 | 31 | ```shell 32 | ➜ ~/celo/celo-monorepo yarn run celocli account:new 33 | ... 34 | ... 35 | mnemonic: ahead puppy smile edge interest resource element piano stem protect flush spring leader urban paddle gospel fuel rotate offer calm bottom enemy awake hub 36 | accountAddress: 0x2665BBD75eca45870a7202371dA328B8DbF66B09 37 | privateKey: 371c2cd7e789496861d483b20c71ec8c49b8cb93739d3648cd7364b318c8923e 38 | publicKey: 02a8243ec653a3e5244d436077ca7da13d2fdc1122fb97794acf5df6625d213dfa 39 | ``` 40 | 41 | Because we'll be using an in memory private key for development we can go ahead and copy that private key into the file `/tmp/defaultPrivateKey`. The file to use is configurable via the environment variable `PRIVATE_KEY_PATH` when running an Oracle later. 42 | 43 | We'll need to add this account address in two places, once in the `migration-override.json` file so our address is registered as an Oracle, and once again in an `initial-accounts.json` file where we assign it a balance of CELO so we can make reports. The diff may look something like this: 44 | 45 | ```diff 46 | --- a/packages/dev-utils/src/migration-override.json 47 | +++ b/packages/dev-utils/src/migration-override.json 48 | }, 49 | "stableToken": { 50 | @@ -59,11 +59,17 @@ 51 | "0x5409ED021D9299bf6814279A6A1411A7e866A631", 52 | "0xE36Ea790bc9d7AB70C55260C66D52b1eca985f84", 53 | "0x06cEf8E666768cC40Cc78CF93d9611019dDcB628", 54 | - "0x7457d5E02197480Db681D3fdF256c7acA21bDc12" 55 | + "0x7457d5E02197480Db681D3fdF256c7acA21bDc12", 56 | + "0x2665BBD75eca45870a7202371dA328B8DbF66B09" 57 | ], 58 | "frozen": false 59 | } 60 | ``` 61 | 62 | ```diff 63 | --- a/initial-accounts.json 64 | +++ b/initial-accounts.json 65 | + [ 66 | + { 67 | + "address": "0x2665BBD75eca45870a7202371dA328B8DbF66B09", 68 | + "balance": "120000000000000000000000000" 69 | + } 70 | + ] 71 | ``` 72 | 73 | ### Run a local Celo blockchain node 74 | 75 | This step requires you to have the helper tool `celotooljs` installed on your path, if you didn't do that while setting up the monorepo add this to your `.bashrc`/`.zshrc`/`.XXXrc` file `alias celotooljs=/packages/celotool/bin/celotooljs.sh`. 76 | 77 | 1. Make sure you have [built](https://github.com/celo-org/celo-blockchain/blob/master/README.md#building-the-source) the `geth` package on the blockchain repo. 78 | 79 | 2. Run the following command in a new terminal window or tab to start up a blockchain node: 80 | 81 | ```shell 82 | ➜ ~/celo/celo-oracle celotooljs geth start --data-dir /tmp/chain-data --geth-dir ~/celo/celo-blockchain --verbose --migrate --purge --monorepo-dir ~/celo/celo-monorepo --mining --verbosity 1 --migration-overrides ~/celo/celo-monorepo/packages/dev-utils/src/migration-override.json --migrateTo 24 --initial-accounts ~/celo/celo-monorepo/initial-accounts.json 83 | 84 | ... 85 | ... 86 | ... 87 | 88 | ... done migrating contracts! 89 | ``` 90 | 91 | This may take a while. 92 | 93 | ### Startup the oracle 94 | 95 | Back in your original terminal run the following: 96 | 97 | ```shell 98 | ➜ ~/celo/celo-oracle yarn build && yarn start | npx bunyan 99 | ``` 100 | 101 | After running this your Oracle should be observing blocks and reporting prices to your node. 102 | -------------------------------------------------------------------------------- /README-metrics.md: -------------------------------------------------------------------------------- 1 | # Metrics 2 | 3 | If the application is configured such that `METRICS` is true, various metrics using [prom-client](https://github.com/siimon/prom-client) are kept track of and exposed at port `http://localhost:${PROMETHEUS_PORT}/metrics`. The recommended [default metrics](https://github.com/siimon/prom-client#default-metrics) are also exposed. 4 | 5 | ## Metric breakdown 6 | 7 | ### `oracle_action_duration_bucket` 8 | 9 | This is a histogram that keeps track of the durations in seconds for various async actions of a specific type. 10 | 11 | Labels: 12 | ``` 13 | ['type', 'action', 'token'] 14 | ``` 15 | 16 | Example labels: 17 | ``` 18 | {type="report",action="getSortedOracles",token="StableToken"} 19 | ``` 20 | 21 | Valid types and their actions: 22 | ``` 23 | type: report 24 | actions: getSortedOracles, report, send, waitReceipt, getTransaction 25 | 26 | type: expiry 27 | actions: getSortedOracles, isOldestReportExpired, removeExpiredReports, send, waitReceipt, getTransaction 28 | ``` 29 | 30 | ### `oracle_errors_total` 31 | 32 | This is a counter that keeps track of the total number of errors in various contexts. 33 | 34 | Labels: 35 | ``` 36 | ['context'] 37 | ``` 38 | 39 | Example labels: 40 | ``` 41 | {context="report"} 42 | ``` 43 | 44 | Valid contexts: 45 | ``` 46 | type Context = 'app' | 'block_header_subscription' | 'wallet_init' | 'expiry' | 'report' | 'report_price' | Exchange 47 | ``` 48 | 49 | ### `oracle_exchange_api_request_duration_seconds` 50 | 51 | This is a histogram that keeps track of the API request durations in seconds for each exchange. 52 | 53 | Labels: 54 | ``` 55 | ['exchange', 'endpoint', 'pair'] 56 | ``` 57 | 58 | Example labels: 59 | ``` 60 | {exchange="Bittrex",endpoint="markets/CELO-USD/ticker",pair="CELO/USD"} 61 | ``` 62 | 63 | ### `oracle_exchange_api_request_error_count` 64 | 65 | This is a counter that shows how many API request errors have occurred for an exchange, endpoint, and either an HTTP status code or another type of error (fetching or json parsing). 66 | 67 | Labels: 68 | ``` 69 | ['exchange', 'endpoint', 'pair', 'type'] 70 | ``` 71 | 72 | Example labels: 73 | ``` 74 | {exchange="Bittrex",endpoint="markets/CELO-USD/ticker",pair="CELO/USD",type="404"} 75 | {exchange="Bittrex",endpoint="markets/CELO-USD/ticker",pair="CELO/USD",type="fetch"} 76 | {exchange="Bittrex",endpoint="markets/CELO-USD/ticker",pair="CELO/USD",type="json_parse"} 77 | ``` 78 | 79 | ### `oracle_last_block_header_number` 80 | 81 | This is a gauge that indicates the number of the most recent block header seen when using block-based reporting. 82 | 83 | Labels: 84 | ``` 85 | ['type'] 86 | ``` 87 | 88 | Example labels: 89 | ``` 90 | {type="assigned"} 91 | ``` 92 | 93 | Valid types: 94 | ``` 95 | all, assigned 96 | ``` 97 | 98 | ### `oracle_potential_report_value` 99 | 100 | This is a gauge to show the most recently evaluated price to report when using block-based reporting. 101 | 102 | Labels: 103 | ``` 104 | ['token'] 105 | ``` 106 | 107 | Example labels: 108 | ``` 109 | {token="StableToken"} 110 | ``` 111 | 112 | ### `oracle_price_source` 113 | 114 | This is a gauge that shows price and weight for a configured PriceSource. 115 | 116 | Labels: 117 | ``` 118 | ['pair', 'source', 'property'] 119 | ``` 120 | 121 | Example labels: 122 | ``` 123 | {pair="CELOEUR",source="COINBASE:CGLD-EUR:false",property="price"} 124 | ``` 125 | 126 | Valid property values: 127 | ``` 128 | price, weight 129 | ``` 130 | 131 | ### `oracle_report_count` 132 | 133 | This is a counter that counts the number of reports by trigger. 134 | 135 | Labels: 136 | ``` 137 | ['token', 'trigger'] 138 | ``` 139 | 140 | Example labels: 141 | ``` 142 | {token="StableToken",trigger="heartbeat"} 143 | ``` 144 | 145 | Valid triggers: 146 | ``` 147 | timer, heartbeat, price_change 148 | ``` 149 | 150 | ### `oracle_report_time_since_last_report_seconds` 151 | 152 | This is a gauge that keeps track of the time in seconds between the last report and the report before that. 153 | 154 | Labels: 155 | ``` 156 | ['token'] 157 | ``` 158 | 159 | Example labels: 160 | ``` 161 | {token="StableToken"} 162 | ``` 163 | 164 | ### `oracle_report_value` 165 | 166 | This is a gauge that keeps track of the price of the most recent Oracle report. 167 | 168 | Labels: 169 | ``` 170 | ['token'] 171 | ``` 172 | 173 | Example labels: 174 | ``` 175 | {token="StableToken"} 176 | ``` 177 | 178 | ### `oracle_ticker_property` 179 | 180 | This is a gauge that provides some properties of the last ticker data retrieved from a particular exchange. 181 | 182 | Labels: 183 | ``` 184 | ['exchange', 'pair', 'property'] 185 | ``` 186 | 187 | Example labels: 188 | ``` 189 | {exchange="BITTREX",pair="CELO/USD",property="ask"} 190 | ``` 191 | 192 | Valid property values: 193 | ``` 194 | ask, baseVolume, bid, lastPrice, timestamp 195 | ``` 196 | 197 | ### `oracle_transaction_block_number` 198 | 199 | This is a gauge that keeps track of the block number of the most recent transaction of a given type. 200 | 201 | Labels: 202 | ``` 203 | ['type', 'token'] 204 | ``` 205 | 206 | Example labels: 207 | ``` 208 | {type="report",token="StableToken"} 209 | ``` 210 | 211 | Valid types: 212 | ``` 213 | report, expiry 214 | ``` 215 | 216 | ### `oracle_transaction_gas` 217 | 218 | This is a gauge that keeps track of the amount of gas provided for the most recent transaction of a given type. 219 | 220 | Labels: 221 | ``` 222 | ['type', 'token'] 223 | ``` 224 | 225 | Example labels: 226 | ``` 227 | {type="report",token="StableToken"} 228 | ``` 229 | 230 | Valid types: 231 | ``` 232 | report, expiry 233 | ``` 234 | 235 | ### `oracle_transaction_gas_price` 236 | 237 | This is a gauge that keeps track of the gas price for the most recent transaction of a given type. 238 | 239 | Labels: 240 | ``` 241 | ['type', 'token'] 242 | ``` 243 | 244 | Example labels: 245 | ``` 246 | {type="report",token="StableToken"} 247 | ``` 248 | 249 | Valid types: 250 | ``` 251 | report, expiry 252 | ``` 253 | 254 | ### `oracle_transaction_gas_used` 255 | 256 | This is a gauge that keeps track of the gas used for the most recent transaction of a given type. 257 | 258 | Labels: 259 | ``` 260 | ['type', 'token'] 261 | ``` 262 | 263 | Example labels: 264 | ``` 265 | {type="report",token="StableToken"} 266 | ``` 267 | 268 | Valid types: 269 | ``` 270 | report, expiry 271 | ``` 272 | 273 | ### `oracle_transaction_success_count` 274 | 275 | This is a counter that records the number of transactions for a given type that have successfully been mined. 276 | 277 | Labels: 278 | ``` 279 | ['type', 'token'] 280 | ``` 281 | 282 | Example labels: 283 | ``` 284 | {type="report",token="StableToken"} 285 | ``` 286 | 287 | Valid types: 288 | ``` 289 | report, expiry 290 | ``` 291 | 292 | ### `oracle_websocket_provider_setup_counter` 293 | 294 | This is a counter that records the number of times the websocket provider has been setup. This only occurs when using block-based reporting, and happens when there is an error with the existing websocket provider. 295 | 296 | Labels: none 297 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security 2 | 3 | ## Security Announcements 4 | 5 | Public announcements of new releases with security fixes and of disclosure of any vulnerabilities will be made in the Celo Forum's [Security Announcements](https://forum.celo.org/c/security-announcements/) channel. 6 | 7 | ## Reporting a Vulnerability 8 | 9 | We’re extremely grateful for security researchers and users that report vulnerabilities to the Celo community. All reports are thoroughly investigated. 10 | 11 | **Please do not file a public ticket** mentioning any vulnerability. 12 | 13 | The Celo community asks that all suspected vulnerabilities be privately and responsibly disclosed. 14 | 15 | To make a report, submit your vulnerability to [Celo on HackerOne](https://hackerone.com/celo). 16 | 17 | You can also email the [security@celo.org](mailto:security@celo.org) list with the details of reproducing the vulnerability as well as the usual details expected for all bug reports. 18 | 19 | While the primary focus of this disclosure program is the Celo protocol and the Celo wallet, the team may be able to assist in coordinating a response to a vulnerability in the third-party apps or tools in the Celo ecosystem. 20 | 21 | You may encrypt your email to this list using this GPG key (but encryption using GPG is NOT required to make a disclosure): 22 | 23 | ``` 24 | -----BEGIN PGP PUBLIC KEY BLOCK----- 25 | 26 | mQINBF5Vg0MBEADNmoPEf2HiSGGqJZOE8liv783KZVKRle5oTwI9VNF7rnHUq0ub 27 | /jf6B/saUliO8JbYTyfbUXfPjaeIRxA1zvbHPMtWdj6coPUFwvZ77okDHeXGAnFl 28 | 6ZcKs/q8mpcNP8E4ATtvrNUW3aRkDvud2e+ysIHyjaae1mf29cWMGInxjm3YUyMr 29 | 5/YnJEzSiVN+krtTDDVg4N2qZbR1gX7uvVlXytzD92vKWNurWi2ZXhwWhC0BbcCK 30 | HlHnEhok2njMqmKlY1rzj35hNwzxwj8fZi3JGgTPQAUZP6vHqvo7GxmUYPQqo/f0 31 | 2y7dshL3An7AM3OMIWbtwsh72PX+SqeKTF9Y00TsYz4raVv4ub2HT/0TtOwBlNJD 32 | fBr3XgRMkUtBGhaGWe8D4P6UNUM/imz8EQLCbFa6qhYr+asrYzvCGHHNy9v3OeJF 33 | fyYyqn/k+44zMTCZx7FGR/SFjEDnROqjFmOYio+Tuv5U7ycJu8Bhu2qqCCxYApR6 34 | NyVZQ1U7cTWTLLWkFfe359pSM2KhK9ftuRm2Jf1CmkgTsxYxpzyuFYf37FvI8LFK 35 | 06h1R5yr7lTNY0EfKG6UWTHmJo5oYSTJ1tWEItxT0H4cjCRVyJxs2ze9sdPc1a6f 36 | hP05fk+VLyN29WgmVuKtrMHUjtWwVhjbmOe9fCMHe4+N/4jPZ/ivvT5c2QARAQAB 37 | tDJDZWxvIFZ1bG5lcmFiaWxpdHkgRGlzY2xvc3VyZXMgPHNlY3VyaXR5QGNlbG8u 38 | b3JnPokCTgQTAQgAOBYhBPXFzhvm4XlmxS2jdaoCJpJIGPpJBQJeVYNDAhsDBQsJ 39 | CAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEKoCJpJIGPpJqqcQAKWp35OEhP/Je8Xg 40 | XmMuBeaOqdjdWoBQD7lYIBwd6I+Y1vF6mk69yWLhWqIqgSaTf8BtHyZmuKPAs8rX 41 | mSwdQY9X/cy3ECLBFP5TABGwZx6F6cYNXH6b/drSo6Mh/ZEJE/M4rwuHImEIVO9f 42 | jljA66HaNOZp3C9CBVqUlsJkezXR7JCWcKwrlzXyG7NeP/6/yqlCADX8zgGEjBLa 43 | bsFrSLnBgxncSmxEmSjlT3BxpUv6T5QGu/QxfhmgqgmwzUKA5Ak1aZ4jxZQ0W5ZB 44 | bT2jyifJXszg1qeZ+bbyFSINqiuDB9/gJTNwZ5WTtf2Q8HBJOS6i0uRpHaXqY841 45 | 4srFeu7bOM35FCUL6kZ2snMQSVuw9cnvWtcby/rJZ3QUFn0Sffh4b/c7zS+/aUOM 46 | 8e2MrF76veHa4uqA1gHOtsT2LKtyaVn6HdImeMH0TQJVvAm8u8B0jrcgB1z8BjrR 47 | qbJeJnqjz9pGQo1AMuuSJ5pj3BemfGlUyDx4KuJVJBFDqzcP8DjOEYY9ylcqS7MO 48 | lZS36e00npd2sHcty0FmHpYKMODNfZ+XW6OAfOMX9eZlispOV5JmT78FTr5HLHjG 49 | oTOBGeOTDB4Y16d4EO47AZvOBPMuXsw2SJ4aqyyxhMVlgdnp+0A3CMpkv0BnvVFT 50 | ewaQUMIrvaTKyF5B4Mf3E0pTDQ5OuQINBF5Vg0MBEACzQa+flDsevx9Utb5hQN+S 51 | mKPQpkno7AN1PdLA23DAiXoV2L3Mwa7lpqSKrwo8yRnlq7aNo6j4/G1xnmgFyd6c 52 | oYHWOwSfF/nkio2Stjf6UQSe8WlHNwKTOyXA2ABZ9Xr4BpeuTh+tZIf02VEfdDEd 53 | tTfCcq/iA/UScUDG2LmPcFt62shjJ5+bP0HHalIn28kgaTJIWgp8tWkIFvt7TRYt 54 | UrBxcjgmVnpb2eC1AI9UoQ04+7hV4mXb19GfM0WdssmOPArtmHo2daGg/WwcbgJ8 55 | oqSBe/DEqmjrn7ETNn1wbCgsA8nPS7NCrBxl0pFEiav+2ZJ0B6jOA1m8UI4FQUPS 56 | oXnZMyq7jd8liBeWQrrK8OpUUKcyaBDnM31wNgmWJAq9Ck42JnnLijYoESbpOk9r 57 | e0RnDv2H86oIBXpwnVRZebJgbw2Nuv9GsvCB4hYsBbL0UVrxRGl7pOnRnEMd94RY 58 | P4KzB6lELVxUDt3NCs/PFXSKId9NpLYxomox6B+9SDh9e0MnFTn8vT8hOEkCBYMW 59 | bZldMAengb7jagC0Z2TfwukiDWMcVHObhxjR6fl03ACnt+EXqAZjr5x2QOko7CXU 60 | xBo+wTDZTmuD6S8LI0q9v0BrIszZiJxWti7BSqYGnDPOLA84d0p8DIEy6W9GSqAD 61 | CBfYi5r24BkLaslh6Uyb2QARAQABiQI2BBgBCAAgFiEE9cXOG+bheWbFLaN1qgIm 62 | kkgY+kkFAl5Vg0MCGwwACgkQqgImkkgY+kkhXw//YtV8tNpmDo2oZfUltYE0sZrr 63 | YN/0wchkHMs3rUt6K8/5Lbbzi3GcLVtG5PSbkGs6eTfbCYzJkhQO+vdA+T5CgZld 64 | HrEQ4nPXHBcr7BFBRPQ4LCM7q4ygVldRw2qusWvf/YdcMmLk7pg/F1wSSkWZHUpp 65 | a0BNbZBeCZ1xWlu/+VUlCpsT2m5Ak1gdm58zwJ4uZwTc4hRPxS7q4GuSQBNrvNx8 66 | Os6Grt6lcZyJ6zGYr73/5PraZyQnprQ4FzJjwSLb7doVfhUoGVf4wiECsVhoNByu 67 | /ojfERTErUzhw9Wu44EmY0Imot99SbbJ9ifchF69TnSMcb17PNnbV10VbbQuNLun 68 | 7GiTfF3wd80MhI+KpDAlFpy08M5i+kyk97uzMzFcOZn68KUbNIsn/JFaKc1orMmy 69 | b8mhFCXTeX1UJsfSwRodSmKRrKSPq6MflNQYPwpKOU5hOHaQ71gavNunQVEbEoXO 70 | ny+/RyALrlDt0ffuKoVBHHQjThIGXnb1ItTjuCI5d6yiuFuRz3AvHmhqm/4sqX96 71 | t1yroLhsk8x7HaLuVdKB/SW+DhTAxPqFJzEw09KpZci5m9VMyJxplI3uh0rJb6pz 72 | HssYE9a+LjVUEzu3sZxvKhDzG7v4Nn7NZ4Ve8Q5tRZJEXvwEi6KKBxrDKV9wKdqY 73 | ztmP6RvHPG6jYvyqofo= 74 | =Sy1W 75 | -----END PGP PUBLIC KEY BLOCK----- 76 | ``` 77 | -------------------------------------------------------------------------------- /__mocks__/@celo/contractkit/index.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js' 2 | 3 | export class CeloTransactionObject { 4 | constructor() { 5 | // @ts-ignore 6 | } 7 | send = jest.fn(async () => ({ 8 | waitReceipt: jest.fn(async () => ({ 9 | transactionHash: '0x00000000000000000000000000000000000000000000000000000000000000aa', 10 | })), 11 | })) 12 | } 13 | 14 | const gasPriceMinGetGasPriceFn = jest.fn(async () => { 15 | return new BigNumber(100) 16 | }) 17 | 18 | const sortedOraclesReportFn = jest.fn( 19 | async (_token: CeloToken, _value: BigNumber.Value, _oracleAddress: string) => { 20 | return new CeloTransactionObject() 21 | } 22 | ) 23 | 24 | const sortedOraclesRemoveExpiredReportsFn = jest.fn( 25 | async (_token: CeloToken, _numReports?: number) => { 26 | return new CeloTransactionObject() 27 | } 28 | ) 29 | 30 | const sortedOraclesIsOldestReportExpiredFn = jest.fn( 31 | async (_token: CeloToken): Promise<[boolean, string]> => { 32 | // @ts-ignore - contractkit stopped giving it in array form 33 | return { 34 | '0': false, 35 | '1': '0x0123', 36 | } 37 | } 38 | ) 39 | 40 | const sortedOraclesIsOracleFn = jest.fn( 41 | async (_token: CeloToken, _oracleAddress: string): Promise => { 42 | return true 43 | } 44 | ) 45 | 46 | const sortedOraclesGetOraclesFn = jest.fn( 47 | async (_token: CeloToken): Promise => { 48 | return ['0x0123'] 49 | } 50 | ) 51 | 52 | const sortedOraclesReportExpirySecondsFn = jest.fn( 53 | async (): Promise => { 54 | return new BigNumber(5 * 60) // 5 minutes 55 | } 56 | ) 57 | 58 | // Any number > 1 to allow expire to process transaction 59 | const sortedOraclesNumRatesFn = jest.fn( 60 | async (_token: CeloToken): Promise => { 61 | return 2 62 | } 63 | ) 64 | 65 | export const newKit = () => ({ 66 | addAccount: (_: string) => undefined, 67 | contracts: { 68 | getSortedOracles: async () => ({ 69 | isOracle: sortedOraclesIsOracleFn, 70 | isOldestReportExpired: sortedOraclesIsOldestReportExpiredFn, 71 | getOracles: sortedOraclesGetOraclesFn, 72 | numRates: sortedOraclesNumRatesFn, 73 | removeExpiredReports: sortedOraclesRemoveExpiredReportsFn, 74 | report: sortedOraclesReportFn, 75 | reportExpirySeconds: sortedOraclesReportExpirySecondsFn, 76 | }), 77 | getExchange: async () => ({ 78 | updateFrequency: jest.fn(async () => new BigNumber(5 * 60)), 79 | }), 80 | getGasPriceMinimum: async () => ({ 81 | gasPriceMinimum: gasPriceMinGetGasPriceFn, 82 | }), 83 | }, 84 | web3: { 85 | eth: { 86 | getTransaction: async (_: string) => Promise.resolve(), 87 | getBalance: jest.fn(async (_: string) => Promise.resolve('1000000000000000000')), 88 | }, 89 | utils: { 90 | fromWei: jest.fn((value: number) => value / 1e18), 91 | }, 92 | }, 93 | }) 94 | 95 | export enum CeloContract { 96 | Accounts = 'Accounts', 97 | Attestations = 'Attestations', 98 | BlockchainParameters = 'BlockchainParameters', 99 | DoubleSigningSlasher = 'DoubleSigningSlasher', 100 | DowntimeSlasher = 'DowntimeSlasher', 101 | Election = 'Election', 102 | EpochRewards = 'EpochRewards', 103 | Escrow = 'Escrow', 104 | Exchange = 'Exchange', 105 | FeeCurrencyWhitelist = 'FeeCurrencyWhitelist', 106 | GasPriceMinimum = 'GasPriceMinimum', 107 | GoldToken = 'GoldToken', 108 | Governance = 'Governance', 109 | LockedGold = 'LockedGold', 110 | Random = 'Random', 111 | Registry = 'Registry', 112 | Reserve = 'Reserve', 113 | SortedOracles = 'SortedOracles', 114 | StableToken = 'StableToken', 115 | Validators = 'Validators', 116 | } 117 | 118 | export type CeloToken = CeloContract.GoldToken | CeloContract.StableToken 119 | -------------------------------------------------------------------------------- /__mocks__/bunyan/index.ts: -------------------------------------------------------------------------------- 1 | function createLogger(): any { 2 | return { 3 | fatal: jest.fn(() => {}), 4 | error: jest.fn(() => {}), 5 | warn: jest.fn(() => {}), 6 | info: jest.fn(() => {}), 7 | debug: jest.fn(() => {}), 8 | trace: jest.fn(() => {}), 9 | child: jest.fn(createLogger), 10 | } 11 | } 12 | 13 | export default { 14 | createLogger: jest.fn(createLogger), 15 | } 16 | -------------------------------------------------------------------------------- /build_and_publish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | REPOSITORY="us-west1-docker.pkg.dev/celo-testnet-production/celo-oracle" 4 | 5 | PACKAGE_NAME=$(grep name package.json | awk -F \" '{print $4}') 6 | PACKAGE_VERSION=$(grep version package.json | awk -F \" '{print $4}') 7 | COMMIT_HASH=$(git log -1 --pretty=%h) 8 | 9 | VERSION="$PACKAGE_NAME-$PACKAGE_VERSION" 10 | 11 | # Check if the package version already exists in the repository 12 | # 2>&1 is used to redirect stderr to stdout so that grep can 13 | # search in the full output of the gcloud command 14 | gcloud artifacts docker tags list $REPOSITORY --filter "tag~$PACKAGE_VERSION\$" 2>&1 | grep "Listed 0 items" > /dev/null 15 | # if grep finds "Listed 0 items" exit code is 0 which means that the package version does not exist 16 | PACKAGE_VERSION_EXISTS=$? 17 | 18 | if [[ $PACKAGE_VERSION_EXISTS -eq 1 && $BUILD_ENV == "production" ]]; then 19 | echo "Package version already exists and build env is production." 20 | echo "In order to build a production image you should bump the package version." 21 | exit 1 22 | fi 23 | 24 | if [[ $BUILD_ENV != "production" && $BUILD_ENV != "staging" ]]; then 25 | echo "Invalid BUILD_ENV: $BUILD_ENV" 26 | exit 1 27 | fi 28 | 29 | echo "Building version $VERSION" 30 | docker buildx build --platform linux/amd64 -t $PACKAGE_NAME . 31 | 32 | if [[ $? -ne 0 ]]; then 33 | echo "Build failed" 34 | exit 1 35 | fi 36 | 37 | echo "Tagging and pushing" 38 | docker tag $PACKAGE_NAME $REPOSITORY/$PACKAGE_NAME:$COMMIT_HASH 39 | docker push $REPOSITORY/$PACKAGE_NAME:$COMMIT_HASH 40 | 41 | if [[ $BUILD_ENV == "production" ]]; then 42 | docker tag $PACKAGE_NAME $REPOSITORY/$PACKAGE_NAME:$PACKAGE_VERSION 43 | docker push $REPOSITORY/$PACKAGE_NAME:$PACKAGE_VERSION 44 | else 45 | docker tag $PACKAGE_NAME $REPOSITORY/$PACKAGE_NAME:latest 46 | docker push $REPOSITORY/$PACKAGE_NAME:latest 47 | fi 48 | -------------------------------------------------------------------------------- /devApiKeys.txt: -------------------------------------------------------------------------------- 1 | ALPHAVANTAGE:1234567 -------------------------------------------------------------------------------- /devPriceSourcesConfig.txt: -------------------------------------------------------------------------------- 1 | [ 2 | [{ exchange: 'BITTREX', symbol: 'CELOUSD', toInvert: false }], 3 | [{ exchange: 'COINBASE', symbol: 'CELOUSD', toInvert: false }], 4 | [{ exchange: 'OKCOIN', symbol: 'CELOUSD', toInvert: false }], 5 | [ 6 | { exchange: 'BINANCE', symbol: 'CELOBUSD', toInvert: false }, 7 | { exchange: 'COINBASE', symbol: 'BUSDUSD', toInvert: false }, 8 | ], 9 | ] -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | fakeTimers: { enableGlobally: true }, 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "celo-oracle", 3 | "version": "2.0.17-beta", 4 | "description": "Oracle application to aggregate and report exchange rates to the Celo network", 5 | "author": "Celo", 6 | "license": "Apache-2.0", 7 | "repository": "git@github.com:celo-org/celo-oracle.git", 8 | "private": true, 9 | "scripts": { 10 | "build": "tsc -b .", 11 | "clean": "tsc -b . --clean", 12 | "start": "node lib/index.js", 13 | "dev": "pnpm run build && . .env.dev && pnpm run start", 14 | "test": "jest", 15 | "lint": "tslint -c tslint.json --project .", 16 | "lint:tests": "tslint -c tslint.json 'test/**/*.test.ts'", 17 | "prettify": "pnpm prettier --config .prettierrc.js --write '**/*.+(ts|tsx|js|jsx|sol|java)'", 18 | "prettify:diff": "pnpm prettier --config .prettierrc.js --list-different '**/*.+(ts|tsx|js|jsx|sol|java)'", 19 | "get-cert": "./scripts/get_cert_fingerprint.sh" 20 | }, 21 | "dependencies": { 22 | "@celo/connect": "5.3.0", 23 | "@celo/contractkit": "8.0.0", 24 | "@celo/utils": "6.0.0", 25 | "@celo/wallet-hsm-aws": "5.2.0", 26 | "@celo/wallet-hsm-azure": "5.2.0", 27 | "bignumber.js": "^9.0.0", 28 | "bunyan": "1.8.15", 29 | "express": "^4.17.1", 30 | "js-yaml": "^3.13.1", 31 | "mathjs": "^7.5.1", 32 | "node-fetch": "^2.6.1", 33 | "prom-client": "^12.0.0", 34 | "tslint-no-focused-test": "^0.5.0", 35 | "web3": "1.10.4", 36 | "web3-core": "1.10.4", 37 | "web3-core-subscriptions": "1.10.4", 38 | "web3-eth": "1.10.4", 39 | "web3-eth-contract": "1.10.4" 40 | }, 41 | "devDependencies": { 42 | "@celo/typescript": "^0.0.2", 43 | "@types/bunyan": "^1.8.6", 44 | "@types/express": "^4.17.6", 45 | "@types/jest": "29.5.12", 46 | "@types/mathjs": "^6.0.5", 47 | "@types/node-fetch": "^2.5.5", 48 | "dotenv": "^16.4.5", 49 | "jest": "29.7.0", 50 | "prettier": "2.1.1", 51 | "ts-jest": "^29.1.2", 52 | "tslint": "^6.1.1", 53 | "tslint-config-prettier": "^1.18.0", 54 | "tslint-eslint-rules": "^5.4.0", 55 | "tslint-microsoft-contrib": "^6.2.0", 56 | "typescript": "5.4.2" 57 | }, 58 | "packageManager": "pnpm@9.15.0" 59 | } 60 | -------------------------------------------------------------------------------- /readinessProbe.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Default port for Prometheus is 9090 3 | port=$1 4 | currencyPair=$2 5 | # Look for success metric line at /metrics endpoint 6 | successMetric="oracle_transaction_success_count{type=\"report\",currencyPair=\"$currencyPair\"}" 7 | match=$(curl 'http://127.0.0.1:'$port'/metrics' | grep -Po "$successMetric \d*") 8 | # Remove all but the count 9 | successCount="${match/$successMetric/}" 10 | 11 | if [[ "$successCount" -gt 0 ]]; then 12 | echo "Found successful reports"; 13 | exit 0; 14 | fi 15 | 16 | echo "No successful reports yet"; 17 | exit 1; -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>celo-org/.github:renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /scripts/get_cert_fingerprint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # This script retrieves the intermediate certificate with the specified index 4 | # from the certificate chain of the specified server and computes its SHA-256 5 | # fingerprint. 6 | 7 | # The script requires the following arguments: 8 | # - server_name: the name of the server to connect to 9 | # - intermediate_cert_index: the index of the intermediate certificate to 10 | 11 | # The script can be run from the root directory of the project as follows: 12 | # pmpm get-cert [server_name] [intermediate_cert_index] 13 | # pnpm get-cert api-cloud.bitmart.com 1 14 | 15 | # Check if two arguments are provided 16 | if [ "$#" -ne 2 ]; then 17 | echo "Usage: $0 " 18 | exit 1 19 | fi 20 | 21 | SERVER_NAME=$1 22 | CERT_INDEX=$2 23 | 24 | # Retrieve the entire certificate chain and store it in an array 25 | mapfile -t CERT_CHAIN < <(echo | openssl s_client -servername $SERVER_NAME -connect $SERVER_NAME:443 -showcerts 2>/dev/null | sed -n '/-----BEGIN CERTIFICATE-----/,/-----END CERTIFICATE-----/p') 26 | 27 | # Check if the specified index is valid 28 | if [ "$CERT_INDEX" -lt 1 ] || [ "$CERT_INDEX" -gt "${#CERT_CHAIN[@]}" ]; then 29 | echo "Invalid intermediate certificate index: $CERT_INDEX" 30 | exit 2 31 | fi 32 | 33 | # Extract the specified intermediate certificate 34 | INTERMEDIATE_CERT="" 35 | for ((i=0; i<${#CERT_CHAIN[@]}; i++)); do 36 | if [[ ${CERT_CHAIN[$i]} =~ 'BEGIN CERTIFICATE' ]]; then 37 | let cert_count++ 38 | fi 39 | if [ $cert_count -eq $CERT_INDEX ]; then 40 | INTERMEDIATE_CERT+="${CERT_CHAIN[$i]}"$'\n' 41 | fi 42 | done 43 | 44 | # Check if the intermediate certificate was found 45 | if [ -z "$INTERMEDIATE_CERT" ]; then 46 | echo "Intermediate certificate with index $CERT_INDEX not found." 47 | exit 3 48 | fi 49 | 50 | # Compute and display the SHA-256 fingerprint 51 | echo "$INTERMEDIATE_CERT" | openssl x509 -noout -fingerprint -sha256 52 | 53 | -------------------------------------------------------------------------------- /src/aggregator_functions.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js' 2 | import Logger from 'bunyan' 3 | import { DataAggregatorConfig } from './data_aggregator' 4 | import { Ticker } from './exchange_adapters/base' 5 | import { doFnWithErrorContext } from './utils' 6 | import { MetricCollector } from './metric_collector' 7 | import { WeightedPrice } from './price_source' 8 | 9 | export function weightedMeanPrice(prices: WeightedPrice[]): BigNumber { 10 | const baseVolumes = prices.map((price: WeightedPrice) => price.weight) 11 | const mids = prices.map((price: WeightedPrice) => price.price) 12 | const weightedMids = mids.map((mid, i) => mid.multipliedBy(baseVolumes[i])) 13 | 14 | const baseVolumesSum = baseVolumes.reduce( 15 | (sum: BigNumber, mid: BigNumber) => sum.plus(mid), 16 | new BigNumber(0) 17 | ) 18 | const weightedMidsSum = weightedMids.reduce( 19 | (sum: BigNumber, mid: BigNumber) => sum.plus(mid), 20 | new BigNumber(0) 21 | ) 22 | const weightedMidAverage = weightedMidsSum.div(baseVolumesSum) 23 | 24 | return weightedMidAverage 25 | } 26 | 27 | /** 28 | * Checks to be performed for a ticker. 29 | */ 30 | export function individualTickerChecks(tickerData: Ticker, maxPercentageBidAskSpread: BigNumber) { 31 | // types 32 | assert( 33 | typeof tickerData.timestamp === 'number', 34 | `${tickerData.source}: timestamp is ${tickerData.timestamp} and not of type number` 35 | ) 36 | assert( 37 | BigNumber.isBigNumber(tickerData.ask), 38 | `${tickerData.source}: ask is ${tickerData.ask} and not of type BigNumber` 39 | ) 40 | assert( 41 | BigNumber.isBigNumber(tickerData.bid), 42 | `${tickerData.source}: bid is ${tickerData.bid} and not of type BigNumber` 43 | ) 44 | assert( 45 | BigNumber.isBigNumber(tickerData.baseVolume), 46 | `${tickerData.source}: baseVolume is ${tickerData.baseVolume} and not of type BigNumber` 47 | ) 48 | 49 | // Check percentage bid-ask spread smaller than maxPercentageBidAskSpread. 50 | const percentageBidAskSpread = tickerData.ask.minus(tickerData.bid).div(tickerData.ask) 51 | assert( 52 | percentageBidAskSpread.isLessThanOrEqualTo(maxPercentageBidAskSpread), 53 | `${tickerData.source}: percentage bid-ask spread (${percentageBidAskSpread}) larger than maxPercentageBidAskSpread (${maxPercentageBidAskSpread})` 54 | ) 55 | // values are greater than zero 56 | assert( 57 | tickerData.ask.isGreaterThan(0), 58 | `${tickerData.source}: ask (${tickerData.ask}) not positive` 59 | ) 60 | assert( 61 | tickerData.bid.isGreaterThan(0), 62 | `${tickerData.source}: bid (${tickerData.bid}) not positive` 63 | ) 64 | // ask bigger equal bid 65 | assert( 66 | tickerData.ask.isGreaterThanOrEqualTo(tickerData.bid), 67 | `${tickerData.source}: bid (${tickerData.bid}) larger than ask (${tickerData.ask})` 68 | ) 69 | // Check that there is some volume on the exchange 70 | assert( 71 | tickerData.baseVolume.isGreaterThan(0), 72 | `${tickerData.source}: volume (${tickerData.baseVolume}) not positive` 73 | ) 74 | // TODO: Check timestamp not older than X (X as config parameter) seconds 75 | } 76 | 77 | export function checkIndividualTickerData( 78 | tickerData: Ticker[], 79 | maxPercentageBidAskSpread: BigNumber, 80 | metricCollector?: MetricCollector, 81 | logger?: Logger 82 | ): Ticker[] { 83 | const validTickerData: Ticker[] = [] 84 | 85 | for (const thisTickerData of tickerData) { 86 | // 1. Non-recoverable errors (should lead to the client not reporting) 87 | // Ignore individual ticker if any of these checks fail 88 | const checkRecoverableErrors = () => { 89 | individualTickerChecks(thisTickerData, maxPercentageBidAskSpread) 90 | // keep current ticker if all checks passed 91 | validTickerData.push(thisTickerData) 92 | } 93 | 94 | doFnWithErrorContext({ 95 | fn: checkRecoverableErrors, 96 | context: thisTickerData.source, 97 | metricCollector, 98 | logger, 99 | logMsg: 'Recoverable error in individual ticker check', 100 | swallowError: true, 101 | }) 102 | } 103 | assert(validTickerData.length > 0, `No valid tickers available`) 104 | return validTickerData 105 | } 106 | 107 | /** 108 | * checks to be performed across prices 109 | */ 110 | export function crossCheckPriceData( 111 | tickerData: WeightedPrice[], 112 | config: DataAggregatorConfig 113 | ): WeightedPrice[] { 114 | // 1. Prices should not deviate more than maxPercentageDeviation. 115 | const prices = tickerData.map((price: WeightedPrice) => price.price) 116 | const maxNormalizedAbsMeanDev = maxPercentageDeviaton(prices) 117 | assert( 118 | maxNormalizedAbsMeanDev.isLessThanOrEqualTo(config.maxPercentageDeviation), 119 | `Max price cross-sectional deviation too large (${maxNormalizedAbsMeanDev} >= ${config.maxPercentageDeviation} )` 120 | ) 121 | 122 | // 2. No source should make up more than maxSourceWeightShare 123 | const volumes = tickerData.map((price: WeightedPrice) => price.weight) 124 | const volumesSum = volumes.reduce( 125 | (sum: BigNumber, el: BigNumber) => sum.plus(el), 126 | new BigNumber(0) 127 | ) 128 | const exchangeVolumeShares = volumes.map((el: BigNumber) => el.div(volumesSum)) 129 | const largestExchangeVolumeShare = BigNumber.max.apply(null, exchangeVolumeShares) 130 | assert( 131 | largestExchangeVolumeShare.isLessThanOrEqualTo(config.maxSourceWeightShare), 132 | `The weight share of one source is too large (${largestExchangeVolumeShare} > ${config.maxSourceWeightShare})` 133 | ) 134 | 135 | // 3. The sum of all weights should be greater than the min threshold 136 | const validTickerAggregateVolume: BigNumber = tickerData.reduce( 137 | (sum, el) => sum.plus(el.weight), 138 | new BigNumber(0) 139 | ) 140 | assert( 141 | validTickerAggregateVolume.isGreaterThanOrEqualTo(config.minAggregatedVolume), 142 | `Aggregate volume ${validTickerAggregateVolume} is less than minimum threshold ${config.minAggregatedVolume}` 143 | ) 144 | 145 | // 4. The number of price sources should be greater than or equal to minPriceSourceCount. 146 | assert( 147 | tickerData.length >= config.minPriceSourceCount, 148 | `The number of price sources available (${tickerData.length}) is less than the minimum required (${config.minPriceSourceCount})` 149 | ) 150 | 151 | return tickerData 152 | } 153 | 154 | export function maxPercentageDeviaton(arr: BigNumber[]) { 155 | const arrSum = arr.reduce((sum: BigNumber, el: BigNumber) => sum.plus(el), new BigNumber(0)) 156 | const arrMean = arrSum.div(arr.length) 157 | const arrNormalizedAbsMeanDev = arr.map((el: BigNumber) => el.div(arrMean).minus(1).abs()) 158 | const arrMaxNormalizedAbsMeanDev = BigNumber.max.apply(null, arrNormalizedAbsMeanDev) 159 | 160 | return arrMaxNormalizedAbsMeanDev 161 | } 162 | 163 | function assert(condition: boolean, message: string) { 164 | if (!condition) { 165 | throw Error(message) 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/default_config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AggregationMethod, 3 | OracleCurrencyPair, 4 | ReportStrategy, 5 | WalletType, 6 | minutesToMs, 7 | secondsToMs, 8 | } from './utils' 9 | import { 10 | BaseReporterConfigSubset, 11 | BlockBasedReporterConfigSubset, 12 | DataAggregatorConfigSubset, 13 | OracleApplicationConfig, 14 | SSLFingerprintServiceConfigSubset, 15 | } from './app' 16 | 17 | import BigNumber from 'bignumber.js' 18 | import Logger from 'bunyan' 19 | 20 | export const baseLogger = Logger.createLogger({ 21 | name: 'oracle', 22 | serializers: Logger.stdSerializers, 23 | level: 'debug', 24 | src: true, 25 | }) 26 | 27 | export const defaultSSLFingerprintServiceConfig: SSLFingerprintServiceConfigSubset = { 28 | wsRpcProviderUrl: 'ws://localhost:8546', 29 | sslRegistryAddress: '0x72d96b39d207c231a3b803f569f05118514673ac', 30 | } 31 | 32 | export const defaultDataAggregatorConfig: DataAggregatorConfigSubset = { 33 | aggregationMethod: AggregationMethod.MIDPRICES, 34 | aggregationWindowDuration: minutesToMs(5), 35 | apiRequestTimeout: secondsToMs(5), 36 | baseLogger, 37 | maxSourceWeightShare: new BigNumber(0.99), 38 | maxPercentageBidAskSpread: new BigNumber(0.1), 39 | maxPercentageDeviation: new BigNumber(0.2), 40 | minPriceSourceCount: 1, 41 | minAggregatedVolume: new BigNumber(0), 42 | devMode: false, 43 | } 44 | 45 | export const defaultBaseReporterConfig: BaseReporterConfigSubset = { 46 | baseLogger, 47 | circuitBreakerPriceChangeThresholdMax: new BigNumber(0.25), // 25% 48 | circuitBreakerPriceChangeThresholdMin: new BigNumber(0.15), // 15% 49 | circuitBreakerPriceChangeThresholdTimeMultiplier: new BigNumber(0.0075), 50 | circuitBreakerDurationTimeMs: 20 * 60 * 1000, // 20 minutes. 51 | gasPriceMultiplier: new BigNumber(1.5), 52 | transactionRetryLimit: 3, 53 | transactionRetryGasPriceMultiplier: new BigNumber(0.1), 54 | unusedOracleAddresses: [], 55 | devMode: false, 56 | } 57 | 58 | export const defaultBlockBasedReporterConfig: BlockBasedReporterConfigSubset = { 59 | ...defaultBaseReporterConfig, 60 | expectedBlockTimeMs: secondsToMs(5), 61 | maxBlockTimestampAgeMs: secondsToMs(30), 62 | minReportPriceChangeThreshold: new BigNumber(0.005), // 0.5% 63 | targetMaxHeartbeatPeriodMs: minutesToMs(4.5), 64 | } 65 | 66 | export const defaultApplicationConfig: OracleApplicationConfig = { 67 | apiKeys: {}, 68 | awsKeyRegion: 'eu-central-1', 69 | azureHsmInitMaxRetryBackoffMs: secondsToMs(30), 70 | azureHsmInitTryCount: 5, 71 | baseLogger, 72 | currencyPair: OracleCurrencyPair.CELOUSD, 73 | dataAggregatorConfig: defaultDataAggregatorConfig, 74 | httpRpcProviderUrl: 'http://localhost:8545', 75 | metrics: true, 76 | privateKeyPath: '/tmp/defaultPrivateKey', 77 | prometheusPort: 9090, 78 | reporterConfig: defaultBlockBasedReporterConfig, 79 | reportStrategy: ReportStrategy.BLOCK_BASED, 80 | reportTargetOverride: undefined, 81 | sslFingerprintServiceConfig: defaultSSLFingerprintServiceConfig, 82 | walletType: WalletType.PRIVATE_KEY, 83 | wsRpcProviderUrl: 'ws://localhost:8546', 84 | devMode: false, 85 | mockAccount: '0x243860e8216B4F6eC2478Ebd613F6F4bDE0704DE', // Just a valid address used for testing 86 | } 87 | -------------------------------------------------------------------------------- /src/exchange_adapters/alphavantage.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | 3 | import BigNumber from 'bignumber.js' 4 | import { Exchange } from '../utils' 5 | import { strict as assert } from 'assert' 6 | 7 | enum ResponseKeys { 8 | fromCurrency = '1. From_Currency Code', 9 | toCurrency = '3. To_Currency Code', 10 | rate = '5. Exchange Rate', 11 | lastUpdated = '6. Last Refreshed', 12 | timezone = '7. Time Zone', 13 | bid = '8. Bid Price', 14 | ask = '9. Ask Price', 15 | } 16 | 17 | type ResponseData = { 18 | [K in ResponseKeys]: string 19 | } 20 | 21 | interface Response { 22 | 'Realtime Currency Exchange Rate': ResponseData 23 | } 24 | 25 | export class AlphavantageAdapter extends BaseExchangeAdapter implements ExchangeAdapter { 26 | baseApiUrl = 'https://www.alphavantage.co' 27 | readonly _exchangeName: Exchange = Exchange.ALPHAVANTAGE 28 | 29 | protected generatePairSymbol(): string { 30 | const base = AlphavantageAdapter.standardTokenSymbolMap.get(this.config.baseCurrency) 31 | const quote = AlphavantageAdapter.standardTokenSymbolMap.get(this.config.quoteCurrency) 32 | 33 | return `${base}${quote}` 34 | } 35 | 36 | async fetchTicker(): Promise { 37 | assert(this.config.apiKey !== undefined, 'Alphavantage API key was not set') 38 | 39 | const base = this.config.baseCurrency 40 | const quote = this.config.quoteCurrency 41 | 42 | const tickerJson: Response = await this.fetchFromApi( 43 | ExchangeDataType.TICKER, 44 | `query?function=CURRENCY_EXCHANGE_RATE&from_currency=${base}&to_currency=${quote}&apikey=${this.config.apiKey}` 45 | ) 46 | return this.parseTicker(tickerJson) 47 | } 48 | 49 | /** 50 | * 51 | * @param json parsed response from Alphavantage's rate endpoint 52 | * { 53 | * "Realtime Currency Exchange Rate": { 54 | * "1. From_Currency Code": "XOF", 55 | * "2. From_Currency Name": "CFA Franc BCEAO", 56 | * "3. To_Currency Code": "EUR", 57 | * "4. To_Currency Name": "Euro", 58 | * "5. Exchange Rate": "0.00153000", 59 | * "6. Last Refreshed": "2023-08-03 07:41:09", 60 | * "7. Time Zone": "UTC", 61 | * "8. Bid Price": "0.00152900", 62 | * "9. Ask Price": "0.00153000" 63 | * } 64 | * } 65 | */ 66 | parseTicker(json: Response): Ticker { 67 | const response = json['Realtime Currency Exchange Rate'] 68 | 69 | const from = response[ResponseKeys.fromCurrency] 70 | const to = response[ResponseKeys.toCurrency] 71 | const dateString = `${response[ResponseKeys.lastUpdated]} UTC` 72 | assert( 73 | from === this.config.baseCurrency, 74 | `From currency mismatch in response: ${from} != ${this.config.baseCurrency}` 75 | ) 76 | assert( 77 | to === this.config.quoteCurrency, 78 | `To currency mismatch in response: ${to} != ${this.config.quoteCurrency}` 79 | ) 80 | 81 | assert( 82 | response[ResponseKeys.timezone] === 'UTC', 83 | `Timezone mismatch in response: ${response[ResponseKeys.timezone]} != UTC` 84 | ) 85 | 86 | const lastPrice = this.safeBigNumberParse(response[ResponseKeys.rate])! 87 | const ticker = { 88 | ...this.priceObjectMetadata, 89 | ask: this.safeBigNumberParse(response[ResponseKeys.ask])!, 90 | bid: this.safeBigNumberParse(response[ResponseKeys.bid])!, 91 | lastPrice, 92 | timestamp: this.safeDateParse(dateString)! / 1000, 93 | // These FX API's do not provide volume data, 94 | // therefore we set all of them to 1 to weight them equally. 95 | baseVolume: new BigNumber(1), 96 | quoteVolume: lastPrice, // baseVolume * lastPrice, so 1 * lastPrice in this case 97 | } 98 | this.verifyTicker(ticker) 99 | return ticker 100 | } 101 | 102 | async isOrderbookLive(): Promise { 103 | return !BaseExchangeAdapter.fxMarketsClosed(Date.now()) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/exchange_adapters/binance.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | 3 | import { Exchange } from '../utils' 4 | 5 | export class BinanceAdapter extends BaseExchangeAdapter implements ExchangeAdapter { 6 | baseApiUrl = 'https://data-api.binance.vision/api/v3' 7 | readonly _exchangeName: Exchange = Exchange.BINANCE 8 | 9 | private static readonly tokenSymbolMap = BinanceAdapter.standardTokenSymbolMap 10 | 11 | protected generatePairSymbol(): string { 12 | return `${BinanceAdapter.tokenSymbolMap.get( 13 | this.config.baseCurrency 14 | )}${BinanceAdapter.tokenSymbolMap.get(this.config.quoteCurrency)}` 15 | } 16 | 17 | async fetchTicker(): Promise { 18 | const tickerJson = await this.fetchFromApi( 19 | ExchangeDataType.TICKER, 20 | `ticker/24hr?symbol=${this.pairSymbol}` 21 | ) 22 | return this.parseTicker(tickerJson) 23 | } 24 | 25 | /** 26 | * 27 | * @param json parsed response from Binance's ticker endpoint 28 | * 29 | * { 30 | * "symbol": "CELOBTC", 31 | * "priceChange": "0.00000023", 32 | * "priceChangePercent": "0.281", 33 | * "weightedAvgPrice": "0.00008154", 34 | * "prevClosePrice": "0.00008173", 35 | * "lastPrice": "0.00008219", 36 | * "lastQty": "7.10000000", 37 | * "bidPrice": "0.00008213", 38 | * "bidQty": "9.90000000", 39 | * "askPrice": "0.00008243", 40 | * "askQty": "100.00000000", 41 | * "openPrice": "0.00008196", 42 | * "highPrice": "0.00008386", 43 | * "lowPrice": "0.00007948", 44 | * "volume": "155146.90000000", 45 | * "quoteVolume": "12.65048684", 46 | * "openTime": 1614597075604, 47 | * "closeTime": 1614683475604, 48 | * "firstId": 849549, // First tradeId 49 | * "lastId": 854852, // Last tradeId 50 | * "count": 5304 // Trade count 51 | * } 52 | */ 53 | parseTicker(json: any): Ticker { 54 | const ticker = { 55 | ...this.priceObjectMetadata, 56 | ask: this.safeBigNumberParse(json.askPrice)!, 57 | baseVolume: this.safeBigNumberParse(json.volume)!, 58 | bid: this.safeBigNumberParse(json.bidPrice)!, 59 | high: this.safeBigNumberParse(json.highPrice), 60 | lastPrice: this.safeBigNumberParse(json.lastPrice)!, 61 | low: this.safeBigNumberParse(json.lowPrice), 62 | open: this.safeBigNumberParse(json.openPrice), 63 | quoteVolume: this.safeBigNumberParse(json.quoteVolume)!, 64 | timestamp: this.safeBigNumberParse(json.closeTime)?.toNumber()!, 65 | } 66 | this.verifyTicker(ticker) 67 | return ticker 68 | } 69 | 70 | async isOrderbookLive(): Promise { 71 | const res = await this.fetchFromApi(ExchangeDataType.ORDERBOOK_STATUS, 'exchangeInfo') 72 | 73 | const marketInfo = (res?.symbols as { 74 | status: string 75 | symbol: string 76 | isSpotTradingAllowed: boolean 77 | orderTypes: string[] 78 | }[])?.find((info) => info?.symbol === this.pairSymbol) 79 | 80 | return ( 81 | !!marketInfo && 82 | marketInfo.status === 'TRADING' && 83 | marketInfo.isSpotTradingAllowed && 84 | marketInfo.orderTypes.includes('LIMIT') && 85 | marketInfo.orderTypes.includes('MARKET') 86 | ) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/exchange_adapters/binance_us.ts: -------------------------------------------------------------------------------- 1 | import { Exchange } from '../utils' 2 | import { ExchangeAdapter } from './base' 3 | import { BinanceAdapter } from './binance' 4 | 5 | export class BinanceUSAdapter extends BinanceAdapter implements ExchangeAdapter { 6 | baseApiUrl = 'https://api.binance.us/api/v3' 7 | readonly _exchangeName = Exchange.BINANCEUS 8 | } 9 | -------------------------------------------------------------------------------- /src/exchange_adapters/bitget.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | 3 | import { Exchange } from '../utils' 4 | 5 | export class BitgetAdapter extends BaseExchangeAdapter { 6 | baseApiUrl = 'https://api.bitget.com/api' 7 | readonly _exchangeName = Exchange.BITGET 8 | 9 | async fetchTicker(): Promise { 10 | return this.parseTicker( 11 | await this.fetchFromApi( 12 | ExchangeDataType.TICKER, 13 | `spot/v1/market/ticker?symbol=${this.pairSymbol}_SPBL` 14 | ) 15 | ) 16 | } 17 | 18 | protected generatePairSymbol(): string { 19 | const base = BaseExchangeAdapter.standardTokenSymbolMap.get(this.config.baseCurrency) 20 | const quote = BaseExchangeAdapter.standardTokenSymbolMap.get(this.config.quoteCurrency) 21 | 22 | return `${base}${quote}` 23 | } 24 | 25 | /** 26 | * Parses the json responses from the ticker and summary endpoints into the 27 | * standard format for a Ticker object 28 | * 29 | * @param pubtickerJson json response from the ticker endpoint 30 | * spot/v1/market/ticker?symbol=/${this.pairSymbol}_SPBL 31 | * https://api.bitget.com/api/spot/v1/market/ticker?symbol=BTCBRL_SPBL 32 | * https://bitgetlimited.github.io/apidoc/en/spot/#get-single-ticker 33 | * 34 | * {"code":"00000", 35 | * "data": 36 | * { 37 | * "baseVol":"9.18503", // (price symbol, e.g. "USD") The volume denominated in the price currency 38 | * "buyOne":"121890", // buy one price 39 | * "close":"121905", // Latest transaction price 40 | * "quoteVol":"1119715.23314", // (price symbol, e.g. "USD") The volume denominated in the quantity currency 41 | * "sellOne":"122012", // sell one price 42 | * "symbol":"BTCBRL", // Symbol 43 | * "ts":"1677490448241", // Timestamp 44 | * }, 45 | * "msg":"success", 46 | * "requestTime":"1677490448872" // Request status 47 | * } 48 | */ 49 | parseTicker(pubtickerJson: any): Ticker { 50 | const data = pubtickerJson.data || {} 51 | const ticker = { 52 | ...this.priceObjectMetadata, 53 | timestamp: Number(data.ts)!, 54 | bid: this.safeBigNumberParse(data.buyOne)!, 55 | ask: this.safeBigNumberParse(data.sellOne)!, 56 | lastPrice: this.safeBigNumberParse(data.close)!, 57 | baseVolume: this.safeBigNumberParse(data.baseVol)!, 58 | quoteVolume: this.safeBigNumberParse(data.quoteVol)!, 59 | } 60 | 61 | this.verifyTicker(ticker) 62 | return ticker 63 | } 64 | 65 | /** 66 | * Checks if the orderbook for the relevant pair is live. If it's not, the price 67 | * data from Ticker endpoint may be inaccurate. 68 | * 69 | * https://api.bitget.com/api/spot/v1/public/product?symbol=BTCBRL_SPBL 70 | * 71 | * API response example: 72 | * {"code":"00000", 73 | * "data": 74 | * { 75 | * "baseCoin":"BTC", 76 | * "status":"online", 77 | * symbol":"btcbrl_SPBL", 78 | * }, 79 | * "msg":"success", 80 | * "requestTime":"0" 81 | * } 82 | * 83 | * @returns bool 84 | */ 85 | async isOrderbookLive(): Promise { 86 | const res = await this.fetchFromApi( 87 | ExchangeDataType.ORDERBOOK_STATUS, 88 | `spot/v1/public/product?symbol=${this.pairSymbol}_SPBL` 89 | ) 90 | 91 | return res.data.status === 'online' 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/exchange_adapters/bitmart.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | 3 | import { Currency, Exchange, ExternalCurrency } from '../utils' 4 | 5 | export class BitMartAdapter extends BaseExchangeAdapter implements ExchangeAdapter { 6 | baseApiUrl = 'https://api-cloud.bitmart.com' 7 | readonly _exchangeName = Exchange.BITMART 8 | 9 | /** 10 | * Bitmart is currently using `EURC` as the symbol for EUROC. 11 | */ 12 | private static readonly tokenSymbolMap = new Map([ 13 | ...BitMartAdapter.standardTokenSymbolMap, 14 | [ExternalCurrency.EUROC, 'EURC'], 15 | ]) 16 | 17 | protected generatePairSymbol(): string { 18 | return `${BitMartAdapter.tokenSymbolMap.get( 19 | this.config.baseCurrency 20 | )}_${BitMartAdapter.tokenSymbolMap.get(this.config.quoteCurrency)}` 21 | } 22 | 23 | async fetchTicker(): Promise { 24 | const tickerJson = await this.fetchFromApi( 25 | ExchangeDataType.TICKER, 26 | `spot/quotation/v3/ticker?symbol=${this.pairSymbol}` 27 | ) 28 | return this.parseTicker(tickerJson) 29 | } 30 | 31 | /** 32 | * 33 | * @param json parsed response from bitmart's V3 ticker endpoint 34 | * https://api-cloud.bitmart.com/spot/quotation/v3/ticker?symbol=BTC_USDT 35 | * { 36 | * "code": 1000, 37 | * "trace":"886fb6ae-456b-4654-b4e0-1231", 38 | * "message": "success", 39 | * "data": { 40 | * "symbol": "BTC_USDT", 41 | * "last": "30000.00", 42 | * "v_24h": "582.08066", 43 | * "qv_24h": "4793098.48", 44 | * "open_24h": "28596.30", 45 | * "high_24h": "31012.44", 46 | * "low_24h": "12.44", 47 | * "fluctuation": "0.04909", 48 | * "bid_px": "30000", 49 | * "bid_sz": "1", 50 | * "ask_px": "31012.44", 51 | * "ask_sz": "69994.75267", 52 | * "ts": "1691671061919" 53 | * } 54 | * } 55 | * @returns Ticker object 56 | */ 57 | parseTicker(json: any): Ticker { 58 | const ticker = { 59 | ...this.priceObjectMetadata, 60 | ask: this.safeBigNumberParse(json.data.ask_px)!, 61 | baseVolume: this.safeBigNumberParse(json.data.v_24h)!, 62 | bid: this.safeBigNumberParse(json.data.bid_px)!, 63 | lastPrice: this.safeBigNumberParse(json.data.last)!, 64 | quoteVolume: this.safeBigNumberParse(json.data.qv_24h)!, 65 | timestamp: this.safeBigNumberParse(json.data.ts)?.toNumber()!, 66 | } 67 | this.verifyTicker(ticker) 68 | return ticker 69 | } 70 | 71 | /** 72 | * 73 | * @param json parsed response from bitmart's trading pair details endpoint 74 | * https://api-cloud.bitmart.com/spot/v1/symbols/details 75 | * 76 | * { 77 | * "message":"OK", 78 | * "code":1000, 79 | * "trace":"48cff315816f4e1aa26ca72cccb46051.69.16892383896653019", 80 | * "data":{ 81 | * "symbols":[ 82 | * { "symbol":"SOLAR_USDT", 83 | * "symbol_id":2342, 84 | * "base_currency":"SOLAR", 85 | * "quote_currency":"USDT", 86 | * "quote_increment":"1", 87 | * "base_min_size":"1.000000000000000000000000000000", 88 | * "price_min_precision":3, 89 | * "price_max_precision":6, 90 | * "expiration":"NA", 91 | * "min_buy_amount":"5.000000000000000000000000000000", 92 | * "min_sell_amount":"5.000000000000000000000000000000", 93 | * "trade_status":"trading" 94 | * }, 95 | * ] 96 | * } 97 | * @returns bool 98 | */ 99 | async isOrderbookLive(): Promise { 100 | const response = await this.fetchFromApi( 101 | ExchangeDataType.ORDERBOOK_STATUS, 102 | `spot/v1/symbols/details` 103 | ) 104 | const pair = response?.data.symbols.find( 105 | (p: { symbol: string }) => p?.symbol === this.pairSymbol 106 | ) 107 | return !!pair && pair.trade_status === 'trading' 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/exchange_adapters/bitso.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | 3 | import { Exchange } from '../utils' 4 | 5 | export class BitsoAdapter extends BaseExchangeAdapter implements ExchangeAdapter { 6 | baseApiUrl = 'https://api.bitso.com/api/v3' 7 | readonly _exchangeName = Exchange.BITSO 8 | 9 | private static readonly tokenSymbolMap = BitsoAdapter.standardTokenSymbolMap 10 | 11 | protected generatePairSymbol(): string { 12 | return `${BitsoAdapter.tokenSymbolMap 13 | .get(this.config.baseCurrency) 14 | ?.toLowerCase()}_${BitsoAdapter.tokenSymbolMap.get(this.config.quoteCurrency)?.toLowerCase()}` 15 | } 16 | 17 | async fetchTicker(): Promise { 18 | const tickerJson = await this.fetchFromApi( 19 | ExchangeDataType.TICKER, 20 | `ticker?book=${this.pairSymbol}` 21 | ) 22 | return this.parseTicker(tickerJson.payload) 23 | } 24 | 25 | /** 26 | * 27 | * @param json parsed response from Bitso's ticker endpoint 28 | * 29 | * { 30 | * "high": "689735.63", 31 | * "last": "658600.01", 32 | * "created_at": "2021-07-02T05:55:25+00:00", 33 | * "book": "btc_mxn", 34 | * "volume": "188.62575176", 35 | * "vwap": "669760.9564740908", 36 | * "low": "658000.00", 37 | * "ask": "658600.01", 38 | * "bid": "658600.00", 39 | * "change_24": "-29399.96" 40 | * } 41 | */ 42 | parseTicker(json: any): Ticker { 43 | const lastPrice = this.safeBigNumberParse(json.last)! 44 | const baseVolume = this.safeBigNumberParse(json.volume)! 45 | // Quote volume is equivalent to the vwap 46 | const quoteVolume = this.safeBigNumberParse(json.vwap)! 47 | const ticker = { 48 | ...this.priceObjectMetadata, 49 | ask: this.safeBigNumberParse(json.ask)!, 50 | baseVolume, 51 | bid: this.safeBigNumberParse(json.bid)!, 52 | high: this.safeBigNumberParse(json.high), 53 | lastPrice, 54 | low: this.safeBigNumberParse(json.low), 55 | open: lastPrice, 56 | quoteVolume, 57 | timestamp: this.safeDateParse(json.created_at)!, 58 | } 59 | this.verifyTicker(ticker) 60 | return ticker 61 | } 62 | 63 | /** 64 | * No endpoint available to check this from Bitso. 65 | * @returns bool 66 | */ 67 | async isOrderbookLive(): Promise { 68 | return true 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/exchange_adapters/bitstamp.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | 3 | import { Exchange } from '../utils' 4 | 5 | export class BitstampAdapter extends BaseExchangeAdapter implements ExchangeAdapter { 6 | baseApiUrl = 'https://www.bitstamp.net/api/v2' 7 | readonly _exchangeName = Exchange.BITSTAMP 8 | 9 | private static readonly tokenSymbolMap = BitstampAdapter.standardTokenSymbolMap 10 | 11 | protected generatePairSymbol(): string { 12 | const base = BitstampAdapter.tokenSymbolMap.get(this.config.baseCurrency) 13 | const quote = BitstampAdapter.tokenSymbolMap.get(this.config.quoteCurrency) 14 | return `${base}${quote}`.toLowerCase() 15 | } 16 | 17 | async fetchTicker(): Promise { 18 | const tickerJson = await this.fetchFromApi(ExchangeDataType.TICKER, `ticker/${this.pairSymbol}`) 19 | return this.parseTicker(tickerJson) 20 | } 21 | 22 | /** 23 | * 24 | * @param json parsed response from bitstamps's ticker endpoint 25 | * https://www.bitstamp.net/api/v2/ticker/usdcusd 26 | * { 27 | * "timestamp": "1673617671", 28 | * "open": "1.00083", 29 | * "high": "1.00100", 30 | * "low": "0.99865", 31 | * "last": "1.00031", 32 | * "volume": "949324.40769", 33 | * "vwap": "1.00013", 34 | * "bid": "1.00005", 35 | * "ask": "1.00031", 36 | * "open_24": "0.99961", 37 | * "percent_change_24": "0.07" 38 | * } 39 | * 40 | */ 41 | 42 | parseTicker(json: any): Ticker { 43 | const baseVolume = this.safeBigNumberParse(json.volume)! 44 | const vwap = this.safeBigNumberParse(json.vwap)! 45 | const quoteVolume = baseVolume?.multipliedBy(vwap) 46 | const ticker = { 47 | ...this.priceObjectMetadata, 48 | ask: this.safeBigNumberParse(json.ask)!, 49 | baseVolume, 50 | bid: this.safeBigNumberParse(json.bid)!, 51 | lastPrice: this.safeBigNumberParse(json.last)!, 52 | quoteVolume, 53 | timestamp: this.safeBigNumberParse(json.timestamp)?.toNumber()!, 54 | } 55 | this.verifyTicker(ticker) 56 | return ticker 57 | } 58 | 59 | /** 60 | * Checks status of orderbook 61 | * https://www.bitstamp.net/api/v2/trading-pairs-info/ 62 | * https://www.bitstamp.net/api/#trading-pairs-info 63 | * 64 | * { 65 | * "name": "USDC/USD", 66 | * "url_symbol": "usdcusd", 67 | * "base_decimals": 5, 68 | * "counter_decimals": 5, 69 | * "instant_order_counter_decimals": 5, 70 | * "minimum_order": "10.00000 USD", 71 | * "trading": "Enabled", 72 | * "instant_and_market_orders": "Enabled", 73 | * "description": "USD Coin / U.S. dollar" 74 | * } 75 | * 76 | * @returns bool 77 | */ 78 | async isOrderbookLive(): Promise { 79 | const response = await this.fetchFromApi( 80 | ExchangeDataType.ORDERBOOK_STATUS, 81 | `trading-pairs-info/` 82 | ) 83 | const marketInfo = (response as { 84 | name: string 85 | url_symbol: string 86 | base_decimals: number 87 | counter_decimals: number 88 | instant_order_counter_decimals: number 89 | minimum_order: string 90 | trading: string 91 | instant_and_market_orders: string 92 | description: string 93 | }[])?.find((pair) => pair?.url_symbol === this.pairSymbol) 94 | 95 | return ( 96 | !!marketInfo && 97 | marketInfo.trading === 'Enabled' && 98 | marketInfo.instant_and_market_orders === 'Enabled' 99 | ) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/exchange_adapters/bittrex.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | 3 | import { Exchange } from '../utils' 4 | 5 | export class BittrexAdapter extends BaseExchangeAdapter { 6 | baseApiUrl = 'https://api.bittrex.com/v3' 7 | readonly _exchangeName = Exchange.BITTREX 8 | 9 | private static readonly tokenSymbolMap = BittrexAdapter.standardTokenSymbolMap 10 | 11 | async fetchTicker(): Promise { 12 | const [tickerJson, summaryJson] = await Promise.all([ 13 | this.fetchFromApi(ExchangeDataType.TICKER, `markets/${this.pairSymbol}/ticker`), 14 | this.fetchFromApi(ExchangeDataType.TICKER, `markets/${this.pairSymbol}/summary`), 15 | ]) 16 | 17 | return this.parseTicker(tickerJson, summaryJson) 18 | } 19 | 20 | protected generatePairSymbol(): string { 21 | return `${BittrexAdapter.tokenSymbolMap.get( 22 | this.config.baseCurrency 23 | )}-${BittrexAdapter.tokenSymbolMap.get(this.config.quoteCurrency)}` 24 | } 25 | 26 | /** 27 | * Parses the json responses from the ticker and summary endpoints into the 28 | * standard format for a Ticker object 29 | * 30 | * @param tickerJson json response from the ticker endpoint 31 | * markets/${this.pairSymbol}/ticker 32 | * https://bittrex.github.io/api/v3#operation--markets--marketSymbol--ticker-get 33 | * 34 | * { 35 | * symbol: "string", 36 | * lastTradeRate: "number (double)", 37 | * bidRate: "number (double)", 38 | * askRate: "number (double)" 39 | * } 40 | * 41 | * @param summaryJson json response from the summary endpoint 42 | * markets/${this.pairSymbol}/summary 43 | * https://bittrex.github.io/api/v3#operation--markets--marketSymbol--summary-get 44 | * 45 | * { 46 | * symbol: "string", // describes the currency pair 47 | * high: "number (double)", // the highest price over the last 24 hours 48 | * low: "number (double)", // the lowest price over the last 24 hours 49 | * volume: "number (double)", // the total amount of the base currency traded 50 | * quoteVolume: "number (double)", // the total amount of the quote currency traded 51 | * percentChange: "number (double)", // percent change from the beginning to end of the 24 hour period 52 | * updatedAt: "string (date-time)" // last time the summary was updated 53 | * } 54 | */ 55 | parseTicker(tickerJson: any, summaryJson: any): Ticker { 56 | const ticker = { 57 | ...this.priceObjectMetadata, 58 | timestamp: this.safeDateParse(summaryJson.updatedAt)!, 59 | high: this.safeBigNumberParse(summaryJson.high), 60 | low: this.safeBigNumberParse(summaryJson.low), 61 | bid: this.safeBigNumberParse(tickerJson.bidRate)!, 62 | ask: this.safeBigNumberParse(tickerJson.askRate)!, 63 | lastPrice: this.safeBigNumberParse(tickerJson.lastTradeRate)!, 64 | baseVolume: this.safeBigNumberParse(summaryJson.volume)!, 65 | quoteVolume: this.safeBigNumberParse(summaryJson.quoteVolume)!, 66 | } 67 | this.verifyTicker(ticker) 68 | return ticker 69 | } 70 | 71 | /** 72 | * https://bittrex.github.io/api/v3#/definitions/Market 73 | * 74 | * { 75 | * symbol: "CELO-USD", 76 | * baseCurrencySymbol: "CELO", 77 | * quoteCurrencySymbol: "USD", 78 | * minTradeSize: "3.00000000", 79 | * precision: 3, 80 | * status: "ONLINE", 81 | * createdAt: "2020-05-21T16:43:29.013Z", 82 | * notice: "", 83 | * prohibitedIn: [], 84 | * associatedTermsOfService: [] 85 | * } 86 | */ 87 | async isOrderbookLive(): Promise { 88 | const res = await this.fetchFromApi( 89 | ExchangeDataType.ORDERBOOK_STATUS, 90 | `markets/${this.pairSymbol}` 91 | ) 92 | 93 | return res.status === 'ONLINE' 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/exchange_adapters/coinbase.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | import { Currency, Exchange, ExternalCurrency } from '../utils' 3 | 4 | import { CeloContract } from '@celo/contractkit' 5 | 6 | export class CoinbaseAdapter extends BaseExchangeAdapter implements ExchangeAdapter { 7 | baseApiUrl = 'https://api.exchange.coinbase.com' 8 | readonly _exchangeName = Exchange.COINBASE 9 | 10 | /** 11 | * Coinbase is currently using `CGLD` as the symbol for CELO and `EURC` as the symbol for EUROC. 12 | */ 13 | private static readonly tokenSymbolMap = new Map([ 14 | ...CoinbaseAdapter.standardTokenSymbolMap, 15 | [CeloContract.GoldToken, 'CGLD'], 16 | [ExternalCurrency.EUROC, 'EURC'], 17 | ]) 18 | 19 | protected generatePairSymbol(): string { 20 | return `${CoinbaseAdapter.tokenSymbolMap.get( 21 | this.config.baseCurrency 22 | )}-${CoinbaseAdapter.tokenSymbolMap.get(this.config.quoteCurrency)}` 23 | } 24 | 25 | async fetchTicker(): Promise { 26 | const res = await this.fetchFromApi( 27 | ExchangeDataType.TICKER, 28 | `products/${this.pairSymbol}/ticker` 29 | ) 30 | return this.parseTicker(res) 31 | } 32 | 33 | /** 34 | * @param json a json object from Coinbase's API 35 | * Expected format, as described in the docs 36 | * source: https://docs.pro.coinbase.com/#get-product-ticker 37 | * 38 | * { 39 | * trade_id: 4729088, 40 | * price: "333.99", 41 | * size: "0.193", 42 | * bid: "333.98", 43 | * ask: "333.99", 44 | * volume: "5957.11914015", 45 | * time: "2015-11-14T20:46:03.511254Z" 46 | * } 47 | */ 48 | parseTicker(json: any): Ticker { 49 | const lastPrice = this.safeBigNumberParse(json.price)! 50 | const baseVolume = this.safeBigNumberParse(json.volume)! 51 | // Using lastPrice to convert from baseVolume to quoteVolume, as CoinBase's 52 | // API does not provide this information. The correct price for the 53 | // conversion would be the VWAP over the period contemplated by the ticker, 54 | // but it's also not available. As a price has to be chosen for the 55 | // conversion, and none of them are correct, lastPrice is chose as it 56 | // was actually on one trade (whereas the bid, ask or mid could have no 57 | // relation to the VWAP). 58 | const quoteVolume = baseVolume?.multipliedBy(lastPrice) 59 | const ticker = { 60 | ...this.priceObjectMetadata, 61 | ask: this.safeBigNumberParse(json.ask)!, 62 | baseVolume, 63 | bid: this.safeBigNumberParse(json.bid)!, 64 | close: lastPrice, 65 | lastPrice, 66 | quoteVolume, 67 | timestamp: this.safeDateParse(json.time)!, 68 | } 69 | this.verifyTicker(ticker) 70 | return ticker 71 | } 72 | 73 | /** 74 | * Checks if the orderbook for the relevant pair is live. If it's not, the price 75 | * data from ticker endpoint may be inaccurate. 76 | * 77 | * { 78 | * id: "CGLD-USD", 79 | * base_currency: "CGLD", 80 | * quote_currency: "USD", 81 | * base_min_size: "0.10000000", 82 | * base_max_size: "34000.00000000", 83 | * quote_increment: "0.00010000", 84 | * base_increment: "0.01000000", 85 | * display_name: "CGLD/USD", 86 | * min_market_funds: "1.0", 87 | * max_market_funds: "100000", 88 | * margin_enabled: false, 89 | * post_only: false, 90 | * limit_only: false, 91 | * cancel_only: false, 92 | * trading_disabled: false, 93 | * status: "online", 94 | * status_message: "" 95 | * } 96 | */ 97 | async isOrderbookLive(): Promise { 98 | const res = await this.fetchFromApi( 99 | ExchangeDataType.ORDERBOOK_STATUS, 100 | `products/${this.pairSymbol}` 101 | ) 102 | 103 | return ( 104 | res.status === 'online' && 105 | res.post_only === false && 106 | // There used to be a `limit_only` check, but it was removed due to Coinbase using this mode for stablecoin pairs. 107 | res.cancel_only === false && 108 | res.trading_disabled === false 109 | ) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/exchange_adapters/currencyapi.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | 3 | import BigNumber from 'bignumber.js' 4 | import { Exchange } from '../utils' 5 | import { strict as assert } from 'assert' 6 | 7 | export class CurrencyApiAdapter extends BaseExchangeAdapter implements ExchangeAdapter { 8 | baseApiUrl = 'https://currencyapi.net/api/v1' 9 | readonly _exchangeName: Exchange = Exchange.CURRENCYAPI 10 | 11 | protected generatePairSymbol(): string { 12 | const base = CurrencyApiAdapter.standardTokenSymbolMap.get(this.config.baseCurrency) 13 | const quote = CurrencyApiAdapter.standardTokenSymbolMap.get(this.config.quoteCurrency) 14 | 15 | return `${base}${quote}` 16 | } 17 | 18 | async fetchTicker(): Promise { 19 | assert(this.config.apiKey !== undefined, 'CurrencyApi API key was not set') 20 | 21 | const base = this.config.baseCurrency 22 | const quote = this.config.quoteCurrency 23 | 24 | const tickerJson: Response = await this.fetchFromApi( 25 | ExchangeDataType.TICKER, 26 | `convert?key=${this.config.apiKey}&amount=1&from=${base}&to=${quote}&output=JSON` 27 | ) 28 | return this.parseTicker(tickerJson) 29 | } 30 | 31 | /** 32 | * 33 | * @param json parsed response from CurrencyApi latest endpoint 34 | * { 35 | * "valid": true, 36 | * "updated": 1695168063, 37 | * "conversion": { 38 | * "amount": 1, 39 | * "from": "EUR", 40 | * "to": "XOF", 41 | * "result": 655.315694 42 | * } 43 | * } 44 | */ 45 | parseTicker(json: any): Ticker { 46 | assert(json.valid, 'CurrencyApi response object contains false valid field') 47 | assert(json.conversion.amount === 1, 'CurrencyApi response object amount field is not 1') 48 | assert( 49 | json.conversion.from === this.config.baseCurrency, 50 | 'CurrencyApi response object from field does not match base currency' 51 | ) 52 | assert( 53 | json.conversion.to === this.config.quoteCurrency, 54 | 'CurrencyApi response object to field does not match quote currency' 55 | ) 56 | 57 | const price = this.safeBigNumberParse(json.conversion.result)! 58 | const ticker = { 59 | ...this.priceObjectMetadata, 60 | ask: price, 61 | bid: price, 62 | lastPrice: price, 63 | timestamp: this.safeBigNumberParse(json.updated)?.toNumber()!, 64 | // These FX API's do not provide volume data, 65 | // therefore we set all of them to 1 to weight them equally. 66 | baseVolume: new BigNumber(1), 67 | quoteVolume: price, // baseVolume * lastPrice, so 1 * lastPrice in this case 68 | } 69 | this.verifyTicker(ticker) 70 | return ticker 71 | } 72 | 73 | async isOrderbookLive(): Promise { 74 | return !BaseExchangeAdapter.fxMarketsClosed(Date.now()) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/exchange_adapters/gemini.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | 3 | import { Exchange } from '../utils' 4 | 5 | export class GeminiAdapter extends BaseExchangeAdapter { 6 | baseApiUrl = 'https://api.gemini.com/v1/' 7 | readonly _exchangeName = Exchange.GEMINI 8 | 9 | async fetchTicker(): Promise { 10 | return this.parseTicker( 11 | await this.fetchFromApi(ExchangeDataType.TICKER, `pubticker/${this.pairSymbol}`) 12 | ) 13 | } 14 | 15 | protected generatePairSymbol(): string { 16 | const base = BaseExchangeAdapter.standardTokenSymbolMap.get(this.config.baseCurrency) 17 | const quote = BaseExchangeAdapter.standardTokenSymbolMap.get(this.config.quoteCurrency) 18 | 19 | return `${base}${quote}`.toLocaleLowerCase() 20 | } 21 | 22 | /** 23 | * Parses the json responses from the ticker and summary endpoints into the 24 | * standard format for a Ticker object 25 | * 26 | * @param pubtickerJson json response from the ticker endpoint 27 | * pubticker/${this.pairSymbol} 28 | * https://api.gemini.com/v1/pubticker/${this.pairSymbol} 29 | * https://docs.gemini.com/rest-api/#pubticker 30 | * 31 | * { 32 | * "ask": "977.59", // The lowest ask currently available 33 | * "bid": "977.35", // The highest bid currently available 34 | * "last": "977.65", // The price of the last executed trade 35 | * "volume": { // Information about the 24 hour volume on the exchange. See below 36 | * "BTC": "2210.505328803", // (price symbol, e.g. "USD") The volume denominated in the price currency 37 | * "USD": "2135477.463379586263", // (price symbol, e.g. "USD") The volume denominated in the quantity currency 38 | * "timestamp": 1483018200000 // The end of the 24-hour period over which volume was measured 39 | * } 40 | * } 41 | */ 42 | parseTicker(pubtickerJson: any): Ticker { 43 | const volume = pubtickerJson.volume || {} 44 | const ticker = { 45 | ...this.priceObjectMetadata, 46 | timestamp: volume.timestamp, 47 | bid: this.safeBigNumberParse(pubtickerJson.bid)!, 48 | ask: this.safeBigNumberParse(pubtickerJson.ask)!, 49 | lastPrice: this.safeBigNumberParse(pubtickerJson.last)!, 50 | baseVolume: this.safeBigNumberParse(volume[this.config.baseCurrency])!, 51 | quoteVolume: this.safeBigNumberParse(volume[this.config.quoteCurrency])!, 52 | } 53 | 54 | this.verifyTicker(ticker) 55 | return ticker 56 | } 57 | 58 | /** 59 | * Checks if the orderbook for the relevant pair is live. If it's not, the price 60 | * data from ticker endpoint may be inaccurate. 61 | * 62 | * API response example: 63 | * { 64 | * "symbol": "BTCUSD", 65 | * "base_currency": "BTC", 66 | * "quote_currency": "USD", 67 | * "tick_size": 1E-8, 68 | * "quote_increment": 0.01, 69 | * "min_order_size": "0.00001", 70 | * "status": "open", 71 | * "wrap_enabled": false 72 | * } 73 | * 74 | * @returns bool 75 | */ 76 | async isOrderbookLive(): Promise { 77 | const res = await this.fetchFromApi( 78 | ExchangeDataType.ORDERBOOK_STATUS, 79 | `symbols/details/${this.pairSymbol}` 80 | ) 81 | 82 | return res.status === 'open' 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/exchange_adapters/kraken.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | 3 | import { Exchange } from '../utils' 4 | 5 | export class KrakenAdapter extends BaseExchangeAdapter implements ExchangeAdapter { 6 | baseApiUrl = 'https://api.kraken.com' 7 | readonly _exchangeName = Exchange.KRAKEN 8 | 9 | private static readonly tokenSymbolMap = KrakenAdapter.standardTokenSymbolMap 10 | 11 | protected generatePairSymbol(): string { 12 | const base = KrakenAdapter.tokenSymbolMap.get(this.config.baseCurrency) 13 | const quote = KrakenAdapter.tokenSymbolMap.get(this.config.quoteCurrency) 14 | return `${base}${quote}` 15 | } 16 | 17 | async fetchTicker(): Promise { 18 | const json = await this.fetchFromApi( 19 | ExchangeDataType.TICKER, 20 | `0/public/Ticker?pair=${this.pairSymbol}` 21 | ) 22 | return this.parseTicker(json) 23 | } 24 | 25 | /** 26 | * @param json a json object representing the ticker from Kraken's API 27 | * Expected format can be seen in the public docs": https://docs.kraken.com/rest/#tag/Market-Data/operation/getTickerInformation 28 | * 29 | */ 30 | parseTicker(json: any): Ticker { 31 | if (Object.keys(json.result).length !== 1) { 32 | throw new Error( 33 | `Unexpected number of pairs in ticker response: ${Object.keys(json.result).length}` 34 | ) 35 | } 36 | 37 | const data = json.result[Object.keys(json.result)[0]] 38 | 39 | const baseVolume = this.safeBigNumberParse(data.v[1])! 40 | const lastPrice = this.safeBigNumberParse(data.p[1])! 41 | 42 | const quoteVolume = baseVolume?.multipliedBy(lastPrice) 43 | 44 | const ticker = { 45 | ...this.priceObjectMetadata, 46 | ask: this.safeBigNumberParse(data.a[0])!, 47 | baseVolume, 48 | bid: this.safeBigNumberParse(data.b[0])!, 49 | lastPrice, 50 | quoteVolume: quoteVolume!, 51 | timestamp: 0, // Timestamp is not provided by Kraken and is not used by the oracle 52 | } 53 | this.verifyTicker(ticker) 54 | return ticker 55 | } 56 | 57 | /** 58 | * Checks status of orderbook 59 | * https://api.kraken.com/0/public/SystemStatus" 60 | * 61 | * { 62 | * "error": [], 63 | * "result": { 64 | * "status": "string ("online"|"maintenance"|"cancel_only"|"post_only")", 65 | * "timestamp": "timestamp" 66 | * } 67 | * } 68 | * 69 | * @returns bool 70 | */ 71 | async isOrderbookLive(): Promise { 72 | const response = await this.fetchFromApi( 73 | ExchangeDataType.ORDERBOOK_STATUS, 74 | `0/public/SystemStatus` 75 | ) 76 | return response.result.status === 'online' 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/exchange_adapters/kucoin.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | 3 | import { Exchange } from '../utils' 4 | 5 | export class KuCoinAdapter extends BaseExchangeAdapter implements ExchangeAdapter { 6 | baseApiUrl = 'https://api.kucoin.com' 7 | readonly _exchangeName = Exchange.KUCOIN 8 | 9 | private static readonly tokenSymbolMap = KuCoinAdapter.standardTokenSymbolMap 10 | 11 | protected generatePairSymbol(): string { 12 | const base = KuCoinAdapter.tokenSymbolMap.get(this.config.baseCurrency) 13 | const quote = KuCoinAdapter.tokenSymbolMap.get(this.config.quoteCurrency) 14 | return `${base}-${quote}` 15 | } 16 | 17 | async fetchTicker(): Promise { 18 | const tickerJson = await this.fetchFromApi(ExchangeDataType.TICKER, `api/v1/market/allTickers`) 19 | 20 | return this.parseTicker(tickerJson) 21 | } 22 | 23 | /** 24 | * 25 | * @param json parsed response from kucoins's ticker endpoint 26 | * https://api.kucoin.com/api/v1/market/allTickers 27 | * 28 | * "code":"200000", 29 | * "data":{ 30 | * "time":1674501725001, 31 | * "ticker":[ 32 | * { 33 | * "symbol":"CELO-USDT", 34 | * "symbolName":"CELO-USDT", 35 | * "buy":"0.7555", 36 | * "sell":"0.7563", 37 | * "changeRate":"0.0907", 38 | * "changePrice":"0.0629", 39 | * "high":"0.8281", 40 | * "low":"0.6654", 41 | * "vol":"1598294.854", 42 | * "volValue":"1213224.94637127", 43 | * "last":"0.7561", 44 | * "averagePrice":"0.75703415", 45 | * "takerFeeRate":"0.001", 46 | * "makerFeeRate":"0.001", 47 | * "takerCoefficient":"1", 48 | * "makerCoefficient":"1" 49 | * }, 50 | * ] 51 | * } 52 | */ 53 | parseTicker(json: any): Ticker { 54 | const pair = json.data.ticker.find((p: { symbol: string }) => p?.symbol === this.pairSymbol) 55 | const ticker = { 56 | ...this.priceObjectMetadata, 57 | ask: this.safeBigNumberParse(pair.sell)!, 58 | baseVolume: this.safeBigNumberParse(pair.vol)!, 59 | bid: this.safeBigNumberParse(pair.buy)!, 60 | lastPrice: this.safeBigNumberParse(pair.last)!, 61 | quoteVolume: this.safeBigNumberParse(pair.volValue)!, 62 | timestamp: this.safeBigNumberParse(json.data.time)?.toNumber()!, 63 | } 64 | this.verifyTicker(ticker) 65 | return ticker 66 | } 67 | 68 | /** 69 | * 70 | * @param json parsed response from kucoin's symbols endpoint 71 | * https://api.kucoin.com/api/v1/symbols 72 | * 73 | * { 74 | * "code":"200000", 75 | * "data":[ 76 | * { 77 | * "symbol":"CELO-USDT", 78 | * "name":"CELO-USDT", 79 | * "baseCurrency":"CELO", 80 | * "quoteCurrency":"USDT", 81 | * "feeCurrency":"USDT", 82 | * "market":"USDS", 83 | * "baseMinSize":"0.1", 84 | * "quoteMinSize":"0.1", 85 | * "baseMaxSize":"10000000000", 86 | * "quoteMaxSize":"99999999", 87 | * "baseIncrement":"0.0001", 88 | * "quoteIncrement":"0.0001", 89 | * "priceIncrement":"0.0001", 90 | * "priceLimitRate":"0.1", 91 | * "minFunds":"0.1", 92 | * "isMarginEnabled":true, 93 | * "enableTrading":true 94 | * }, 95 | * ] 96 | * } 97 | * @returns bool 98 | */ 99 | async isOrderbookLive(): Promise { 100 | const response = await this.fetchFromApi(ExchangeDataType.ORDERBOOK_STATUS, `api/v1/symbols`) 101 | const pair = response.data.find((p: { symbol: string }) => p?.symbol === this.pairSymbol) 102 | 103 | return !!response && pair.enableTrading === true 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/exchange_adapters/mercado.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | 3 | import { Exchange } from '../utils' 4 | 5 | export class MercadoAdapter extends BaseExchangeAdapter implements ExchangeAdapter { 6 | baseApiUrl = 'https://api.mercadobitcoin.net/api/v4' 7 | readonly _exchangeName = Exchange.MERCADO 8 | 9 | private static readonly tokenSymbolMap = MercadoAdapter.standardTokenSymbolMap 10 | 11 | protected generatePairSymbol(): string { 12 | const base = MercadoAdapter.tokenSymbolMap.get(this.config.baseCurrency) 13 | const quote = MercadoAdapter.tokenSymbolMap.get(this.config.quoteCurrency) 14 | return `${base}-${quote}` 15 | } 16 | 17 | async fetchTicker(): Promise { 18 | const [tickerJson, orderbookJson] = await Promise.all([ 19 | this.fetchFromApi(ExchangeDataType.TICKER, `tickers?symbols=${this.pairSymbol}`), 20 | this.fetchFromApi(ExchangeDataType.TICKER, `${this.pairSymbol}/orderbook`), 21 | ]) 22 | return this.parseTicker(tickerJson, orderbookJson) 23 | } 24 | 25 | /** 26 | * 27 | * @param json parsed response from mercado's orderbook endpoint 28 | * https://api.mercadobitcoin.net/api/v4/docs#tag/Public-Data/paths/~1{symbol}~1orderbook/get 29 | * https://api.mercadobitcoin.net/api/v4/BTC-BRL/orderbook 30 | * "asks":[ 31 | * ["117275.49879111", "0.0256"], 32 | * ["117532.16627745", "0.01449"], 33 | * ... 34 | * ], 35 | * "bids":[ 36 | * ["117223.32117", "0.00001177"], 37 | * ["117200", "0.00002"], 38 | * ..... 39 | * ] 40 | * 41 | * @param json parsed response from mercado's ticker endpoint 42 | * https://api.mercadobitcoin.net/api/v4/docs#tag/Public-Data/paths/~1tickers/get 43 | * https://api.mercadobitcoin.net/api/v4/tickers?symbols=BTC-BRL 44 | * [ 45 | * { 46 | * "pair":"BTC-BRL", 47 | * "high":"120700.00000000", 48 | * "low":"117000.00001000", 49 | * "vol":"52.00314436", 50 | * "last":"119548.04744932", 51 | * "buy":"119457.96889001", 52 | * "sell":"119546.04397687", 53 | * "open":"119353.86994450", 54 | * "date":1674561363 55 | * } 56 | * ] 57 | * 58 | */ 59 | // Using lastPrice to convert from baseVolume to quoteVolume, as Mercado's 60 | // API does not provide this information. The correct price for the 61 | // conversion would be the VWAP over the period contemplated by the ticker, 62 | // but it's also not available. As a price has to be chosen for the 63 | // conversion, and none of them are correct, lastPrice is chose as it 64 | // was actually on one trade (whereas the buy or sell could have no 65 | // relation to the VWAP). 66 | 67 | parseTicker(tickerJson: any, orderbookJson: any): Ticker { 68 | const baseVolume = this.safeBigNumberParse(tickerJson[0].vol)! 69 | const lastPrice = this.safeBigNumberParse(tickerJson[0].last)! 70 | const quoteVolume = baseVolume?.multipliedBy(lastPrice) 71 | const ticker = { 72 | ...this.priceObjectMetadata, 73 | ask: this.safeBigNumberParse(orderbookJson.asks[0][0])!, 74 | baseVolume, 75 | bid: this.safeBigNumberParse(orderbookJson.bids[0][0])!, 76 | lastPrice, 77 | quoteVolume, 78 | timestamp: this.safeBigNumberParse(tickerJson[0].date)?.toNumber()!, 79 | } 80 | this.verifyTicker(ticker) 81 | return ticker 82 | } 83 | 84 | /** 85 | * @param json parsed response from mercado's symbols endpoint 86 | * Checks status of orderbook 87 | * 88 | * https://api.mercadobitcoin.net/api/v4/symbols?symbols=BTC-BRL 89 | * https://api.mercadobitcoin.net/api/v4/docs#tag/Public-Data/paths/~1symbols/get 90 | * { 91 | * "symbol":[ "BTC-BRL" ], 92 | * "description":[ "Bitcoin" ], 93 | * "currency":[ "BRL" ], 94 | * "base-currency":[ "BTC" ], 95 | * "exchange-listed":[ true ], 96 | * "exchange-traded":[ true ], 97 | * "minmovement":[ "1" ], 98 | * "pricescale":[ 100000000 ], 99 | * "type":[ "CRYPTO" ], 100 | * "timezone":[ "America/Sao_Paulo" ], 101 | * "session-regular":[ "24x7" ], 102 | * "withdrawal-fee":[ "0.0004" ], 103 | * "withdraw-minimum":[ "0.001" ], 104 | * "deposit-minimum":[ "0.00001" ] 105 | * } 106 | * 107 | * @returns bool 108 | */ 109 | async isOrderbookLive(): Promise { 110 | const response = await this.fetchFromApi( 111 | ExchangeDataType.ORDERBOOK_STATUS, 112 | `symbols?symbols=${this.pairSymbol}` 113 | ) 114 | return ( 115 | !!response && 116 | response['exchange-traded'][0] === true && 117 | response['exchange-listed'][0] === true 118 | ) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/exchange_adapters/novadax.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | 3 | import { Exchange } from '../utils' 4 | 5 | export class NovaDaxAdapter extends BaseExchangeAdapter implements ExchangeAdapter { 6 | baseApiUrl = 'https://api.novadax.com/v1/market' 7 | readonly _exchangeName = Exchange.NOVADAX 8 | 9 | private static readonly tokenSymbolMap = NovaDaxAdapter.standardTokenSymbolMap 10 | 11 | protected generatePairSymbol(): string { 12 | const base = NovaDaxAdapter.tokenSymbolMap.get(this.config.baseCurrency) 13 | const quote = NovaDaxAdapter.tokenSymbolMap.get(this.config.quoteCurrency) 14 | return `${base}_${quote}` 15 | } 16 | 17 | async fetchTicker(): Promise { 18 | const tickerJson = await this.fetchFromApi( 19 | ExchangeDataType.TICKER, 20 | `ticker?symbol=${this.pairSymbol}` 21 | ) 22 | return this.parseTicker(tickerJson) 23 | } 24 | 25 | /** 26 | * 27 | * @param json parsed response from NovaDAX's ticker endpoint 28 | * 29 | * { 30 | * "code": "A10000", 31 | * "data": { 32 | * "ask": "34708.15", 33 | * "baseVolume24h": "34.08241488", 34 | * "bid": "34621.74", 35 | * "high24h": "35079.77", 36 | * "lastPrice": "34669.81", 37 | * "low24h": "34330.64", 38 | * "open24h": "34492.08", 39 | * "quoteVolume24h": "1182480.09502814", 40 | * "symbol": "BTC_BRL", 41 | * "timestamp": 1571112216346 42 | * }, 43 | * "message": "Success" 44 | * } 45 | * 46 | */ 47 | parseTicker(json: any): Ticker { 48 | const data = json.data 49 | const lastPrice = this.safeBigNumberParse(data.lastPrice)! 50 | const baseVolume = this.safeBigNumberParse(data.baseVolume24h)! 51 | const quoteVolume = this.safeBigNumberParse(data.quoteVolume24h)! 52 | const ticker = { 53 | ...this.priceObjectMetadata, 54 | ask: this.safeBigNumberParse(data.ask)!, 55 | baseVolume, 56 | bid: this.safeBigNumberParse(data.bid)!, 57 | high: this.safeBigNumberParse(data.high24h), 58 | lastPrice, 59 | low: this.safeBigNumberParse(data.low24h), 60 | open: this.safeBigNumberParse(data.open24h), 61 | quoteVolume, 62 | timestamp: this.safeBigNumberParse(data.timestamp)?.toNumber()!, 63 | } 64 | this.verifyTicker(ticker) 65 | return ticker 66 | } 67 | 68 | /** 69 | * No NovaDax endpoint available to check for order book liveness. 70 | * @returns bool 71 | */ 72 | async isOrderbookLive(): Promise { 73 | return true 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/exchange_adapters/okcoin.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | 3 | import { Exchange } from '../utils' 4 | 5 | export class OKCoinAdapter extends BaseExchangeAdapter { 6 | baseApiUrl = 'https://www.okcoin.com/api' 7 | readonly _exchangeName = Exchange.OKCOIN 8 | 9 | // There are no known deviations from the standard mapping 10 | private static readonly tokenSymbolMap = OKCoinAdapter.standardTokenSymbolMap 11 | 12 | async fetchTicker(): Promise { 13 | const json = await this.fetchFromApi( 14 | ExchangeDataType.TICKER, 15 | `spot/v3/instruments/${this.pairSymbol}/ticker` 16 | ) 17 | return this.parseTicker(json) 18 | } 19 | 20 | /** 21 | * Parses the json response from the ticker endpoint and returns a Ticker object 22 | * 23 | * @param json response from /spot/v3/instruments/${this.pairSymbol}/ticker 24 | * 25 | * Example response from OKCoin docs: https://www.okcoin.com/docs/en/#spot-some 26 | * { 27 | * "best_ask": "7222.2", 28 | * "best_bid": "7222.1", 29 | * "instrument_id": "BTC-USDT", 30 | * "product_id": "BTC-USDT", 31 | * "last": "7222.2", 32 | * "last_qty": "0.00136237", 33 | * "ask": "7222.2", 34 | * "best_ask_size": "0.09207739", 35 | * "bid": "7222.1", 36 | * "best_bid_size": "3.61314948", 37 | * "open_24h": "7356.8", 38 | * "high_24h": "7367.7", 39 | * "low_24h": "7160", 40 | * "base_volume_24h": "18577.2", 41 | * "timestamp": "2019-12-11T07:48:04.014Z", 42 | * "quote_volume_24h": "134899542.8" 43 | * } 44 | */ 45 | parseTicker(json: any): Ticker { 46 | const ticker = { 47 | ...this.priceObjectMetadata, 48 | ask: this.safeBigNumberParse(json.ask)!, 49 | baseVolume: this.safeBigNumberParse(json.base_volume_24h)!, 50 | bid: this.safeBigNumberParse(json.bid)!, 51 | high: this.safeBigNumberParse(json.high_24h), 52 | lastPrice: this.safeBigNumberParse(json.last)!, 53 | low: this.safeBigNumberParse(json.low_24h), 54 | open: this.safeBigNumberParse(json.open_24h), 55 | quoteVolume: this.safeBigNumberParse(json.quote_volume_24h)!, 56 | timestamp: this.safeDateParse(json.timestamp)!, 57 | } 58 | this.verifyTicker(ticker) 59 | return ticker 60 | } 61 | 62 | protected generatePairSymbol(): string { 63 | return `${OKCoinAdapter.tokenSymbolMap.get( 64 | this.config.baseCurrency 65 | )}-${OKCoinAdapter.tokenSymbolMap.get(this.config.quoteCurrency)}` 66 | } 67 | 68 | /** 69 | * OKCoin doesn't have an endpoint to check this. So, return true, and assume 70 | * that if the API can be reached, the orderbook is live. 71 | */ 72 | async isOrderbookLive(): Promise { 73 | return true 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/exchange_adapters/okx.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | 3 | import { Exchange } from '../utils' 4 | 5 | export class OKXAdapter extends BaseExchangeAdapter implements ExchangeAdapter { 6 | baseApiUrl = 'https://www.okx.com/api/v5' 7 | readonly _exchangeName = Exchange.OKX 8 | 9 | private static readonly tokenSymbolMap = OKXAdapter.standardTokenSymbolMap 10 | 11 | protected generatePairSymbol(): string { 12 | const base = OKXAdapter.tokenSymbolMap.get(this.config.baseCurrency) 13 | const quote = OKXAdapter.tokenSymbolMap.get(this.config.quoteCurrency) 14 | return `${base}-${quote}` 15 | } 16 | 17 | async fetchTicker(): Promise { 18 | const tickerJson = await this.fetchFromApi( 19 | ExchangeDataType.TICKER, 20 | `market/ticker?instId=${this.pairSymbol}` 21 | ) 22 | 23 | return this.parseTicker(tickerJson) 24 | } 25 | 26 | /** 27 | * 28 | * @param json parsed response from bitstamps's ticker endpoint 29 | * https://www.okx.com/api/v5/market/ticker?instId=CELO-USDT 30 | * { 31 | * "code":"0", 32 | * "msg":"", 33 | * "data":[ 34 | * { 35 | * "instType":"SPOT", 36 | * "instId":"CELO-USDT", 37 | * "last":"0.792", 38 | * "lastSz":"193.723363", 39 | * "askPx":"0.793", 40 | * "askSz":"802.496954", 41 | * "bidPx":"0.792", 42 | * "bidSz":"55.216944", 43 | * "open24h":"0.691", 44 | * "high24h":"0.828", 45 | * "low24h":"0.665", 46 | * "volCcy24h":"1642445.37682", 47 | * "vol24h":"2177089.719932", 48 | * "ts":"1674479195109", 49 | * "sodUtc0":"0.685", 50 | * "sodUtc8":"0.698" 51 | * } 52 | * ] 53 | * } 54 | */ 55 | parseTicker(json: any): Ticker { 56 | const ticker = { 57 | ...this.priceObjectMetadata, 58 | ask: this.safeBigNumberParse(json.data[0].askPx)!, 59 | baseVolume: this.safeBigNumberParse(json.data[0].vol24h)!, 60 | bid: this.safeBigNumberParse(json.data[0].bidPx)!, 61 | lastPrice: this.safeBigNumberParse(json.data[0].last)!, 62 | quoteVolume: this.safeBigNumberParse(json.data[0].volCcy24h)!, 63 | timestamp: this.safeBigNumberParse(json.data[0].ts)?.toNumber()!, 64 | } 65 | this.verifyTicker(ticker) 66 | return ticker 67 | } 68 | 69 | /** 70 | * 71 | * @param json parsed response from bitstamps's ticker endpoint 72 | * https://www.okx.com/api/v5/market/ticker?instId=CELO-USDT 73 | * { 74 | * "code":"0", 75 | * "msg":"", 76 | * "data":[ 77 | * { 78 | * "instType":"SPOT", 79 | * "instId":"CELO-USDT", 80 | * "last":"0.792", 81 | * "lastSz":"193.723363", 82 | * "askPx":"0.793", 83 | * "askSz":"802.496954", 84 | * "bidPx":"0.792", 85 | * "bidSz":"55.216944", 86 | * "open24h":"0.691", 87 | * "high24h":"0.828", 88 | * "low24h":"0.665", 89 | * "volCcy24h":"1642445.37682", 90 | * "vol24h":"2177089.719932", 91 | * "ts":"1674479195109", 92 | * "sodUtc0":"0.685", 93 | * "sodUtc8":"0.698" 94 | * } 95 | * ] 96 | * } 97 | * @returns bool 98 | */ 99 | async isOrderbookLive(): Promise { 100 | const response = await this.fetchFromApi( 101 | ExchangeDataType.ORDERBOOK_STATUS, 102 | `market/ticker?instId=${this.pairSymbol}` 103 | ) 104 | 105 | return !!response && response.code === '0' 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/exchange_adapters/whitebit.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | 3 | import { Exchange } from '../utils' 4 | 5 | export class WhitebitAdapter extends BaseExchangeAdapter { 6 | baseApiUrl = 'https://whitebit.com/api/v4/public/' 7 | readonly _exchangeName = Exchange.WHITEBIT 8 | 9 | private static readonly tokenSymbolMap = this.standardTokenSymbolMap 10 | 11 | protected generatePairSymbol(): string { 12 | const base = WhitebitAdapter.tokenSymbolMap.get(this.config.baseCurrency)?.toUpperCase() 13 | const quote = WhitebitAdapter.tokenSymbolMap.get(this.config.quoteCurrency)?.toUpperCase() 14 | 15 | return `${base}_${quote}` 16 | } 17 | 18 | async fetchTicker(): Promise { 19 | const json = await this.fetchFromApi(ExchangeDataType.TICKER, `ticker`) 20 | const tickerData = json[this.generatePairSymbol()] 21 | 22 | if (!tickerData) { 23 | throw new Error(`Ticker data not found for ${this.generatePairSymbol()}`) 24 | } 25 | 26 | // Get orderbook data as ticker data does not contain bid/ask 27 | const orderBookData = await this.fetchFromApi( 28 | ExchangeDataType.TICKER, 29 | `orderbook/${this.generatePairSymbol()}?limit=1&level=2&` 30 | ) 31 | 32 | return this.parseTicker({ 33 | ...tickerData, 34 | ask: orderBookData.asks[0][0], 35 | bid: orderBookData.bids[0][0], 36 | }) 37 | } 38 | 39 | /** 40 | * 41 | * @param json parsed response from whitebits's ticker endpoint 42 | * https://whitebit.com/api/v4/public/ticker 43 | * 44 | * "1INCH_UAH": { 45 | * "base_id": 8104, 46 | * "quote_id": 0, 47 | * "last_price": "20.991523", 48 | * "quote_volume": "1057381.44765064", 49 | * "base_volume": "48537.28", 50 | * "isFrozen": false, 51 | * "change": "-4.71" 52 | * }, 53 | */ 54 | parseTicker(tickerData: any): Ticker { 55 | const ticker = { 56 | ...this.priceObjectMetadata, 57 | ask: this.safeBigNumberParse(tickerData.ask)!, 58 | baseVolume: this.safeBigNumberParse(tickerData.base_volume)!, 59 | bid: this.safeBigNumberParse(tickerData.bid)!, 60 | lastPrice: this.safeBigNumberParse(tickerData.last_price)!, 61 | quoteVolume: this.safeBigNumberParse(tickerData.quote_volume)!, 62 | timestamp: 0, // Timestamp is not provided by Whitebit and is not used by the oracle 63 | } 64 | this.verifyTicker(ticker) 65 | return ticker 66 | } 67 | 68 | /** 69 | * Checks status of orderbook 70 | * [GET] /api/v4/public/markets 71 | * [ 72 | * { 73 | * "name": "SON_USD", // Market pair name 74 | * "stock": "SON", // Ticker of stock currency 75 | * "money": "USD", // Ticker of money currency 76 | * "stockPrec": "3", // Stock currency precision 77 | * "moneyPrec": "2", // Precision of money currency 78 | * "feePrec": "4", // Fee precision 79 | * "makerFee": "0.001", // Default maker fee ratio 80 | * "takerFee": "0.001", // Default taker fee ratio 81 | * "minAmount": "0.001", // Minimal amount of stock to trade 82 | * "minTotal": "0.001", // Minimal amount of money to trade 83 | * "tradesEnabled": true, // Is trading enabled 84 | * "isCollateral": true, // Is margin trading enabled 85 | * "type": "spot" // Market type. Possible values: "spot", "futures" 86 | * } 87 | * 88 | * @returns bool 89 | */ 90 | async isOrderbookLive(): Promise { 91 | const marketInfoData = await this.fetchFromApi(ExchangeDataType.ORDERBOOK_STATUS, `markets`) 92 | 93 | const filteredMarketInfo = marketInfoData.filter( 94 | (market: any) => market.name === this.generatePairSymbol() 95 | ) 96 | 97 | if (filteredMarketInfo.length !== 1) { 98 | throw new Error(`Market info not found for ${this.generatePairSymbol()}`) 99 | } 100 | 101 | return filteredMarketInfo[0].tradesEnabled && filteredMarketInfo[0].type === 'spot' 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/exchange_adapters/xignite.ts: -------------------------------------------------------------------------------- 1 | import { BaseExchangeAdapter, ExchangeAdapter, ExchangeDataType, Ticker } from './base' 2 | 3 | import BigNumber from 'bignumber.js' 4 | import { Exchange } from '../utils' 5 | import { strict as assert } from 'assert' 6 | 7 | export class XigniteAdapter extends BaseExchangeAdapter implements ExchangeAdapter { 8 | baseApiUrl = 'https://globalcurrencies.xignite.com/xGlobalCurrencies.json' 9 | readonly _exchangeName: Exchange = Exchange.XIGNITE 10 | 11 | protected generatePairSymbol(): string { 12 | const base = XigniteAdapter.standardTokenSymbolMap.get(this.config.baseCurrency) 13 | const quote = XigniteAdapter.standardTokenSymbolMap.get(this.config.quoteCurrency) 14 | 15 | return `${base}${quote}` 16 | } 17 | 18 | async fetchTicker(): Promise { 19 | assert(this.config.apiKey !== undefined, 'XigniteAdapter API key was not set') 20 | 21 | const tickerJson = await this.fetchFromApi( 22 | ExchangeDataType.TICKER, 23 | `GetRealTimeRate?Symbol=${this.pairSymbol}&_token=${this.config.apiKey}` 24 | ) 25 | return this.parseTicker(tickerJson) 26 | } 27 | 28 | /** 29 | * 30 | * @param json parsed response from Xignite's rate endpoint 31 | * { 32 | * "BaseCurrency": "EUR", 33 | * "QuoteCurrency": "XOF", 34 | * "Symbol": "EURXOF", 35 | * "Date": "09/29/2023", 36 | * "Time": "9:59:50 PM", 37 | * "QuoteType": "Calculated", 38 | * "Bid": 653.626, 39 | * "Mid": 654.993, 40 | * "Ask": 656.36, 41 | * "Spread": 2.734, 42 | * "Text": "1 European Union euro = 654.993 West African CFA francs", 43 | * "Source": "Rates calculated by crossing via ZAR(Morningstar).", 44 | * "Outcome": "Success", 45 | * "Message": null, 46 | * "Identity": "Request", 47 | * "Delay": 0.0032363 48 | * } 49 | */ 50 | parseTicker(json: any): Ticker { 51 | assert( 52 | json.BaseCurrency === this.config.baseCurrency, 53 | `Base currency mismatch in response: ${json.BaseCurrency} != ${this.config.baseCurrency}` 54 | ) 55 | assert( 56 | json.QuoteCurrency === this.config.quoteCurrency, 57 | `Quote currency mismatch in response: ${json.QuoteCurrency} != ${this.config.quoteCurrency}` 58 | ) 59 | 60 | const ticker = { 61 | ...this.priceObjectMetadata, 62 | ask: this.safeBigNumberParse(json.Ask)!, 63 | bid: this.safeBigNumberParse(json.Bid)!, 64 | lastPrice: this.safeBigNumberParse(json.Mid)!, 65 | timestamp: this.toUnixTimestamp(json.Date, json.Time), 66 | // These FX API's do not provide volume data, 67 | // therefore we set all of them to 1 to weight them equally 68 | baseVolume: new BigNumber(1), 69 | quoteVolume: new BigNumber(1), 70 | } 71 | this.verifyTicker(ticker) 72 | return ticker 73 | } 74 | 75 | toUnixTimestamp(date: string, time: string): number { 76 | const [month, day, year] = date.split('/').map(Number) // date format: MM/DD/YYYY 77 | const [hours, minutes, seconds] = time.split(' ')[0].split(':').map(Number) // time format: HH:MM:SS AM/PM 78 | 79 | let adjustedHours = hours 80 | if (time.includes('PM') && hours !== 12) adjustedHours += 12 81 | if (time.includes('AM') && hours === 12) adjustedHours = 0 82 | 83 | // month should be 0-indexed 84 | return Date.UTC(year, month - 1, day, adjustedHours, minutes, seconds) / 1000 85 | } 86 | 87 | async isOrderbookLive(): Promise { 88 | return !BaseExchangeAdapter.fxMarketsClosed(Date.now()) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { start } from './run_app' 2 | 3 | // tslint:disable-next-line 4 | start() 5 | -------------------------------------------------------------------------------- /src/price_source.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js' 2 | 3 | export type WeightedPrice = { 4 | price: BigNumber 5 | weight: BigNumber 6 | } 7 | 8 | export interface PriceSource { 9 | name(): string 10 | fetchWeightedPrice(): Promise 11 | } 12 | -------------------------------------------------------------------------------- /src/reporters/transaction_manager/index.ts: -------------------------------------------------------------------------------- 1 | export { default as sendTransaction } from './send' 2 | export { default as sendTransactionWithRetries } from './send_with_retries' 3 | -------------------------------------------------------------------------------- /src/reporters/transaction_manager/send.ts: -------------------------------------------------------------------------------- 1 | import { CeloTransactionObject } from '@celo/connect' 2 | import Logger from 'bunyan' 3 | import { TransactionReceipt } from 'web3-core' 4 | 5 | /** 6 | * Sends a transaction wrapped by the metricAction. Gas is estimated-- 7 | * in the event that gas estimation fails due to this race condition: 8 | * https://github.com/celo-org/celo-blockchain/issues/1419, which can be identified 9 | * by gas estimation failing but the subsequent eth_call done by contractkit not 10 | * indicating a revert, fallbackGas is used. 11 | * @param tx the transaction to send 12 | * @param gasPrice the gas price for the transaction 13 | * @param from the from address for the transaction 14 | * @param metricAction a function that wraps the sending of the tx, intended to record any metrics 15 | * @param fallbackGas the fallback gas to use in the event gas estimation incorrectly fails 16 | */ 17 | export default async function send( 18 | logger: Logger, 19 | tx: CeloTransactionObject, 20 | gasPrice: number, 21 | from: string, 22 | metricAction: (fn: () => Promise, action: string) => Promise, 23 | fallbackGas: number 24 | ) { 25 | const txResult = await metricAction(async () => { 26 | try { 27 | // First, attempt to send transaction without a gas amount to have 28 | // contractkit estimate gas 29 | return await tx.send({ 30 | from, 31 | gasPrice, 32 | }) 33 | } catch (err: any) { 34 | // If anything fails, the error is caught here. 35 | // We seek the case where gas estimation has failed but the subsequent 36 | // eth_call made by contractkit to get the revert reason has not given 37 | // a revert reason. In this situation, the following string will be 38 | // included in the error string: 'Gas estimation failed: Could not decode transaction failure reason' 39 | if ( 40 | err.message.includes( 41 | 'Gas estimation failed: Could not decode transaction failure reason' 42 | ) && 43 | fallbackGas !== undefined 44 | ) { 45 | logger.info( 46 | { 47 | tx, 48 | gasPrice, 49 | from, 50 | fallbackGas, 51 | err, 52 | }, 53 | 'Gas estimation failed but eth_call did not, using fallback gas' 54 | ) 55 | // Retry with the fallbackGas to avoid gas estimation 56 | return tx.send({ 57 | from, 58 | gasPrice, 59 | gas: fallbackGas, 60 | }) 61 | } 62 | // If there was a legitimate error, we still throw 63 | throw err 64 | } 65 | }, 'send') 66 | return metricAction(() => txResult.waitReceipt(), 'waitReceipt') 67 | } 68 | -------------------------------------------------------------------------------- /src/reporters/transaction_manager/send_with_retries.ts: -------------------------------------------------------------------------------- 1 | import { CeloTransactionObject } from '@celo/connect' 2 | import Logger from 'bunyan' 3 | import { TransactionReceipt } from 'web3-core' 4 | import { TransactionManagerConfig } from '../../app' 5 | import { Context } from '../../metric_collector' 6 | import { onError } from '../../utils' 7 | import send from './send' 8 | 9 | export default async function sendWithRetries( 10 | logger: Logger, 11 | tx: CeloTransactionObject, 12 | initialGasPrice: number, 13 | config: TransactionManagerConfig, 14 | metricAction: (fn: () => Promise, action: string) => Promise, 15 | fallbackGas: number 16 | ): Promise { 17 | let attempt = 0 18 | let lastCaughtError = null 19 | 20 | do { 21 | const calculatedGasPrice = config.transactionRetryGasPriceMultiplier 22 | .times(attempt) 23 | .times(initialGasPrice) 24 | .plus(initialGasPrice) 25 | .toNumber() 26 | try { 27 | return await send( 28 | logger, 29 | tx, 30 | calculatedGasPrice, 31 | config.oracleAccount, 32 | metricAction, 33 | fallbackGas 34 | ) 35 | } catch (err: any) { 36 | lastCaughtError = err 37 | onError(err, { 38 | context: Context.TRANSACTION_MANAGER, 39 | logger: config.logger, 40 | logMsg: 'Unable to send transaction', 41 | metricCollector: config.metricCollector, 42 | swallowError: true, 43 | }) 44 | } 45 | attempt++ 46 | } while (attempt <= config.transactionRetryLimit) 47 | 48 | throw lastCaughtError 49 | } 50 | -------------------------------------------------------------------------------- /src/run_app.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseReporterConfigSubset, 3 | BlockBasedReporterConfigSubset, 4 | DataAggregatorConfigSubset, 5 | OracleApplication, 6 | OracleApplicationConfig, 7 | SSLFingerprintServiceConfigSubset, 8 | } from './app' 9 | import { EnvVar, fetchParseValidateEnvVar } from './envvar_utils' 10 | import { 11 | baseLogger, 12 | defaultApplicationConfig, 13 | defaultBlockBasedReporterConfig, 14 | defaultDataAggregatorConfig, 15 | defaultSSLFingerprintServiceConfig, 16 | } from './default_config' 17 | 18 | import { ReportStrategy } from './utils' 19 | 20 | type EnvVarMap = { [key in keyof Partial]: EnvVar } 21 | 22 | /** 23 | * This object keeps track of each value in OracleApplicationConfig that is 24 | * configurable via env vars. 25 | */ 26 | export const applicationConfigEnvVars: EnvVarMap = { 27 | apiKeys: EnvVar.API_KEYS, 28 | address: EnvVar.ADDRESS, 29 | awsKeyRegion: EnvVar.AWS_KEY_REGION, 30 | azureHsmInitMaxRetryBackoffMs: EnvVar.AZURE_HSM_INIT_MAX_RETRY_BACKOFF_MS, 31 | azureHsmInitTryCount: EnvVar.AZURE_HSM_INIT_TRY_COUNT, 32 | azureKeyVaultName: EnvVar.AZURE_KEY_VAULT_NAME, 33 | currencyPair: EnvVar.CURRENCY_PAIR, 34 | devMode: EnvVar.DEVMODE, 35 | httpRpcProviderUrl: EnvVar.HTTP_RPC_PROVIDER_URL, 36 | metrics: EnvVar.METRICS, 37 | privateKeyPath: EnvVar.PRIVATE_KEY_PATH, 38 | prometheusPort: EnvVar.PROMETHEUS_PORT, 39 | reportStrategy: EnvVar.REPORT_STRATEGY, 40 | reportTargetOverride: EnvVar.REPORT_TARGET_OVERRIDE, 41 | walletType: EnvVar.WALLET_TYPE, 42 | wsRpcProviderUrl: EnvVar.WS_RPC_PROVIDER_URL, 43 | } 44 | 45 | export const dataAggregatorConfigEnvVars: EnvVarMap = { 46 | aggregationMethod: EnvVar.AGGREGATION_METHOD, 47 | aggregationWindowDuration: EnvVar.AGGREGATION_PERIOD, 48 | apiRequestTimeout: EnvVar.API_REQUEST_TIMEOUT, 49 | maxSourceWeightShare: EnvVar.MID_AGGREGATION_MAX_EXCHANGE_VOLUME_SHARE, 50 | maxPercentageBidAskSpread: EnvVar.MID_AGGREGATION_MAX_PERCENTAGE_BID_ASK_SPREAD, 51 | maxPercentageDeviation: EnvVar.MID_AGGREGATION_MAX_PERCENTAGE_DEVIATION, 52 | minPriceSourceCount: EnvVar.MINIMUM_PRICE_SOURCES, 53 | priceSourceConfigs: EnvVar.PRICE_SOURCES, 54 | } 55 | 56 | const baseReporterConfigEnvVars: EnvVarMap = { 57 | circuitBreakerPriceChangeThresholdMin: EnvVar.CIRCUIT_BREAKER_PRICE_CHANGE_THRESHOLD_MIN, 58 | circuitBreakerPriceChangeThresholdTimeMultiplier: 59 | EnvVar.CIRCUIT_BREAKER_PRICE_CHANGE_THRESHOLD_TIME_MULTIPLIER, 60 | circuitBreakerPriceChangeThresholdMax: EnvVar.CIRCUIT_BREAKER_PRICE_CHANGE_THRESHOLD_MAX, 61 | devMode: EnvVar.DEVMODE, 62 | circuitBreakerDurationTimeMs: EnvVar.CIRCUIT_BREAKER_DURATION_MS, 63 | gasPriceMultiplier: EnvVar.GAS_PRICE_MULTIPLIER, 64 | transactionRetryGasPriceMultiplier: EnvVar.TRANSACTION_RETRY_GAS_PRICE_MULTIPLIER, 65 | transactionRetryLimit: EnvVar.TRANSACTION_RETRY_LIMIT, 66 | overrideIndex: EnvVar.OVERRIDE_INDEX, 67 | overrideTotalOracleCount: EnvVar.OVERRIDE_ORACLE_COUNT, 68 | unusedOracleAddresses: EnvVar.UNUSED_ORACLE_ADDRESSES, 69 | } 70 | 71 | export const blockBasedReporterConfigEnvVars: EnvVarMap = { 72 | ...baseReporterConfigEnvVars, 73 | maxBlockTimestampAgeMs: EnvVar.MAX_BLOCK_TIMESTAMP_AGE_MS, 74 | minReportPriceChangeThreshold: EnvVar.MIN_REPORT_PRICE_CHANGE_THRESHOLD, 75 | targetMaxHeartbeatPeriodMs: EnvVar.TARGET_MAX_HEARTBEAT_PERIOD_MS, 76 | } 77 | 78 | export const sslFingerprintServiceConfigEnvVars: EnvVarMap = { 79 | sslRegistryAddress: EnvVar.SSL_REGISTRY_ADDRESS, 80 | wsRpcProviderUrl: EnvVar.WS_RPC_PROVIDER_URL, 81 | } 82 | 83 | export function getComponentConfig(defaultConfig: T, envVarMap: EnvVarMap): T { 84 | const overrides: { [key: string]: any } = {} 85 | const invalidEnvVars = [] 86 | for (const k of Object.keys(envVarMap)) { 87 | const key = k as keyof T 88 | const envVarName = envVarMap[key] 89 | try { 90 | const override = fetchParseValidateEnvVar(envVarName!) 91 | if (override !== undefined) { 92 | overrides[key as string] = override 93 | } 94 | } catch (err: any) { 95 | invalidEnvVars.push(err.message) 96 | } 97 | } 98 | if (invalidEnvVars.length) { 99 | throw Error(`EnvVar invalid input errors:\n${invalidEnvVars.join(', ')}`) 100 | } 101 | return { 102 | ...defaultConfig, 103 | ...overrides, 104 | } 105 | } 106 | 107 | /** 108 | * This function returns the OracleApplicationConfig that is defaultApplicationConfig 109 | * with any overrides from env variables found in applicationConfigEnvVars 110 | */ 111 | export function getApplicationConfig(): OracleApplicationConfig { 112 | const baseConfig = getComponentConfig(defaultApplicationConfig, applicationConfigEnvVars) 113 | const dataAggregatorConfig = getComponentConfig( 114 | defaultDataAggregatorConfig, 115 | dataAggregatorConfigEnvVars 116 | ) 117 | let reporterConfig: BlockBasedReporterConfigSubset 118 | switch (baseConfig.reportStrategy) { 119 | case ReportStrategy.BLOCK_BASED: 120 | reporterConfig = getComponentConfig( 121 | defaultBlockBasedReporterConfig, 122 | blockBasedReporterConfigEnvVars 123 | ) 124 | break 125 | default: 126 | throw Error(`Invalid report strategy: ${baseConfig.reportStrategy}`) 127 | } 128 | const sslFingerprintServiceConfig = getComponentConfig( 129 | defaultSSLFingerprintServiceConfig, 130 | sslFingerprintServiceConfigEnvVars 131 | ) 132 | return { 133 | ...baseConfig, 134 | dataAggregatorConfig, 135 | reporterConfig, 136 | sslFingerprintServiceConfig, 137 | } 138 | } 139 | 140 | async function startApp() { 141 | const appConfig = getApplicationConfig() 142 | const oracleApp = new OracleApplication(appConfig) 143 | await oracleApp.init() 144 | oracleApp.start() 145 | } 146 | 147 | export async function start() { 148 | try { 149 | await startApp() 150 | } catch (e) { 151 | baseLogger.error(e, 'Error starting up') 152 | // stop the process 153 | process.exit(1) 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/services/SSLFingerprintService.ts: -------------------------------------------------------------------------------- 1 | import Logger from 'bunyan' 2 | import { MetricCollector } from '../metric_collector' 3 | import { Exchange } from '../utils' 4 | import { AbiItem, Contract } from '@celo/connect' 5 | import Web3 from 'web3' 6 | import { WebsocketProvider } from 'web3-core' 7 | import { Subscription } from 'web3-core-subscriptions' 8 | 9 | export interface ISSLFingerprintService { 10 | getFingerprint(identifier: string): string | undefined 11 | } 12 | 13 | const ALL_EXCHANGE_IDENTIFIERS = Object.values(Exchange) 14 | const REGISTRY_ABI = [ 15 | { 16 | type: 'function', 17 | name: 'getFingerprints', 18 | inputs: [ 19 | { 20 | name: 'identifiers', 21 | type: 'string[]', 22 | internalType: 'string[]', 23 | }, 24 | ], 25 | outputs: [ 26 | { 27 | name: 'fingerprints', 28 | type: 'bytes32[]', 29 | internalType: 'bytes32[]', 30 | }, 31 | ], 32 | stateMutability: 'view', 33 | }, 34 | { 35 | type: 'event', 36 | name: 'FingerprintUpdated', 37 | inputs: [ 38 | { 39 | name: 'hashedIdentifier', 40 | type: 'bytes32', 41 | indexed: true, 42 | internalType: 'bytes32', 43 | }, 44 | { 45 | name: 'fingerprint', 46 | type: 'bytes32', 47 | indexed: true, 48 | internalType: 'bytes32', 49 | }, 50 | { 51 | name: 'identifier', 52 | type: 'string', 53 | indexed: false, 54 | internalType: 'string', 55 | }, 56 | ], 57 | anonymous: false, 58 | }, 59 | ] as AbiItem[] 60 | 61 | export interface SSLFingerprintServiceConfig { 62 | /** 63 | * The registry contract address 64 | * See: https://github.com/mento-protocol/oracle-ssl-registry 65 | */ 66 | sslRegistryAddress: string 67 | /** 68 | * Used to create a web3 instance that can subscribe to chain events. 69 | */ 70 | wsRpcProviderUrl: string 71 | /** 72 | * A base instance of the logger that can be extended for a particular context 73 | */ 74 | baseLogger: Logger 75 | /** An optional MetricCollector instance to report metrics */ 76 | metricCollector?: MetricCollector 77 | /** 78 | * The currency in which to get the price of the baseCurrency. 79 | */ 80 | } 81 | 82 | export const formatFingerprint = (hexString: string) => { 83 | // Remove the leading '0x' if present 84 | if (hexString.startsWith('0x')) { 85 | hexString = hexString.slice(2) 86 | } 87 | 88 | // Convert to uppercase 89 | hexString = hexString.toUpperCase() 90 | 91 | // Add colons every 2 characters 92 | return hexString.match(/.{1,2}/g)!.join(':') 93 | } 94 | 95 | export class SSLFingerprintService implements ISSLFingerprintService { 96 | private readonly logger: Logger 97 | private readonly sslRegistryAddress: string 98 | private readonly fingerprintMapping: Map 99 | private readonly registry: Contract 100 | private readonly web3: Web3 101 | private readonly provider: WebsocketProvider 102 | private eventSubscription: Subscription | undefined 103 | 104 | private readonly wsConnectionOptions = { 105 | // to enable auto reconnection 106 | reconnect: { 107 | auto: true, 108 | delay: 5000, // ms, roughly a block 109 | }, 110 | } 111 | 112 | constructor(config: SSLFingerprintServiceConfig) { 113 | this.sslRegistryAddress = config.sslRegistryAddress 114 | this.logger = config.baseLogger.child({ 115 | context: 'ssl_fingerprint_service', 116 | }) 117 | this.fingerprintMapping = new Map() 118 | this.provider = new Web3.providers.WebsocketProvider( 119 | config.wsRpcProviderUrl, 120 | this.wsConnectionOptions 121 | ) 122 | this.web3 = new Web3(this.provider) 123 | this.registry = new this.web3.eth.Contract(REGISTRY_ABI, this.sslRegistryAddress) 124 | } 125 | 126 | async init() { 127 | const fingerprints = await this.registry.methods 128 | .getFingerprints(ALL_EXCHANGE_IDENTIFIERS) 129 | .call() 130 | for (let i = 0; i < ALL_EXCHANGE_IDENTIFIERS.length; i++) { 131 | this.fingerprintMapping.set(ALL_EXCHANGE_IDENTIFIERS[i], formatFingerprint(fingerprints[i])) 132 | } 133 | this.eventSubscription = this.registry.events.FingerprintUpdated( 134 | { 135 | fromBlock: 'latest', 136 | }, 137 | this.updateFingerprint 138 | ) 139 | this.logger.info('Pulled SSL Certificates from registry') 140 | } 141 | 142 | stop() { 143 | this.eventSubscription?.unsubscribe() 144 | this.provider.disconnect() 145 | } 146 | 147 | updateFingerprint = (error: any, event: any) => { 148 | if (error) { 149 | this.logger.error(error) 150 | return 151 | } 152 | this.logger.info('Fingerprint update detected') 153 | try { 154 | if ( 155 | !event.returnValues || 156 | !event.returnValues.fingerprint || 157 | !event.returnValues.identifier 158 | ) { 159 | throw new Error(`FingerprintUpdated event is invalid or missing returnValues`) 160 | } 161 | const { fingerprint, identifier } = event.returnValues 162 | if (!(identifier in Exchange)) { 163 | throw new Error(`Unexpected identifier: ${identifier}`) 164 | } 165 | if (!/0x[a-zA-Z0-9]{32}/.exec(fingerprint)) { 166 | throw new Error(`Invalid fingerprint: ${fingerprint}`) 167 | } 168 | if (/0x0{32}/.exec(fingerprint)) { 169 | throw new Error(`Fingerprint is empty`) 170 | } 171 | const formattedFingerprint = formatFingerprint(fingerprint) 172 | this.logger.info(`Updating ${identifier} fingerprint to ${formattedFingerprint}`) 173 | this.fingerprintMapping.set(identifier, formattedFingerprint) 174 | } catch (e) { 175 | this.logger.error('Failed to process FingerprintUpdated event') 176 | this.logger.error(e) 177 | } 178 | } 179 | 180 | getFingerprint(identifier: string): string { 181 | const fingerprint = this.fingerprintMapping.get(identifier) 182 | if (!!fingerprint) return fingerprint 183 | throw new Error(`${identifier} not found in fingerprint mapping. 184 | Either the service is not initialized or the you've added a new exchange type that wasn't updated in the SSL Registry. 185 | See: https://github.com/mento-protocol/oracle-ssl-registry`) 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /test/aggregator_functions.test.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js' 2 | import { checkIndividualTickerData } from '../src/aggregator_functions' 3 | import { testTickerArray } from './data_aggregator_testdata_utils' 4 | 5 | describe('checkIndividualTickerData', () => { 6 | const maxPercentageBidAskSpread = new BigNumber(0.1) 7 | 8 | it('tickers with zero ask are removed', () => { 9 | expect(checkIndividualTickerData(testTickerArray[3], maxPercentageBidAskSpread)).toStrictEqual( 10 | testTickerArray[3].slice(1) 11 | ) 12 | }) 13 | 14 | it('all tickers with zero ask throws', () => { 15 | expect(() => checkIndividualTickerData(testTickerArray[15], maxPercentageBidAskSpread)).toThrow( 16 | `No valid tickers available` 17 | ) 18 | }) 19 | 20 | it('ticker with negative bids are removed', () => { 21 | expect(checkIndividualTickerData(testTickerArray[4], maxPercentageBidAskSpread)).toStrictEqual( 22 | testTickerArray[4].slice(0, 2) 23 | ) 24 | }) 25 | 26 | it('all tickers with negative volume throws', () => { 27 | expect(() => checkIndividualTickerData(testTickerArray[14], maxPercentageBidAskSpread)).toThrow( 28 | `No valid tickers available` 29 | ) 30 | }) 31 | 32 | it('tickers with ask < bid are removed', () => { 33 | expect(checkIndividualTickerData(testTickerArray[6], maxPercentageBidAskSpread)).toStrictEqual( 34 | testTickerArray[6].slice(1) 35 | ) 36 | }) 37 | 38 | it('tickers with a too large bid-ask spread are removed', () => { 39 | expect(checkIndividualTickerData(testTickerArray[7], maxPercentageBidAskSpread)).toStrictEqual( 40 | testTickerArray[7].slice(1) 41 | ) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /test/data_aggregator_testdata_utils.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js' 2 | import { Ticker } from '../src/exchange_adapters/base' 3 | import { Exchange } from '../src/utils' 4 | 5 | // Ticker test cases: The combination of rows i of each testTicker array make test case i 6 | const testTickerAsks = [ 7 | [11.0, 11.2, 10.8], 8 | [0.111, 0.09, 0.14], 9 | [2.1, 2.1, 2.2], 10 | [0, 2.0, 2.2], 11 | [2.1, 2.0, 2.2], 12 | [2.1, 2.0, 2.2], 13 | [1.9, 2.1, 2.2], 14 | [2.4, 2.1, 2.2], 15 | [2.3, 1.6, 2.2], 16 | [2.1, 2.0, 2.2], 17 | [2.1, 2.0, 2.2], 18 | [2.1, 2.2, 2.151], 19 | [2.1, 2.0, 2.2], 20 | [11.0, 11.2, 10.8], 21 | [11.0, 11.2, 10.8], 22 | [0, 0, 0], 23 | ] 24 | 25 | const testTickerBids = [ 26 | [10.0, 9.8, 10.2], 27 | [0.11, 0.088, 0.13412], 28 | [1.9, 2.0, 2.1], 29 | [1.9, 2.0, 2.1], 30 | [1.9, 2.0, -0.1], 31 | [1.9, 2.0, 2.1], 32 | [2.1, 2.0, 2.1], 33 | [1.9, 2.0, 2.1], 34 | [2.0, 1.5, 2.0], 35 | [1.9, 1.3, 2.1], 36 | [1.9, 2.0, 2.1], 37 | [1.9, 2.0, 2.149], 38 | [1.9, 2.0, 2.1], 39 | [1.9, 2.0, 2.1], 40 | [1.9, 2.0, 2.1], 41 | [1.9, 2.0, 2.1], 42 | ] 43 | 44 | const testTickerVolumes = [ 45 | [1000, 40000, 12000], 46 | [1000000, 12000, 40000], 47 | [1000, 2000, 2000], 48 | [1000, 2000, 2000], 49 | [1000, 2000, 2000], 50 | [-1000, 2000, 2000], 51 | [1000, 2000, 2000], 52 | [1000, 2000, 2000], 53 | [1000, 2000, 2000], 54 | [1000, 2000, 2000], 55 | [1000, 2000, 1000000], 56 | [100000, 60000, 40000], 57 | [1000, 2000, 2000], 58 | [1000, 2000, 2000], 59 | [-1000, -2000, -2000], 60 | [1000, 2000, 2000], 61 | ] 62 | 63 | const testTickerQuoteVolumes = [ 64 | [1000, 40000, 12000], 65 | [1000000, 12000, 40000], 66 | [1000, 2000, 2000], 67 | [1000, 2000, 2000], 68 | [1000, 2000, 2000], 69 | [-1000, 2000, 2000], 70 | [1000, 2000, 2000], 71 | [1000, 2000, 2000], 72 | [1000, 2000, 2000], 73 | [1000, 2000, 2000], 74 | [1000, 2000, 1000000], 75 | [100000, 60000, 40000], 76 | [1000, 2000, 2000], 77 | [1000, 2000, 2000], 78 | [-1000, -2000, -2000], 79 | [1000, 2000, 2000], 80 | ] 81 | 82 | const testTickerExchanges = [ 83 | [Exchange.COINBASE, Exchange.OKCOIN, Exchange.BITTREX], 84 | [Exchange.COINBASE, Exchange.OKCOIN, Exchange.BITTREX], 85 | [Exchange.COINBASE, Exchange.OKCOIN, Exchange.BITTREX], 86 | [Exchange.COINBASE, Exchange.OKCOIN, Exchange.BITTREX], 87 | [Exchange.COINBASE, Exchange.OKCOIN, Exchange.BITTREX], 88 | [Exchange.COINBASE, Exchange.OKCOIN, Exchange.BITTREX], 89 | [Exchange.COINBASE, Exchange.OKCOIN, Exchange.BITTREX], 90 | [Exchange.COINBASE, Exchange.OKCOIN, Exchange.BITTREX], 91 | [Exchange.COINBASE, Exchange.OKCOIN, Exchange.BITTREX], 92 | [Exchange.COINBASE, Exchange.OKCOIN, Exchange.BITTREX], 93 | [Exchange.COINBASE, Exchange.OKCOIN, Exchange.BITTREX], 94 | [Exchange.COINBASE, Exchange.OKCOIN, Exchange.BITTREX], 95 | [Exchange.COINBASE, Exchange.OKCOIN, Exchange.OKCOIN], 96 | [Exchange.COINBASE, Exchange.OKCOIN, Exchange.BITTREX], 97 | [Exchange.COINBASE, Exchange.OKCOIN, Exchange.BITTREX], 98 | [Exchange.COINBASE, Exchange.OKCOIN, Exchange.BITTREX], 99 | ] 100 | 101 | export const testTickerArray: Ticker[][] = testTickerAsks.map((row, rowIndex) => { 102 | const thisTickerRow = row.map((thisAsk, colIndex) => { 103 | let thisTicker: Ticker 104 | thisTicker = { 105 | source: testTickerExchanges[rowIndex][colIndex], 106 | symbol: 'CELO/USD', 107 | timestamp: 1000000 + rowIndex * 100, 108 | ask: new BigNumber(thisAsk), 109 | bid: new BigNumber(testTickerBids[rowIndex][colIndex]), 110 | lastPrice: new BigNumber(testTickerBids[rowIndex][colIndex] - 0.03), 111 | baseVolume: new BigNumber(testTickerVolumes[rowIndex][colIndex]), 112 | quoteVolume: new BigNumber(testTickerQuoteVolumes[rowIndex][colIndex]), 113 | } 114 | return thisTicker 115 | }) 116 | return thisTickerRow 117 | }) 118 | -------------------------------------------------------------------------------- /test/envvar_utils.test.ts: -------------------------------------------------------------------------------- 1 | import { EnvVar, fetchParseValidateEnvVar } from '../src/envvar_utils' 2 | 3 | import { AggregationMethod } from '../src/utils' 4 | import BigNumber from 'bignumber.js' 5 | 6 | describe('fetchParseValidateEnvVar()', () => { 7 | const env = { ...process.env } 8 | 9 | beforeEach(() => { 10 | // Ensure no envvars are set 11 | for (const k of Object.keys(EnvVar)) { 12 | delete process.env[EnvVar[k as EnvVar]] 13 | } 14 | }) 15 | 16 | afterEach(() => (process.env = env)) 17 | 18 | it('returns undefined when the given envvar has not been set', () => { 19 | expect(fetchParseValidateEnvVar(EnvVar.ADDRESS)).toBeUndefined() 20 | }) 21 | it('returns undefined with the value is a blank string', () => { 22 | process.env[EnvVar.AZURE_KEY_VAULT_NAME] = '' 23 | expect(fetchParseValidateEnvVar(EnvVar.AZURE_KEY_VAULT_NAME)).toBeUndefined() 24 | }) 25 | it('correctly handles a defined number-type envvar', () => { 26 | process.env[EnvVar.MINIMUM_PRICE_SOURCES] = '2' 27 | expect(fetchParseValidateEnvVar(EnvVar.MINIMUM_PRICE_SOURCES)).toEqual(2) 28 | }) 29 | it('correctly handles a non-integer number type envvar', () => { 30 | process.env[EnvVar.MIN_REPORT_PRICE_CHANGE_THRESHOLD] = '0.0123' 31 | expect(fetchParseValidateEnvVar(EnvVar.MIN_REPORT_PRICE_CHANGE_THRESHOLD)).toEqual( 32 | new BigNumber(0.0123) 33 | ) 34 | }) 35 | it('correctly handles a boolean', () => { 36 | process.env[EnvVar.METRICS] = 'true' 37 | expect(fetchParseValidateEnvVar(EnvVar.METRICS)).toEqual(true) 38 | process.env[EnvVar.METRICS] = 'false' 39 | expect(fetchParseValidateEnvVar(EnvVar.METRICS)).toEqual(false) 40 | }) 41 | it('correctly handles currency pairs', () => { 42 | process.env[EnvVar.CURRENCY_PAIR] = 'CELOUSD' 43 | expect(fetchParseValidateEnvVar(EnvVar.CURRENCY_PAIR)).toEqual('CELOUSD') 44 | process.env[EnvVar.CURRENCY_PAIR] = 'CELOBTC' 45 | expect(fetchParseValidateEnvVar(EnvVar.CURRENCY_PAIR)).toEqual('CELOBTC') 46 | }) 47 | it('correctly handles API_KEYS', () => { 48 | process.env[EnvVar.API_KEYS] = 'COINBASE:foo,BINANCE:bar' 49 | expect(fetchParseValidateEnvVar(EnvVar.API_KEYS)).toEqual({ COINBASE: 'foo', BINANCE: 'bar' }) 50 | 51 | process.env[EnvVar.API_KEYS] = 'BITSTAMP:foo' 52 | expect(fetchParseValidateEnvVar(EnvVar.API_KEYS)).toEqual({ BITSTAMP: 'foo' }) 53 | 54 | process.env[EnvVar.API_KEYS] = 'invalidExchange:foo' 55 | expect(() => fetchParseValidateEnvVar(EnvVar.API_KEYS)).toThrow() 56 | }) 57 | 58 | describe('correctly handles PRICE_SOURCES', () => { 59 | it('parses a single source correctly', () => { 60 | process.env[EnvVar.PRICE_SOURCES] = 61 | '[[{ exchange: "COINBASE", symbol: "CELOBTC", toInvert: false }]]' 62 | const parsed = fetchParseValidateEnvVar(EnvVar.PRICE_SOURCES) 63 | 64 | expect(parsed.length).toEqual(1) 65 | expect(parsed[0].pairs.length).toEqual(1) 66 | expect(parsed[0].pairs[0]).toEqual({ 67 | exchange: 'COINBASE', 68 | symbol: 'CELOBTC', 69 | toInvert: false, 70 | ignoreVolume: false, 71 | }) 72 | }) 73 | it('handles ignoreVolume property correctly', () => { 74 | process.env[EnvVar.PRICE_SOURCES] = 75 | '[[{ exchange: "COINBASE", symbol: "CELOBTC", toInvert: false, ignoreVolume: true }]]' 76 | const parsed = fetchParseValidateEnvVar(EnvVar.PRICE_SOURCES) 77 | expect(parsed[0].pairs[0].ignoreVolume).toBeTruthy() 78 | 79 | process.env[EnvVar.PRICE_SOURCES] = 80 | '[[{ exchange: "BINANCE", symbol: "CELOBTC", toInvert: false, ignoreVolume: false }]]' 81 | const parsed2 = fetchParseValidateEnvVar(EnvVar.PRICE_SOURCES) 82 | expect(parsed2[0].pairs[0].ignoreVolume).toBeFalsy() 83 | 84 | process.env[EnvVar.PRICE_SOURCES] = 85 | '[[{ exchange: "KRAKEN", symbol: "CELOBTC", toInvert: false}]]' 86 | const parsed3 = fetchParseValidateEnvVar(EnvVar.PRICE_SOURCES) 87 | expect(parsed3[0].pairs[0].ignoreVolume).toBeFalsy() 88 | }) 89 | it('throws when any property has an invalid value', () => { 90 | process.env[EnvVar.PRICE_SOURCES] = '[[{ exchange: 123, symbol: "CELOBTC", toInvert: false}]]' 91 | expect(() => fetchParseValidateEnvVar(EnvVar.PRICE_SOURCES)).toThrow( 92 | 'exchange is 123 and not of type string' 93 | ) 94 | 95 | process.env[EnvVar.PRICE_SOURCES] = 96 | '[[{ exchange: "BINANCE", symbol: true, toInvert: false}]]' 97 | expect(() => fetchParseValidateEnvVar(EnvVar.PRICE_SOURCES)).toThrow( 98 | 'symbol is true and not of type string' 99 | ) 100 | 101 | process.env[EnvVar.PRICE_SOURCES] = 102 | '[[{ exchange: "BINANCE", symbol: "CELOBTC", toInvert: 345}]]' 103 | expect(() => fetchParseValidateEnvVar(EnvVar.PRICE_SOURCES)).toThrow( 104 | 'toInvert is 345 and not of type boolean' 105 | ) 106 | 107 | process.env[EnvVar.PRICE_SOURCES] = 108 | '[[{ exchange: "BINANCE", symbol: "CELOBTC", toInvert: false, ignoreVolume: "BTC"}]]' 109 | expect(() => fetchParseValidateEnvVar(EnvVar.PRICE_SOURCES)).toThrow( 110 | 'ignoreVolume is BTC and not of type boolean' 111 | ) 112 | }) 113 | it('parses multiple source correctly', () => { 114 | process.env[EnvVar.PRICE_SOURCES] = ` 115 | [ 116 | [ 117 | { exchange: "COINBASE", symbol: "CELOBTC", toInvert: false}, 118 | { exchange: "BINANCE", symbol: "CELOBTC", toInvert: false, ignoreVolume: true} 119 | ], 120 | [ 121 | { exchange: "BITTREX", symbol: "CELOBTC", toInvert: true, ignoreVolume: false } 122 | ] 123 | ] 124 | ` 125 | const parsed = fetchParseValidateEnvVar(EnvVar.PRICE_SOURCES) 126 | 127 | expect(parsed.length).toEqual(2) 128 | expect(parsed[0].pairs.length).toEqual(2) 129 | expect(parsed[1].pairs.length).toEqual(1) 130 | expect(parsed[0].pairs).toEqual([ 131 | { exchange: 'COINBASE', symbol: 'CELOBTC', toInvert: false, ignoreVolume: false }, 132 | { exchange: 'BINANCE', symbol: 'CELOBTC', toInvert: false, ignoreVolume: true }, 133 | ]) 134 | expect(parsed[1].pairs[0]).toEqual({ 135 | exchange: 'BITTREX', 136 | symbol: 'CELOBTC', 137 | toInvert: true, 138 | ignoreVolume: false, 139 | }) 140 | }) 141 | }) 142 | 143 | it('parses aggregation method correctly', () => { 144 | process.env[EnvVar.AGGREGATION_METHOD] = 'Midprices' 145 | expect(fetchParseValidateEnvVar(EnvVar.AGGREGATION_METHOD)).toEqual(AggregationMethod.MIDPRICES) 146 | }) 147 | 148 | it('sets a missing REPORT_TARGET_OVERRIDE to undefined', () => { 149 | expect(fetchParseValidateEnvVar(EnvVar.REPORT_TARGET_OVERRIDE)).toEqual(undefined) 150 | }) 151 | }) 152 | -------------------------------------------------------------------------------- /test/exchange_adapters/alphavantage.test.ts: -------------------------------------------------------------------------------- 1 | import { Exchange, ExternalCurrency } from '../../src/utils' 2 | 3 | import { AlphavantageAdapter } from '../../src/exchange_adapters/alphavantage' 4 | import BigNumber from 'bignumber.js' 5 | import { ExchangeAdapterConfig } from '../../src/exchange_adapters/base' 6 | import { baseLogger } from '../../src/default_config' 7 | import { MockSSLFingerprintService } from '../services/mock_ssl_fingerprint_service' 8 | 9 | describe('Alphavantage adapter', () => { 10 | let adapter: AlphavantageAdapter 11 | 12 | const config: ExchangeAdapterConfig = { 13 | baseCurrency: ExternalCurrency.XOF, 14 | baseLogger, 15 | quoteCurrency: ExternalCurrency.EUR, 16 | sslFingerprintService: new MockSSLFingerprintService(), 17 | } 18 | 19 | beforeEach(() => { 20 | adapter = new AlphavantageAdapter(config) 21 | }) 22 | 23 | afterEach(() => { 24 | jest.clearAllTimers() 25 | jest.clearAllMocks() 26 | }) 27 | 28 | const validMockTickerJson = { 29 | 'Realtime Currency Exchange Rate': { 30 | '1. From_Currency Code': 'XOF', 31 | '2. From_Currency Name': 'CFA Franc BCEAO', 32 | '3. To_Currency Code': 'EUR', 33 | '4. To_Currency Name': 'Euro', 34 | '5. Exchange Rate': '0.00152950', 35 | '6. Last Refreshed': '2023-08-03 08:13:36', 36 | '7. Time Zone': 'UTC', 37 | '8. Bid Price': '0.00152900', 38 | '9. Ask Price': '0.00153000', 39 | }, 40 | } 41 | 42 | const invalidJsonWithFromCurrencyMissmatch = { 43 | ...validMockTickerJson, 44 | 'Realtime Currency Exchange Rate': { 45 | ...validMockTickerJson['Realtime Currency Exchange Rate'], 46 | '1. From_Currency Code': 'USD', 47 | }, 48 | } 49 | 50 | const invalidJsonWithToCurrencyMissmatch = { 51 | ...validMockTickerJson, 52 | 'Realtime Currency Exchange Rate': { 53 | ...validMockTickerJson['Realtime Currency Exchange Rate'], 54 | '3. To_Currency Code': 'USD', 55 | }, 56 | } 57 | 58 | const invalidJsonWithNonUtcTimezone = { 59 | ...validMockTickerJson, 60 | 'Realtime Currency Exchange Rate': { 61 | ...validMockTickerJson['Realtime Currency Exchange Rate'], 62 | '7. Time Zone': 'CET', 63 | }, 64 | } 65 | 66 | const invalidJsonWithMissingFields = { 67 | 'Realtime Currency Exchange Rate': { 68 | '1. From_Currency Code': 'XOF', 69 | '2. From_Currency Name': 'CFA Franc BCEAO', 70 | '3. To_Currency Code': 'EUR', 71 | '4. To_Currency Name': 'Euro', 72 | '5. Exchange Rate': '0.00152950', 73 | '6. Last Refreshed': '2023-08-03 08:13:36', 74 | '7. Time Zone': 'UTC', 75 | }, 76 | } 77 | 78 | describe('parseTicker', () => { 79 | it('handles a response that matches the documentation', () => { 80 | const ticker = adapter.parseTicker(validMockTickerJson) 81 | 82 | expect(ticker).toEqual({ 83 | source: Exchange.ALPHAVANTAGE, 84 | symbol: adapter.standardPairSymbol, 85 | ask: new BigNumber(0.00153), 86 | bid: new BigNumber(0.001529), 87 | lastPrice: new BigNumber(0.0015295), 88 | timestamp: 1691050416, 89 | baseVolume: new BigNumber(1), 90 | quoteVolume: new BigNumber(0.0015295), 91 | }) 92 | }) 93 | 94 | it('throws an error when the from currency does not match the base currency', () => { 95 | expect(() => { 96 | adapter.parseTicker(invalidJsonWithFromCurrencyMissmatch) 97 | }).toThrowError('From currency mismatch in response: USD != XOF') 98 | }) 99 | 100 | it('throws an error when the quote currency does not match', () => { 101 | expect(() => { 102 | adapter.parseTicker(invalidJsonWithToCurrencyMissmatch) 103 | }).toThrowError('To currency mismatch in response: USD != EUR') 104 | }) 105 | 106 | it('throws an error when the timezone is non UTC', () => { 107 | expect(() => { 108 | adapter.parseTicker(invalidJsonWithNonUtcTimezone) 109 | }).toThrowError('Timezone mismatch in response: CET != UTC') 110 | }) 111 | 112 | it('throws an error when some required fields are missing', () => { 113 | expect(() => { 114 | adapter.parseTicker(invalidJsonWithMissingFields as any) 115 | }).toThrowError('bid, ask not defined') 116 | }) 117 | }) 118 | }) 119 | -------------------------------------------------------------------------------- /test/exchange_adapters/binance.test.ts: -------------------------------------------------------------------------------- 1 | import { CeloContract } from '@celo/contractkit' 2 | import BigNumber from 'bignumber.js' 3 | import { baseLogger } from '../../src/default_config' 4 | import { ExchangeAdapterConfig } from '../../src/exchange_adapters/base' 5 | import { BinanceAdapter } from '../../src/exchange_adapters/binance' 6 | import { Exchange, ExternalCurrency } from '../../src/utils' 7 | import { MockSSLFingerprintService } from '../services/mock_ssl_fingerprint_service' 8 | 9 | describe('BinanceAdapter', () => { 10 | let binanceAdapter: BinanceAdapter 11 | const config: ExchangeAdapterConfig = { 12 | baseCurrency: CeloContract.GoldToken, 13 | baseLogger, 14 | quoteCurrency: ExternalCurrency.BTC, 15 | sslFingerprintService: new MockSSLFingerprintService(), 16 | } 17 | beforeEach(() => { 18 | binanceAdapter = new BinanceAdapter(config) 19 | }) 20 | afterEach(() => { 21 | jest.clearAllTimers() 22 | jest.clearAllMocks() 23 | }) 24 | describe('parseTicker', () => { 25 | const tickerJson = { 26 | symbol: 'CELOBTC', 27 | priceChange: '0.00000115', 28 | priceChangePercent: '1.427', 29 | weightedAvgPrice: '0.00008156', 30 | prevClosePrice: '0.00008051', 31 | lastPrice: '0.00008174', 32 | lastQty: '100.00000000', 33 | bidPrice: '0.00008159', 34 | bidQty: '18.40000000', 35 | askPrice: '0.00008185', 36 | askQty: '100.00000000', 37 | openPrice: '0.00008059', 38 | highPrice: '0.00008386', 39 | lowPrice: '0.00007948', 40 | volume: '149857.10000000', 41 | quoteVolume: '12.22296665', 42 | openTime: 1614604599055, 43 | closeTime: 1614690999055, 44 | firstId: 850037, 45 | lastId: 855106, 46 | count: 5070, 47 | } 48 | it('handles a response that matches the documentation', () => { 49 | expect(binanceAdapter.parseTicker(tickerJson)).toEqual({ 50 | source: Exchange.BINANCE, 51 | symbol: binanceAdapter.standardPairSymbol, 52 | ask: new BigNumber(0.00008185), 53 | baseVolume: new BigNumber(149857.1), 54 | bid: new BigNumber(0.00008159), 55 | high: new BigNumber(0.00008386), 56 | lastPrice: new BigNumber(0.00008174), 57 | low: new BigNumber(0.00007948), 58 | open: new BigNumber(0.00008059), 59 | quoteVolume: new BigNumber(12.22296665), 60 | timestamp: 1614690999055, 61 | }) 62 | }) 63 | // timestamp, bid, ask, lastPrice, baseVolume 64 | const requiredFields = ['askPrice', 'bidPrice', 'lastPrice', 'closeTime', 'volume'] 65 | 66 | for (const field of Object.keys(tickerJson)) { 67 | // @ts-ignore 68 | const { [field]: _removed, ...incompleteTickerJson } = tickerJson 69 | if (requiredFields.includes(field)) { 70 | it(`throws an error if ${field} is missing`, () => { 71 | expect(() => { 72 | binanceAdapter.parseTicker(incompleteTickerJson) 73 | }).toThrowError() 74 | }) 75 | } else { 76 | it(`parses a ticker if ${field} is missing`, () => { 77 | expect(() => { 78 | binanceAdapter.parseTicker(incompleteTickerJson) 79 | }).not.toThrowError() 80 | }) 81 | } 82 | } 83 | }) 84 | 85 | describe('isOrderbookLive', () => { 86 | // Note: in the real response, these contain much more info. Only relevant 87 | // fields are included in this test 88 | const mockCeloUsdInfo = { 89 | symbol: 'CELOBTC', 90 | status: 'TRADING', 91 | orderTypes: ['LIMIT', 'LIMIT_MAKER', 'MARKET', 'STOP_LOSS_LIMIT', 'TAKE_PROFIT_LIMIT'], 92 | isSpotTradingAllowed: true, 93 | isMarginTradingAllowed: false, 94 | } 95 | const mockOtherInfo = { 96 | symbol: 'BTCUSD', 97 | status: 'TRADING', 98 | orderTypes: ['LIMIT', 'LIMIT_MAKER', 'MARKET', 'STOP_LOSS_LIMIT', 'TAKE_PROFIT_LIMIT'], 99 | isSpotTradingAllowed: true, 100 | isMarginTradingAllowed: false, 101 | } 102 | const mockStatusJson = { 103 | timezone: 'UTC', 104 | serverTime: 1605887014867, 105 | rateLimits: [], 106 | exchangeFilters: [], 107 | symbols: [mockOtherInfo, mockCeloUsdInfo], 108 | } 109 | 110 | it('returns false if the symbol is not found', async () => { 111 | jest.spyOn(binanceAdapter, 'fetchFromApi').mockReturnValue( 112 | Promise.resolve({ 113 | ...mockStatusJson, 114 | symbols: [mockOtherInfo, mockOtherInfo, mockOtherInfo], 115 | }) 116 | ) 117 | expect(await binanceAdapter.isOrderbookLive()).toEqual(false) 118 | }) 119 | 120 | const otherStatuses = [ 121 | 'PRE_TRADING', 122 | 'POST_TRADING', 123 | 'END_OF_DAY', 124 | 'HALT', 125 | 'AUCTION_MATCH', 126 | 'BREAK', 127 | ] 128 | for (const status of otherStatuses) { 129 | it(`returns false if the status is ${status}`, async () => { 130 | jest.spyOn(binanceAdapter, 'fetchFromApi').mockReturnValue( 131 | Promise.resolve({ 132 | ...mockStatusJson, 133 | symbols: [{ ...mockCeloUsdInfo, status }, mockOtherInfo], 134 | }) 135 | ) 136 | expect(await binanceAdapter.isOrderbookLive()).toEqual(false) 137 | }) 138 | } 139 | 140 | it('returns false if isSpotTradingAllowed is false', async () => { 141 | jest.spyOn(binanceAdapter, 'fetchFromApi').mockReturnValue( 142 | Promise.resolve({ 143 | ...mockStatusJson, 144 | symbols: [{ ...mockCeloUsdInfo, isSpotTradingAllowed: false }, mockOtherInfo], 145 | }) 146 | ) 147 | expect(await binanceAdapter.isOrderbookLive()).toEqual(false) 148 | }) 149 | 150 | it('returns false if both LIMIT or MARKET are not present in orderTypes', async () => { 151 | const invalidOrderTypesResponses = [ 152 | ['LIMIT_MAKER', 'MARKET', 'STOP_LOSS_LIMIT', 'TAKE_PROFIT_LIMIT'], 153 | ['LIMIT', 'LIMIT_MAKER', 'STOP_LOSS_LIMIT', 'TAKE_PROFIT_LIMIT'], 154 | ['LIMIT_MAKER', 'STOP_LOSS_LIMIT', 'TAKE_PROFIT_LIMIT'], 155 | ] 156 | for (const orderTypes of invalidOrderTypesResponses) { 157 | jest.spyOn(binanceAdapter, 'fetchFromApi').mockReturnValue( 158 | Promise.resolve({ 159 | ...mockStatusJson, 160 | symbols: [{ ...mockCeloUsdInfo, orderTypes }, mockOtherInfo], 161 | }) 162 | ) 163 | expect(await binanceAdapter.isOrderbookLive()).toEqual(false) 164 | } 165 | }) 166 | 167 | it('returns true if symbol is found, status === TRADING, isSpotTradingAllowed is true and orderTypes contains both LIMIT and MARKET', async () => { 168 | jest.spyOn(binanceAdapter, 'fetchFromApi').mockReturnValue(Promise.resolve(mockStatusJson)) 169 | expect(await binanceAdapter.isOrderbookLive()).toEqual(true) 170 | }) 171 | }) 172 | }) 173 | -------------------------------------------------------------------------------- /test/exchange_adapters/binance_us.test.ts: -------------------------------------------------------------------------------- 1 | import { CeloContract } from '@celo/contractkit' 2 | import BigNumber from 'bignumber.js' 3 | import { baseLogger } from '../../src/default_config' 4 | import { ExchangeAdapterConfig } from '../../src/exchange_adapters/base' 5 | import { BinanceUSAdapter } from '../../src/exchange_adapters/binance_us' 6 | import { Exchange, ExternalCurrency } from '../../src/utils' 7 | import { MockSSLFingerprintService } from '../services/mock_ssl_fingerprint_service' 8 | 9 | describe('binanceUSAdapter', () => { 10 | let binanceUSAdapter: BinanceUSAdapter 11 | const config: ExchangeAdapterConfig = { 12 | baseCurrency: CeloContract.GoldToken, 13 | baseLogger, 14 | quoteCurrency: ExternalCurrency.BTC, 15 | sslFingerprintService: new MockSSLFingerprintService(), 16 | } 17 | beforeEach(() => { 18 | binanceUSAdapter = new BinanceUSAdapter(config) 19 | }) 20 | afterEach(() => { 21 | jest.clearAllTimers() 22 | jest.clearAllMocks() 23 | }) 24 | describe('parseTicker', () => { 25 | const tickerJson = { 26 | symbol: 'CELOBTC', 27 | priceChange: '0.00000115', 28 | priceChangePercent: '1.427', 29 | weightedAvgPrice: '0.00008156', 30 | prevClosePrice: '0.00008051', 31 | lastPrice: '0.00008174', 32 | lastQty: '100.00000000', 33 | bidPrice: '0.00008159', 34 | bidQty: '18.40000000', 35 | askPrice: '0.00008185', 36 | askQty: '100.00000000', 37 | openPrice: '0.00008059', 38 | highPrice: '0.00008386', 39 | lowPrice: '0.00007948', 40 | volume: '149857.10000000', 41 | quoteVolume: '12.22296665', 42 | openTime: 1614604599055, 43 | closeTime: 1614690999055, 44 | firstId: 850037, 45 | lastId: 855106, 46 | count: 5070, 47 | } 48 | it('handles a response that matches the documentation', () => { 49 | expect(binanceUSAdapter.parseTicker(tickerJson)).toEqual({ 50 | source: Exchange.BINANCEUS, 51 | symbol: binanceUSAdapter.standardPairSymbol, 52 | ask: new BigNumber(0.00008185), 53 | baseVolume: new BigNumber(149857.1), 54 | bid: new BigNumber(0.00008159), 55 | high: new BigNumber(0.00008386), 56 | lastPrice: new BigNumber(0.00008174), 57 | low: new BigNumber(0.00007948), 58 | open: new BigNumber(0.00008059), 59 | quoteVolume: new BigNumber(12.22296665), 60 | timestamp: 1614690999055, 61 | }) 62 | }) 63 | // timestamp, bid, ask, lastPrice, baseVolume 64 | const requiredFields = ['askPrice', 'bidPrice', 'lastPrice', 'closeTime', 'volume'] 65 | 66 | for (const field of Object.keys(tickerJson)) { 67 | // @ts-ignore 68 | const { [field]: _removed, ...incompleteTickerJson } = tickerJson 69 | if (requiredFields.includes(field)) { 70 | it(`throws an error if ${field} is missing`, () => { 71 | expect(() => { 72 | binanceUSAdapter.parseTicker(incompleteTickerJson) 73 | }).toThrowError() 74 | }) 75 | } else { 76 | it(`parses a ticker if ${field} is missing`, () => { 77 | expect(() => { 78 | binanceUSAdapter.parseTicker(incompleteTickerJson) 79 | }).not.toThrowError() 80 | }) 81 | } 82 | } 83 | }) 84 | 85 | describe('isOrderbookLive', () => { 86 | // Note: in the real response, these contain much more info. Only relevant 87 | // fields are included in this test 88 | const mockCeloUsdInfo = { 89 | symbol: 'CELOBTC', 90 | status: 'TRADING', 91 | orderTypes: ['LIMIT', 'LIMIT_MAKER', 'MARKET', 'STOP_LOSS_LIMIT', 'TAKE_PROFIT_LIMIT'], 92 | isSpotTradingAllowed: true, 93 | isMarginTradingAllowed: false, 94 | } 95 | const mockOtherInfo = { 96 | symbol: 'BTCUSD', 97 | status: 'TRADING', 98 | orderTypes: ['LIMIT', 'LIMIT_MAKER', 'MARKET', 'STOP_LOSS_LIMIT', 'TAKE_PROFIT_LIMIT'], 99 | isSpotTradingAllowed: true, 100 | isMarginTradingAllowed: false, 101 | } 102 | const mockStatusJson = { 103 | timezone: 'UTC', 104 | serverTime: 1605887014867, 105 | rateLimits: [], 106 | exchangeFilters: [], 107 | symbols: [mockOtherInfo, mockCeloUsdInfo], 108 | } 109 | 110 | it('returns false if the symbol is not found', async () => { 111 | jest.spyOn(binanceUSAdapter, 'fetchFromApi').mockReturnValue( 112 | Promise.resolve({ 113 | ...mockStatusJson, 114 | symbols: [mockOtherInfo, mockOtherInfo, mockOtherInfo], 115 | }) 116 | ) 117 | expect(await binanceUSAdapter.isOrderbookLive()).toEqual(false) 118 | }) 119 | 120 | const otherStatuses = [ 121 | 'PRE_TRADING', 122 | 'POST_TRADING', 123 | 'END_OF_DAY', 124 | 'HALT', 125 | 'AUCTION_MATCH', 126 | 'BREAK', 127 | ] 128 | for (const status of otherStatuses) { 129 | it(`returns false if the status is ${status}`, async () => { 130 | jest.spyOn(binanceUSAdapter, 'fetchFromApi').mockReturnValue( 131 | Promise.resolve({ 132 | ...mockStatusJson, 133 | symbols: [{ ...mockCeloUsdInfo, status }, mockOtherInfo], 134 | }) 135 | ) 136 | expect(await binanceUSAdapter.isOrderbookLive()).toEqual(false) 137 | }) 138 | } 139 | 140 | it('returns false if isSpotTradingAllowed is false', async () => { 141 | jest.spyOn(binanceUSAdapter, 'fetchFromApi').mockReturnValue( 142 | Promise.resolve({ 143 | ...mockStatusJson, 144 | symbols: [{ ...mockCeloUsdInfo, isSpotTradingAllowed: false }, mockOtherInfo], 145 | }) 146 | ) 147 | expect(await binanceUSAdapter.isOrderbookLive()).toEqual(false) 148 | }) 149 | 150 | it('returns false if both LIMIT or MARKET are not present in orderTypes', async () => { 151 | const invalidOrderTypesResponses = [ 152 | ['LIMIT_MAKER', 'MARKET', 'STOP_LOSS_LIMIT', 'TAKE_PROFIT_LIMIT'], 153 | ['LIMIT', 'LIMIT_MAKER', 'STOP_LOSS_LIMIT', 'TAKE_PROFIT_LIMIT'], 154 | ['LIMIT_MAKER', 'STOP_LOSS_LIMIT', 'TAKE_PROFIT_LIMIT'], 155 | ] 156 | for (const orderTypes of invalidOrderTypesResponses) { 157 | jest.spyOn(binanceUSAdapter, 'fetchFromApi').mockReturnValue( 158 | Promise.resolve({ 159 | ...mockStatusJson, 160 | symbols: [{ ...mockCeloUsdInfo, orderTypes }, mockOtherInfo], 161 | }) 162 | ) 163 | expect(await binanceUSAdapter.isOrderbookLive()).toEqual(false) 164 | } 165 | }) 166 | 167 | it('returns true if symbol is found, status === TRADING, isSpotTradingAllowed is true and orderTypes contains both LIMIT and MARKET', async () => { 168 | jest.spyOn(binanceUSAdapter, 'fetchFromApi').mockReturnValue(Promise.resolve(mockStatusJson)) 169 | expect(await binanceUSAdapter.isOrderbookLive()).toEqual(true) 170 | }) 171 | }) 172 | }) 173 | -------------------------------------------------------------------------------- /test/exchange_adapters/bitget.test.ts: -------------------------------------------------------------------------------- 1 | import { Exchange, ExternalCurrency } from '../../src/utils' 2 | 3 | import BigNumber from 'bignumber.js' 4 | import { BitgetAdapter } from '../../src/exchange_adapters/bitget' 5 | import { ExchangeAdapterConfig } from '../../src/exchange_adapters/base' 6 | import { baseLogger } from '../../src/default_config' 7 | import { MockSSLFingerprintService } from '../services/mock_ssl_fingerprint_service' 8 | 9 | describe('BitgetAdapter', () => { 10 | let bitgetAdapter: BitgetAdapter 11 | const config: ExchangeAdapterConfig = { 12 | baseCurrency: ExternalCurrency.BTC, 13 | baseLogger, 14 | quoteCurrency: ExternalCurrency.BRL, 15 | sslFingerprintService: new MockSSLFingerprintService(), 16 | } 17 | beforeEach(() => { 18 | bitgetAdapter = new BitgetAdapter(config) 19 | }) 20 | afterEach(() => { 21 | jest.clearAllTimers() 22 | jest.clearAllMocks() 23 | }) 24 | 25 | const mockPubtickerJson = { 26 | code: '00000', 27 | data: { 28 | baseVol: '9.18503', // (price symbol, e.g. "USD") The volume denominated in the price currency 29 | buyOne: '121890', // buy one price = bid pice 30 | close: '121905', // Latest transaction price 31 | quoteVol: '1119715.23314', // (price symbol, e.g. "USD") The volume denominated in the quantity currency 32 | sellOne: '122012', // sell one price = ask price 33 | symbol: 'BTCBRL', // Symbol 34 | ts: 1677490448241, // Timestamp 35 | }, 36 | msg: 'success', 37 | requestTime: '1677490448872', // Request status 38 | } 39 | 40 | describe('parseTicker', () => { 41 | it('handles a response that matches the documentation', () => { 42 | const ticker = bitgetAdapter.parseTicker(mockPubtickerJson) 43 | expect(ticker).toEqual({ 44 | source: Exchange.BITGET, 45 | symbol: bitgetAdapter.standardPairSymbol, 46 | ask: new BigNumber(122012), 47 | baseVolume: new BigNumber(9.18503), 48 | bid: new BigNumber(121890), 49 | lastPrice: new BigNumber(121905), 50 | quoteVolume: new BigNumber(1119715.23314), 51 | timestamp: 1677490448241, 52 | }) 53 | }) 54 | 55 | it('throws an error when a json field mapped to a required ticker field is missing', () => { 56 | expect(() => { 57 | bitgetAdapter.parseTicker({ 58 | ...mockPubtickerJson, 59 | data: { 60 | sellOne: undefined, 61 | buyOne: undefined, 62 | close: undefined, 63 | baseVol: undefined, 64 | }, 65 | }) 66 | }).toThrowError('bid, ask, lastPrice, baseVolume not defined') 67 | }) 68 | }) 69 | 70 | describe('isOrderbookLive', () => { 71 | const mockStatusJson = { 72 | code: '00000', 73 | data: { 74 | base_coin: 'BTC', 75 | status: 'online', 76 | symbol: 'btcbrl_SPBL', 77 | }, 78 | msg: 'success', 79 | requestTime: '0', 80 | } 81 | 82 | it("returns false when status isn't 'online'", async () => { 83 | const response = { ...mockStatusJson, data: { status: 'closed' } } 84 | jest.spyOn(bitgetAdapter, 'fetchFromApi').mockReturnValue(Promise.resolve(response)) 85 | expect(await bitgetAdapter.isOrderbookLive()).toEqual(false) 86 | }) 87 | 88 | it("returns true when status is 'online'", async () => { 89 | jest.spyOn(bitgetAdapter, 'fetchFromApi').mockReturnValue(Promise.resolve(mockStatusJson)) 90 | expect(await bitgetAdapter.isOrderbookLive()).toEqual(true) 91 | }) 92 | }) 93 | }) 94 | -------------------------------------------------------------------------------- /test/exchange_adapters/bitso.test.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js' 2 | import { baseLogger } from '../../src/default_config' 3 | import { ExchangeAdapterConfig } from '../../src/exchange_adapters/base' 4 | import { BitsoAdapter } from '../../src/exchange_adapters/bitso' 5 | import { Exchange, ExternalCurrency } from '../../src/utils' 6 | import { MockSSLFingerprintService } from '../services/mock_ssl_fingerprint_service' 7 | 8 | describe('BitsoAdapter', () => { 9 | let bitsoAdapter: BitsoAdapter 10 | const config: ExchangeAdapterConfig = { 11 | baseCurrency: ExternalCurrency.BTC, 12 | baseLogger, 13 | quoteCurrency: ExternalCurrency.USD, 14 | sslFingerprintService: new MockSSLFingerprintService(), 15 | } 16 | beforeEach(() => { 17 | bitsoAdapter = new BitsoAdapter(config) 18 | }) 19 | afterEach(() => { 20 | jest.clearAllTimers() 21 | jest.clearAllMocks() 22 | }) 23 | describe('parseTicker', () => { 24 | const tickerJson = { 25 | success: true, 26 | payload: { 27 | high: '689735.63', 28 | last: '658600.01', 29 | created_at: '2021-07-02T05:55:25+00:00', 30 | book: 'btc_mxn', 31 | volume: '188.62575176', 32 | vwap: '669760.9564740908', 33 | low: '658000.00', 34 | ask: '658600.01', 35 | bid: '658600.00', 36 | change_24: '-29399.96', 37 | }, 38 | } 39 | 40 | it('handles a response that matches the documentation', () => { 41 | expect(bitsoAdapter.parseTicker(tickerJson.payload)).toEqual({ 42 | source: Exchange.BITSO, 43 | symbol: bitsoAdapter.standardPairSymbol, 44 | ask: new BigNumber(658600.01), 45 | baseVolume: new BigNumber(188.62575176), 46 | bid: new BigNumber(658600.0), 47 | high: new BigNumber(689735.63), 48 | lastPrice: new BigNumber(658600.01), 49 | low: new BigNumber(658000.0), 50 | open: new BigNumber(658600.01), 51 | quoteVolume: new BigNumber(669760.9564740908), 52 | timestamp: 1625205325000, 53 | }) 54 | }) 55 | // timestamp, bid, ask, lastPrice, baseVolume 56 | const requiredFields = ['ask', 'bid', 'last', 'created_at', 'volume'] 57 | 58 | for (const field of Object.keys(tickerJson.payload)) { 59 | // @ts-ignore 60 | const { [field]: _removed, ...incompleteTickerJson } = tickerJson.payload 61 | if (requiredFields.includes(field)) { 62 | it(`throws an error if ${field} is missing`, () => { 63 | expect(() => { 64 | bitsoAdapter.parseTicker(incompleteTickerJson) 65 | }).toThrowError() 66 | }) 67 | } else { 68 | it(`parses a ticker if ${field} is missing`, () => { 69 | expect(() => { 70 | bitsoAdapter.parseTicker(incompleteTickerJson) 71 | }).not.toThrowError() 72 | }) 73 | } 74 | } 75 | }) 76 | }) 77 | -------------------------------------------------------------------------------- /test/exchange_adapters/bitstamp.test.ts: -------------------------------------------------------------------------------- 1 | import { Exchange, ExternalCurrency } from '../../src/utils' 2 | 3 | import BigNumber from 'bignumber.js' 4 | import { BitstampAdapter } from '../../src/exchange_adapters/bitstamp' 5 | import { ExchangeAdapterConfig } from '../../src/exchange_adapters/base' 6 | import { baseLogger } from '../../src/default_config' 7 | import { MockSSLFingerprintService } from '../services/mock_ssl_fingerprint_service' 8 | 9 | describe('Bitstamp adapter', () => { 10 | let bitstampAdapter: BitstampAdapter 11 | 12 | const config: ExchangeAdapterConfig = { 13 | baseCurrency: ExternalCurrency.USDC, 14 | baseLogger, 15 | quoteCurrency: ExternalCurrency.USD, 16 | sslFingerprintService: new MockSSLFingerprintService(), 17 | } 18 | 19 | beforeEach(() => { 20 | bitstampAdapter = new BitstampAdapter(config) 21 | }) 22 | 23 | afterEach(() => { 24 | jest.clearAllTimers() 25 | jest.clearAllMocks() 26 | }) 27 | 28 | const validMockTickerJson = { 29 | timestamp: '0', 30 | open: '1.00083', 31 | high: '1.00100', 32 | low: '0.99865', 33 | last: '1.00031', 34 | volume: '949324.40769', 35 | vwap: '1.00013', 36 | bid: '1.00005', 37 | ask: '1.00031', 38 | open_24: '0.99961', 39 | percent_change_24: '0.07', 40 | } 41 | 42 | const inValidMockTickerJson = { 43 | open: '', 44 | high: '', 45 | low: '', 46 | last: '', 47 | volume: '', 48 | vwap: '', 49 | ask: '', 50 | open_24: '', 51 | percent_change_24: '', 52 | } 53 | 54 | const mockStatusJson = [ 55 | { 56 | name: 'USDC/USD', 57 | url_symbol: 'usdcusd', 58 | base_decimals: 5, 59 | counter_decimals: 5, 60 | instant_order_counter_decimals: 5, 61 | minimum_order: '10.00000 USD', 62 | trading: 'Enabled', 63 | instant_and_market_orders: 'Enabled', 64 | description: 'USD Coin / U.S. dollar', 65 | }, 66 | { 67 | name: 'USDC/EUR', 68 | url_symbol: 'usdceur', 69 | base_decimals: 5, 70 | counter_decimals: 5, 71 | instant_order_counter_decimals: 5, 72 | minimum_order: '10.00000 EUR', 73 | trading: 'Enabled', 74 | instant_and_market_orders: 'Enabled', 75 | description: 'USD Coin / Euro', 76 | }, 77 | ] 78 | 79 | const mockWrongStatusJson = [ 80 | { 81 | name: 'USDC/USD', 82 | url_symbol: 'usdcusd', 83 | base_decimals: 5, 84 | counter_decimals: 5, 85 | instant_order_counter_decimals: 5, 86 | minimum_order: '10.00000 USD', 87 | trading: 'Enable', 88 | instant_and_market_orders: 'Disabled', 89 | description: 'USD Coin / U.S. dollar', 90 | }, 91 | { 92 | name: 'USDC/EUR', 93 | url_symbol: 'usdceur', 94 | base_decimals: 5, 95 | counter_decimals: 5, 96 | instant_order_counter_decimals: 5, 97 | minimum_order: '10.00000 EUR', 98 | trading: 'Enabled', 99 | instant_and_market_orders: 'Enabled', 100 | description: 'USD Coin / Euro', 101 | }, 102 | ] 103 | 104 | describe('parseTicker', () => { 105 | it('handles a response that matches the documentation', () => { 106 | const ticker = bitstampAdapter.parseTicker(validMockTickerJson) 107 | expect(ticker).toEqual({ 108 | source: Exchange.BITSTAMP, 109 | symbol: bitstampAdapter.standardPairSymbol, 110 | ask: new BigNumber(1.00031), 111 | baseVolume: new BigNumber(949324.40769), 112 | bid: new BigNumber(1.00005), 113 | lastPrice: new BigNumber(1.00031), 114 | quoteVolume: new BigNumber(949324.40769).multipliedBy(new BigNumber(1.00013)), 115 | timestamp: 0, 116 | }) 117 | }) 118 | 119 | it('throws an error when a json field mapped to a required ticker field is missing or empty', () => { 120 | expect(() => { 121 | bitstampAdapter.parseTicker(inValidMockTickerJson) 122 | }).toThrowError('bid, ask, lastPrice, baseVolume not defined') 123 | }) 124 | }) 125 | 126 | describe('isOrderbookLive', () => { 127 | it('returns true', async () => { 128 | jest.spyOn(bitstampAdapter, 'fetchFromApi').mockReturnValue(Promise.resolve(mockStatusJson)) 129 | expect(await bitstampAdapter.isOrderbookLive()).toEqual(true) 130 | }) 131 | 132 | it('returns false when Orderbook is not live', async () => { 133 | jest 134 | .spyOn(bitstampAdapter, 'fetchFromApi') 135 | .mockReturnValue(Promise.resolve(mockWrongStatusJson)) 136 | expect(await bitstampAdapter.isOrderbookLive()).toEqual(false) 137 | }) 138 | }) 139 | }) 140 | -------------------------------------------------------------------------------- /test/exchange_adapters/bittrex.test.ts: -------------------------------------------------------------------------------- 1 | import { Exchange, ExternalCurrency } from '../../src/utils' 2 | 3 | import BigNumber from 'bignumber.js' 4 | import { BittrexAdapter } from '../../src/exchange_adapters/bittrex' 5 | import { CeloContract } from '@celo/contractkit' 6 | import { ExchangeAdapterConfig } from '../../src/exchange_adapters/base' 7 | import { baseLogger } from '../../src/default_config' 8 | import { MockSSLFingerprintService } from '../services/mock_ssl_fingerprint_service' 9 | 10 | describe('BittrexAdapter', () => { 11 | let bittrexAdapter: BittrexAdapter 12 | const config: ExchangeAdapterConfig = { 13 | baseCurrency: CeloContract.GoldToken, 14 | baseLogger, 15 | quoteCurrency: ExternalCurrency.USD, 16 | sslFingerprintService: new MockSSLFingerprintService(), 17 | } 18 | beforeEach(() => { 19 | bittrexAdapter = new BittrexAdapter(config) 20 | }) 21 | afterEach(() => { 22 | jest.clearAllTimers() 23 | jest.clearAllMocks() 24 | }) 25 | describe('parseTicker', () => { 26 | const correctlyFormattedSummaryJson = { 27 | symbol: 'CELO-USD', 28 | high: '215.83000000', 29 | low: '210.33300000', 30 | volume: '3335.48514449', 31 | quoteVolume: '711062.81323057', 32 | percentChange: '0.2', 33 | updatedAt: '2020-05-20T10:12:41.393Z', 34 | } 35 | const correctlyFormattedTickerJson = { 36 | symbol: 'CELO-USD', 37 | lastTradeRate: '213.76200000', 38 | bidRate: '213.56500000', 39 | askRate: '213.83400000', 40 | } 41 | 42 | it('handles a response that matches the documentation', () => { 43 | const ticker = bittrexAdapter.parseTicker( 44 | correctlyFormattedTickerJson, 45 | correctlyFormattedSummaryJson 46 | ) 47 | 48 | expect(ticker).toEqual({ 49 | source: Exchange.BITTREX, 50 | symbol: bittrexAdapter.standardPairSymbol, 51 | timestamp: 1589969561393, 52 | high: new BigNumber(215.83), 53 | low: new BigNumber(210.333), 54 | bid: new BigNumber(213.565), 55 | ask: new BigNumber(213.834), 56 | lastPrice: new BigNumber(213.762), 57 | baseVolume: new BigNumber(3335.48514449), 58 | quoteVolume: new BigNumber(711062.81323057), 59 | }) 60 | }) 61 | it('throws an error when a required BigNumber field is missing', () => { 62 | expect(() => { 63 | bittrexAdapter.parseTicker( 64 | { 65 | ...correctlyFormattedTickerJson, 66 | lastTradeRate: undefined, 67 | }, 68 | correctlyFormattedSummaryJson 69 | ) 70 | }).toThrowError('lastPrice not defined') 71 | }) 72 | it('throws an error when the date could not be parsed', () => { 73 | expect(() => { 74 | bittrexAdapter.parseTicker(correctlyFormattedTickerJson, { 75 | ...correctlyFormattedSummaryJson, 76 | updatedAt: 'the 20th of May, 2020 at 1:22 pm', 77 | }) 78 | }).toThrowError('timestamp not defined') 79 | }) 80 | }) 81 | 82 | describe('isOrderbookLive', () => { 83 | const mockStatusJson = { 84 | symbol: 'CELO-USD', 85 | baseCurrencySymbol: 'CELO', 86 | quoteCurrencySymbol: 'USD', 87 | minTradeSize: '3.00000000', 88 | precision: 3, 89 | status: 'ONLINE', 90 | createdAt: '2020-05-21T16:43:29.013Z', 91 | notice: '', 92 | prohibitedIn: [], 93 | associatedTermsOfService: [], 94 | } 95 | 96 | it('returns true if status is online', async () => { 97 | jest.spyOn(bittrexAdapter, 'fetchFromApi').mockReturnValue(Promise.resolve(mockStatusJson)) 98 | expect(await bittrexAdapter.isOrderbookLive()).toEqual(true) 99 | }) 100 | it('returns false if status is offline', async () => { 101 | jest.spyOn(bittrexAdapter, 'fetchFromApi').mockReturnValue( 102 | Promise.resolve({ 103 | ...mockStatusJson, 104 | status: 'OFFLINE', 105 | }) 106 | ) 107 | expect(await bittrexAdapter.isOrderbookLive()).toEqual(false) 108 | }) 109 | }) 110 | }) 111 | -------------------------------------------------------------------------------- /test/exchange_adapters/coinbase.test.ts: -------------------------------------------------------------------------------- 1 | import { Exchange, ExternalCurrency } from '../../src/utils' 2 | import { ExchangeAdapterConfig, ExchangeDataType } from '../../src/exchange_adapters/base' 3 | 4 | import BigNumber from 'bignumber.js' 5 | import { CeloContract } from '@celo/contractkit' 6 | import { CoinbaseAdapter } from '../../src/exchange_adapters/coinbase' 7 | import { baseLogger } from '../../src/default_config' 8 | import { MockSSLFingerprintService } from '../services/mock_ssl_fingerprint_service' 9 | 10 | describe('CoinbaseAdapter', () => { 11 | let coinbaseAdapter: CoinbaseAdapter 12 | const config: ExchangeAdapterConfig = { 13 | baseCurrency: CeloContract.GoldToken, 14 | baseLogger, 15 | quoteCurrency: ExternalCurrency.USD, 16 | sslFingerprintService: new MockSSLFingerprintService(), 17 | } 18 | beforeEach(() => { 19 | coinbaseAdapter = new CoinbaseAdapter(config) 20 | }) 21 | afterEach(() => { 22 | jest.clearAllTimers() 23 | jest.clearAllMocks() 24 | }) 25 | const mockTickerJson = { 26 | trade_id: 4729088, 27 | price: '200.81', 28 | size: '0.193', 29 | bid: '200.93', 30 | ask: '200.99', 31 | volume: '2556.5805', 32 | time: '2020-05-26T12:49:05.049Z', 33 | } 34 | 35 | const mockStatusJson = { 36 | id: 'CGLD-USD', 37 | base_currency: 'CGLD', 38 | quote_currency: 'USD', 39 | base_min_size: '0.10000000', 40 | base_max_size: '34000.00000000', 41 | quote_increment: '0.00010000', 42 | base_increment: '0.01000000', 43 | display_name: 'CGLD/USD', 44 | min_market_funds: '1.0', 45 | max_market_funds: '100000', 46 | margin_enabled: false, 47 | post_only: false, 48 | limit_only: false, 49 | cancel_only: false, 50 | trading_disabled: false, 51 | status: 'online', 52 | status_message: '', 53 | } 54 | 55 | describe('using the non-standard symbol for CELO', () => { 56 | let fetchFromApiSpy: jest.SpyInstance 57 | beforeEach(() => { 58 | fetchFromApiSpy = jest.spyOn(coinbaseAdapter, 'fetchFromApi') 59 | }) 60 | it('uses the right symbols when fetching the ticker', async () => { 61 | fetchFromApiSpy.mockReturnValue(Promise.resolve(mockTickerJson)) 62 | await coinbaseAdapter.fetchTicker() 63 | expect(fetchFromApiSpy).toHaveBeenCalledWith( 64 | ExchangeDataType.TICKER, 65 | 'products/CGLD-USD/ticker' 66 | ) 67 | }) 68 | }) 69 | 70 | describe('using the non-standard symbol for EUROC', () => { 71 | let coinbaseAdapter2: CoinbaseAdapter 72 | const config2: ExchangeAdapterConfig = { 73 | baseCurrency: ExternalCurrency.EUROC, 74 | baseLogger, 75 | quoteCurrency: ExternalCurrency.USD, 76 | sslFingerprintService: new MockSSLFingerprintService(), 77 | } 78 | 79 | let fetchFromApiSpy: jest.SpyInstance 80 | beforeEach(() => { 81 | coinbaseAdapter2 = new CoinbaseAdapter(config2) 82 | fetchFromApiSpy = jest.spyOn(coinbaseAdapter2, 'fetchFromApi') 83 | }) 84 | it('uses the right symbols when fetching the ticker', async () => { 85 | fetchFromApiSpy.mockReturnValue(Promise.resolve(mockTickerJson)) 86 | await coinbaseAdapter2.fetchTicker() 87 | expect(fetchFromApiSpy).toHaveBeenCalledWith( 88 | ExchangeDataType.TICKER, 89 | 'products/EURC-USD/ticker' 90 | ) 91 | }) 92 | }) 93 | 94 | describe('parseTicker', () => { 95 | it('handles a response that matches the documentation', () => { 96 | const ticker = coinbaseAdapter.parseTicker(mockTickerJson) 97 | expect(ticker).toEqual({ 98 | source: Exchange.COINBASE, 99 | symbol: coinbaseAdapter.standardPairSymbol, 100 | ask: new BigNumber(200.99), 101 | baseVolume: new BigNumber(2556.5805), 102 | bid: new BigNumber(200.93), 103 | close: new BigNumber(200.81), 104 | lastPrice: new BigNumber(200.81), 105 | quoteVolume: new BigNumber(513386.930205), 106 | timestamp: 1590497345049, 107 | }) 108 | }) 109 | it('throws an error when a json field mapped to a required ticker field is missing', () => { 110 | expect(() => { 111 | coinbaseAdapter.parseTicker({ 112 | ...mockTickerJson, 113 | ask: undefined, 114 | volume: undefined, 115 | bid: undefined, 116 | time: undefined, 117 | price: undefined, 118 | }) 119 | }).toThrowError('timestamp, bid, ask, lastPrice, baseVolume not defined') 120 | }) 121 | it('throws an error when the timestamp is in a bad format and cannot be parsed', () => { 122 | expect(() => { 123 | coinbaseAdapter.parseTicker({ 124 | ...mockTickerJson, 125 | time: 'the 20th of May, 2020 at 1:22 pm', 126 | }) 127 | }).toThrowError('timestamp not defined') 128 | }) 129 | }) 130 | 131 | describe('isOrderbookLive', () => { 132 | const falseStatusIndicators = [ 133 | { post_only: true }, 134 | { cancel_only: true }, 135 | { status: 'offline' }, 136 | ] 137 | 138 | it("returns false when status isn't 'online' or any of the 'only' flags are true", async () => { 139 | for (const indicator of falseStatusIndicators) { 140 | const response = { ...mockStatusJson, ...indicator } 141 | jest.spyOn(coinbaseAdapter, 'fetchFromApi').mockReturnValue(Promise.resolve(response)) 142 | expect(await coinbaseAdapter.isOrderbookLive()).toEqual(false) 143 | } 144 | }) 145 | it("returns true when status is 'online' and all the 'only' flags are false", async () => { 146 | jest.spyOn(coinbaseAdapter, 'fetchFromApi').mockReturnValue(Promise.resolve(mockStatusJson)) 147 | expect(await coinbaseAdapter.isOrderbookLive()).toEqual(true) 148 | }) 149 | }) 150 | }) 151 | -------------------------------------------------------------------------------- /test/exchange_adapters/currencyapi.test.ts: -------------------------------------------------------------------------------- 1 | import { Exchange, ExternalCurrency } from '../../src/utils' 2 | 3 | import BigNumber from 'bignumber.js' 4 | import { CurrencyApiAdapter } from '../../src/exchange_adapters/currencyapi' 5 | import { ExchangeAdapterConfig } from '../../src/exchange_adapters/base' 6 | import { baseLogger } from '../../src/default_config' 7 | import { MockSSLFingerprintService } from '../services/mock_ssl_fingerprint_service' 8 | 9 | describe('CurrencyApi adapter', () => { 10 | let adapter: CurrencyApiAdapter 11 | 12 | const config: ExchangeAdapterConfig = { 13 | baseCurrency: ExternalCurrency.EUR, 14 | baseLogger, 15 | quoteCurrency: ExternalCurrency.XOF, 16 | sslFingerprintService: new MockSSLFingerprintService(), 17 | } 18 | 19 | beforeEach(() => { 20 | adapter = new CurrencyApiAdapter(config) 21 | }) 22 | 23 | afterEach(() => { 24 | jest.clearAllTimers() 25 | jest.clearAllMocks() 26 | }) 27 | 28 | const validMockTickerJson = { 29 | valid: true, 30 | updated: 1695168063, 31 | conversion: { 32 | amount: 1, 33 | from: 'EUR', 34 | to: 'XOF', 35 | result: 655.315694, 36 | }, 37 | } 38 | 39 | const invalidJsonWithFalseValid = { 40 | ...validMockTickerJson, 41 | valid: false, 42 | } 43 | 44 | const invalidJsonWithAmountNotOne = { 45 | ...validMockTickerJson, 46 | conversion: { 47 | ...validMockTickerJson.conversion, 48 | amount: 2, 49 | }, 50 | } 51 | 52 | const invalidJsonWithInvalidFrom = { 53 | ...validMockTickerJson, 54 | conversion: { 55 | ...validMockTickerJson.conversion, 56 | from: 'USD', 57 | }, 58 | } 59 | 60 | const invalidJsonWithInvalidTo = { 61 | ...validMockTickerJson, 62 | conversion: { 63 | ...validMockTickerJson.conversion, 64 | to: 'USD', 65 | }, 66 | } 67 | 68 | describe('parseTicker', () => { 69 | it('handles a response that matches the documentation', () => { 70 | const ticker = adapter.parseTicker(validMockTickerJson) 71 | 72 | expect(ticker).toEqual({ 73 | source: Exchange.CURRENCYAPI, 74 | symbol: adapter.standardPairSymbol, 75 | ask: new BigNumber(655.315694), 76 | bid: new BigNumber(655.315694), 77 | lastPrice: new BigNumber(655.315694), 78 | timestamp: 1695168063, 79 | baseVolume: new BigNumber(1), 80 | quoteVolume: new BigNumber(655.315694), 81 | }) 82 | }) 83 | 84 | it('throws an error when the valid field in the response is false', () => { 85 | expect(() => { 86 | adapter.parseTicker(invalidJsonWithFalseValid) 87 | }).toThrowError('CurrencyApi response object contains false valid field') 88 | }) 89 | 90 | it('throws an error when the amount in the response is not 1', () => { 91 | expect(() => { 92 | adapter.parseTicker(invalidJsonWithAmountNotOne) 93 | }).toThrowError('CurrencyApi response object amount field is not 1') 94 | }) 95 | 96 | it('throws an error when the from field in the response is not the base currency', () => { 97 | expect(() => { 98 | adapter.parseTicker(invalidJsonWithInvalidFrom) 99 | }).toThrowError('CurrencyApi response object from field does not match base currency') 100 | }) 101 | 102 | it('throws an error when the to field in the response is not the quote currency', () => { 103 | expect(() => { 104 | adapter.parseTicker(invalidJsonWithInvalidTo) 105 | }).toThrowError('CurrencyApi response object to field does not match quote currency') 106 | }) 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /test/exchange_adapters/gemini.test.ts: -------------------------------------------------------------------------------- 1 | import { Exchange, ExternalCurrency } from '../../src/utils' 2 | 3 | import BigNumber from 'bignumber.js' 4 | import { ExchangeAdapterConfig } from '../../src/exchange_adapters/base' 5 | import { GeminiAdapter } from '../../src/exchange_adapters/gemini' 6 | import { baseLogger } from '../../src/default_config' 7 | import { MockSSLFingerprintService } from '../services/mock_ssl_fingerprint_service' 8 | 9 | describe('GeminiAdapter', () => { 10 | let geminiAdapter: GeminiAdapter 11 | const config: ExchangeAdapterConfig = { 12 | baseCurrency: ExternalCurrency.BTC, 13 | baseLogger, 14 | quoteCurrency: ExternalCurrency.USD, 15 | sslFingerprintService: new MockSSLFingerprintService(), 16 | } 17 | beforeEach(() => { 18 | geminiAdapter = new GeminiAdapter(config) 19 | }) 20 | afterEach(() => { 21 | jest.clearAllTimers() 22 | jest.clearAllMocks() 23 | }) 24 | 25 | const mockPubtickerJson = { 26 | ask: '9347.67', 27 | bid: '9345.70', 28 | last: '9346.20', 29 | volume: { 30 | BTC: '2210.50', 31 | USD: '2135477.46', 32 | timestamp: 1483018200000, 33 | }, 34 | } 35 | 36 | describe('parseTicker', () => { 37 | it('handles a response that matches the documentation', () => { 38 | const ticker = geminiAdapter.parseTicker(mockPubtickerJson) 39 | expect(ticker).toEqual({ 40 | source: Exchange.GEMINI, 41 | symbol: geminiAdapter.standardPairSymbol, 42 | ask: new BigNumber(9347.67), 43 | baseVolume: new BigNumber(2210.5), 44 | bid: new BigNumber(9345.7), 45 | lastPrice: new BigNumber(9346.2), 46 | quoteVolume: new BigNumber(2135477.46), 47 | timestamp: 1483018200000, 48 | }) 49 | }) 50 | 51 | it('throws an error when a json field mapped to a required ticker field is missing', () => { 52 | expect(() => { 53 | geminiAdapter.parseTicker({ 54 | ...mockPubtickerJson, 55 | volume: undefined, 56 | last: undefined, 57 | ask: undefined, 58 | bid: undefined, 59 | }) 60 | }).toThrowError('timestamp, bid, ask, lastPrice, baseVolume not defined') 61 | }) 62 | }) 63 | 64 | describe('isOrderbookLive', () => { 65 | const mockStatusJson = { 66 | symbol: 'BTCUSD', 67 | base_currency: 'BTC', 68 | quote_currency: 'USD', 69 | tick_size: 1e-8, 70 | quote_increment: 0.01, 71 | min_order_size: '0.00001', 72 | status: 'open', 73 | wrap_enabled: false, 74 | } 75 | 76 | it("returns false when status isn't 'open'", async () => { 77 | const response = { ...mockStatusJson, status: 'closed' } 78 | jest.spyOn(geminiAdapter, 'fetchFromApi').mockReturnValue(Promise.resolve(response)) 79 | expect(await geminiAdapter.isOrderbookLive()).toEqual(false) 80 | }) 81 | 82 | it("returns true when status is 'open'", async () => { 83 | jest.spyOn(geminiAdapter, 'fetchFromApi').mockReturnValue(Promise.resolve(mockStatusJson)) 84 | expect(await geminiAdapter.isOrderbookLive()).toEqual(true) 85 | }) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /test/exchange_adapters/kraken.test.ts: -------------------------------------------------------------------------------- 1 | import { KrakenAdapter } from '../../src/exchange_adapters/kraken' 2 | import { ExchangeAdapterConfig } from '../../src/exchange_adapters/base' 3 | import { baseLogger } from '../../src/default_config' 4 | import { Exchange, ExternalCurrency } from '../../src/utils' 5 | import BigNumber from 'bignumber.js' 6 | import { MockSSLFingerprintService } from '../services/mock_ssl_fingerprint_service' 7 | 8 | describe('kraken adapter', () => { 9 | let krakenAdapter: KrakenAdapter 10 | 11 | const config: ExchangeAdapterConfig = { 12 | baseCurrency: ExternalCurrency.USDC, 13 | baseLogger, 14 | quoteCurrency: ExternalCurrency.USD, 15 | sslFingerprintService: new MockSSLFingerprintService(), 16 | } 17 | 18 | beforeEach(() => { 19 | krakenAdapter = new KrakenAdapter(config) 20 | }) 21 | 22 | afterEach(() => { 23 | jest.clearAllTimers() 24 | jest.clearAllMocks() 25 | }) 26 | 27 | const validMockTickerJson = { 28 | error: [], 29 | result: { 30 | USDCUSD: { 31 | a: ['1.00000000', '3881916', '3881916.000'], 32 | b: ['0.99990000', '1158130', '1158130.000'], 33 | c: ['0.99990000', '20839.31050808'], 34 | v: ['5847624.42992545', '22093459.42678782'], 35 | p: ['0.99991974', '0.99994190'], 36 | t: [694, 3056], 37 | l: ['0.99990000', '0.99990000'], 38 | h: ['1.00000000', '1.00000000'], 39 | o: '1.00000000', 40 | }, 41 | }, 42 | } 43 | 44 | const inValidMockMultipleTickerJson = { 45 | error: [], 46 | result: { 47 | USDCUSD: { 48 | a: [], 49 | b: [], 50 | c: [], 51 | v: [], 52 | p: [], 53 | t: [], 54 | l: [], 55 | h: [], 56 | o: '', 57 | }, 58 | FARTBUXUSD: { 59 | a: [], 60 | b: [], 61 | c: [], 62 | v: [], 63 | p: [], 64 | t: [], 65 | l: [], 66 | h: [], 67 | o: '', 68 | }, 69 | }, 70 | } 71 | 72 | const inValidMockTickerJson = { 73 | error: [], 74 | result: { 75 | USDCUSD: { 76 | a: [], 77 | b: [], 78 | c: [], 79 | v: [], 80 | p: [], 81 | t: [], 82 | l: [], 83 | h: [], 84 | o: '', 85 | }, 86 | }, 87 | } 88 | 89 | const mockStatusJson = { 90 | error: [], 91 | result: { 92 | status: 'online', 93 | timestamp: '2023-01-12T14:10:47Z', 94 | }, 95 | } 96 | 97 | describe('parseTicker', () => { 98 | it('handles a response that matches the documentation', () => { 99 | const ticker = krakenAdapter.parseTicker(validMockTickerJson) 100 | expect(ticker).toEqual({ 101 | source: Exchange.KRAKEN, 102 | symbol: krakenAdapter.standardPairSymbol, 103 | ask: new BigNumber(1), 104 | baseVolume: new BigNumber(22093459.42678782), 105 | bid: new BigNumber(0.9999), 106 | lastPrice: new BigNumber(0.9999419), 107 | quoteVolume: new BigNumber('22092175.796795123627658'), 108 | timestamp: 0, 109 | }) 110 | }) 111 | 112 | it('throws an error when ticker repsonse contains more than one pair', () => { 113 | expect(() => { 114 | krakenAdapter.parseTicker(inValidMockMultipleTickerJson) 115 | }).toThrowError('Unexpected number of pairs in ticker response: 2') 116 | }) 117 | 118 | it('throws an error when a json field mapped to a required ticker field is missing', () => { 119 | expect(() => { 120 | krakenAdapter.parseTicker(inValidMockTickerJson) 121 | }).toThrowError('bid, ask, lastPrice, baseVolume not defined') 122 | }) 123 | }) 124 | 125 | describe('isOrderbookLive', () => { 126 | it("returns false when status isn't online", async () => { 127 | const response = { 128 | error: [], 129 | result: { 130 | status: 'maintainance', 131 | timestamp: '2023-01-12T14:10:47Z', 132 | }, 133 | } 134 | 135 | jest.spyOn(krakenAdapter, 'fetchFromApi').mockReturnValue(Promise.resolve(response)) 136 | expect(await krakenAdapter.isOrderbookLive()).toEqual(false) 137 | }) 138 | it("returns true when status is 'online'", async () => { 139 | jest.spyOn(krakenAdapter, 'fetchFromApi').mockReturnValue(Promise.resolve(mockStatusJson)) 140 | expect(await krakenAdapter.isOrderbookLive()).toEqual(true) 141 | }) 142 | }) 143 | }) 144 | -------------------------------------------------------------------------------- /test/exchange_adapters/mercado.test.ts: -------------------------------------------------------------------------------- 1 | import { Exchange, ExternalCurrency } from '../../src/utils' 2 | 3 | import BigNumber from 'bignumber.js' 4 | import { ExchangeAdapterConfig } from '../../src/exchange_adapters/base' 5 | import { MercadoAdapter } from '../../src/exchange_adapters/mercado' 6 | import { baseLogger } from '../../src/default_config' 7 | import { MockSSLFingerprintService } from '../services/mock_ssl_fingerprint_service' 8 | 9 | describe(' adapter', () => { 10 | let mercadoAdapter: MercadoAdapter 11 | 12 | const config: ExchangeAdapterConfig = { 13 | baseCurrency: ExternalCurrency.USDC, 14 | baseLogger, 15 | quoteCurrency: ExternalCurrency.USD, 16 | sslFingerprintService: new MockSSLFingerprintService(), 17 | } 18 | 19 | beforeEach(() => { 20 | mercadoAdapter = new MercadoAdapter(config) 21 | }) 22 | 23 | afterEach(() => { 24 | jest.clearAllTimers() 25 | jest.clearAllMocks() 26 | }) 27 | 28 | const validMockTickerJson = [ 29 | { 30 | pair: 'BTC-BRL', 31 | high: '120700.00000000', 32 | low: '117000.00001000', 33 | vol: '52.00314436', 34 | last: '119548.04744932', 35 | buy: '119457.96889001', 36 | sell: '119546.04397687', 37 | open: '119353.86994450', 38 | date: 1674561363, 39 | }, 40 | ] 41 | 42 | const validOrderbookJson = { 43 | asks: [ 44 | ['117275.49879111', '0.0256'], 45 | ['117532.16627745', '0.01449'], 46 | ], 47 | bids: [ 48 | ['117223.32117', '0.00001177'], 49 | ['117200', '0.00002'], 50 | ], 51 | } 52 | 53 | const inValidMockTickerJson = [ 54 | { 55 | pair: 'BTC-BRL', 56 | high: '120700.00000000', 57 | low: '117000.00001000', 58 | vol: undefined, 59 | last: undefined, 60 | buy: '', 61 | sell: '', 62 | open: '119353.86994450', 63 | date: undefined, 64 | }, 65 | ] 66 | 67 | const inValidOrderbookJson = { 68 | asks: [[], ['117532.16627745', '0.01449']], 69 | bids: [[], ['117200', '0.00002']], 70 | } 71 | 72 | describe('parseTicker', () => { 73 | it('handles a response that matches the documentation', () => { 74 | const ticker = mercadoAdapter.parseTicker(validMockTickerJson, validOrderbookJson) 75 | expect(ticker).toEqual({ 76 | source: Exchange.MERCADO, 77 | symbol: mercadoAdapter.standardPairSymbol, 78 | ask: new BigNumber(117275.49879111), 79 | baseVolume: new BigNumber(52.00314436), 80 | bid: new BigNumber(117223.32117), 81 | lastPrice: new BigNumber(119548.04744932), 82 | quoteVolume: new BigNumber(52.00314436).multipliedBy(new BigNumber(119548.04744932)), 83 | timestamp: 1674561363, 84 | }) 85 | }) 86 | 87 | it('throws an error when a json field mapped to a required ticker field is missing or empty', () => { 88 | expect(() => { 89 | mercadoAdapter.parseTicker(inValidMockTickerJson, inValidOrderbookJson) 90 | }).toThrowError('bid, ask, lastPrice, baseVolume not defined') 91 | }) 92 | }) 93 | 94 | const mockStatusJson = { 95 | symbol: ['BTC-BRL'], 96 | description: ['Bitcoin'], 97 | currency: ['BRL'], 98 | 'base-currency': ['BTC'], 99 | 'exchange-listed': [true], 100 | 'exchange-traded': [true], 101 | minmovement: ['1'], 102 | pricescale: [100000000], 103 | type: ['CRYPTO'], 104 | timezone: ['America/Sao_Paulo'], 105 | 'session-regular': ['24x7'], 106 | 'withdrawal-fee': ['0.0004'], 107 | 'withdraw-minimum': ['0.001'], 108 | 'deposit-minimum': ['0.00001'], 109 | } 110 | 111 | const mockWrongStatusJson = { 112 | symbol: ['BTC-BRL'], 113 | description: ['Bitcoin'], 114 | currency: ['BRL'], 115 | 'base-currency': ['BTC'], 116 | 'exchange-listed': [true], 117 | 'exchange-traded': [false], 118 | minmovement: ['1'], 119 | pricescale: [100000000], 120 | type: ['CRYPTO'], 121 | timezone: ['America/Sao_Paulo'], 122 | 'session-regular': ['24x7'], 123 | 'withdrawal-fee': ['0.0004'], 124 | 'withdraw-minimum': ['0.001'], 125 | 'deposit-minimum': ['0.00001'], 126 | } 127 | 128 | describe('isOrderbookLive', () => { 129 | it('returns true', async () => { 130 | jest.spyOn(mercadoAdapter, 'fetchFromApi').mockReturnValue(Promise.resolve(mockStatusJson)) 131 | expect(await mercadoAdapter.isOrderbookLive()).toEqual(true) 132 | }) 133 | 134 | it('returns false when Orderbook is not live', async () => { 135 | jest 136 | .spyOn(mercadoAdapter, 'fetchFromApi') 137 | .mockReturnValue(Promise.resolve(mockWrongStatusJson)) 138 | expect(await mercadoAdapter.isOrderbookLive()).toEqual(false) 139 | }) 140 | }) 141 | }) 142 | -------------------------------------------------------------------------------- /test/exchange_adapters/novadax.ts: -------------------------------------------------------------------------------- 1 | import BigNumber from 'bignumber.js' 2 | import { baseLogger } from '../../src/default_config' 3 | import { ExchangeAdapterConfig } from '../../src/exchange_adapters/base' 4 | import { NovaDaxAdapter } from '../../src/exchange_adapters/novadax' 5 | import { Exchange, ExternalCurrency } from '../../src/utils' 6 | import { MockSSLFingerprintService } from '../services/mock_ssl_fingerprint_service' 7 | 8 | describe('NovaDaxAdapter', () => { 9 | let novadaxAdapter: NovaDaxAdapter 10 | const config: ExchangeAdapterConfig = { 11 | baseCurrency: ExternalCurrency.BTC, 12 | baseLogger, 13 | quoteCurrency: ExternalCurrency.USD, 14 | sslFingerprintService: new MockSSLFingerprintService(), 15 | } 16 | beforeEach(() => { 17 | novadaxAdapter = new NovaDaxAdapter(config) 18 | }) 19 | afterEach(() => { 20 | jest.clearAllTimers() 21 | jest.clearAllMocks() 22 | }) 23 | describe('parseTicker', () => { 24 | const tickerJson = { 25 | code: 'A10000', 26 | data: [ 27 | { 28 | ask: '658600.01', 29 | baseVolume24h: '34.08241488', 30 | bid: '658600.0', 31 | high24h: '689735.63', 32 | low24h: '658000.0', 33 | lastPrice: '658600.01', 34 | open24h: '658600.01', 35 | quoteVolume24h: '669760.9564740908', 36 | symbol: 'BTC_BRL', 37 | timestamp: 1625205325000, 38 | }, 39 | ], 40 | message: 'Success', 41 | } 42 | 43 | it('handles a response that matches the documentation', () => { 44 | expect(novadaxAdapter.parseTicker(tickerJson)).toEqual({ 45 | source: Exchange.NOVADAX, 46 | symbol: novadaxAdapter.standardPairSymbol, 47 | ask: new BigNumber(658600.01), 48 | baseVolume: new BigNumber(188.62575176), 49 | bid: new BigNumber(658600.0), 50 | high: new BigNumber(689735.63), 51 | low: new BigNumber(658000.0), 52 | lastPrice: new BigNumber(658600.01), 53 | open: new BigNumber(658600.01), 54 | quoteVolume: new BigNumber(669760.9564740908), 55 | timestamp: 1625205325000, 56 | }) 57 | }) 58 | // timestamp, bid, ask, lastPrice, baseVolume 59 | const requiredFields = ['ask', 'bid', 'last', 'created_at', 'volume'] 60 | 61 | for (const field of Object.keys(tickerJson)) { 62 | // @ts-ignore 63 | const { [field]: _removed, ...incompleteTickerJson } = tickerJson.payload 64 | if (requiredFields.includes(field)) { 65 | it(`throws an error if ${field} is missing`, () => { 66 | expect(() => { 67 | novadaxAdapter.parseTicker(incompleteTickerJson) 68 | }).toThrowError() 69 | }) 70 | } else { 71 | it(`parses a ticker if ${field} is missing`, () => { 72 | expect(() => { 73 | novadaxAdapter.parseTicker(incompleteTickerJson) 74 | }).not.toThrowError() 75 | }) 76 | } 77 | } 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /test/exchange_adapters/okcoin.test.ts: -------------------------------------------------------------------------------- 1 | import { Exchange, ExternalCurrency } from '../../src/utils' 2 | 3 | import BigNumber from 'bignumber.js' 4 | import { CeloContract } from '@celo/contractkit' 5 | import { ExchangeAdapterConfig } from '../../src/exchange_adapters/base' 6 | import { OKCoinAdapter } from '../../src/exchange_adapters/okcoin' 7 | import { baseLogger } from '../../src/default_config' 8 | import { MockSSLFingerprintService } from '../services/mock_ssl_fingerprint_service' 9 | 10 | describe('OKCoinAdapter', () => { 11 | let okcoinAdapter: OKCoinAdapter 12 | const config: ExchangeAdapterConfig = { 13 | baseCurrency: CeloContract.GoldToken, 14 | baseLogger, 15 | quoteCurrency: ExternalCurrency.USD, 16 | sslFingerprintService: new MockSSLFingerprintService(), 17 | } 18 | beforeEach(() => { 19 | okcoinAdapter = new OKCoinAdapter(config) 20 | }) 21 | afterEach(() => { 22 | jest.clearAllTimers() 23 | jest.clearAllMocks() 24 | }) 25 | describe('parseTicker', () => { 26 | const correctlyFormattedJson = { 27 | best_ask: '200.99', 28 | best_bid: '200.93', 29 | instrument_id: 'CELO-USD', 30 | product_id: 'CELO-USD', 31 | last: '200.81', 32 | last_qty: '0', 33 | ask: '200.99', 34 | best_ask_size: '62.09', 35 | bid: '200.93', 36 | best_bid_size: '2.46', 37 | open_24h: '203.43', 38 | high_24h: '205.33', 39 | low_24h: '200.36', 40 | base_volume_24h: '2556.5805', 41 | timestamp: '2020-05-26T12:49:05.049Z', 42 | quote_volume_24h: '519342.82', 43 | } 44 | it('handles a response that matches the documentation', () => { 45 | const ticker = okcoinAdapter.parseTicker(correctlyFormattedJson) 46 | expect(ticker).toEqual({ 47 | source: Exchange.OKCOIN, 48 | symbol: okcoinAdapter.standardPairSymbol, 49 | ask: new BigNumber(200.99), 50 | baseVolume: new BigNumber(2556.5805), 51 | bid: new BigNumber(200.93), 52 | high: new BigNumber(205.33), 53 | lastPrice: new BigNumber(200.81), 54 | low: new BigNumber(200.36), 55 | open: new BigNumber(203.43), 56 | quoteVolume: new BigNumber(519342.82), 57 | timestamp: 1590497345049, 58 | }) 59 | }) 60 | it('throws an error when a json field mapped to a required ticker field is missing', () => { 61 | expect(() => { 62 | okcoinAdapter.parseTicker({ 63 | ...correctlyFormattedJson, 64 | ask: undefined, 65 | base_volume_24h: undefined, 66 | bid: undefined, 67 | last: undefined, 68 | timestamp: undefined, 69 | }) 70 | }).toThrowError('timestamp, bid, ask, lastPrice, baseVolume not defined') 71 | }) 72 | it('throws an error when the timestamp is in a bad format and cannot be parsed', () => { 73 | expect(() => { 74 | okcoinAdapter.parseTicker({ 75 | ...correctlyFormattedJson, 76 | timestamp: 'the 20th of May, 2020 at 1:22 pm', 77 | }) 78 | }).toThrowError('timestamp not defined') 79 | }) 80 | }) 81 | }) 82 | -------------------------------------------------------------------------------- /test/exchange_adapters/okx.test.ts: -------------------------------------------------------------------------------- 1 | import { Exchange, ExternalCurrency } from '../../src/utils' 2 | 3 | import BigNumber from 'bignumber.js' 4 | import { CeloContract } from '@celo/contractkit' 5 | import { ExchangeAdapterConfig } from '../../src/exchange_adapters/base' 6 | import { OKXAdapter } from '../../src/exchange_adapters/okx' 7 | import { baseLogger } from '../../src/default_config' 8 | import { MockSSLFingerprintService } from '../services/mock_ssl_fingerprint_service' 9 | 10 | describe('OKXAdapter', () => { 11 | let okxAdapter: OKXAdapter 12 | const config: ExchangeAdapterConfig = { 13 | baseCurrency: CeloContract.GoldToken, 14 | baseLogger, 15 | quoteCurrency: ExternalCurrency.USDT, 16 | sslFingerprintService: new MockSSLFingerprintService(), 17 | } 18 | beforeEach(() => { 19 | okxAdapter = new OKXAdapter(config) 20 | }) 21 | afterEach(() => { 22 | jest.clearAllTimers() 23 | jest.clearAllMocks() 24 | }) 25 | 26 | const mockTickerJson = { 27 | code: '0', 28 | msg: '', 29 | data: [ 30 | { 31 | instType: 'SPOT', 32 | instId: 'CELO-USDT', 33 | last: '0.792', 34 | lastSz: '193.723363', 35 | askPx: '0.793', 36 | askSz: '802.496954', 37 | bidPx: '0.792', 38 | bidSz: '55.216944', 39 | open24h: '0.691', 40 | high24h: '0.828', 41 | low24h: '0.665', 42 | volCcy24h: '1642445.37682', 43 | vol24h: '2177089.719932', 44 | ts: '1674479195109', 45 | sodUtc0: '0.685', 46 | sodUtc8: '0.698', 47 | }, 48 | ], 49 | } 50 | const mockFalseTickerJson = { 51 | code: '0', 52 | msg: '', 53 | data: [ 54 | { 55 | instType: 'SPOT', 56 | instId: 'CELO-USDT', 57 | last: undefined, 58 | lastSz: '193.723363', 59 | askPx: undefined, 60 | askSz: '802.496954', 61 | bidPx: undefined, 62 | bidSz: '55.216944', 63 | open24h: '0.691', 64 | high24h: '0.828', 65 | low24h: '0.665', 66 | volCcy24h: undefined, 67 | vol24h: undefined, 68 | ts: undefined, 69 | sodUtc0: '0.685', 70 | sodUtc8: '0.698', 71 | }, 72 | ], 73 | } 74 | 75 | describe('parseTicker', () => { 76 | it('handles a response that matches the documentation', () => { 77 | const ticker = okxAdapter.parseTicker(mockTickerJson) 78 | expect(ticker).toEqual({ 79 | source: Exchange.OKX, 80 | symbol: okxAdapter.standardPairSymbol, 81 | ask: new BigNumber(0.793), 82 | baseVolume: new BigNumber(2177089.719932), 83 | bid: new BigNumber(0.792), 84 | lastPrice: new BigNumber(0.792), 85 | quoteVolume: new BigNumber(1642445.37682), 86 | timestamp: 1674479195109, 87 | }) 88 | }) 89 | 90 | it('throws an error when a json field mapped to a required ticker field is missing', () => { 91 | expect(() => { 92 | okxAdapter.parseTicker(mockFalseTickerJson) 93 | }).toThrowError('timestamp, bid, ask, lastPrice, baseVolume not defined') 94 | }) 95 | }) 96 | 97 | describe('isOrderbookLive', () => { 98 | const mockStatusJson = { 99 | code: '0', 100 | msg: '', 101 | data: [], 102 | } 103 | 104 | it("returns false when code isn't 0", async () => { 105 | const response = { ...mockStatusJson, code: '1' } 106 | jest.spyOn(okxAdapter, 'fetchFromApi').mockReturnValue(Promise.resolve(response)) 107 | expect(await okxAdapter.isOrderbookLive()).toEqual(false) 108 | }) 109 | 110 | it('returns true when code is 0', async () => { 111 | jest.spyOn(okxAdapter, 'fetchFromApi').mockReturnValue(Promise.resolve(mockStatusJson)) 112 | expect(await okxAdapter.isOrderbookLive()).toEqual(true) 113 | }) 114 | }) 115 | }) 116 | -------------------------------------------------------------------------------- /test/exchange_adapters/whitebit.test.ts: -------------------------------------------------------------------------------- 1 | import { Exchange, ExternalCurrency } from '../../src/utils' 2 | import { ExchangeAdapterConfig, ExchangeDataType } from '../../src/exchange_adapters/base' 3 | 4 | import BigNumber from 'bignumber.js' 5 | import { WhitebitAdapter } from '../../src/exchange_adapters/whitebit' 6 | import { baseLogger } from '../../src/default_config' 7 | import { MockSSLFingerprintService } from '../services/mock_ssl_fingerprint_service' 8 | 9 | // Mock data 10 | const validMockTickerJson = { 11 | ask: 0.95, 12 | base_volume: 23401032.8528, 13 | bid: 0.9, 14 | last_price: 0.9998, 15 | quote_volume: 23399576.58906071, 16 | timestamp: 0, 17 | } 18 | 19 | const inValidMockTickerJson = { 20 | quote_volume: 23399576.58906071, 21 | timestamp: 0, 22 | } 23 | 24 | const mockValidTickerData = { 25 | '1INCH_BTC': { 26 | base_id: 8104, 27 | quote_id: 1, 28 | last_price: '0.0000246', 29 | quote_volume: '1.16888304', 30 | base_volume: '48268', 31 | isFrozen: false, 32 | change: '0.94', 33 | }, 34 | USDC_USDT: { 35 | base_id: 8104, 36 | quote_id: 1, 37 | last_price: '0.9876', 38 | quote_volume: '1.16888304', 39 | base_volume: '48268', 40 | isFrozen: false, 41 | change: '0.94', 42 | }, 43 | } 44 | 45 | const mockValidOrderbookData = { 46 | timestamp: 1676047317, 47 | asks: [['1.001', '5486968.7515']], 48 | bids: [['0.999', '385192']], 49 | } 50 | 51 | describe('Whitebit adapter', () => { 52 | let whitebitAdapter: WhitebitAdapter 53 | 54 | const config: ExchangeAdapterConfig = { 55 | baseCurrency: ExternalCurrency.USDC, 56 | baseLogger, 57 | quoteCurrency: ExternalCurrency.USDT, 58 | sslFingerprintService: new MockSSLFingerprintService(), 59 | } 60 | beforeEach(() => { 61 | whitebitAdapter = new WhitebitAdapter(config) 62 | }) 63 | 64 | afterEach(() => { 65 | jest.clearAllTimers() 66 | jest.clearAllMocks() 67 | }) 68 | 69 | describe('fetchFromApi', () => { 70 | let fetchFromApiSpy: jest.SpyInstance 71 | beforeEach(() => { 72 | fetchFromApiSpy = jest.spyOn(whitebitAdapter, 'fetchFromApi') 73 | fetchFromApiSpy 74 | .mockReturnValueOnce(mockValidTickerData) 75 | .mockReturnValueOnce(mockValidOrderbookData) 76 | }) 77 | 78 | it('calls correct endpoints on whitebit api', async () => { 79 | fetchFromApiSpy 80 | .mockReturnValueOnce(mockValidTickerData) 81 | .mockReturnValueOnce(mockValidOrderbookData) 82 | 83 | await whitebitAdapter.fetchTicker() 84 | expect(fetchFromApiSpy).toHaveBeenCalledTimes(2) 85 | expect(fetchFromApiSpy).toHaveBeenNthCalledWith(1, ExchangeDataType.TICKER, 'ticker') 86 | expect(fetchFromApiSpy).toHaveBeenNthCalledWith( 87 | 2, 88 | ExchangeDataType.TICKER, 89 | 'orderbook/USDC_USDT?limit=1&level=2&' 90 | ) 91 | }) 92 | 93 | it('calls parseTicker with the right parameters', async () => { 94 | const parseTickerSpy = jest.spyOn(whitebitAdapter, 'parseTicker') 95 | await whitebitAdapter.fetchTicker() 96 | 97 | expect(parseTickerSpy).toHaveBeenCalledTimes(1) 98 | expect(parseTickerSpy).toHaveBeenCalledWith({ 99 | ...mockValidTickerData.USDC_USDT, 100 | ask: mockValidOrderbookData.asks[0][0], 101 | bid: mockValidOrderbookData.bids[0][0], 102 | }) 103 | }) 104 | }) 105 | 106 | describe('parseTicker', () => { 107 | it('handles a response that matches the documentation', async () => { 108 | const ticker = await whitebitAdapter.parseTicker(validMockTickerJson) 109 | 110 | expect(ticker).toEqual({ 111 | source: Exchange.WHITEBIT, 112 | symbol: whitebitAdapter.standardPairSymbol, 113 | ask: new BigNumber(0.95), 114 | baseVolume: new BigNumber(23401032.8528), 115 | bid: new BigNumber(0.9), 116 | lastPrice: new BigNumber(0.9998), 117 | quoteVolume: new BigNumber('23399576.58906071'), 118 | timestamp: 0, 119 | }) 120 | }) 121 | 122 | it('throws an error when a json field mapped to a required ticker field is missing', () => { 123 | expect(() => { 124 | whitebitAdapter.parseTicker(inValidMockTickerJson) 125 | }).toThrowError(new Error('bid, ask, lastPrice, baseVolume not defined')) 126 | }) 127 | }) 128 | 129 | describe('isOrderbookLive', () => { 130 | const statusResponse = { 131 | name: 'USDC_USDT', 132 | tradesEnabled: true, 133 | type: 'spot', 134 | } 135 | 136 | it('returns false when trading is not enabled', async () => { 137 | jest.spyOn(whitebitAdapter, 'fetchFromApi').mockReturnValue( 138 | Promise.resolve([ 139 | { 140 | ...statusResponse, 141 | tradesEnabled: false, 142 | }, 143 | ]) 144 | ) 145 | expect(await whitebitAdapter.isOrderbookLive()).toEqual(false) 146 | }) 147 | 148 | it('returns false when market is not spot', async () => { 149 | jest.spyOn(whitebitAdapter, 'fetchFromApi').mockReturnValue( 150 | Promise.resolve([ 151 | { 152 | ...statusResponse, 153 | type: 'spotty', 154 | }, 155 | ]) 156 | ) 157 | expect(await whitebitAdapter.isOrderbookLive()).toEqual(false) 158 | }) 159 | 160 | it('returns true when trades are enabled and market is spot', async () => { 161 | jest.spyOn(whitebitAdapter, 'fetchFromApi').mockReturnValue(Promise.resolve([statusResponse])) 162 | expect(await whitebitAdapter.isOrderbookLive()).toEqual(true) 163 | }) 164 | }) 165 | }) 166 | -------------------------------------------------------------------------------- /test/exchange_adapters/xignite.test.ts: -------------------------------------------------------------------------------- 1 | import { Exchange, ExternalCurrency } from '../../src/utils' 2 | 3 | import BigNumber from 'bignumber.js' 4 | import { ExchangeAdapterConfig } from '../../src/exchange_adapters/base' 5 | import { XigniteAdapter } from '../../src/exchange_adapters/xignite' 6 | import { baseLogger } from '../../src/default_config' 7 | import { MockSSLFingerprintService } from '../services/mock_ssl_fingerprint_service' 8 | 9 | describe('Xignite adapter', () => { 10 | let adapter: XigniteAdapter 11 | 12 | const config: ExchangeAdapterConfig = { 13 | baseCurrency: ExternalCurrency.EUR, 14 | baseLogger, 15 | quoteCurrency: ExternalCurrency.XOF, 16 | sslFingerprintService: new MockSSLFingerprintService(), 17 | } 18 | 19 | beforeEach(() => { 20 | adapter = new XigniteAdapter(config) 21 | }) 22 | 23 | const validMockTickerJson = { 24 | BaseCurrency: 'EUR', 25 | QuoteCurrency: 'XOF', 26 | Symbol: 'EURXOF', 27 | Date: '09/29/2023', 28 | Time: '9:59:50 PM', 29 | QuoteType: 'Calculated', 30 | Bid: 653.626, 31 | Mid: 654.993, 32 | Ask: 656.36, 33 | Spread: 2.734, 34 | Text: '1 European Union euro = 654.993 West African CFA francs', 35 | Source: 'Rates calculated by crossing via ZAR(Morningstar).', 36 | Outcome: 'Success', 37 | Message: null, 38 | Identity: 'Request', 39 | Delay: 0.0032363, 40 | } 41 | 42 | const invalidJsonWithBaseCurrencyMissmatch = { 43 | ...validMockTickerJson, 44 | BaseCurrency: 'USD', 45 | } 46 | 47 | const invalidJsonWithQuoteCurrencyMissmatch = { 48 | ...validMockTickerJson, 49 | QuoteCurrency: 'USD', 50 | } 51 | 52 | const invalidJsonWithMissingFields = { 53 | Spread: 0.000001459666, 54 | Mid: 0.001524435453, 55 | Delay: 0.0570077, 56 | Time: '2:36:48 PM', 57 | Date: '07/26/2023', 58 | Symbol: 'EURXOF', 59 | QuoteCurrency: 'XOF', 60 | BaseCurrency: 'EUR', 61 | } 62 | 63 | describe('parseTicker', () => { 64 | it('handles a response that matches the documentation', () => { 65 | const ticker = adapter.parseTicker(validMockTickerJson) 66 | 67 | expect(ticker).toEqual({ 68 | source: Exchange.XIGNITE, 69 | symbol: adapter.standardPairSymbol, 70 | ask: new BigNumber(656.36), 71 | bid: new BigNumber(653.626), 72 | lastPrice: new BigNumber(654.993), 73 | timestamp: 1696024790, 74 | baseVolume: new BigNumber(1), 75 | quoteVolume: new BigNumber(1), 76 | }) 77 | }) 78 | 79 | it('throws an error when the base currency does not match', () => { 80 | expect(() => { 81 | adapter.parseTicker(invalidJsonWithBaseCurrencyMissmatch) 82 | }).toThrowError('Base currency mismatch in response: USD != EUR') 83 | }) 84 | 85 | it('throws an error when the quote currency does not match', () => { 86 | expect(() => { 87 | adapter.parseTicker(invalidJsonWithQuoteCurrencyMissmatch) 88 | }).toThrowError('Quote currency mismatch in response: USD != XOF') 89 | }) 90 | 91 | it('throws an error when some required fields are missing', () => { 92 | expect(() => { 93 | adapter.parseTicker(invalidJsonWithMissingFields) 94 | }).toThrowError('bid, ask not defined') 95 | }) 96 | }) 97 | 98 | describe('toUnixTimestamp', () => { 99 | it('handles date strings with AM time', () => { 100 | expect(adapter.toUnixTimestamp('07/26/2023', '10:00:00 AM')).toEqual(1690365600) 101 | expect(adapter.toUnixTimestamp('01/01/2023', '4:29:03 AM')).toEqual(1672547343) 102 | }) 103 | it('handles date strins with PM time', () => { 104 | expect(adapter.toUnixTimestamp('03/15/2023', '4:53:27 PM')).toEqual(1678899207) 105 | expect(adapter.toUnixTimestamp('07/26/2023', '8:29:37 PM')).toEqual(1690403377) 106 | }) 107 | it('handles 12 PM edge case', () => { 108 | expect(adapter.toUnixTimestamp('07/20/2023', '12:53:15 PM')).toEqual(1689857595) 109 | }) 110 | it('handles 12 AM edge case', () => { 111 | expect(adapter.toUnixTimestamp('07/20/2023', '12:53:15 AM')).toEqual(1689814395) 112 | }) 113 | }) 114 | }) 115 | -------------------------------------------------------------------------------- /test/services/mock_ssl_fingerprint_service.ts: -------------------------------------------------------------------------------- 1 | import { ISSLFingerprintService } from '../../src/services/SSLFingerprintService' 2 | 3 | export class MockSSLFingerprintService implements ISSLFingerprintService { 4 | public mapping = new Map() 5 | 6 | getFingerprint(identifier: string): string { 7 | return this.mapping.get(identifier) || 'mock-fingerprint' 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /test/services/ssl_fingerprint_service.test.ts: -------------------------------------------------------------------------------- 1 | /* tslint:disable:no-string-literal */ 2 | import { MetricCollector } from '../../src/metric_collector' 3 | import { 4 | SSLFingerprintService, 5 | SSLFingerprintServiceConfig, 6 | formatFingerprint, 7 | } from '../../src/services/SSLFingerprintService' 8 | import { baseLogger } from '../../src/default_config' 9 | import { Exchange } from '../../src/utils' 10 | import Web3 from 'web3' 11 | 12 | jest.mock('../../src/metric_collector') 13 | 14 | let service: SSLFingerprintService 15 | const metricCollector = new MetricCollector(baseLogger) 16 | const sslRegistryAddress = '0xD5E9E0E53Ea1925BfBdAEA9525376D780FF8a4C3' 17 | const wsRpcProviderUrl = 'ws://bar.foo' 18 | 19 | const MOCK_FINGERPRINTS = Object.values(Exchange).map((_) => Web3.utils.randomHex(32)) 20 | const EXCHANGE_TO_FINGERPRINT = Object.fromEntries( 21 | Object.values(Exchange).map((e, i) => [e, formatFingerprint(MOCK_FINGERPRINTS[i])]) 22 | ) 23 | 24 | describe('SSLFingerprintService', () => { 25 | beforeEach(async () => { 26 | const config: SSLFingerprintServiceConfig = { 27 | wsRpcProviderUrl, 28 | sslRegistryAddress, 29 | metricCollector, 30 | baseLogger, 31 | } 32 | service = new SSLFingerprintService(config) 33 | // await service.init() 34 | }) 35 | 36 | afterEach(() => { 37 | jest.clearAllMocks() 38 | service.stop() 39 | }) 40 | 41 | beforeEach(async () => { 42 | jest.spyOn(service['registry'].methods, 'getFingerprints').mockImplementation(() => ({ 43 | call: () => MOCK_FINGERPRINTS, 44 | })) 45 | jest.spyOn(service['registry'].events, 'FingerprintUpdated') 46 | await service.init() 47 | }) 48 | 49 | describe('#init', () => { 50 | it('gets current fingerprints', async () => { 51 | Object.values(Exchange).forEach((e, index) => { 52 | expect(service.getFingerprint(e)).toBe(formatFingerprint(MOCK_FINGERPRINTS[index])) 53 | }) 54 | }) 55 | 56 | it('sets up an event subscription', async () => { 57 | expect(service['registry'].events.FingerprintUpdated).toHaveBeenCalledWith( 58 | { 59 | fromBlock: 'latest', 60 | }, 61 | service['updateFingerprint'] 62 | ) 63 | }) 64 | }) 65 | 66 | describe('#updateFingerprint', () => { 67 | beforeAll(() => { 68 | jest.spyOn(service['logger'], 'error') 69 | jest.spyOn(service['logger'], 'info') 70 | jest.spyOn(service['fingerprintMapping'], 'set') 71 | }) 72 | 73 | it('when called with an error, it logs', () => { 74 | const error = new Error('fail') 75 | service.updateFingerprint(error, undefined) 76 | expect(service['logger'].error).toHaveBeenCalledWith(error) 77 | }) 78 | 79 | it('when the event object is invalid, it logs', () => { 80 | service.updateFingerprint(null, { 81 | returnValues: {}, 82 | }) 83 | expect(service['logger'].error).toHaveBeenCalledWith( 84 | `Failed to process FingerprintUpdated event` 85 | ) 86 | expect(service['logger'].error).toHaveBeenCalledWith( 87 | expect.objectContaining({ 88 | message: `FingerprintUpdated event is invalid or missing returnValues`, 89 | }) 90 | ) 91 | }) 92 | 93 | it('when the identifier is unknown, it logs', () => { 94 | service.updateFingerprint(null, { 95 | returnValues: { 96 | identifier: 'NOT_AN_EXCHANGE', 97 | fingerprint: '0x0', 98 | }, 99 | }) 100 | expect(service['logger'].error).toHaveBeenCalledWith( 101 | `Failed to process FingerprintUpdated event` 102 | ) 103 | expect(service['logger'].error).toHaveBeenCalledWith( 104 | expect.objectContaining({ 105 | message: `Unexpected identifier: NOT_AN_EXCHANGE`, 106 | }) 107 | ) 108 | }) 109 | 110 | it('when the fingerprint is invalid, it logs', () => { 111 | service.updateFingerprint(null, { 112 | returnValues: { 113 | identifier: Exchange.OKX, 114 | fingerprint: '0x12', 115 | }, 116 | }) 117 | expect(service['logger'].error).toHaveBeenCalledWith( 118 | `Failed to process FingerprintUpdated event` 119 | ) 120 | expect(service['logger'].error).toHaveBeenCalledWith( 121 | expect.objectContaining({ 122 | message: `Invalid fingerprint: 0x12`, 123 | }) 124 | ) 125 | }) 126 | 127 | it('when the fingerprint is valid but 0, it logs', () => { 128 | service.updateFingerprint(null, { 129 | returnValues: { 130 | identifier: Exchange.OKX, 131 | fingerprint: '0x0000000000000000000000000000000000000000000000000000000000000000', 132 | }, 133 | }) 134 | expect(service['logger'].error).toHaveBeenCalledWith( 135 | `Failed to process FingerprintUpdated event` 136 | ) 137 | expect(service['logger'].error).toHaveBeenCalledWith( 138 | expect.objectContaining({ 139 | message: `Fingerprint is empty`, 140 | }) 141 | ) 142 | }) 143 | 144 | it('when all is well, it logs and updates the mapping', () => { 145 | const newFingerprint = Web3.utils.randomHex(32) 146 | service.updateFingerprint(null, { 147 | returnValues: { 148 | identifier: Exchange.OKX, 149 | fingerprint: newFingerprint, 150 | }, 151 | }) 152 | const formattedFingerprint = formatFingerprint(newFingerprint) 153 | 154 | expect(service['logger'].info).toHaveBeenCalledWith( 155 | `Updating ${Exchange.OKX} fingerprint to ${formattedFingerprint}` 156 | ) 157 | expect(service.getFingerprint(Exchange.OKX)).toBe(formattedFingerprint) 158 | }) 159 | }) 160 | 161 | describe('#getFingerprint', () => { 162 | it('returns the fingerprint when found', () => { 163 | const fingerprint = service.getFingerprint(Exchange.BINANCE) 164 | expect(fingerprint).toBe(EXCHANGE_TO_FINGERPRINT[Exchange.BINANCE]) 165 | }) 166 | 167 | it('throws an error when not found, as this should never happen', () => { 168 | expect(() => service.getFingerprint('NOT_AN_EXCHANGE')).toThrow() 169 | }) 170 | }) 171 | }) 172 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "lib", 5 | "lib": ["dom", "es2015", "es2016", "es2017"], 6 | "target": "es6", 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "jsx": "preserve", 10 | "composite": true, 11 | "allowSyntheticDefaultImports": true, 12 | "esModuleInterop": true, 13 | "strict": true, 14 | "declaration": true, 15 | "sourceMap": true, 16 | "skipLibCheck": true, 17 | "noImplicitAny": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | }, 21 | "include": ["src", "./node_modules/@celo/contractkit/types"], 22 | } 23 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@celo/typescript/tslint.json"], 3 | "rulesDirectory": [ 4 | "tslint-no-focused-test" 5 | ], 6 | "rules": { 7 | "indent": [true, "spaces", 2], 8 | "no-global-arrow-functions": false, 9 | "no-console": true, 10 | "member-ordering": false, 11 | "max-classes-per-file": false, 12 | "no-focused-test": true 13 | } 14 | } 15 | --------------------------------------------------------------------------------