├── .cargo └── audit.toml ├── .github ├── dependabot.yml └── workflows │ ├── delphi.yml │ └── security_audit.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── delphi.toml.example ├── img ├── delphi.png └── delphi.svg └── src ├── application.rs ├── bin └── delphi │ └── main.rs ├── commands.rs ├── commands ├── start.rs └── version.rs ├── config.rs ├── config ├── https.rs ├── listen.rs ├── network.rs └── source.rs ├── currency.rs ├── error.rs ├── lib.rs ├── networks.rs ├── networks ├── terra.rs └── terra │ ├── denom.rs │ ├── msg.rs │ ├── oracle.rs │ ├── protos.rs │ └── schema.toml ├── prelude.rs ├── price.rs ├── protos.rs ├── router.rs ├── sources.rs ├── sources ├── alphavantage.rs ├── binance.rs ├── bithumb.rs ├── coinone.rs ├── currencylayer.rs ├── dunamu.rs ├── gdac.rs ├── gopax.rs └── imf_sdr.rs └── trading_pair.rs /.cargo/audit.toml: -------------------------------------------------------------------------------- 1 | # https://github.com/RustSec/cargo-audit/blob/master/audit.toml.example 2 | 3 | [advisories] 4 | ignore = [ 5 | "RUSTSEC-2020-0016", 6 | "RUSTSEC-2020-0036", 7 | "RUSTSEC-2021-0073", 8 | "RUSTSEC-2021-0078", 9 | "RUSTSEC-2021-0079" 10 | ] 11 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: '13:00' 8 | open-pull-requests-limit: 10 9 | -------------------------------------------------------------------------------- /.github/workflows/delphi.yml: -------------------------------------------------------------------------------- 1 | name: Delphi 2 | 3 | on: 4 | pull_request: {} 5 | push: 6 | branches: main 7 | 8 | env: 9 | CARGO_INCREMENTAL: 0 10 | RUSTFLAGS: "-Dwarnings" 11 | 12 | jobs: 13 | check: 14 | name: Check 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout sources 18 | uses: actions/checkout@v1 19 | 20 | - name: Install stable toolchain 21 | uses: actions-rs/toolchain@v1 22 | with: 23 | toolchain: stable 24 | override: true 25 | 26 | - name: Run cargo check 27 | uses: actions-rs/cargo@v1 28 | with: 29 | command: check 30 | 31 | test: 32 | name: Test Suite 33 | strategy: 34 | matrix: 35 | platform: 36 | - ubuntu-latest 37 | toolchain: 38 | - 1.51.0 # MSRV 39 | - stable 40 | runs-on: ${{ matrix.platform }} 41 | steps: 42 | - name: Checkout sources 43 | uses: actions/checkout@v1 44 | 45 | - name: Cache cargo registry 46 | uses: actions/cache@v1 47 | with: 48 | path: ~/.cargo/registry 49 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }} 50 | 51 | - name: Cache cargo index 52 | uses: actions/cache@v1 53 | with: 54 | path: ~/.cargo/git 55 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('Cargo.lock') }} 56 | 57 | - name: Cache cargo build 58 | uses: actions/cache@v1 59 | with: 60 | path: target 61 | key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('Cargo.lock') }} 62 | 63 | - name: Install stable toolchain 64 | uses: actions-rs/toolchain@v1 65 | with: 66 | toolchain: ${{ matrix.toolchain }} 67 | override: true 68 | 69 | - name: Run cargo test 70 | uses: actions-rs/cargo@v1 71 | with: 72 | command: test 73 | args: --all --release 74 | 75 | clippy: 76 | runs-on: ubuntu-latest 77 | steps: 78 | - uses: actions/checkout@v1 79 | - uses: actions-rs/toolchain@v1 80 | with: 81 | profile: minimal 82 | toolchain: 1.51.0 # MSRV 83 | components: clippy 84 | - run: cargo clippy --all --all-features -- -D warnings 85 | 86 | rustfmt: 87 | runs-on: ubuntu-latest 88 | steps: 89 | - name: Checkout sources 90 | uses: actions/checkout@v1 91 | 92 | - name: Install stable toolchain 93 | uses: actions-rs/toolchain@v1 94 | with: 95 | profile: minimal 96 | toolchain: stable 97 | components: rustfmt 98 | 99 | - name: Run cargo fmt 100 | uses: actions-rs/cargo@v1 101 | with: 102 | command: fmt 103 | args: --all -- --check 104 | -------------------------------------------------------------------------------- /.github/workflows/security_audit.yml: -------------------------------------------------------------------------------- 1 | name: Security Audit 2 | on: 3 | pull_request: 4 | paths: Cargo.lock 5 | push: 6 | branches: main 7 | paths: Cargo.lock 8 | schedule: 9 | - cron: '0 0 * * *' 10 | 11 | jobs: 12 | security_audit: 13 | name: Security Audit 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v1 17 | - uses: actions/cache@v1 18 | with: 19 | path: ~/.cargo/registry 20 | key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }} 21 | - uses: actions/cache@v1 22 | with: 23 | path: ~/.cargo/git 24 | key: ${{ runner.os }}-cargo-index-${{ hashFiles('Cargo.lock') }} 25 | - uses: actions/cache@v1 26 | with: 27 | path: target 28 | key: ${{ runner.os }}-cargo-build-security-audit-${{ hashFiles('Cargo.lock') }} 29 | - uses: actions-rs/toolchain@v1 30 | with: 31 | toolchain: stable 32 | override: true 33 | - run: cargo install cargo-audit 34 | - uses: actions-rs/cargo@v1 35 | with: 36 | command: audit 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | delphi.toml 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## 0.0.5 (2021-08-30) 8 | ### Changed 9 | - README.md: fix build status badge ([#195]) 10 | - Adding Terra IDR and PHP ([#197]) 11 | 12 | [#195]: https://github.com/iqlusioninc/delphi/pull/195 13 | [#197]: https://github.com/iqlusioninc/delphi/pull/197 14 | 15 | ## 0.0.4 (2021-08-29) 16 | ### Changed 17 | - Update dependencies ([#193]) 18 | 19 | ### Removed 20 | - `utwd` and `nok` denoms ([#193]) 21 | 22 | [#193]: https://github.com/iqlusioninc/delphi/pull/193 23 | 24 | ## 0.0.3 (2021-08-25) 25 | ### Removed 26 | - Terra Nok ([#188]) 27 | 28 | [#188]: https://github.com/iqlusioninc/delphi/pull/188 29 | 30 | ## 0.0.2 (2021-06-26) 31 | ### Removed 32 | - utwd until launch on Terra 33 | 34 | ## 0.0.1 (2021-06-24) 35 | - Initial release 36 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | education, socio-economic status, nationality, personal appearance, race, 10 | religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at [oss@iqlusion.io]. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | [oss@iqlusion.io]: mailto:oss@iqlusion.io 75 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "delphi" 3 | version = "0.0.5" 4 | description = "Oracle feeder service (presently supporting Terra)" 5 | authors = [ 6 | "Tony Arcieri ", 7 | "Kristi Põldsam ", 8 | "Zaki Manian ", 9 | "Shella Stephens " 10 | ] 11 | license = "Apache-2.0" 12 | repository = "https://github.com/iqlusioninc/delphi" 13 | readme = "README.md" 14 | edition = "2018" 15 | keywords = ["oracle", "terra"] 16 | categories = ["cryptography::cryptocurrencies"] 17 | 18 | [dependencies] 19 | abscissa_core = "=0.6.0-pre.1" 20 | abscissa_tokio = "=0.6.0-pre.1" 21 | bytes = "1" 22 | cosmrs = "0.2" 23 | eyre = "0.6" 24 | gumdrop = "0.7" 25 | iqhttp = { version = "0.1", features = ["json", "proxy"] } 26 | percent-encoding = "2.1" 27 | rand = "0.8" 28 | rust_decimal = "1" 29 | once_cell = "1" 30 | prost = "0.7" 31 | serde = { version = "1", features = ["serde_derive"] } 32 | serde_json = "1" 33 | sha2 = "0.9" 34 | stdtx = "0.5" 35 | tendermint = "0.22" 36 | tendermint-rpc = "0.22" 37 | thiserror = "1" 38 | tokio = { version = "1", features = ["full"] } 39 | warp = "0.3" 40 | csv = "1.1" 41 | futures = "0.3" 42 | 43 | [dev-dependencies] 44 | abscissa_core = { version = "=0.6.0-pre.1", features = ["testing"] } 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sagan 2 | 3 | [![Build Status][build-image]][build-link] 4 | [![Safety Dance][safety-image]][safety-link] 5 | ![MSRV][msrv-image] 6 | [![Apache 2.0 Licensed][license-image]][license-link] 7 | [![Gitter Chat][gitter-image]][gitter-link] 8 | 9 | ## About 10 | Oracle feeder service (presently supporting [Terra]). Currently `delphi` is used in production by iqlusion and has been integrated with [Tendermint KMS' transaction signer][tmkms]. Detailed [architecture available here][tmkms_batch]. 11 | 12 | Terra 🌏🔗: 13 | - [Oracle Feeder docs][terra_oracle] 14 | - [iqlusion aggregate exchange rate votes][iqlusion_stakeid] 15 | 16 | 17 | ## Sources 18 | Following exchanges are supported: 19 | - [Alpha Vantage][alphavantage] 20 | - [Binance][binance] 21 | - [Coinone][coinone] 22 | - [Dunamu][dunamu] 23 | - [GDAC][gdac] 24 | - [GOPAX][gopax] 25 | - [IMF SDR][imfsdr] 26 | - [IMF SDR][imfsdr] 27 | - [Currencylayer][currencylayer] 28 | 29 | ### Alpha Vantage 30 | This source requires an API key. Request key from [Alpha Vantage][alphavantage_api] then add to following config file. 31 | 32 | ### Currencylayer 33 | This source requires an API key. Request key from [Currencylayer][currencylayer_api] then add to following config file. 34 | 35 | ### Config 36 | Create config with `touch delphi.toml` then add your relevant network configuration. 37 | ``` 38 | # Example Delphi configuration file 39 | 40 | # Listen address configuration 41 | [listen] 42 | addr = "127.0.0.1" 43 | port = 3822 44 | protocol = "http" 45 | 46 | # HTTPS client configuration 47 | # [https] 48 | # proxy = "https://webproxy.example.com:8080" # send outgoing requests through proxy 49 | 50 | # Network configuration: blockchains for which oracle service is provided 51 | [network.terra] 52 | chain_id = "columbus-4" 53 | feeder = "terra1..." 54 | validator = "terravaloper1..." 55 | fee = { denom = "Ukrw", amount = "356100", gas = "200000" } 56 | 57 | # Source configuration: exchanges where price information is gathered from 58 | [source.alphavantage] 59 | # Get API key here (quick-and-simple form): https://www.alphavantage.co/support/#api-key 60 | apikey = "api key goes here" 61 | ``` 62 | 63 | 64 | ## Operating Systems 65 | - Linux (recommended) 66 | 67 | ## Code of Conduct 68 | 69 | We abide by the [Contributor Covenant][cc] and ask that you do as well. 70 | 71 | For more information, please see [CODE_OF_CONDUCT.md]. 72 | 73 | ## License 74 | 75 | Copyright © 2019 iqlusion 76 | 77 | Licensed under the Apache License, Version 2.0 (the "License"); 78 | you may not use this file except in compliance with the License. 79 | You may obtain a copy of the License at 80 | 81 | https://www.apache.org/licenses/LICENSE-2.0 82 | 83 | Unless required by applicable law or agreed to in writing, software 84 | distributed under the License is distributed on an "AS IS" BASIS, 85 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 86 | See the License for the specific language governing permissions and 87 | limitations under the License. 88 | 89 | [//]: # (badges) 90 | 91 | [build-image]: https://github.com/iqlusioninc/delphi/actions/workflows/delphi.yml/badge.svg?branch=main&event=push 92 | [build-link]: https://github.com/iqlusioninc/delphi/actions 93 | [safety-image]: https://img.shields.io/badge/unsafe-forbidden-success.svg 94 | [safety-link]: https://github.com/rust-secure-code/safety-dance/ 95 | [msrv-image]: https://img.shields.io/badge/rustc-1.44+-blue.svg 96 | [license-image]: https://img.shields.io/badge/license-Apache2.0-blue.svg 97 | [license-link]: https://github.com/iqlusioninc/delphi/blob/master/LICENSE 98 | [gitter-image]: https://badges.gitter.im/badge.svg 99 | [gitter-link]: https://gitter.im/iqlusioninc/community 100 | 101 | [//]: # (general links) 102 | 103 | [alphavantage]: https://www.alphavantage.co/ 104 | [alphavantage_api]: https://www.alphavantage.co/support/#api-key 105 | [currencylayer]: https://www.currencylayer.com 106 | [currencylayer_api]: https://currencylayer.com/product 107 | [binance]: https://binance-docs.github.io/apidocs/spot/en/#change-log 108 | [cc]: https://contributor-covenant.org 109 | [CODE_OF_CONDUCT.md]: https://github.com/iqlusioninc/delphi/blob/main/CODE_OF_CONDUCT.md 110 | [coinone]: https://doc.coinone.co.kr/ 111 | [dunamu]: https://www.dunamu.com/ 112 | [gdac]: https://docs.gdac.com/#introduction 113 | [gopax]: https://www.gopax.co.id/API/ 114 | [imfsdr]: https://www.imf.org/external/index.htm 115 | [iqlusion_stakeid]: https://terra.stake.id/?#/validator/EA2D131F0DE4A91CC7ECA70FAAEB7F088F5DC6C3 116 | [Terra]: https://terra.money/ 117 | [terra_oracle]: https://docs.terra.money/validator/oracle.html 118 | [tmkms]: https://github.com/iqlusioninc/tmkms/blob/main/README.txsigner.md 119 | [tmkms_batch]: https://github.com/iqlusioninc/tmkms/blob/main/README.txsigner.md#architecture 120 | -------------------------------------------------------------------------------- /delphi.toml.example: -------------------------------------------------------------------------------- 1 | # Example Delphi configuration file 2 | 3 | # Listen address configuration 4 | [listen] 5 | addr = "127.0.0.1" 6 | port = 3822 7 | protocol = "http" 8 | 9 | # HTTPS client configuration 10 | # [https] 11 | # proxy = "https://webproxy.example.com:8080" # send outgoing requests through proxy 12 | 13 | # Network configuration: blockchains for which oracle service is provided 14 | [network.terra] 15 | chain_id = "columbus-4" 16 | feeder = "terra1..." 17 | validator = "terravaloper1..." 18 | fee = { denom = "Ukrw", amount = "356100", gas = "200000" } 19 | 20 | # Source configuration: exchanges where price information is gathered from 21 | [source.alphavantage] 22 | # Get API key here (quick-and-simple form): https://www.alphavantage.co/support/#api-key 23 | apikey = "api key goes here" 24 | 25 | # Source configuration: exchanges where price information is gathered from 26 | [source.currencylayer] 27 | # Get API key here (quick-and-simple form): https://currencylayer.com/product 28 | access_key = "access key goes here" 29 | -------------------------------------------------------------------------------- /img/delphi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iqlusioninc/delphi/ccfbc5b9794bc6a72c63202b56b0635efcdd52c0/img/delphi.png -------------------------------------------------------------------------------- /img/delphi.svg: -------------------------------------------------------------------------------- 1 | delphi -------------------------------------------------------------------------------- /src/application.rs: -------------------------------------------------------------------------------- 1 | //! Delphi Application 2 | 3 | use crate::{commands::DelphiCmd, config::DelphiConfig}; 4 | use abscissa_core::{ 5 | application::{self, AppCell}, 6 | config::{self, CfgCell}, 7 | trace, Application, EntryPoint, FrameworkError, StandardPaths, 8 | }; 9 | 10 | /// Application state 11 | pub static APP: AppCell = AppCell::new(); 12 | 13 | /// Delphi Application 14 | #[derive(Debug)] 15 | pub struct DelphiApp { 16 | /// Application configuration. 17 | config: CfgCell, 18 | 19 | /// Application state. 20 | state: application::State, 21 | } 22 | 23 | /// Initialize a new application instance. 24 | /// 25 | /// By default no configuration is loaded, and the framework state is 26 | /// initialized to a default, empty state (no components, threads, etc). 27 | impl Default for DelphiApp { 28 | fn default() -> Self { 29 | Self { 30 | config: CfgCell::default(), 31 | state: application::State::default(), 32 | } 33 | } 34 | } 35 | 36 | impl Application for DelphiApp { 37 | /// Entrypoint command for this application. 38 | type Cmd = EntryPoint; 39 | 40 | /// Application configuration. 41 | type Cfg = DelphiConfig; 42 | 43 | /// Paths to resources within the application. 44 | type Paths = StandardPaths; 45 | 46 | /// Accessor for application configuration. 47 | fn config(&self) -> config::Reader { 48 | self.config.read() 49 | } 50 | 51 | /// Borrow the application state immutably. 52 | fn state(&self) -> &application::State { 53 | &self.state 54 | } 55 | 56 | /// Register all components used by this application. 57 | fn register_components(&mut self, command: &Self::Cmd) -> Result<(), FrameworkError> { 58 | let mut components = self.framework_components(command)?; 59 | components.push(Box::new(abscissa_tokio::TokioComponent::new()?)); 60 | let mut component_registry = self.state.components_mut(); 61 | component_registry.register(components) 62 | } 63 | 64 | /// Post-configuration lifecycle callback. 65 | fn after_config(&mut self, config: Self::Cfg) -> Result<(), FrameworkError> { 66 | // Configure components 67 | let mut component_registry = self.state.components_mut(); 68 | component_registry.after_config(&config)?; 69 | self.config.set_once(config); 70 | Ok(()) 71 | } 72 | 73 | /// Get tracing configuration from command-line options 74 | fn tracing_config(&self, command: &EntryPoint) -> trace::Config { 75 | if command.verbose { 76 | trace::Config::verbose() 77 | } else { 78 | trace::Config::default() 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/bin/delphi/main.rs: -------------------------------------------------------------------------------- 1 | //! Main entry point for Delphi 2 | 3 | #![deny(warnings, missing_docs, trivial_casts, unused_qualifications)] 4 | #![forbid(unsafe_code)] 5 | 6 | /// Boot Delphi 7 | fn main() { 8 | abscissa_core::boot(&delphi::application::APP); 9 | } 10 | -------------------------------------------------------------------------------- /src/commands.rs: -------------------------------------------------------------------------------- 1 | //! Delphi Subcommands 2 | //! 3 | //! This is where you specify the subcommands of your application. 4 | //! 5 | //! The default application comes with two subcommands: 6 | //! 7 | //! - `start`: launches the application 8 | //! - `version`: print application version 9 | //! 10 | //! See the `impl Configurable` below for how to specify the path to the 11 | //! application's configuration file. 12 | 13 | mod start; 14 | mod version; 15 | 16 | use self::{start::StartCmd, version::VersionCmd}; 17 | use crate::config::DelphiConfig; 18 | use abscissa_core::{Command, Configurable, Help, Options, Runnable}; 19 | use std::path::PathBuf; 20 | 21 | /// Delphi Configuration Filename 22 | pub const CONFIG_FILE: &str = "delphi.toml"; 23 | 24 | /// Delphi Subcommands 25 | #[derive(Command, Debug, Options, Runnable)] 26 | pub enum DelphiCmd { 27 | /// The `help` subcommand 28 | #[options(help = "get usage information")] 29 | Help(Help), 30 | 31 | /// The `start` subcommand 32 | #[options(help = "start the application")] 33 | Start(StartCmd), 34 | 35 | /// The `version` subcommand 36 | #[options(help = "display version information")] 37 | Version(VersionCmd), 38 | } 39 | 40 | /// This trait allows you to define how application configuration is loaded. 41 | impl Configurable for DelphiCmd { 42 | /// Location of the configuration file 43 | fn config_path(&self) -> Option { 44 | // Check if the config file exists, and if it does not, ignore it. 45 | // If you'd like for a missing configuration file to be a hard error 46 | // instead, always return `Some(CONFIG_FILE)` here. 47 | let filename = PathBuf::from(CONFIG_FILE); 48 | 49 | if filename.exists() { 50 | Some(filename) 51 | } else { 52 | None 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/commands/start.rs: -------------------------------------------------------------------------------- 1 | //! `start` subcommand 2 | 3 | use crate::{application::APP, prelude::*, router::Router}; 4 | use abscissa_core::{Command, Options, Runnable}; 5 | use std::process; 6 | 7 | /// `start` subcommand 8 | #[derive(Command, Debug, Options)] 9 | pub struct StartCmd {} 10 | 11 | impl Runnable for StartCmd { 12 | /// Start the application. 13 | fn run(&self) { 14 | // Initialize router from the app's configuration 15 | let router = Router::init().unwrap_or_else(|e| { 16 | status_err!("{}", e); 17 | process::exit(1); 18 | }); 19 | 20 | // Run the application 21 | abscissa_tokio::run(&APP, async { router.route().await }).unwrap_or_else(|e| { 22 | status_err!("executor exited with error: {}", e); 23 | process::exit(1); 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/commands/version.rs: -------------------------------------------------------------------------------- 1 | //! `version` subcommand 2 | 3 | #![allow(clippy::never_loop)] 4 | 5 | use super::DelphiCmd; 6 | use abscissa_core::{Command, Options, Runnable}; 7 | 8 | /// `version` subcommand 9 | #[derive(Command, Debug, Default, Options)] 10 | pub struct VersionCmd {} 11 | 12 | impl Runnable for VersionCmd { 13 | /// Print version message 14 | fn run(&self) { 15 | println!("{} {}", DelphiCmd::name(), DelphiCmd::version()); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | //! Delphi Config 2 | //! 3 | //! See instructions in `commands.rs` to specify the path to your 4 | //! application's configuration file and/or command-line options 5 | //! for specifying it. 6 | 7 | pub mod https; 8 | pub mod listen; 9 | pub mod network; 10 | pub mod source; 11 | 12 | pub use self::{ 13 | https::HttpsConfig, listen::ListenConfig, network::NetworkConfig, source::SourceConfig, 14 | }; 15 | 16 | use serde::{Deserialize, Serialize}; 17 | 18 | /// Delphi Configuration 19 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 20 | #[serde(deny_unknown_fields)] 21 | pub struct DelphiConfig { 22 | /// Listen configuration 23 | #[serde(default)] 24 | pub listen: ListenConfig, 25 | 26 | /// HTTPS client configuration 27 | #[serde(default)] 28 | pub https: HttpsConfig, 29 | 30 | /// Network (i.e. chain) configuration 31 | #[serde(default)] 32 | pub network: NetworkConfig, 33 | 34 | /// Source configuration 35 | #[serde(default)] 36 | pub source: SourceConfig, 37 | } 38 | -------------------------------------------------------------------------------- /src/config/https.rs: -------------------------------------------------------------------------------- 1 | //! HTTPS configuration 2 | 3 | use iqhttp::{HttpsClient, Uri}; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | /// Shared HTTPS configuration settings for Delphi sources 7 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 8 | #[serde(deny_unknown_fields)] 9 | pub struct HttpsConfig { 10 | /// URI to egress proxy 11 | #[serde(with = "iqhttp::serializers::uri_optional")] 12 | pub proxy: Option, 13 | } 14 | 15 | impl HttpsConfig { 16 | /// Create a new client using this configuration 17 | pub fn new_client(&self, hostname: impl Into) -> iqhttp::Result { 18 | match &self.proxy { 19 | Some(proxy_uri) => HttpsClient::new_with_proxy(hostname, proxy_uri.clone()), 20 | None => Ok(HttpsClient::new(hostname)), 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/config/listen.rs: -------------------------------------------------------------------------------- 1 | //! Listen config 2 | 3 | use serde::{Deserialize, Serialize}; 4 | use std::net::Ipv4Addr; 5 | 6 | /// Default port number (38°28′56″ N, 22°30′4″ E) 7 | pub const DEFAULT_PORT: u16 = 3822; 8 | 9 | /// Listen config: control how Delphi listens on the network for requests 10 | #[derive(Clone, Debug, Deserialize, Serialize)] 11 | #[serde(deny_unknown_fields)] 12 | pub struct ListenConfig { 13 | /// IPv4 address to listen on 14 | // TODO(tarcieri): IPv6 15 | pub addr: Ipv4Addr, 16 | 17 | /// Port to listen on 18 | pub port: u16, 19 | 20 | /// Protocol to listen on 21 | pub protocol: Protocol, 22 | } 23 | 24 | impl Default for ListenConfig { 25 | fn default() -> Self { 26 | Self { 27 | addr: Ipv4Addr::new(127, 0, 0, 1), 28 | port: DEFAULT_PORT, 29 | protocol: Protocol::default(), 30 | } 31 | } 32 | } 33 | 34 | /// Protocol to listen on 35 | #[derive(Copy, Clone, Debug, Deserialize, Serialize)] 36 | pub enum Protocol { 37 | /// Plaintext HTTP 38 | // TODO(tarcieri): HTTPS, gRPC 39 | #[serde(rename = "http")] 40 | Http, 41 | } 42 | 43 | impl Default for Protocol { 44 | fn default() -> Self { 45 | Protocol::Http 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/config/network.rs: -------------------------------------------------------------------------------- 1 | //! Network configuration 2 | 3 | use crate::networks::terra::Denom; 4 | use serde::{Deserialize, Serialize}; 5 | use stdtx::amino::types::{Coin, StdFee}; 6 | 7 | /// Network/chain specific configuration 8 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 9 | #[serde(deny_unknown_fields)] 10 | pub struct NetworkConfig { 11 | /// Terra configuration 12 | pub terra: Option, 13 | } 14 | 15 | /// Terra configuration 16 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 17 | #[serde(deny_unknown_fields)] 18 | pub struct TerraConfig { 19 | /// Terra chain id 20 | pub chain_id: String, 21 | 22 | /// Feeder address (Bech32) 23 | pub feeder: String, 24 | 25 | /// Validator address (Bech32) 26 | pub validator: String, 27 | 28 | /// Oracle transaction fee 29 | #[serde(default)] 30 | pub fee: TerraOracleFee, 31 | 32 | /// Timeout for an oracle vote in seconds (default 10) 33 | pub timeout_secs: Option, 34 | } 35 | 36 | /// Terra oracle fee configuration 37 | #[derive(Clone, Debug, Deserialize, Serialize)] 38 | pub struct TerraOracleFee { 39 | /// Fee denomination 40 | pub denom: Denom, 41 | 42 | /// Fee amount 43 | pub amount: u64, 44 | 45 | /// Gas amount 46 | pub gas: u64, 47 | } 48 | 49 | impl Default for TerraOracleFee { 50 | fn default() -> Self { 51 | Self { 52 | denom: Denom::Ukrw, 53 | amount: 356_100, 54 | gas: 200_000, 55 | } 56 | } 57 | } 58 | 59 | impl From<&TerraOracleFee> for StdFee { 60 | fn from(fee: &TerraOracleFee) -> StdFee { 61 | StdFee { 62 | amount: vec![Coin { 63 | denom: fee.denom.to_string(), 64 | amount: fee.amount.to_string(), 65 | }], 66 | gas: fee.gas, 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/config/source.rs: -------------------------------------------------------------------------------- 1 | //! Source configuration 2 | 3 | use serde::{Deserialize, Serialize}; 4 | 5 | /// Source Configuration 6 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 7 | #[serde(deny_unknown_fields)] 8 | pub struct SourceConfig { 9 | /// AlphaVantage 10 | pub alphavantage: Option, 11 | /// Currencylayer 12 | pub currencylayer: Option, 13 | } 14 | 15 | /// AlphaVantage Configuration 16 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 17 | #[serde(deny_unknown_fields)] 18 | pub struct AlphavantageConfig { 19 | /// API key 20 | pub apikey: String, 21 | } 22 | 23 | /// Currencylayer Configuration 24 | #[derive(Clone, Debug, Default, Deserialize, Serialize)] 25 | #[serde(deny_unknown_fields)] 26 | pub struct CurrencylayerConfig { 27 | /// API key 28 | pub access_key: String, 29 | } 30 | -------------------------------------------------------------------------------- /src/currency.rs: -------------------------------------------------------------------------------- 1 | //! Currency support (i.e. assets) 2 | 3 | use crate::networks::terra::denom::Denom; 4 | use crate::Error; 5 | use serde::{de, ser, Deserialize, Serialize}; 6 | use std::{ 7 | fmt::{self, Display}, 8 | str::FromStr, 9 | }; 10 | 11 | /// Currencies for use in trading pairs 12 | #[derive(Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] 13 | pub enum Currency { 14 | /// Cosmos Atom 15 | Atom, 16 | 17 | ///Australian Dollar 18 | Aud, 19 | 20 | /// Binance Coin 21 | Bnb, 22 | 23 | /// Binance KRW (won) stablecoin 24 | Bkrw, 25 | 26 | /// Bitcoin 27 | Btc, 28 | 29 | /// Binance USD stablecoin 30 | Busd, 31 | 32 | ///Canadian Dollar 33 | Cad, 34 | 35 | ///Swiss Franc 36 | Chf, 37 | 38 | ///Chinese Yuan 39 | Cny, 40 | 41 | ///Danish Krone 42 | Dkk, 43 | 44 | /// Ethereum 45 | Eth, 46 | 47 | /// Euro 48 | Eur, 49 | 50 | /// UK Pounds 51 | Gbp, 52 | 53 | ///Hong Kong Dollar 54 | Hkd, 55 | 56 | ///Indian Rupee 57 | Inr, 58 | 59 | ///Japanese Yen 60 | Jpy, 61 | 62 | /// South Korean won 63 | Krw, 64 | 65 | /// Terra Luna 66 | Luna, 67 | 68 | /// Mongolian Tugrik 69 | Mnt, 70 | 71 | /// IMF Special Drawing Rights 72 | Sdr, 73 | 74 | ///Singapore Dollar 75 | Sgd, 76 | 77 | ///Thai baht 78 | Thb, 79 | 80 | /// US dollars 81 | Usd, 82 | 83 | /// Circle stablecoin 84 | Usdc, 85 | 86 | /// Tether USDT stablecoin 87 | Usdt, 88 | 89 | /// Swedish Krona 90 | Sek, 91 | 92 | ///Indonesian rupiah 93 | Idr, 94 | 95 | ///Philippine peso 96 | Php, 97 | 98 | /// Other (open-ended) 99 | Other(String), 100 | } 101 | 102 | impl Display for Currency { 103 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 104 | f.write_str(match self { 105 | Currency::Atom => "ATOM", 106 | Currency::Bnb => "BNB", 107 | Currency::Bkrw => "BKRW", 108 | Currency::Btc => "BTC", 109 | Currency::Busd => "BUSD", 110 | Currency::Cad => "CAD", 111 | Currency::Chf => "CHF", 112 | Currency::Eth => "ETH", 113 | Currency::Eur => "EUR", 114 | Currency::Gbp => "GBP", 115 | Currency::Krw => "KRW", 116 | Currency::Luna => "LUNA", 117 | Currency::Usd => "USD", 118 | Currency::Usdc => "USDC", 119 | Currency::Usdt => "USDT", 120 | Currency::Other(other) => other.as_ref(), 121 | Currency::Sdr => "XDR", 122 | Currency::Mnt => "MNT", 123 | Currency::Cny => "CNY", 124 | Currency::Jpy => "JPY", 125 | Currency::Inr => "INR", 126 | Currency::Hkd => "HKD", 127 | Currency::Aud => "AUD", 128 | Currency::Sgd => "SGD", 129 | Currency::Thb => "THB", 130 | Currency::Sek => "SEK", 131 | Currency::Dkk => "DKK", 132 | Currency::Idr => "IDR", 133 | Currency::Php => "PHP", 134 | }) 135 | } 136 | } 137 | 138 | impl FromStr for Currency { 139 | type Err = Error; 140 | 141 | fn from_str(s: &str) -> Result { 142 | Ok(match s.to_ascii_uppercase().as_ref() { 143 | "ATOM" => Currency::Atom, 144 | "BNB" => Currency::Bnb, 145 | "BKRW" => Currency::Bkrw, 146 | "BTC" => Currency::Btc, 147 | "BUSD" => Currency::Busd, 148 | "ETH" => Currency::Eth, 149 | "EUR" => Currency::Eur, 150 | "GBP" => Currency::Gbp, 151 | "KRW" => Currency::Krw, 152 | "LUNA" => Currency::Luna, 153 | "USD" => Currency::Usd, 154 | "USDC" => Currency::Usdc, 155 | "USDT" => Currency::Usdt, 156 | "XDR" => Currency::Sdr, 157 | "CNY" => Currency::Cny, 158 | "JPY" => Currency::Jpy, 159 | "INR" => Currency::Inr, 160 | "CAD" => Currency::Cad, 161 | "CHF" => Currency::Chf, 162 | "HKD" => Currency::Hkd, 163 | "AUD" => Currency::Aud, 164 | "SGD" => Currency::Sgd, 165 | "THB" => Currency::Thb, 166 | "SEK" => Currency::Sek, 167 | "DKK" => Currency::Dkk, 168 | "IDR" => Currency::Idr, 169 | "PHP" => Currency::Php, 170 | other => Currency::Other(other.to_owned()), 171 | }) 172 | } 173 | } 174 | 175 | impl From for Currency { 176 | fn from(denom: Denom) -> Currency { 177 | match denom { 178 | Denom::Ueur => Currency::Eur, 179 | Denom::Ucny => Currency::Cny, 180 | Denom::Ujpy => Currency::Jpy, 181 | Denom::Ugbp => Currency::Gbp, 182 | Denom::Uinr => Currency::Inr, 183 | Denom::Ucad => Currency::Cad, 184 | Denom::Uchf => Currency::Chf, 185 | Denom::Uhkd => Currency::Hkd, 186 | Denom::Uaud => Currency::Aud, 187 | Denom::Usgd => Currency::Sgd, 188 | Denom::Ukrw => Currency::Krw, 189 | Denom::Umnt => Currency::Mnt, 190 | Denom::Usdr => Currency::Sdr, 191 | Denom::Uusd => Currency::Usd, 192 | Denom::Uthb => Currency::Thb, 193 | Denom::Usek => Currency::Sek, 194 | Denom::Udkk => Currency::Dkk, 195 | Denom::Uidr => Currency::Idr, 196 | Denom::Uphp => Currency::Php, 197 | } 198 | } 199 | } 200 | 201 | impl<'de> Deserialize<'de> for Currency { 202 | fn deserialize>(deserializer: D) -> Result { 203 | use de::Error; 204 | String::deserialize(deserializer)? 205 | .parse() 206 | .map_err(D::Error::custom) 207 | } 208 | } 209 | 210 | impl Serialize for Currency { 211 | fn serialize(&self, serializer: S) -> Result { 212 | self.to_string().serialize(serializer) 213 | } 214 | } 215 | 216 | impl Currency { 217 | ///Long name for IMF csv 218 | pub fn imf_long_name(&self) -> String { 219 | match self { 220 | Currency::Krw => "Korean won".to_string(), 221 | Currency::Usd => "U.S. dollar".to_string(), 222 | Currency::Luna => "N/A".to_string(), 223 | Currency::Sdr => "XDR".to_string(), 224 | Currency::Other(other) => other.to_string(), 225 | Currency::Atom => "N/A".to_string(), 226 | Currency::Bnb => "N/A".to_string(), 227 | Currency::Bkrw => "N/A".to_string(), 228 | Currency::Btc => "N/A".to_string(), 229 | Currency::Busd => "N/A".to_string(), 230 | Currency::Eth => "N/A".to_string(), 231 | Currency::Eur => "EUR".to_string(), 232 | Currency::Gbp => "N/A".to_string(), 233 | Currency::Usdc => "N/A".to_string(), 234 | Currency::Usdt => "N/A".to_string(), 235 | Currency::Mnt => "N/A".to_string(), 236 | Currency::Cny => "N/A".to_string(), 237 | Currency::Jpy => "N/A".to_string(), 238 | Currency::Inr => "N/A".to_string(), 239 | Currency::Cad => "N/A".to_string(), 240 | Currency::Chf => "N/A".to_string(), 241 | Currency::Hkd => "N/A".to_string(), 242 | Currency::Aud => "N/A".to_string(), 243 | Currency::Sgd => "N/A".to_string(), 244 | Currency::Thb => "N/A".to_string(), 245 | Currency::Sek => "N/A".to_string(), 246 | Currency::Dkk => "N/A".to_string(), 247 | Currency::Idr => "N/A".to_string(), 248 | Currency::Php => "N/A".to_string(), 249 | } 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | //! Error types 2 | 3 | use abscissa_core::error::{BoxError, Context}; 4 | use std::{ 5 | fmt::{self, Display}, 6 | io, 7 | ops::Deref, 8 | }; 9 | use thiserror::Error; 10 | 11 | /// Kinds of errors 12 | #[derive(Copy, Clone, Debug, Eq, Error, PartialEq)] 13 | pub enum ErrorKind { 14 | /// Error in configuration file 15 | #[error("config error")] 16 | Config, 17 | 18 | /// Currency-related error 19 | #[error("currency error")] 20 | Currency, 21 | 22 | /// HTTP errors 23 | #[error("HTTP error")] 24 | Http, 25 | 26 | /// Input/output error 27 | #[error("I/O error")] 28 | Io, 29 | 30 | /// Parse errors 31 | #[error("parse error")] 32 | Parse, 33 | 34 | /// Source errors 35 | #[error("source")] 36 | Source, 37 | } 38 | 39 | impl ErrorKind { 40 | /// Create an error context from this error 41 | pub fn context(self, source: impl Into) -> Context { 42 | Context::new(self, Some(source.into())) 43 | } 44 | } 45 | 46 | /// Error type 47 | #[derive(Debug)] 48 | pub struct Error(Box>); 49 | 50 | impl Deref for Error { 51 | type Target = Context; 52 | 53 | fn deref(&self) -> &Context { 54 | &self.0 55 | } 56 | } 57 | 58 | impl Display for Error { 59 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 60 | self.0.fmt(f) 61 | } 62 | } 63 | 64 | impl std::error::Error for Error { 65 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 66 | self.0.source() 67 | } 68 | } 69 | 70 | impl From for Error { 71 | fn from(kind: ErrorKind) -> Self { 72 | Context::new(kind, None).into() 73 | } 74 | } 75 | 76 | impl From> for Error { 77 | fn from(context: Context) -> Self { 78 | Error(Box::new(context)) 79 | } 80 | } 81 | 82 | impl From for Error { 83 | fn from(err: iqhttp::Error) -> Self { 84 | ErrorKind::Http.context(err).into() 85 | } 86 | } 87 | 88 | impl From for Error { 89 | fn from(err: io::Error) -> Self { 90 | ErrorKind::Io.context(err).into() 91 | } 92 | } 93 | 94 | impl From for Error { 95 | fn from(err: serde_json::Error) -> Self { 96 | ErrorKind::Parse.context(err).into() 97 | } 98 | } 99 | 100 | impl From for Error { 101 | fn from(err: rust_decimal::Error) -> Self { 102 | ErrorKind::Parse.context(err).into() 103 | } 104 | } 105 | 106 | impl From for Error { 107 | fn from(err: stdtx::Error) -> Error { 108 | Context::new(ErrorKind::Parse, Some(err.into())).into() 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Delphi Oracle Service 2 | 3 | #![forbid(unsafe_code)] 4 | #![warn(missing_docs, rust_2018_idioms, trivial_casts, unused_qualifications)] 5 | 6 | pub mod application; 7 | pub mod commands; 8 | pub mod config; 9 | pub mod currency; 10 | pub mod error; 11 | pub mod networks; 12 | pub mod prelude; 13 | pub mod price; 14 | pub mod protos; 15 | pub mod router; 16 | pub mod sources; 17 | pub mod trading_pair; 18 | 19 | pub use self::{ 20 | currency::Currency, 21 | error::{Error, ErrorKind}, 22 | price::{Price, PriceQuantity}, 23 | trading_pair::TradingPair, 24 | }; 25 | 26 | pub use std::collections::{btree_map as map, BTreeMap as Map}; 27 | 28 | /// User-Agent to send in HTTP request 29 | pub const USER_AGENT: &str = concat!("iqlusion delphi/", env!("CARGO_PKG_VERSION")); 30 | -------------------------------------------------------------------------------- /src/networks.rs: -------------------------------------------------------------------------------- 1 | //! Networks that Delphi provides oracle service for 2 | 3 | pub mod terra; 4 | -------------------------------------------------------------------------------- /src/networks/terra.rs: -------------------------------------------------------------------------------- 1 | //! Terra stablecoin project schema 2 | //! 3 | 4 | pub mod denom; 5 | pub mod msg; 6 | pub mod oracle; 7 | pub mod protos; 8 | 9 | pub use self::{denom::Denom, oracle::ExchangeRateOracle}; 10 | 11 | use once_cell::sync::Lazy; 12 | 13 | /// Memo to include in transactions 14 | pub const MEMO: &str = concat!("delphi/", env!("CARGO_PKG_VERSION")); 15 | 16 | /// StdTx schema as parsed from `schema.toml` 17 | static SCHEMA: Lazy = 18 | Lazy::new(|| include_str!("terra/schema.toml").parse().unwrap()); 19 | -------------------------------------------------------------------------------- /src/networks/terra/denom.rs: -------------------------------------------------------------------------------- 1 | //! Exchange rate denominations 2 | 3 | use crate::{ 4 | currency::Currency, 5 | error::{Error, ErrorKind}, 6 | prelude::*, 7 | sources::Sources, 8 | trading_pair::TradingPair, 9 | }; 10 | use rust_decimal::Decimal; 11 | use serde::{de, ser, Deserialize, Serialize}; 12 | use std::{ 13 | convert::TryInto, 14 | fmt::{self, Display}, 15 | str::FromStr, 16 | }; 17 | use tokio::try_join; 18 | 19 | /// Denomination 20 | #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] 21 | pub enum Denom { 22 | /// Korean Wan 23 | Ukrw, 24 | 25 | /// Mongolian Tugrik 26 | Umnt, 27 | 28 | /// IMF Special Drawing Rights 29 | Usdr, 30 | 31 | /// US Dollars 32 | Uusd, 33 | 34 | /// Euro 35 | Ueur, 36 | 37 | /// Chinese Yuan 38 | Ucny, 39 | 40 | /// Japanese Yen 41 | Ujpy, 42 | 43 | /// UK Pound 44 | Ugbp, 45 | 46 | ///Indian Rupee 47 | Uinr, 48 | 49 | ///Canadian Dollar 50 | Ucad, 51 | 52 | ///Swiss Franc 53 | Uchf, 54 | 55 | ///Hong Kong Dollar 56 | Uhkd, 57 | 58 | ///Australian Dollar 59 | Uaud, 60 | 61 | ///Singapore Dollar 62 | Usgd, 63 | 64 | ///Thai baht 65 | Uthb, 66 | 67 | ///Swedish Krona 68 | Usek, 69 | 70 | ///Danish Krone 71 | Udkk, 72 | 73 | ///Indonesian rupiah 74 | Uidr, 75 | 76 | ///Philippine peso 77 | Uphp, 78 | } 79 | 80 | impl Denom { 81 | /// Get a slice of the [`Denom`] kinds 82 | pub fn kinds() -> &'static [Denom] { 83 | &[ 84 | Denom::Ukrw, 85 | Denom::Umnt, 86 | Denom::Usdr, 87 | Denom::Uusd, 88 | Denom::Ueur, 89 | Denom::Ucny, 90 | Denom::Ujpy, 91 | Denom::Ugbp, 92 | Denom::Uinr, 93 | Denom::Ucad, 94 | Denom::Uchf, 95 | Denom::Uhkd, 96 | Denom::Uaud, 97 | Denom::Usgd, 98 | Denom::Uthb, 99 | Denom::Usek, 100 | Denom::Udkk, 101 | Denom::Uidr, 102 | Denom::Uphp, 103 | ] 104 | } 105 | 106 | /// Get the code corresponding to a [`Denom`] 107 | pub fn as_str(self) -> &'static str { 108 | match self { 109 | Denom::Ukrw => "ukrw", 110 | Denom::Umnt => "umnt", 111 | Denom::Usdr => "usdr", 112 | Denom::Uusd => "uusd", 113 | Denom::Ueur => "ueur", 114 | Denom::Ucny => "ucny", 115 | Denom::Ujpy => "ujpy", 116 | Denom::Ugbp => "ugbp", 117 | Denom::Uinr => "uinr", 118 | Denom::Ucad => "ucad", 119 | Denom::Uchf => "uchf", 120 | Denom::Uhkd => "uhkd", 121 | Denom::Uaud => "uaud", 122 | Denom::Usgd => "usgd", 123 | Denom::Uthb => "uthb", 124 | Denom::Usek => "usek", 125 | Denom::Udkk => "udkk", 126 | Denom::Uidr => "uidr", 127 | Denom::Uphp => "uphp", 128 | } 129 | } 130 | 131 | /// Get the exchange rate for this [`Denom`] 132 | pub async fn get_exchange_rate(self, sources: &Sources) -> Result { 133 | match self { 134 | Denom::Ukrw => { 135 | let bithumb_response = sources 136 | .bithumb 137 | .trading_pairs(&TradingPair(Currency::Luna, Currency::Krw)) 138 | .await?; 139 | 140 | let mut luna_krw: Decimal = bithumb_response.into(); 141 | 142 | luna_krw.rescale(18); 143 | Ok(luna_krw.try_into().map_err(|_| ErrorKind::Parse)?) 144 | } 145 | 146 | Denom::Umnt => { 147 | let ( 148 | currencylayer_response_usd, 149 | currencylayer_response_krw, 150 | binance_response, 151 | coinone_midpoint, 152 | ) = try_join!( 153 | sources 154 | .alphavantage 155 | .trading_pairs(&TradingPair(Currency::Usd, Currency::Mnt)), 156 | sources 157 | .alphavantage 158 | .trading_pairs(&TradingPair(Currency::Krw, Currency::Mnt)), 159 | sources 160 | .binance 161 | .approx_price_for_pair(&TradingPair(Currency::Luna, Currency::Usd)), 162 | sources 163 | .coinone 164 | .trading_pairs(&TradingPair(Currency::Luna, Currency::Krw)) 165 | )?; 166 | 167 | let mut luna_mnt = Decimal::from( 168 | (binance_response * currencylayer_response_usd 169 | + coinone_midpoint * currencylayer_response_krw) 170 | / 2, 171 | ); 172 | 173 | luna_mnt.rescale(18); 174 | Ok(luna_mnt.try_into().map_err(|_| ErrorKind::Parse)?) 175 | } 176 | 177 | Denom::Uusd => { 178 | let binance_response = sources 179 | .binance 180 | .approx_price_for_pair(&TradingPair(Currency::Luna, Currency::Usd)) 181 | .await?; 182 | 183 | let mut luna_usd: Decimal = binance_response.into(); 184 | luna_usd.rescale(18); 185 | Ok(luna_usd.try_into().map_err(|_| ErrorKind::Parse)?) 186 | } 187 | 188 | Denom::Usdr => { 189 | let (imf_sdr_response, coinone_midpoint) = try_join!( 190 | sources 191 | .alphavantage 192 | .trading_pairs(&TradingPair(Currency::Krw, Currency::Sdr)), 193 | sources 194 | .coinone 195 | .trading_pairs(&TradingPair(Currency::Luna, Currency::Krw)) 196 | )?; 197 | 198 | let mut luna_sdr = Decimal::from(coinone_midpoint * imf_sdr_response); 199 | luna_sdr.rescale(18); 200 | Ok(luna_sdr.try_into().map_err(|_| ErrorKind::Parse)?) 201 | } 202 | 203 | _ => luna_rate_via_usd(sources, self.into()).await, 204 | } 205 | } 206 | } 207 | 208 | async fn luna_rate_via_usd(sources: &Sources, cur: Currency) -> Result { 209 | let pair_1 = TradingPair(Currency::Usd, cur); 210 | 211 | let (currencylayer_response_usd, binance_response) = try_join!( 212 | sources.alphavantage.trading_pairs(&pair_1), 213 | sources 214 | .binance 215 | .approx_price_for_pair(&TradingPair(Currency::Luna, Currency::Usd)), 216 | )?; 217 | 218 | let mut luna_cur = Decimal::from(binance_response * currencylayer_response_usd); 219 | 220 | luna_cur.rescale(18); 221 | Ok(luna_cur.try_into().map_err(|_| ErrorKind::Parse)?) 222 | } 223 | 224 | impl Display for Denom { 225 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 226 | f.write_str(self.as_str()) 227 | } 228 | } 229 | 230 | impl FromStr for Denom { 231 | type Err = Error; 232 | 233 | fn from_str(s: &str) -> Result { 234 | match s.to_ascii_lowercase().as_ref() { 235 | "ukrw" => Ok(Denom::Ukrw), 236 | "umnt" => Ok(Denom::Umnt), 237 | "usdr" => Ok(Denom::Usdr), 238 | "uusd" => Ok(Denom::Uusd), 239 | "ueur" => Ok(Denom::Ueur), 240 | "ucny" => Ok(Denom::Ucny), 241 | "ujpy" => Ok(Denom::Ujpy), 242 | "ugbp" => Ok(Denom::Ugbp), 243 | "uinr" => Ok(Denom::Uinr), 244 | "ucad" => Ok(Denom::Ucad), 245 | "uchf" => Ok(Denom::Uchf), 246 | "uthb" => Ok(Denom::Uthb), 247 | "usek" => Ok(Denom::Usek), 248 | "udkk" => Ok(Denom::Udkk), 249 | "uidr" => Ok(Denom::Uidr), 250 | "uphp" => Ok(Denom::Uphp), 251 | _ => fail!(ErrorKind::Currency, "unknown Terra denom: {}", s), 252 | } 253 | } 254 | } 255 | 256 | impl<'de> Deserialize<'de> for Denom { 257 | fn deserialize>(deserializer: D) -> Result { 258 | use de::Error; 259 | let s = String::deserialize(deserializer)?; 260 | s.parse().map_err(D::Error::custom) 261 | } 262 | } 263 | 264 | impl Serialize for Denom { 265 | fn serialize(&self, serializer: S) -> Result { 266 | self.to_string().serialize(serializer) 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /src/networks/terra/msg.rs: -------------------------------------------------------------------------------- 1 | //! Terra message types 2 | 3 | // TODO(tarcieri): autogenerate this from the schema? (possibly after proto migration) 4 | 5 | use super::{Denom, SCHEMA}; 6 | use crate::{ 7 | error::{Error, ErrorKind}, 8 | map, 9 | prelude::*, 10 | Map, 11 | }; 12 | use rand::{distributions::Alphanumeric, thread_rng, Rng}; 13 | use sha2::{Digest, Sha256}; 14 | use std::{ 15 | convert::TryFrom, 16 | fmt::{self, Display}, 17 | }; 18 | use stdtx::{Address, Decimal}; 19 | 20 | /// Truncated SHA-256 hash to include in a pre-vote 21 | pub type Hash = [u8; 20]; 22 | 23 | /// Terra Oracle Aggregate Vote Message (`oracle/MsgAggregateExchangeRateVote`) 24 | /// 25 | #[derive(Clone, Debug)] 26 | pub struct MsgAggregateExchangeRateVote { 27 | /// Exchange rates to be voted on. Negative values are an abstain vote. 28 | pub exchange_rates: ExchangeRates, 29 | 30 | /// Salt for commit reveal protocol 31 | pub salt: String, 32 | 33 | /// Origin of the Feed Msg 34 | pub feeder: Address, 35 | 36 | /// Validator voting on behalf of 37 | pub validator: Address, 38 | } 39 | 40 | impl MsgAggregateExchangeRateVote { 41 | /// Get a random salt value 42 | pub fn random_salt() -> String { 43 | String::from_utf8(thread_rng().sample_iter(&Alphanumeric).take(4).collect()) 44 | .expect("UTF-8 error") 45 | } 46 | 47 | /// Simple builder for an `oracle/MsgAggregateExchangeRateVote` message 48 | pub fn to_stdtx_msg(&self) -> eyre::Result { 49 | Ok( 50 | stdtx::amino::msg::Builder::new(&SCHEMA, "oracle/MsgAggregateExchangeRateVote")? 51 | .string("exchange_rates", self.exchange_rates.to_string())? 52 | .string("salt", &self.salt)? 53 | .acc_address("feeder", self.feeder)? 54 | .val_address("validator", self.validator)? 55 | .to_msg(), 56 | ) 57 | } 58 | 59 | /// Compute prevote from this vote 60 | pub fn prevote(&self) -> MsgAggregateExchangeRatePrevote { 61 | MsgAggregateExchangeRatePrevote { 62 | hash: self.generate_vote_hash(), 63 | feeder: self.feeder, 64 | validator: self.validator, 65 | } 66 | } 67 | 68 | /// Generate hex encoded truncated SHA-256 of vote. Needed to generate prevote 69 | fn generate_vote_hash(&self) -> Hash { 70 | let data = format!( 71 | "{}:{}:{}", 72 | self.salt, 73 | self.exchange_rates.to_string(), 74 | self.validator.to_bech32("terravaloper"), 75 | ); 76 | 77 | // Tendermint truncated sha256 78 | let digest = Sha256::digest(data.as_bytes()); 79 | Hash::try_from(&digest[..20]).unwrap() 80 | } 81 | } 82 | 83 | /// Terra Oracle Aggregate Prevote Message (`oracle/MsgAggregateExchangeRatePrevote`) 84 | /// 85 | #[derive(Clone, Debug)] 86 | pub struct MsgAggregateExchangeRatePrevote { 87 | /// Commitment to future vote 88 | pub hash: Hash, 89 | 90 | /// Origin Address for vote 91 | pub feeder: Address, 92 | 93 | /// Validator voting on behalf of 94 | pub validator: Address, 95 | } 96 | 97 | impl MsgAggregateExchangeRatePrevote { 98 | /// Simple builder for an `oracle/MsgAggregateExchangeRatePrevote` message 99 | pub fn to_stdtx_msg(&self) -> eyre::Result { 100 | Ok( 101 | stdtx::amino::msg::Builder::new(&SCHEMA, "oracle/MsgAggregateExchangeRatePrevote")? 102 | .bytes("hash", self.hash.as_ref())? 103 | .acc_address("feeder", self.feeder)? 104 | .val_address("validator", self.validator)? 105 | .to_msg(), 106 | ) 107 | } 108 | } 109 | 110 | /// Exchange rates 111 | #[derive(Clone, Debug, Default, Eq, PartialEq)] 112 | pub struct ExchangeRates(Map); 113 | 114 | impl ExchangeRates { 115 | /// Create a new set of exchange rates 116 | pub fn new() -> Self { 117 | Self::default() 118 | } 119 | 120 | /// Create a new set of exchange rates from an iterator over 121 | /// `(Denom,Decimal)` tuples 122 | // NOTE: can't use `FromIterator` here because of the `Result` 123 | pub fn from_exchange_rates<'a, I>(iter: I) -> Result 124 | where 125 | I: Iterator, 126 | { 127 | let mut exchange_rates = ExchangeRates::new(); 128 | 129 | for &(denom, rate) in iter { 130 | exchange_rates.add(denom, rate)?; 131 | } 132 | 133 | Ok(exchange_rates) 134 | } 135 | 136 | /// Add an exchange rate 137 | pub fn add(&mut self, denom: Denom, rate: Decimal) -> Result<(), Error> { 138 | let duplicate = self.0.insert(denom, rate).is_some(); 139 | 140 | ensure!( 141 | !duplicate, 142 | ErrorKind::Currency, 143 | "duplicate exchange rate for denom: {}", 144 | denom 145 | ); 146 | 147 | Ok(()) 148 | } 149 | 150 | /// Iterate over the exchange rates 151 | pub fn iter(&self) -> map::Iter<'_, Denom, Decimal> { 152 | self.0.iter() 153 | } 154 | } 155 | 156 | impl Display for ExchangeRates { 157 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 158 | for (i, (denom, rate)) in self.0.iter().enumerate() { 159 | write!(f, "{}{}", rate, denom)?; 160 | 161 | if i < self.0.len() - 1 { 162 | write!(f, ",")?; 163 | } 164 | } 165 | 166 | Ok(()) 167 | } 168 | } 169 | 170 | #[cfg(test)] 171 | mod tests { 172 | use super::{Denom, ExchangeRates}; 173 | 174 | #[test] 175 | fn exchange_rate_to_string() { 176 | let exchange_rates = ExchangeRates::from_exchange_rates( 177 | [ 178 | (Denom::Uusd, "1".parse().unwrap()), 179 | (Denom::Usdr, "1".parse().unwrap()), 180 | (Denom::Umnt, "888".parse().unwrap()), 181 | (Denom::Ukrw, "362".parse().unwrap()), 182 | ] 183 | .iter(), 184 | ) 185 | .unwrap(); 186 | 187 | let serialized_rates = exchange_rates.to_string(); 188 | assert_eq!( 189 | &serialized_rates, 190 | "362.000000000000000000ukrw,\ 191 | 888.000000000000000000umnt,\ 192 | 1.000000000000000000usdr,\ 193 | 1.000000000000000000uusd" 194 | ); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/networks/terra/oracle.rs: -------------------------------------------------------------------------------- 1 | //! Terra exchange rate oracle 2 | 3 | use super::{ 4 | denom::Denom, 5 | msg::{self, MsgAggregateExchangeRateVote}, 6 | MEMO, SCHEMA, 7 | }; 8 | use crate::{config::DelphiConfig, prelude::*, router::Request, sources::Sources, Error}; 9 | use futures::future::join_all; 10 | use serde_json::json; 11 | use std::{ 12 | convert::Infallible, 13 | sync::Arc, 14 | time::{Duration, Instant}, 15 | }; 16 | use stdtx::address::Address; 17 | use stdtx::amino::types::StdFee; 18 | use tendermint_rpc::endpoint::broadcast::tx_commit; 19 | use tokio::{sync::Mutex, time::timeout}; 20 | use warp::http::StatusCode; 21 | 22 | /// Number of seconds to wait for an oracle vote complete 23 | pub const DEFAULT_TIMEOUT_SECS: u64 = 10; 24 | 25 | /// Terra exchange rate oracle 26 | #[derive(Clone)] 27 | pub struct ExchangeRateOracle(Arc>); 28 | 29 | impl ExchangeRateOracle { 30 | /// Create a new [`ExchangeRateOracle`] 31 | pub fn new(config: &DelphiConfig) -> Result { 32 | let state = OracleState::new(config)?; 33 | Ok(ExchangeRateOracle(Arc::new(Mutex::new(state)))) 34 | } 35 | 36 | /// Handle an incoming oracle request, providing a set of transactions to 37 | /// respond with. 38 | pub async fn handle_request(self, req: Request) -> Result { 39 | let chain_id = self.get_chain_id().await; 40 | let msgs = self.get_vote_msgs(req.last_tx_response).await; 41 | 42 | let response = if msgs.is_empty() { 43 | json!({"status": "ok"}) 44 | } else { 45 | let msg_json = msgs 46 | .iter() 47 | .map(|msg| msg.to_json_value(&SCHEMA)) 48 | .collect::>(); 49 | 50 | let tx = json!({ 51 | "chain_id": chain_id, 52 | "fee": self.oracle_fee().await, 53 | "memo": MEMO, 54 | "msgs": msg_json, 55 | }); 56 | 57 | json!({ 58 | "status": "ok", 59 | "tx": tx 60 | }) 61 | }; 62 | 63 | Ok(warp::reply::with_status( 64 | warp::reply::json(&response), 65 | StatusCode::OK, 66 | )) 67 | } 68 | 69 | /// Get the chain ID 70 | async fn get_chain_id(&self) -> String { 71 | let state = self.0.lock().await; 72 | state.chain_id.clone() 73 | } 74 | 75 | /// Get oracle vote messages 76 | async fn get_vote_msgs( 77 | &self, 78 | last_tx_response: Option, 79 | ) -> Vec { 80 | let started_at = Instant::now(); 81 | let mut state = self.0.lock().await; 82 | let mut exchange_rates = msg::ExchangeRates::new(); 83 | let mut exchange_rate_fut = vec![]; 84 | 85 | for denom in Denom::kinds() { 86 | exchange_rate_fut.push(denom.get_exchange_rate(&state.sources)) 87 | } 88 | 89 | let rates = match timeout(state.timeout, join_all(exchange_rate_fut)).await { 90 | Ok(res) => res, 91 | Err(e) => { 92 | warn!("oracle vote timed out after {:?}: {}", state.timeout, e); 93 | return vec![]; 94 | } 95 | }; 96 | 97 | for (rate, denom) in rates.iter().zip(Denom::kinds()) { 98 | match rate { 99 | Ok(rate) => exchange_rates.add(*denom, *rate).expect("duplicate denom"), 100 | Err(err) => { 101 | error!("error getting exchange rate for {}: {}", denom, err); 102 | continue; 103 | } 104 | }; 105 | } 106 | 107 | info!( 108 | "voting {} ({:?})", 109 | exchange_rates 110 | .iter() 111 | .map(|(denom, decimal)| format!("{}={}", denom, decimal)) 112 | .collect::>() 113 | .join(", "), 114 | Instant::now().duration_since(started_at) 115 | ); 116 | 117 | // Move all previously unrevealed votes into the result 118 | let mut msgs = vec![]; 119 | 120 | if let Some(vote) = state.unrevealed_vote.take() { 121 | // Determine if the last transaction we sent was successful 122 | let last_tx_success = last_tx_response 123 | .map(|tx| tx.check_tx.code.is_ok() && tx.deliver_tx.code.is_ok()) 124 | .unwrap_or(false); 125 | 126 | if last_tx_success { 127 | // Only include the previous vote if we succeeded in publishing 128 | // an oracle prevote. Otherwise DeliverTx fails because we 129 | // don't have a corresponding prevote 130 | msgs.push(vote); 131 | } 132 | } 133 | 134 | let vote_msg = MsgAggregateExchangeRateVote { 135 | exchange_rates, 136 | salt: MsgAggregateExchangeRateVote::random_salt(), 137 | feeder: state.feeder, 138 | validator: state.validator, 139 | }; 140 | 141 | let prevote_msg_stdtx = vote_msg 142 | .prevote() 143 | .to_stdtx_msg() 144 | .expect("can't serialize vote as stdtx"); 145 | 146 | msgs.push(prevote_msg_stdtx); 147 | 148 | let vote_msg_stdtx = vote_msg 149 | .to_stdtx_msg() 150 | .expect("can't serialize vote as stdtx"); 151 | 152 | state.unrevealed_vote = Some(vote_msg_stdtx); 153 | 154 | msgs 155 | } 156 | 157 | /// Compute the oracle fee 158 | pub async fn oracle_fee(&self) -> StdFee { 159 | let state = self.0.lock().await; 160 | state.fee.clone() 161 | } 162 | } 163 | 164 | /// Inner (synchronized) oracle state 165 | struct OracleState { 166 | /// Chain ID 167 | chain_id: String, 168 | 169 | /// Feeder address 170 | feeder: Address, 171 | 172 | /// Validator address 173 | validator: Address, 174 | 175 | /// Fee 176 | fee: StdFee, 177 | 178 | /// Sources 179 | sources: Sources, 180 | 181 | /// Timeout 182 | timeout: Duration, 183 | 184 | /// Previously unrevealed vote 185 | unrevealed_vote: Option, 186 | } 187 | 188 | impl OracleState { 189 | /// Initialize oracle state 190 | fn new(config: &DelphiConfig) -> Result { 191 | let terra_config = config 192 | .network 193 | .terra 194 | .as_ref() 195 | .expect("missing [networks.terra] config"); 196 | 197 | let feeder = Address::from_bech32(&terra_config.feeder) 198 | .expect("invalid terra feeder config") 199 | .1; 200 | 201 | let validator = Address::from_bech32(&terra_config.validator) 202 | .expect("invalid terra validator config") 203 | .1; 204 | 205 | let fee = StdFee::from(&terra_config.fee); 206 | let sources = Sources::new(config)?; 207 | let timeout = 208 | Duration::from_secs(terra_config.timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS)); 209 | 210 | Ok(Self { 211 | chain_id: terra_config.chain_id.to_owned(), 212 | feeder, 213 | validator, 214 | fee, 215 | sources, 216 | timeout, 217 | unrevealed_vote: None, 218 | }) 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/networks/terra/protos.rs: -------------------------------------------------------------------------------- 1 | //! Terra Oracle Protobuf Definitions 2 | //! 3 | //! Protobuf definitions: 4 | //! 5 | //! 6 | 7 | use cosmrs::tx::MsgProto; 8 | use prost::Message; 9 | 10 | /// Struct for aggregate prevoting on the ExchangeRateVote. 11 | /// 12 | /// The purpose of aggregate prevote is to hide vote exchange rates with hash 13 | /// which is formatted as hex string in 14 | /// `SHA256("{salt}:{exchange rate}{denom},...,{exchange rate}{denom}:{voter}")` 15 | #[derive(Clone, PartialEq, Message)] 16 | pub struct AggregateExchangeRatePrevote { 17 | /// Commitment to future vote 18 | #[prost(string, tag = "1")] 19 | pub hash: String, 20 | 21 | /// Origin Address for vote 22 | #[prost(string, tag = "2")] 23 | pub voter: String, 24 | 25 | /// Submit block 26 | #[prost(uint64, tag = "3")] 27 | pub submit_block: u64, 28 | } 29 | 30 | impl MsgProto for AggregateExchangeRatePrevote { 31 | const TYPE_URL: &'static str = "/terra.oracle.v1beta1.AggregateExchangeRatePrevote"; 32 | } 33 | 34 | /// MsgAggregateExchangeRateVote - struct for voting on 35 | /// the exchange rates of Luna denominated in various Terra assets. 36 | #[derive(Clone, PartialEq, Message)] 37 | pub struct AggregateExchangeRateVote { 38 | /// Exchange rate tuples 39 | #[prost(message, repeated, tag = "1")] 40 | pub exchange_rate_tuples: Vec, 41 | 42 | /// Origin Address for vote 43 | #[prost(string, tag = "2")] 44 | pub voter: String, 45 | } 46 | 47 | impl MsgProto for AggregateExchangeRateVote { 48 | const TYPE_URL: &'static str = "/terra.oracle.v1beta1.AggregateExchangeRateVote"; 49 | } 50 | 51 | /// ExchangeRateTuple - struct to store interpreted exchange rates data to store 52 | #[derive(Clone, PartialEq, Message)] 53 | pub struct ExchangeRateTuple { 54 | /// Denomination 55 | #[prost(string, tag = "1")] 56 | pub denom: String, 57 | 58 | /// Exchange rate 59 | #[prost(string, tag = "2")] 60 | pub exchange_rate: String, 61 | } 62 | 63 | /// MsgAggregateExchangeRatePrevote - struct for message to submit aggregate exchange rate prevote. 64 | #[derive(Clone, PartialEq, Message)] 65 | pub struct MsgAggregateExchangeRatePrevote { 66 | /// Hash 67 | #[prost(string, tag = "1")] 68 | pub hash: String, 69 | 70 | /// Feeder 71 | #[prost(string, tag = "2")] 72 | pub feeder: String, 73 | 74 | /// Validator 75 | #[prost(string, tag = "3")] 76 | pub validator: String, 77 | } 78 | 79 | /// MsgAggregateExchangeRateVote - struct for message to submit aggregate exhcnage rate vote. 80 | #[derive(Clone, PartialEq, Message)] 81 | pub struct MsgAggregateExchangeRateVote { 82 | /// Salt 83 | #[prost(string, tag = "1")] 84 | pub salt: String, 85 | 86 | /// Exchange Rates 87 | #[prost(string, tag = "2")] 88 | pub exchange_rates: String, 89 | 90 | /// Feeder 91 | #[prost(string, tag = "3")] 92 | pub feeder: String, 93 | 94 | /// Validator 95 | #[prost(string, tag = "4")] 96 | pub validator: String, 97 | } 98 | 99 | /// MsgDelegateFeedConsent - struct for message to delegate oracle voting 100 | #[derive(Clone, PartialEq, Message)] 101 | pub struct MsgDelegateFeedConsent { 102 | /// Operator 103 | #[prost(string, tag = "1")] 104 | pub operator: String, 105 | 106 | /// Delegate 107 | #[prost(string, tag = "2")] 108 | pub delegate: String, 109 | } 110 | -------------------------------------------------------------------------------- /src/networks/terra/schema.toml: -------------------------------------------------------------------------------- 1 | # Terra stablecoin project schema 2 | # 3 | 4 | namespace = "core/StdTx" 5 | acc_prefix = "terra" 6 | val_prefix = "terravaloper" 7 | 8 | # 9 | # Oracle vote transactions 10 | # 11 | # 12 | 13 | # MsgAggregateExchangeRatePrevote 14 | # (NOTE: presently undocumented. See example below) 15 | # 16 | [[definition]] 17 | type_name = "oracle/MsgAggregateExchangeRatePrevote" 18 | fields = [ 19 | { name = "hash", type = "bytes" }, 20 | { name = "feeder", type = "sdk.AccAddress" }, 21 | { name = "validator", type = "sdk.ValAddress" }, 22 | ] 23 | 24 | # MsgAggregateExchangeRateVote 25 | # (NOTE: presently undocumented. See example below) 26 | # 27 | [[definition]] 28 | type_name = "oracle/MsgAggregateExchangeRateVote" 29 | fields = [ 30 | { name = "salt", type = "string" }, 31 | { name = "exchange_rates", type = "string"}, 32 | { name = "feeder", type = "sdk.AccAddress" }, 33 | { name = "validator", type = "sdk.ValAddress" }, 34 | ] 35 | 36 | # MsgExchangeRatePrevote 37 | # 38 | [[definition]] 39 | type_name = "oracle/MsgExchangeRatePrevote" 40 | fields = [ 41 | { name = "hash", type = "string" }, 42 | { name = "denom", type = "string" }, 43 | { name = "feeder", type = "sdk.AccAddress" }, 44 | { name = "validator", type = "sdk.ValAddress" }, 45 | ] 46 | 47 | # MsgExchangeRateVote 48 | # 49 | [[definition]] 50 | type_name = "oracle/MsgExchangeRateVote" 51 | fields = [ 52 | { name = "exchange_rate", type = "sdk.Dec"}, 53 | { name = "salt", type = "string" }, 54 | { name = "denom", type = "string" }, 55 | { name = "feeder", type = "sdk.AccAddress" }, 56 | { name = "validator", type = "sdk.ValAddress" }, 57 | ] 58 | -------------------------------------------------------------------------------- /src/prelude.rs: -------------------------------------------------------------------------------- 1 | //! Application-local prelude: conveniently import types/functions/macros 2 | //! which are generally useful and should be available in every module with 3 | //! `use crate::prelude::*; 4 | 5 | /// Abscissa core prelude 6 | pub use abscissa_core::prelude::*; 7 | 8 | pub use crate::application::APP; 9 | -------------------------------------------------------------------------------- /src/price.rs: -------------------------------------------------------------------------------- 1 | //! Prices (wrapper for `Decimal`) 2 | 3 | use crate::{prelude::*, Error, ErrorKind}; 4 | use rust_decimal::{prelude::*, Decimal}; 5 | use serde::{de, ser, Deserialize, Serialize}; 6 | use std::{ 7 | cmp::Ordering, 8 | convert::TryFrom, 9 | fmt::{self, Display}, 10 | ops::{Add, Deref, Div, Mul}, 11 | str::FromStr, 12 | }; 13 | 14 | /// Prices of currencies (internally represented as a `Decimal`) 15 | #[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)] 16 | pub struct Price(Decimal); 17 | 18 | impl Price { 19 | /// Create a new price from a `Decimal` 20 | pub(crate) fn new(decimal: Decimal) -> Result { 21 | if decimal.to_f32().is_none() || decimal.to_f64().is_none() { 22 | fail!(ErrorKind::Parse, "price cannot be represented as float"); 23 | } 24 | 25 | Ok(Price(decimal)) 26 | } 27 | } 28 | 29 | impl Add for Price { 30 | type Output = Self; 31 | 32 | fn add(self, rhs: Self) -> Self { 33 | Self(self.0 + rhs.0) 34 | } 35 | } 36 | 37 | impl Mul for Price { 38 | type Output = Self; 39 | 40 | fn mul(self, rhs: Self) -> Self { 41 | Self(self.0 * rhs.0) 42 | } 43 | } 44 | 45 | impl Mul for Price { 46 | type Output = Self; 47 | 48 | fn mul(self, rhs: Decimal) -> Self { 49 | Self(self.0 * rhs) 50 | } 51 | } 52 | 53 | impl Div for Price { 54 | type Output = Self; 55 | 56 | fn div(self, rhs: Decimal) -> Price { 57 | Self(self.0 / rhs) 58 | } 59 | } 60 | 61 | impl Div for Price { 62 | type Output = Self; 63 | 64 | fn div(self, rhs: u64) -> Price { 65 | self / Decimal::from(rhs) 66 | } 67 | } 68 | 69 | impl Deref for Price { 70 | type Target = Decimal; 71 | 72 | fn deref(&self) -> &Decimal { 73 | &self.0 74 | } 75 | } 76 | 77 | impl Display for Price { 78 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 79 | self.0.fmt(f) 80 | } 81 | } 82 | 83 | impl FromStr for Price { 84 | type Err = Error; 85 | 86 | fn from_str(s: &str) -> Result { 87 | Self::new(s.parse()?) 88 | } 89 | } 90 | 91 | impl<'de> Deserialize<'de> for Price { 92 | fn deserialize>(deserializer: D) -> Result { 93 | use de::Error; 94 | String::deserialize(deserializer)? 95 | .parse() 96 | .map_err(D::Error::custom) 97 | } 98 | } 99 | 100 | impl Serialize for Price { 101 | fn serialize(&self, serializer: S) -> Result { 102 | self.to_string().serialize(serializer) 103 | } 104 | } 105 | 106 | impl TryFrom for Price { 107 | type Error = Error; 108 | 109 | fn try_from(decimal: Decimal) -> Result { 110 | Price::new(decimal) 111 | } 112 | } 113 | 114 | impl From for Decimal { 115 | fn from(price: Price) -> Decimal { 116 | price.0 117 | } 118 | } 119 | 120 | impl From<&Price> for Decimal { 121 | fn from(price: &Price) -> Decimal { 122 | price.0 123 | } 124 | } 125 | 126 | /// Quoted prices and quantities as sourced from the order book 127 | #[derive(Clone, Debug, Eq)] 128 | pub struct PriceQuantity { 129 | /// Price 130 | pub price: Price, 131 | 132 | /// Quantity 133 | pub quantity: Decimal, 134 | } 135 | 136 | impl Ord for PriceQuantity { 137 | fn cmp(&self, other: &Self) -> Ordering { 138 | self.price.cmp(&other.price) 139 | } 140 | } 141 | 142 | impl PartialOrd for PriceQuantity { 143 | fn partial_cmp(&self, other: &Self) -> Option { 144 | Some(self.cmp(other)) 145 | } 146 | } 147 | 148 | impl PartialEq for PriceQuantity { 149 | fn eq(&self, other: &Self) -> bool { 150 | self.price == other.price 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/protos.rs: -------------------------------------------------------------------------------- 1 | //! Transaction signing requests 2 | 3 | use cosmrs::proto::cosmos::tx::v1beta1::{AuthInfo, TxBody}; 4 | use prost::Message; 5 | 6 | /// Request to sign a transaction request 7 | #[derive(Clone, PartialEq, Message)] 8 | pub struct TxSigningRequest { 9 | /// Requested chain ID 10 | #[prost(message, tag = "1")] 11 | pub chain_id: Option, 12 | 13 | /// body is the processable content of the transaction 14 | #[prost(message, tag = "2")] 15 | pub tx_body: Option, 16 | 17 | /// body is the processable content of the transaction 18 | #[prost(message, tag = "3")] 19 | pub auth_info: Option, 20 | } 21 | -------------------------------------------------------------------------------- /src/router.rs: -------------------------------------------------------------------------------- 1 | //! HTTP request router (based on warp) 2 | //! 3 | //! Test with: 4 | //! 5 | //! ```text 6 | //! curl -i -X POST -H "Content-Type: application/json" -d '{"network":"columbus-4"}' http://127.0.0.1:23456/oracles/terra 7 | //! ``` 8 | 9 | use crate::{config::listen::Protocol, error::Error, networks::terra, prelude::*}; 10 | use serde::Deserialize; 11 | use std::convert::Infallible; 12 | use tendermint::chain; 13 | use tendermint_rpc::endpoint::{broadcast::tx_commit, status::SyncInfo}; 14 | use warp::Filter; 15 | 16 | /// HTTP request router 17 | #[derive(Clone)] 18 | pub struct Router { 19 | /// Address to listen on 20 | addr: ([u8; 4], u16), 21 | 22 | /// Protocol to listen on 23 | protocol: Protocol, 24 | 25 | /// Terra oracle 26 | terra_oracle: terra::ExchangeRateOracle, 27 | } 28 | 29 | impl Router { 30 | /// Initialize the router from the config 31 | pub fn init() -> Result { 32 | let config = APP.config(); 33 | let addr = (config.listen.addr.octets(), config.listen.port); 34 | let protocol = config.listen.protocol; 35 | let terra_oracle = terra::ExchangeRateOracle::new(&config)?; 36 | Ok(Self { 37 | addr, 38 | protocol, 39 | terra_oracle, 40 | }) 41 | } 42 | 43 | /// Route incoming requests 44 | pub async fn route(self) { 45 | let addr = self.addr; 46 | let protocol = self.protocol; 47 | let terra_oracle_filter = warp::any().map(move || self.terra_oracle.clone()); 48 | 49 | let app = warp::post() 50 | .and(warp::path("oracle")) 51 | .and(warp::path::end()) 52 | .and(terra_oracle_filter.clone()) 53 | .and(warp::body::json()) 54 | .and_then(oracle_request); 55 | 56 | match protocol { 57 | Protocol::Http => warp::serve(app).run(addr).await, 58 | } 59 | } 60 | } 61 | 62 | /// `POST /oracle` - handle incoming oracle requests 63 | /// 64 | /// This endpoint is intended to be triggered by Tendermint KMS 65 | pub async fn oracle_request( 66 | oracle: terra::ExchangeRateOracle, 67 | req: Request, 68 | ) -> Result { 69 | // TODO(tarcieri): dispatch incoming requests based on chain ID 70 | oracle.handle_request(req).await 71 | } 72 | 73 | /// Incoming oracle requests from Tendermint KMS (serialized as JSON) 74 | #[derive(Clone, Debug, Deserialize)] 75 | pub struct Request { 76 | /// Chain ID 77 | pub network: chain::Id, 78 | 79 | /// Arbitrary context string to pass to transaction source 80 | #[serde(default)] 81 | pub context: String, 82 | 83 | /// Network status 84 | pub status: Option, 85 | 86 | /// Response from last signed TX (if available) 87 | pub last_tx_response: Option, 88 | } 89 | -------------------------------------------------------------------------------- /src/sources.rs: -------------------------------------------------------------------------------- 1 | //! Data sources 2 | 3 | pub mod alphavantage; 4 | pub mod binance; 5 | pub mod bithumb; 6 | pub mod coinone; 7 | pub mod currencylayer; 8 | pub mod dunamu; 9 | pub mod gdac; 10 | pub mod gopax; 11 | pub mod imf_sdr; 12 | 13 | use self::{ 14 | alphavantage::AlphavantageSource, binance::BinanceSource, bithumb::BithumbSource, 15 | coinone::CoinoneSource, currencylayer::CurrencylayerSource, dunamu::DunamuSource, 16 | gdac::GdacSource, gopax::GopaxSource, imf_sdr::ImfSdrSource, 17 | }; 18 | use crate::{ 19 | config::{source::AlphavantageConfig, DelphiConfig}, 20 | Error, Price, PriceQuantity, 21 | }; 22 | use rust_decimal::Decimal; 23 | 24 | // TODO(shella): factor this into e.g. a common Tower service when we have 2+ oracles 25 | 26 | /// Terra oracle sources 27 | pub struct Sources { 28 | /// AlphaVantage 29 | /// 30 | pub alphavantage: AlphavantageSource, 31 | 32 | /// Binance 33 | /// 34 | pub binance: BinanceSource, 35 | 36 | /// CoinOne 37 | /// 38 | pub coinone: CoinoneSource, 39 | 40 | /// Dunamu 41 | /// 42 | pub dunamu: DunamuSource, 43 | 44 | /// GDAC 45 | /// 46 | pub gdac: GdacSource, 47 | 48 | /// GOPAX 49 | /// 50 | pub gopax: GopaxSource, 51 | 52 | /// IMF SDR 53 | /// 54 | pub imf_sdr: ImfSdrSource, 55 | 56 | /// Bithumb 57 | /// 58 | pub bithumb: BithumbSource, 59 | 60 | /// Currencylayer 61 | /// 62 | pub currencylayer: CurrencylayerSource, 63 | } 64 | 65 | impl Sources { 66 | /// Initialize sources from config 67 | pub fn new(config: &DelphiConfig) -> Result { 68 | // TODO(tarcieri): support optionally enabling sources based on config 69 | let alphavantage = AlphavantageSource::new( 70 | &config 71 | .source 72 | .alphavantage 73 | .as_ref() 74 | .unwrap_or(&AlphavantageConfig { 75 | apikey: "default key".to_string(), 76 | }) 77 | .apikey, 78 | &config.https, 79 | )?; 80 | let binance = BinanceSource::new(&config.https)?; 81 | let coinone = CoinoneSource::new(&config.https)?; 82 | let gdac = GdacSource::new(&config.https)?; 83 | let dunamu = DunamuSource::new(&config.https)?; 84 | let gopax = GopaxSource::new(&config.https)?; 85 | let imf_sdr = ImfSdrSource::new(&config.https)?; 86 | let bithumb = BithumbSource::new(&config.https)?; 87 | let currencylayer = CurrencylayerSource::new( 88 | &config 89 | .source 90 | .currencylayer 91 | .as_ref() 92 | .expect("missing currencylayer config") 93 | .access_key, 94 | &config.https, 95 | )?; 96 | 97 | Ok(Sources { 98 | alphavantage, 99 | binance, 100 | coinone, 101 | dunamu, 102 | gdac, 103 | gopax, 104 | imf_sdr, 105 | bithumb, 106 | currencylayer, 107 | }) 108 | } 109 | } 110 | 111 | ///This trait allows writing generic functions over ask orderbook from multiple sources 112 | pub trait AskBook { 113 | ///This function returns a vector of ask prices and volumes 114 | fn asks(&self) -> Result, Error>; 115 | } 116 | 117 | ///This trait allows writing generic functions over bid orderbook from multiple sources 118 | pub trait BidBook { 119 | ///This function returns a vector of bid prices and volumes 120 | fn bids(&self) -> Result, Error>; 121 | } 122 | 123 | /// Ask price weighted average 124 | pub fn weighted_avg_ask(asks: &T) -> Result { 125 | let asks = asks.asks()?; 126 | let mut price_sum_product = Decimal::from(0u8); 127 | let mut total = Decimal::from(0u8); 128 | for ask in asks { 129 | price_sum_product += *ask.price * ask.quantity; 130 | total += ask.quantity; 131 | } 132 | 133 | let weighted_avg = Price::new(price_sum_product / total)?; 134 | Ok(weighted_avg) 135 | } 136 | 137 | /// Bid price weighted average 138 | pub fn weighted_avg_bid(bids: &T) -> Result { 139 | let bids = bids.bids()?; 140 | let mut price_sum_product = Decimal::from(0u8); 141 | let mut total = Decimal::from(0u8); 142 | for bid in bids { 143 | price_sum_product += *bid.price * bid.quantity; 144 | total += bid.quantity; 145 | } 146 | 147 | let weighted_avg = Price::new(price_sum_product / total)?; 148 | Ok(weighted_avg) 149 | } 150 | 151 | /// Highest ask price 152 | pub fn lowest_ask(asks: &T) -> Result { 153 | let mut asks = asks.asks()?; 154 | asks.sort(); 155 | Ok(asks.first().unwrap().price) 156 | } 157 | 158 | /// Lowest bid price 159 | pub fn highest_bid(bids: &T) -> Result { 160 | let mut bids = bids.bids()?; 161 | bids.sort(); 162 | Ok(bids.last().unwrap().price) 163 | } 164 | 165 | /// Midpoint of highest ask and lowest bid price 166 | pub fn midpoint(book: &T) -> Result { 167 | let lowest_ask = lowest_ask(book)?; 168 | let highest_bid = highest_bid(book)?; 169 | Ok((lowest_ask + highest_bid) / 2) 170 | } 171 | -------------------------------------------------------------------------------- /src/sources/alphavantage.rs: -------------------------------------------------------------------------------- 1 | //! Alphavantage Source Provider 2 | //! 3 | //! 4 | 5 | use crate::{config::HttpsConfig, prelude::*, Error, ErrorKind, Price, TradingPair}; 6 | use iqhttp::{HttpsClient, Query}; 7 | use serde::{Deserialize, Serialize}; 8 | 9 | /// Hostname for AlphaVantage API 10 | pub const API_HOST: &str = "www.alphavantage.co"; 11 | 12 | /// Source provider for AlphaVantage 13 | pub struct AlphavantageSource { 14 | https_client: HttpsClient, 15 | apikey: String, 16 | } 17 | 18 | ///Parameters for queries 19 | pub struct AlphavantageParams { 20 | function: String, 21 | from_currency: String, 22 | to_currency: String, 23 | apikey: String, 24 | } 25 | 26 | impl AlphavantageParams { 27 | ///Convert params into url query parameters 28 | pub fn to_request_uri(&self) -> Query { 29 | let mut query = Query::new(); 30 | query.add("function".to_owned(), self.function.to_string()); 31 | query.add("from_currency".to_owned(), self.from_currency.to_string()); 32 | query.add("to_currency".to_owned(), self.to_currency.to_string()); 33 | query.add("apikey".to_owned(), self.apikey.to_string()); 34 | 35 | query 36 | } 37 | } 38 | 39 | impl AlphavantageSource { 40 | /// Create a new Alphavantage source provider 41 | pub fn new(apikey: impl Into, config: &HttpsConfig) -> Result { 42 | let https_client = config.new_client(API_HOST)?; 43 | Ok(Self { 44 | https_client, 45 | apikey: apikey.into(), 46 | }) 47 | } 48 | 49 | /// Get trading pairs 50 | pub async fn trading_pairs(&self, pair: &TradingPair) -> Result { 51 | let params = AlphavantageParams { 52 | function: "CURRENCY_EXCHANGE_RATE".to_owned(), 53 | from_currency: pair.0.to_string(), 54 | to_currency: pair.1.to_string(), 55 | apikey: self.apikey.clone(), 56 | }; 57 | 58 | let query = params.to_request_uri(); 59 | match self.https_client.get_json("/query", &query).await? { 60 | Response::Success(resp) => Ok(resp.exchange_rate), 61 | Response::Error(msg) => fail!(ErrorKind::Source, "Alpha Vantage error: {}", msg), 62 | } 63 | } 64 | } 65 | 66 | /// Outer struct of the API responses 67 | #[derive(Serialize, Deserialize)] 68 | pub enum Response { 69 | /// Successful API response 70 | #[serde(rename = "Realtime Currency Exchange Rate")] 71 | Success(RealtimeCurrencyExchangeRate), 72 | 73 | /// Error response 74 | #[serde(rename = "Note")] 75 | Error(String), 76 | } 77 | 78 | #[derive(Serialize, Deserialize)] 79 | ///Inner struct of the API response 80 | pub struct RealtimeCurrencyExchangeRate { 81 | #[serde(rename = "1. From_Currency Code")] 82 | from_currency_code: String, 83 | #[serde(rename = "2. From_Currency Name")] 84 | from_currency_name: String, 85 | #[serde(rename = "3. To_Currency Code")] 86 | to_currency_code: String, 87 | #[serde(rename = "4. To_Currency Name")] 88 | to_currency_name: String, 89 | #[serde(rename = "5. Exchange Rate")] 90 | /// Quote of the exchange rate Price 91 | pub exchange_rate: Price, 92 | #[serde(rename = "6. Last Refreshed")] 93 | timestamp: String, 94 | #[serde(rename = "7. Time Zone")] 95 | timezone: String, 96 | #[serde(rename = "8. Bid Price")] 97 | ///Quote for bid price 98 | pub bid: Price, 99 | #[serde(rename = "9. Ask Price")] 100 | ///Quote for ask price 101 | pub ask: Price, 102 | } 103 | 104 | #[cfg(test)] 105 | mod tests { 106 | use super::AlphavantageSource; 107 | #[tokio::test] 108 | #[ignore] 109 | async fn trading_pairs_ok() { 110 | let pair = "KRW/USD".parse().unwrap(); 111 | let _response = AlphavantageSource::new( 112 | &std::env::var("ALPHAVANTAGE_API") 113 | .expect("Please set the ALPHAVANTAGE_API env variable"), 114 | &Default::default(), 115 | ) 116 | .unwrap() 117 | .trading_pairs(&pair) 118 | .await 119 | .unwrap(); 120 | } 121 | 122 | // / `trading_pairs()` with invalid currency pair 123 | // #[test] 124 | // #[ignore] 125 | // fn trading_pairs_404() { 126 | // let pair = "N/A".parse().unwrap(); 127 | 128 | // // TODO(tarcieri): test 404 handling 129 | // let _err = block_on(AlphavantageSource::new().trading_pairs(&pair)) 130 | // .err() 131 | // .unwrap(); 132 | // } 133 | } 134 | -------------------------------------------------------------------------------- /src/sources/binance.rs: -------------------------------------------------------------------------------- 1 | //! Binance Source Provider 2 | //! 3 | 4 | use crate::{config::HttpsConfig, prelude::*, Currency, Error, ErrorKind, Price, TradingPair}; 5 | use iqhttp::{HttpsClient, Query}; 6 | use rust_decimal::Decimal; 7 | use serde::{Deserialize, Serialize}; 8 | use std::{ 9 | convert::TryFrom, 10 | fmt::{self, Display}, 11 | str::FromStr, 12 | }; 13 | use tokio::try_join; 14 | 15 | /// Hostname for the Binance API 16 | pub const API_HOST: &str = "api.binance.com"; 17 | 18 | /// Source provider for Binance 19 | pub struct BinanceSource { 20 | https_client: HttpsClient, 21 | } 22 | 23 | impl BinanceSource { 24 | /// Create a new Binance source provider 25 | pub fn new(config: &HttpsConfig) -> Result { 26 | let https_client = config.new_client(API_HOST)?; 27 | Ok(Self { https_client }) 28 | } 29 | 30 | /// Get the average price for the given pair, approximating prices for 31 | /// pairs which don't natively exist on binance 32 | pub async fn approx_price_for_pair(&self, pair: &TradingPair) -> Result { 33 | if let Ok(symbol_name) = SymbolName::try_from(pair) { 34 | return self.avg_price_for_symbol(symbol_name).await; 35 | } 36 | 37 | // Approximate prices by querying other currency pairs 38 | match pair { 39 | TradingPair(Currency::Luna, Currency::Krw) => { 40 | let (luna_btc, btc_bkrw) = try_join!( 41 | self.avg_price_for_symbol(SymbolName::LunaBtc), 42 | self.avg_price_for_symbol(SymbolName::BtcBkrw) 43 | )?; 44 | 45 | // Compute KRW by proxy using LUNA -> BTC -> KRW-ish 46 | Ok(luna_btc * btc_bkrw) 47 | } 48 | TradingPair(Currency::Luna, Currency::Usd) => { 49 | let (luna_busd, luna_usdt) = try_join!( 50 | self.avg_price_for_symbol(SymbolName::LunaBusd), 51 | self.avg_price_for_symbol(SymbolName::LunaUsdt) 52 | )?; 53 | 54 | // Give BUSD and USDT equal weight 55 | Ok((luna_busd + luna_usdt) / 2) 56 | } 57 | _ => fail!(ErrorKind::Currency, "unsupported Binance pair: {}", pair), 58 | } 59 | } 60 | 61 | /// `GET /api/v3/avgPrice` - get average price for Binance trading symbol 62 | pub async fn avg_price_for_symbol(&self, symbol_name: SymbolName) -> Result { 63 | let mut query = Query::new(); 64 | query.add("symbol".to_owned(), symbol_name.to_string()); 65 | 66 | let api_response: AvgPriceResponse = self 67 | .https_client 68 | .get_json("/api/v3/avgPrice", &query) 69 | .await?; 70 | 71 | Price::new(api_response.price) 72 | } 73 | } 74 | 75 | /// Registered Binance trading pair symbols 76 | #[derive(Copy, Clone, Debug, Eq, PartialEq)] 77 | pub enum SymbolName { 78 | /// BTC/BKRW 79 | BtcBkrw, 80 | 81 | /// BTC/BUSD 82 | BtcBusd, 83 | 84 | /// BTC/GBP 85 | BtcGbp, 86 | 87 | /// BTC/EUR 88 | BtcEur, 89 | 90 | /// BTC/USDC 91 | BtcUsdc, 92 | 93 | /// BTC/USDT 94 | BtcUsdt, 95 | 96 | /// ETH/BTC 97 | EthBtc, 98 | 99 | /// ETH/BUSD 100 | EthBusd, 101 | 102 | /// ETH/EUR 103 | EthEur, 104 | 105 | /// ETH/GBP 106 | EthGbp, 107 | 108 | /// ETH/USDC 109 | EthUsdc, 110 | 111 | /// ETH/USDT 112 | EthUsdt, 113 | 114 | /// LUNA/BNB 115 | LunaBnb, 116 | 117 | /// LUNA/BTC 118 | LunaBtc, 119 | 120 | /// LUNA/BUSD 121 | LunaBusd, 122 | 123 | /// LUNA/USDT 124 | LunaUsdt, 125 | } 126 | 127 | impl Display for SymbolName { 128 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 129 | f.write_str(match self { 130 | SymbolName::BtcBkrw => "BTCBKRW", 131 | SymbolName::BtcBusd => "BTCBUSD", 132 | SymbolName::BtcGbp => "BTCGBP", 133 | SymbolName::BtcEur => "BTCEUR", 134 | SymbolName::BtcUsdc => "BTCUSDC", 135 | SymbolName::BtcUsdt => "BTCUSDT", 136 | SymbolName::EthBtc => "ETHBTC", 137 | SymbolName::EthBusd => "ETHBUSD", 138 | SymbolName::EthEur => "ETHEUR", 139 | SymbolName::EthGbp => "ETHGBP", 140 | SymbolName::EthUsdc => "ETHUSDC", 141 | SymbolName::EthUsdt => "ETHUSDT", 142 | SymbolName::LunaBnb => "LUNABNB", 143 | SymbolName::LunaBtc => "LUNABTC", 144 | SymbolName::LunaBusd => "LUNABUSD", 145 | SymbolName::LunaUsdt => "LUNAUSDT", 146 | }) 147 | } 148 | } 149 | 150 | impl FromStr for SymbolName { 151 | type Err = Error; 152 | 153 | fn from_str(s: &str) -> Result { 154 | match s.to_ascii_uppercase().as_ref() { 155 | "BTCBKRW" => Ok(SymbolName::BtcBkrw), 156 | "BTCBUSD" => Ok(SymbolName::BtcBusd), 157 | "BTCGBP" => Ok(SymbolName::BtcGbp), 158 | "BTCEUR" => Ok(SymbolName::BtcEur), 159 | "BTCUSDC" => Ok(SymbolName::BtcUsdc), 160 | "BTCUSDT" => Ok(SymbolName::BtcUsdt), 161 | "ETHBTC" => Ok(SymbolName::EthBtc), 162 | "ETHBUSD" => Ok(SymbolName::EthBusd), 163 | "ETHEUR" => Ok(SymbolName::EthEur), 164 | "ETHGBP" => Ok(SymbolName::EthGbp), 165 | "ETHUSDC" => Ok(SymbolName::EthUsdc), 166 | "ETHUSDT" => Ok(SymbolName::EthUsdt), 167 | "LUNABNB" => Ok(SymbolName::LunaBnb), 168 | "LUNABTC" => Ok(SymbolName::LunaBtc), 169 | "LUNABUSD" => Ok(SymbolName::LunaBusd), 170 | "LUNAUSDT" => Ok(SymbolName::LunaUsdt), 171 | _ => fail!(ErrorKind::Currency, "unknown Binance symbol name: {}", s), 172 | } 173 | } 174 | } 175 | 176 | impl TryFrom<&TradingPair> for SymbolName { 177 | type Error = Error; 178 | 179 | fn try_from(pair: &TradingPair) -> Result { 180 | // Strip slash from serialized pair 181 | pair.to_string() 182 | .chars() 183 | .filter(|&c| c != '/') 184 | .collect::() 185 | .parse() 186 | } 187 | } 188 | 189 | /// Binance `/api/v3/avgPrice` response 190 | #[derive(Clone, Debug, Serialize, Deserialize)] 191 | #[serde(rename_all = "camelCase")] 192 | pub struct AvgPriceResponse { 193 | /// Minutes the moving average is computed over 194 | pub mins: u32, 195 | 196 | /// Price 197 | pub price: Decimal, 198 | } 199 | 200 | #[cfg(test)] 201 | mod tests { 202 | use super::BinanceSource; 203 | 204 | #[ignore] 205 | #[tokio::test] 206 | async fn avg_price_for_symbol() { 207 | let binance = BinanceSource::new(&Default::default()).unwrap(); 208 | 209 | let luna_bnb = binance 210 | .avg_price_for_symbol("LUNABNB".parse().unwrap()) 211 | .await 212 | .unwrap(); 213 | 214 | dbg!(luna_bnb); 215 | 216 | let luna_btc = binance 217 | .avg_price_for_symbol("LUNABTC".parse().unwrap()) 218 | .await 219 | .unwrap(); 220 | 221 | dbg!(luna_btc); 222 | 223 | let luna_busd = binance 224 | .avg_price_for_symbol("LUNABUSD".parse().unwrap()) 225 | .await 226 | .unwrap(); 227 | 228 | dbg!(luna_busd); 229 | 230 | let luna_usdt = binance 231 | .avg_price_for_symbol("LUNAUSDT".parse().unwrap()) 232 | .await 233 | .unwrap(); 234 | 235 | dbg!(luna_usdt); 236 | } 237 | 238 | #[ignore] 239 | #[tokio::test] 240 | async fn approx_price_for_pair() { 241 | let binance = BinanceSource::new(&Default::default()).unwrap(); 242 | 243 | let luna_btc = binance 244 | .approx_price_for_pair(&"LUNA/BTC".parse().unwrap()) 245 | .await 246 | .unwrap(); 247 | 248 | dbg!(luna_btc); 249 | 250 | let luna_krw = binance 251 | .approx_price_for_pair(&"LUNA/KRW".parse().unwrap()) 252 | .await 253 | .unwrap(); 254 | 255 | dbg!(luna_krw); 256 | 257 | let luna_usd = binance 258 | .approx_price_for_pair(&"LUNA/USD".parse().unwrap()) 259 | .await 260 | .unwrap(); 261 | 262 | dbg!(luna_usd); 263 | } 264 | } 265 | -------------------------------------------------------------------------------- /src/sources/bithumb.rs: -------------------------------------------------------------------------------- 1 | //! Bithumb Source Provider 2 | //! 3 | //! 4 | //! Only KRW pairs are supported. 5 | 6 | use crate::{config::HttpsConfig, prelude::*, Currency, Error, ErrorKind, Price, TradingPair}; 7 | use iqhttp::{HttpsClient, Query}; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | /// Hostname for Bithumb API 11 | pub const API_HOST: &str = "api.bithumb.com"; 12 | 13 | /// Source provider for Bithumb 14 | pub struct BithumbSource { 15 | https_client: HttpsClient, 16 | } 17 | 18 | impl BithumbSource { 19 | /// Create a new Bithumb source provider 20 | pub fn new(config: &HttpsConfig) -> Result { 21 | let https_client = config.new_client(API_HOST)?; 22 | Ok(Self { https_client }) 23 | } 24 | 25 | /// Get trading pairs 26 | pub async fn trading_pairs(&self, pair: &TradingPair) -> Result { 27 | if pair.1 != Currency::Krw { 28 | fail!(ErrorKind::Currency, "trading pair must be with KRW"); 29 | } 30 | 31 | let query = Query::new(); 32 | 33 | let api_response: Response = self 34 | .https_client 35 | .get_json("/public/ticker/luna_krw", &query) 36 | .await?; 37 | Ok(api_response.data.closing_price.parse::().unwrap()) 38 | } 39 | } 40 | 41 | /// API responses 42 | #[derive(Clone, Debug, Serialize, Deserialize)] 43 | pub struct Response { 44 | status: String, 45 | ///data response 46 | pub data: Data, 47 | } 48 | 49 | ///Data response 50 | #[derive(Clone, Debug, Serialize, Deserialize)] 51 | pub struct Data { 52 | opening_price: String, 53 | ///closing price response 54 | pub closing_price: String, 55 | min_price: String, 56 | max_price: String, 57 | units_traded: String, 58 | acc_trade_value: String, 59 | prev_closing_price: String, 60 | #[serde(rename = "units_traded_24H")] 61 | units_traded_24_h: String, 62 | #[serde(rename = "acc_trade_value_24H")] 63 | acc_trade_value_24_h: String, 64 | #[serde(rename = "fluctate_24H")] 65 | fluctate_24_h: String, 66 | #[serde(rename = "fluctate_rate_24H")] 67 | fluctate_rate_24_h: String, 68 | date: String, 69 | } 70 | /// Prices and associated volumes 71 | #[derive(Clone, Debug, Serialize, Deserialize)] 72 | pub struct PricePoint { 73 | /// Price 74 | pub price: Price, 75 | 76 | /// Quantity 77 | pub qty: String, 78 | } 79 | 80 | #[cfg(test)] 81 | mod tests { 82 | use super::BithumbSource; 83 | 84 | /// `trading_pairs()` test with known currency pair 85 | #[tokio::test] 86 | #[ignore] 87 | async fn trading_pairs_ok() { 88 | let pair = "LUNA/KRW".parse().unwrap(); 89 | let _price = BithumbSource::new(&Default::default()) 90 | .unwrap() 91 | .trading_pairs(&pair) 92 | .await 93 | .unwrap(); 94 | } 95 | 96 | /// `trading_pairs()` with invalid currency pair 97 | #[tokio::test] 98 | #[ignore] 99 | async fn trading_pairs_404() { 100 | let pair = "N/A".parse().unwrap(); 101 | 102 | // TODO(tarcieri): test 404 handling 103 | let _err = BithumbSource::new(&Default::default()) 104 | .unwrap() 105 | .trading_pairs(&pair) 106 | .await 107 | .err() 108 | .unwrap(); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/sources/coinone.rs: -------------------------------------------------------------------------------- 1 | //! Coinone Source Provider 2 | //! 3 | //! 4 | //! Only KRW pairs are supported. 5 | 6 | use super::{midpoint, AskBook, BidBook}; 7 | use crate::{ 8 | config::HttpsConfig, prelude::*, Currency, Error, ErrorKind, Price, PriceQuantity, TradingPair, 9 | }; 10 | use iqhttp::{HttpsClient, Query}; 11 | use serde::{Deserialize, Serialize}; 12 | 13 | /// Hostname for Coinone API 14 | pub const API_HOST: &str = "api.coinone.co.kr"; 15 | 16 | /// Source provider for Coinone 17 | pub struct CoinoneSource { 18 | https_client: HttpsClient, 19 | } 20 | 21 | impl CoinoneSource { 22 | /// Create a new Coinone source provider 23 | pub fn new(config: &HttpsConfig) -> Result { 24 | let https_client = config.new_client(API_HOST)?; 25 | Ok(Self { https_client }) 26 | } 27 | 28 | /// Get trading pairs 29 | pub async fn trading_pairs(&self, pair: &TradingPair) -> Result { 30 | if pair.1 != Currency::Krw { 31 | fail!(ErrorKind::Currency, "trading pair must be with KRW"); 32 | } 33 | 34 | let mut query = Query::new(); 35 | query.add("currency".to_owned(), pair.0.to_string()); 36 | 37 | let api_response: Response = self.https_client.get_json("/orderbook", &query).await?; 38 | midpoint(&api_response) 39 | } 40 | } 41 | 42 | /// API responses 43 | #[derive(Clone, Debug, Serialize, Deserialize)] 44 | pub struct Response { 45 | /// Error code 46 | #[serde(rename = "errorCode")] 47 | pub error_code: String, 48 | 49 | /// Result of the operation 50 | pub result: String, 51 | 52 | /// Requested currency 53 | pub currency: String, 54 | 55 | /// Timestamp 56 | pub timestamp: String, 57 | 58 | /// Ask prices 59 | pub ask: Vec, 60 | 61 | /// Bid prices 62 | pub bid: Vec, 63 | } 64 | 65 | ///This trait returns a vector of ask prices and quantities 66 | impl AskBook for Response { 67 | fn asks(&self) -> Result, Error> { 68 | self.ask 69 | .iter() 70 | .map(|p| { 71 | p.qty 72 | .parse() 73 | .map(|quantity| PriceQuantity { 74 | price: p.price, 75 | quantity, 76 | }) 77 | .map_err(Into::into) 78 | }) 79 | .collect() 80 | } 81 | } 82 | 83 | ///This trait returns a vector of bid prices and quantities 84 | impl BidBook for Response { 85 | fn bids(&self) -> Result, Error> { 86 | self.bid 87 | .iter() 88 | .map(|p| { 89 | p.qty 90 | .parse() 91 | .map(|quantity| PriceQuantity { 92 | price: p.price, 93 | quantity, 94 | }) 95 | .map_err(Into::into) 96 | }) 97 | .collect() 98 | } 99 | } 100 | 101 | /// Prices and associated volumes 102 | #[derive(Clone, Debug, Serialize, Deserialize)] 103 | pub struct PricePoint { 104 | /// Price 105 | pub price: Price, 106 | 107 | /// Quantity 108 | pub qty: String, 109 | } 110 | 111 | #[cfg(test)] 112 | mod tests { 113 | use super::CoinoneSource; 114 | 115 | /// `trading_pairs()` test with known currency pair 116 | #[tokio::test] 117 | #[ignore] 118 | async fn trading_pairs_ok() { 119 | let pair = "LUNA/KRW".parse().unwrap(); 120 | let _price = CoinoneSource::new(&Default::default()) 121 | .unwrap() 122 | .trading_pairs(&pair) 123 | .await 124 | .unwrap(); 125 | } 126 | 127 | /// `trading_pairs()` with invalid currency pair 128 | #[tokio::test] 129 | #[ignore] 130 | async fn trading_pairs_404() { 131 | let pair = "N/A".parse().unwrap(); 132 | 133 | // TODO(tarcieri): test 404 handling 134 | let _err = CoinoneSource::new(&Default::default()) 135 | .unwrap() 136 | .trading_pairs(&pair) 137 | .await 138 | .err() 139 | .unwrap(); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /src/sources/currencylayer.rs: -------------------------------------------------------------------------------- 1 | //! Currencylayer Source Provider 2 | //! 3 | //! 4 | 5 | use crate::{config::HttpsConfig, Error, Price, TradingPair}; 6 | use iqhttp::{HttpsClient, Query}; 7 | use rust_decimal::prelude::FromPrimitive; 8 | use rust_decimal::Decimal; 9 | use serde::{Deserialize, Serialize}; 10 | /// Hostname for Currencylayer API 11 | pub const API_HOST: &str = "api.currencylayer.com"; 12 | 13 | /// Source provider for Currencylayer 14 | pub struct CurrencylayerSource { 15 | https_client: HttpsClient, 16 | access_key: String, 17 | } 18 | 19 | ///Parameters for queries 20 | pub struct CurrencylayerParams { 21 | source: String, 22 | currencies: String, 23 | access_key: String, 24 | } 25 | 26 | impl CurrencylayerParams { 27 | ///Convert params into url query parameters 28 | pub fn to_request_uri(&self) -> Query { 29 | let mut query = Query::new(); 30 | query.add("source".to_owned(), self.source.to_string()); 31 | query.add("currencies".to_owned(), self.currencies.to_string()); 32 | query.add("access_key".to_owned(), self.access_key.to_string()); 33 | 34 | query 35 | } 36 | } 37 | 38 | impl CurrencylayerSource { 39 | /// Create a new Currencylayer source provider 40 | pub fn new(access_key: impl Into, config: &HttpsConfig) -> Result { 41 | let https_client = config.new_client(API_HOST)?; 42 | Ok(Self { 43 | https_client, 44 | access_key: access_key.into(), 45 | }) 46 | } 47 | 48 | /// Get trading pairs 49 | pub async fn trading_pairs(&self, pair: &TradingPair) -> Result { 50 | let params = CurrencylayerParams { 51 | source: pair.0.to_string(), 52 | currencies: pair.1.to_string(), 53 | access_key: self.access_key.clone(), 54 | }; 55 | 56 | let query = params.to_request_uri(); 57 | let resp: Response = self.https_client.get_json("/live", &query).await?; 58 | let price = resp 59 | .quotes 60 | .values() 61 | .into_iter() 62 | .last() 63 | .expect("expected currencylayer to return one value"); 64 | let dec_price = Decimal::from_f64(*price) 65 | .expect("expected currencylayer response to convert to a decimal"); 66 | Price::new(dec_price) 67 | } 68 | } 69 | 70 | /// Outer struct of the API responses 71 | #[derive(Serialize, Deserialize, Debug)] 72 | pub struct Response { 73 | success: bool, 74 | terms: String, 75 | privacy: String, 76 | timestamp: i64, 77 | source: String, 78 | quotes: std::collections::HashMap, 79 | } 80 | 81 | #[cfg(test)] 82 | mod tests { 83 | use super::CurrencylayerSource; 84 | #[tokio::test] 85 | #[ignore] 86 | async fn trading_pairs_ok() { 87 | let pair = "KRW/USD".parse().unwrap(); 88 | let _response = CurrencylayerSource::new( 89 | &std::env::var("CURRENCYLAYER_API") 90 | .expect("Please set the CURRENCYLAYER_API env variable"), 91 | &Default::default(), 92 | ) 93 | .unwrap() 94 | .trading_pairs(&pair) 95 | .await 96 | .unwrap(); 97 | } 98 | 99 | // / `trading_pairs()` with invalid currency pair 100 | // #[test] 101 | // #[ignore] 102 | // fn trading_pairs_404() { 103 | // let pair = "N/A".parse().unwrap(); 104 | 105 | // // TODO(tarcieri): test 404 handling 106 | // let _err = block_on(CurrencylayerSource::new().trading_pairs(&pair)) 107 | // .err() 108 | // .unwrap(); 109 | // } 110 | } 111 | -------------------------------------------------------------------------------- /src/sources/dunamu.rs: -------------------------------------------------------------------------------- 1 | //! Dunamu Source Provider (v0.4 API) 2 | //! 3 | 4 | use super::Price; 5 | use crate::{ 6 | config::HttpsConfig, 7 | error::{Error, ErrorKind}, 8 | prelude::*, 9 | Currency, TradingPair, 10 | }; 11 | use iqhttp::{HttpsClient, Query}; 12 | use rust_decimal::Decimal; 13 | use serde::{Deserialize, Serialize}; 14 | 15 | //https://quotation-api-cdn.dunamu.com/v1/forex/recent?codes=FRX.KRWUSD 16 | 17 | /// Base URI for requests to the Dunamu API 18 | pub const API_HOST: &str = "quotation-api-cdn.dunamu.com"; 19 | 20 | /// Source provider for Dunamu 21 | pub struct DunamuSource { 22 | https_client: HttpsClient, 23 | } 24 | 25 | impl DunamuSource { 26 | /// Create a new Dunamu source provider 27 | #[allow(clippy::new_without_default)] 28 | pub fn new(config: &HttpsConfig) -> Result { 29 | let https_client = config.new_client(API_HOST)?; 30 | Ok(Self { https_client }) 31 | } 32 | 33 | /// Get trading pairs 34 | pub async fn trading_pairs(&self, pair: &TradingPair) -> Result { 35 | if pair.0 != Currency::Krw && pair.1 != Currency::Krw { 36 | fail!(ErrorKind::Currency, "trading pair must be with KRW"); 37 | } 38 | 39 | let mut query = Query::new(); 40 | query.add("codes", format!("FRX.{}{}", pair.0, pair.1)); 41 | 42 | let api_response: Response = self 43 | .https_client 44 | .get_json("/v1/forex/recent", &query) 45 | .await?; 46 | let price: Decimal = api_response[0].base_price.to_string().parse()?; 47 | Ok(Price::new(price)?) 48 | } 49 | } 50 | 51 | /// API responses Vector 52 | pub type Response = Vec; 53 | /// API response entity 54 | #[derive(Serialize, Deserialize)] 55 | #[serde(rename_all = "camelCase")] 56 | pub struct ResponseElement { 57 | code: String, 58 | currency_code: String, 59 | currency_name: String, 60 | country: String, 61 | name: String, 62 | date: String, 63 | time: String, 64 | recurrence_count: i64, 65 | base_price: f64, 66 | opening_price: f64, 67 | high_price: f64, 68 | low_price: f64, 69 | change: String, 70 | change_price: f64, 71 | cash_buying_price: f64, 72 | cash_selling_price: f64, 73 | tt_buying_price: f64, 74 | tt_selling_price: f64, 75 | tc_buying_price: Option, 76 | fc_selling_price: Option, 77 | exchange_commission: f64, 78 | us_dollar_rate: f64, 79 | #[serde(rename = "high52wPrice")] 80 | high52_w_price: f64, 81 | #[serde(rename = "high52wDate")] 82 | high52_w_date: String, 83 | #[serde(rename = "low52wPrice")] 84 | low52_w_price: f64, 85 | #[serde(rename = "low52wDate")] 86 | low52_w_date: String, 87 | currency_unit: i64, 88 | provider: String, 89 | timestamp: i64, 90 | id: i64, 91 | created_at: String, 92 | modified_at: String, 93 | change_rate: f64, 94 | signed_change_price: f64, 95 | signed_change_rate: f64, 96 | } 97 | 98 | #[cfg(test)] 99 | mod tests { 100 | use super::DunamuSource; 101 | 102 | /// `trading_pairs()` test with known currency pair 103 | #[tokio::test] 104 | #[ignore] 105 | async fn trading_pairs_ok() { 106 | let pair = "KRW/USD".parse().unwrap(); 107 | let _response = DunamuSource::new(&Default::default()) 108 | .unwrap() 109 | .trading_pairs(&pair) 110 | .await 111 | .unwrap(); 112 | } 113 | 114 | /// `trading_pairs()` with invalid currency pair 115 | #[tokio::test] 116 | #[ignore] 117 | async fn trading_pairs_404() { 118 | let pair = "N/A".parse().unwrap(); 119 | 120 | // TODO(tarcieri): test 404 handling 121 | let _err = DunamuSource::new(&Default::default()) 122 | .unwrap() 123 | .trading_pairs(&pair) 124 | .await 125 | .err() 126 | .unwrap(); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/sources/gdac.rs: -------------------------------------------------------------------------------- 1 | //! GDAC Source Provider (v0.4 API) 2 | //! 3 | 4 | use super::{midpoint, AskBook, BidBook}; 5 | use crate::{config::HttpsConfig, Error, Price, PriceQuantity, TradingPair}; 6 | use iqhttp::{HttpsClient, Query}; 7 | use serde::{de, Deserialize, Serialize}; 8 | use std::{ 9 | fmt::{self, Display}, 10 | str::FromStr, 11 | }; 12 | 13 | /// Base URI for requests to the GDAC v0.4 API 14 | pub const API_HOST: &str = "partner.gdac.com"; 15 | 16 | /// Source provider for GDAC 17 | pub struct GdacSource { 18 | https_client: HttpsClient, 19 | } 20 | 21 | impl GdacSource { 22 | /// Create a new GDAC source provider 23 | pub fn new(config: &HttpsConfig) -> Result { 24 | let https_client = config.new_client(API_HOST)?; 25 | Ok(Self { https_client }) 26 | } 27 | 28 | /// Get trading pairs 29 | pub async fn trading_pairs(&self, pair: &TradingPair) -> Result { 30 | let mut query = Query::new(); 31 | query.add("pair".to_owned(), pair.percent_encode()); 32 | 33 | let api_response: Quote = self 34 | .https_client 35 | .get_json("/v0.4/public/orderbook", &query) 36 | .await?; 37 | midpoint(&api_response) 38 | } 39 | } 40 | 41 | /// Quoted prices as sourced from the order book 42 | #[derive(Clone, Debug, Serialize, Deserialize)] 43 | pub struct Quote { 44 | /// Ask price 45 | pub ask: Vec, 46 | 47 | /// Bid price 48 | pub bid: Vec, 49 | } 50 | 51 | ///This trait returns a vector of ask prices and quantities 52 | impl AskBook for Quote { 53 | fn asks(&self) -> Result, Error> { 54 | self.ask 55 | .iter() 56 | .map(|p| { 57 | p.volume 58 | .parse() 59 | .map(|quantity| PriceQuantity { 60 | price: p.price, 61 | quantity, 62 | }) 63 | .map_err(Into::into) 64 | }) 65 | .collect() 66 | } 67 | } 68 | 69 | ///This trait returns a vector of bid prices and quantities 70 | impl BidBook for Quote { 71 | fn bids(&self) -> Result, Error> { 72 | self.bid 73 | .iter() 74 | .map(|p| { 75 | p.volume 76 | .parse() 77 | .map(|quantity| PriceQuantity { 78 | price: p.price, 79 | quantity, 80 | }) 81 | .map_err(Into::into) 82 | }) 83 | .collect() 84 | } 85 | } 86 | 87 | /// Prices and associated volumes 88 | #[derive(Clone, Debug, Serialize, Deserialize)] 89 | pub struct PricePoint { 90 | /// Price 91 | pub price: Price, 92 | 93 | /// Volume 94 | pub volume: String, 95 | } 96 | 97 | /// Error responses 98 | #[derive(Clone, Debug, Deserialize)] 99 | pub struct ErrorResponse { 100 | /// Response code 101 | pub code: ErrorCode, 102 | 103 | /// Response data 104 | pub data: serde_json::Value, 105 | } 106 | 107 | /// Error response codes 108 | #[derive(Clone, Debug, Eq, PartialEq)] 109 | pub enum ErrorCode { 110 | /// Application-level error 111 | InternalError, 112 | 113 | /// Not found (404) or bad HTTP method 114 | Unavailable, 115 | 116 | /// Unrecognized error codes 117 | Other(String), 118 | } 119 | 120 | impl Display for ErrorCode { 121 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 122 | f.write_str(match self { 123 | ErrorCode::InternalError => "__internal_error__", 124 | ErrorCode::Unavailable => "__unavailable__", 125 | ErrorCode::Other(other) => other.as_ref(), 126 | }) 127 | } 128 | } 129 | 130 | impl FromStr for ErrorCode { 131 | type Err = Error; 132 | 133 | fn from_str(s: &str) -> Result { 134 | Ok(match s { 135 | "__internal_error__" => ErrorCode::InternalError, 136 | "__unavailable__" => ErrorCode::Unavailable, 137 | other => ErrorCode::Other(other.to_owned()), 138 | }) 139 | } 140 | } 141 | 142 | impl std::error::Error for ErrorCode {} 143 | 144 | impl<'de> Deserialize<'de> for ErrorCode { 145 | fn deserialize>(deserializer: D) -> Result { 146 | use de::Error; 147 | let s = String::deserialize(deserializer)?; 148 | s.parse().map_err(D::Error::custom) 149 | } 150 | } 151 | 152 | #[cfg(test)] 153 | mod tests { 154 | use super::GdacSource; 155 | 156 | /// `trading_pairs()` test with known currency pair 157 | #[tokio::test] 158 | #[ignore] 159 | async fn trading_pairs_ok() { 160 | let pair = "LUNA/KRW".parse().unwrap(); 161 | let _price = GdacSource::new(&Default::default()) 162 | .unwrap() 163 | .trading_pairs(&pair) 164 | .await 165 | .unwrap(); 166 | } 167 | 168 | /// `trading_pairs()` with invalid currency pair 169 | #[tokio::test] 170 | #[ignore] 171 | async fn trading_pairs_404() { 172 | let pair = "N/A".parse().unwrap(); 173 | 174 | let _err = GdacSource::new(&Default::default()) 175 | .unwrap() 176 | .trading_pairs(&pair) 177 | .await 178 | .err() 179 | .unwrap(); 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/sources/gopax.rs: -------------------------------------------------------------------------------- 1 | //! GOPAX Source Provider 2 | //! 3 | //! 4 | 5 | use super::{midpoint, AskBook, BidBook}; 6 | use crate::{config::HttpsConfig, Error, Price, PriceQuantity, TradingPair}; 7 | use iqhttp::{HttpsClient, Query}; 8 | use rust_decimal::Decimal; 9 | use serde::{de, Deserialize, Serialize}; 10 | use std::time::{Duration, SystemTime, UNIX_EPOCH}; 11 | 12 | /// Base URI for requests to the GOPAX API 13 | pub const API_HOST: &str = "api.gopax.co.kr"; 14 | 15 | /// Source provider for GOPAX 16 | pub struct GopaxSource { 17 | https_client: HttpsClient, 18 | } 19 | 20 | impl GopaxSource { 21 | /// Create a new GOPAX source provider 22 | #[allow(clippy::new_without_default)] 23 | pub fn new(config: &HttpsConfig) -> Result { 24 | let https_client = config.new_client(API_HOST)?; 25 | Ok(Self { https_client }) 26 | } 27 | 28 | /// Get trading pairs 29 | pub async fn trading_pairs(&self, pair: &TradingPair) -> Result { 30 | let query = Query::new(); 31 | 32 | let api_response: Response = self 33 | .https_client 34 | .get_json( 35 | &format!("/trading-pairs/{}-{}/book", pair.0, pair.1), 36 | &query, 37 | ) 38 | .await?; 39 | 40 | midpoint(&api_response) 41 | } 42 | } 43 | 44 | /// Quoted prices as sourced from the order book 45 | #[derive(Clone, Debug, Serialize, Deserialize)] 46 | pub struct Response { 47 | /// Sequence 48 | pub sequence: u64, 49 | 50 | /// Bid price 51 | pub bid: Vec, 52 | 53 | /// Ask price 54 | pub ask: Vec, 55 | } 56 | 57 | ///This trait returns a vector of ask prices and quantities 58 | impl AskBook for Response { 59 | fn asks(&self) -> Result, Error> { 60 | Ok(self 61 | .ask 62 | .iter() 63 | .map(|p| PriceQuantity { 64 | price: Price::new(p.price).unwrap(), 65 | quantity: p.volume, 66 | }) 67 | .collect()) 68 | } 69 | } 70 | 71 | ///This trait returns a vector of bid prices and quantities 72 | impl BidBook for Response { 73 | fn bids(&self) -> Result, Error> { 74 | Ok(self 75 | .bid 76 | .iter() 77 | .map(|p| PriceQuantity { 78 | price: Price::new(p.price).unwrap(), 79 | quantity: p.volume, 80 | }) 81 | .collect()) 82 | } 83 | } 84 | /// Prices and associated volumes 85 | #[derive(Clone, Debug, Serialize, Deserialize)] 86 | pub struct PricePoint { 87 | /// Id 88 | pub id: String, 89 | 90 | /// Price 91 | #[serde(deserialize_with = "deserialize_decimal")] 92 | pub price: Decimal, 93 | 94 | /// Volume 95 | #[serde(deserialize_with = "deserialize_decimal")] 96 | pub volume: Decimal, 97 | 98 | /// Timestamp 99 | #[serde(deserialize_with = "deserialize_timestamp")] 100 | pub timestamp: SystemTime, 101 | } 102 | 103 | /// Deserialize decimal value 104 | fn deserialize_decimal<'de, D>(deserializer: D) -> Result 105 | where 106 | D: de::Deserializer<'de>, 107 | { 108 | // TODO: avoid floating point/string conversions 109 | let value = f64::deserialize(deserializer)?; 110 | value.to_string().parse().map_err(de::Error::custom) 111 | } 112 | 113 | /// Deserialize timestamp value 114 | fn deserialize_timestamp<'de, D>(deserializer: D) -> Result 115 | where 116 | D: de::Deserializer<'de>, 117 | { 118 | String::deserialize(deserializer) 119 | .and_then(|s| s.parse().map_err(de::Error::custom)) 120 | .map(|unix_secs| UNIX_EPOCH + Duration::from_secs(unix_secs)) 121 | } 122 | 123 | #[cfg(test)] 124 | mod tests { 125 | use super::GopaxSource; 126 | 127 | /// `trading_pairs()` test with known currency pair 128 | #[tokio::test] 129 | #[ignore] 130 | async fn trading_pairs_ok() { 131 | let pair = "LUNA/KRW".parse().unwrap(); 132 | let _quote = GopaxSource::new(&Default::default()) 133 | .unwrap() 134 | .trading_pairs(&pair) 135 | .await 136 | .unwrap(); 137 | } 138 | 139 | /// `trading_pairs()` with invalid currency pair 140 | #[tokio::test] 141 | #[ignore] 142 | async fn trading_pairs_404() { 143 | let pair = "N/A".parse().unwrap(); 144 | let quote = GopaxSource::new(&Default::default()) 145 | .unwrap() 146 | .trading_pairs(&pair) 147 | .await; 148 | assert!(quote.is_err()); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/sources/imf_sdr.rs: -------------------------------------------------------------------------------- 1 | //! IMF SDR Source Provider 2 | //! 3 | 4 | use crate::{ 5 | config::HttpsConfig, 6 | error::{Error, ErrorKind}, 7 | prelude::*, 8 | Currency, Price, TradingPair, 9 | }; 10 | use bytes::Buf; 11 | use iqhttp::HttpsClient; 12 | use serde::{Deserialize, Serialize}; 13 | use std::convert::TryFrom; 14 | 15 | //https://www.imf.org/external/np/fin/data/rms_five.aspx?tsvflag=Y" 16 | 17 | /// Base URI for requests to the Coinone API 18 | pub const API_HOST: &str = "www.imf.org"; 19 | 20 | /// The IMF returns tab seperated data is form that is designed to for 21 | 22 | pub struct ImfSdrSource { 23 | https_client: HttpsClient, 24 | } 25 | /// importing into Excel spreadsheets rather than being machine 26 | /// friendly.record. The TSV data has irregular structure. 27 | /// The strategy here is to find the subsection of data we are 28 | /// looking for by trying to deserialize each row into ImfsdrRow and 29 | /// ignoring deserialization errors. The IMF provides data for the 30 | /// last 5 days but data may no be available for all days. 31 | 32 | #[derive(Debug, Deserialize)] 33 | struct ImfsdrRow { 34 | currency: String, 35 | price_0: Option, 36 | price_1: Option, 37 | price_2: Option, 38 | price_3: Option, 39 | price_4: Option, 40 | } 41 | 42 | impl ImfsdrRow { 43 | /// Best price is the most recent price. Use 44 | fn response_from_best_price(&self) -> Option { 45 | if let Some(ref price) = self.price_0 { 46 | return Some(Response { price: *price }); 47 | } 48 | if let Some(ref price) = self.price_1 { 49 | return Some(Response { price: *price }); 50 | } 51 | if let Some(ref price) = self.price_2 { 52 | return Some(Response { price: *price }); 53 | } 54 | if let Some(ref price) = self.price_3 { 55 | return Some(Response { price: *price }); 56 | } 57 | if let Some(ref price) = self.price_4 { 58 | return Some(Response { price: *price }); 59 | } 60 | None 61 | } 62 | } 63 | 64 | impl ImfSdrSource { 65 | /// Create a new Dunamu source provider 66 | pub fn new(config: &HttpsConfig) -> Result { 67 | let https_client = config.new_client(API_HOST)?; 68 | Ok(Self { https_client }) 69 | } 70 | 71 | /// Get trading pairs 72 | pub async fn trading_pairs(&self, pair: &TradingPair) -> Result { 73 | if pair.1 != Currency::Sdr { 74 | fail!(ErrorKind::Currency, "trading pair must be with IMF SDR"); 75 | } 76 | 77 | let uri = format!( 78 | "https://{}/external/np/fin/data/rms_five.aspx?tsvflag=Y", 79 | API_HOST 80 | ); 81 | 82 | let body = self 83 | .https_client 84 | .get_body(&uri, &Default::default()) 85 | .await?; 86 | 87 | let mut imf_sdr = csv::ReaderBuilder::new() 88 | .has_headers(false) 89 | .flexible(true) 90 | .delimiter(b'\t') 91 | .from_reader(body.reader()); 92 | 93 | let mut response_row: Option = None; 94 | 95 | for result in imf_sdr.records() { 96 | let record = result.map_err(|e| { 97 | format_err!(ErrorKind::Source, "got error with malformed csv: {}", e) 98 | })?; 99 | 100 | let row: Result = record.deserialize(None); 101 | 102 | match row { 103 | Ok(imf_sdr_row) => { 104 | if imf_sdr_row.currency == pair.0.imf_long_name() { 105 | response_row = Some(imf_sdr_row); 106 | break; 107 | } 108 | } 109 | Err(_e) => continue, 110 | }; 111 | } 112 | 113 | match response_row { 114 | Some(resp) => Response::try_from(resp) 115 | .map_err(|e| format_err!(ErrorKind::Parse, "{}, {}", e, pair).into()), 116 | None => Err(format_err!(ErrorKind::Parse, "price for {} not found", pair).into()), 117 | } 118 | } 119 | } 120 | /// Provides a single price point for a currency pair based extracted data 121 | #[derive(Clone, Debug, Serialize, Deserialize)] 122 | pub struct Response { 123 | /// Price 124 | pub price: Price, 125 | } 126 | 127 | impl TryFrom for Response { 128 | type Error = &'static str; 129 | fn try_from(row: ImfsdrRow) -> Result { 130 | match row.response_from_best_price() { 131 | Some(resp) => Ok(resp), 132 | None => Err("No price data found for for currency pair"), 133 | } 134 | } 135 | } 136 | #[cfg(test)] 137 | mod tests { 138 | use super::ImfSdrSource; 139 | 140 | /// `trading_pairs()` test with known currency pair 141 | #[tokio::test] 142 | #[ignore] 143 | async fn trading_pairs_ok() { 144 | let pair = "KRW/SDR".parse().unwrap(); 145 | let quote = ImfSdrSource::new(&Default::default()) 146 | .unwrap() 147 | .trading_pairs(&pair) 148 | .await 149 | .unwrap(); 150 | dbg!("e); 151 | } 152 | 153 | // `trading_pairs()` with invalid currency pair 154 | // #[test] 155 | // #[ignore] 156 | // fn trading_pairs_404() { 157 | // let pair = "N/A".parse().unwrap(); 158 | // let quote_err = block_on(ImfSdrSource::new().trading_pairs(&pair)) 159 | // .err() 160 | // .unwrap(); 161 | 162 | // use std::error::Error; 163 | // let err: &ErrorCode = quote_err.source().unwrap().downcast_ref().unwrap(); 164 | 165 | // assert_eq!(err, &ErrorCode::InternalError); 166 | // } 167 | } 168 | -------------------------------------------------------------------------------- /src/trading_pair.rs: -------------------------------------------------------------------------------- 1 | //! Trading pairs 2 | 3 | use crate::{prelude::*, Currency, Error, ErrorKind}; 4 | use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; 5 | use serde::{de, ser, Deserialize, Serialize}; 6 | use std::{ 7 | fmt::{self, Display}, 8 | str::FromStr, 9 | }; 10 | 11 | /// Trading pairs 12 | pub struct TradingPair(pub Currency, pub Currency); 13 | 14 | impl TradingPair { 15 | /// Percent encode this pair (for inclusion in a URL) 16 | pub fn percent_encode(&self) -> String { 17 | utf8_percent_encode(&self.to_string(), NON_ALPHANUMERIC).to_string() 18 | } 19 | } 20 | 21 | impl Display for TradingPair { 22 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 23 | write!(f, "{}/{}", self.0, self.1) 24 | } 25 | } 26 | 27 | impl FromStr for TradingPair { 28 | type Err = Error; 29 | 30 | fn from_str(s: &str) -> Result { 31 | let pair: Vec<_> = s.split('/').collect(); 32 | 33 | if pair.len() != 2 { 34 | fail!(ErrorKind::Parse, "malformed trading pair: {}", s); 35 | } 36 | 37 | Ok(TradingPair(pair[0].parse()?, pair[1].parse()?)) 38 | } 39 | } 40 | 41 | impl<'de> Deserialize<'de> for TradingPair { 42 | fn deserialize>(deserializer: D) -> Result { 43 | use de::Error; 44 | String::deserialize(deserializer)? 45 | .parse() 46 | .map_err(D::Error::custom) 47 | } 48 | } 49 | 50 | impl Serialize for TradingPair { 51 | fn serialize(&self, serializer: S) -> Result { 52 | self.to_string().serialize(serializer) 53 | } 54 | } 55 | --------------------------------------------------------------------------------