From d1153307ac75401e27941c5a651310c08b1c5f95 Mon Sep 17 00:00:00 2001 From: Patrick O'Grady Date: Wed, 16 Sep 2020 12:03:14 -0700 Subject: [PATCH] Initial commit --- .circleci/config.yml | 103 ++ .dockerignore | 2 + .github/ISSUE_TEMPLATE/bug_report.md | 19 + .github/ISSUE_TEMPLATE/feature_request.md | 20 + .github/PULL_REQUEST_TEMPLATE.md | 16 + .github/dependabot.yml | 21 + .gitignore | 2 + CONTRIBUTING.md | 53 + Dockerfile | 89 ++ LICENSE.txt | 201 +++ Makefile | 84 ++ README.md | 187 ++- assets/bitcoin-mainnet.conf | 20 + assets/bitcoin-testnet.conf | 22 + assets/bitcoin.json | 17 + assets/mainnet-transaction.zstd | Bin 0 -> 112640 bytes assets/testnet-transaction.zstd | Bin 0 -> 112640 bytes bitcoin/client.go | 834 +++++++++++ bitcoin/client_fixtures/fee_rate.json | 8 + .../get_block_hash_out_of_range_response.json | 8 + .../get_block_hash_response.json | 5 + .../get_block_not_found_response.json | 8 + .../client_fixtures/get_block_response.json | 88 ++ .../client_fixtures/get_block_response_2.json | 174 +++ .../get_blockchain_info_response.json | 45 + .../get_peer_info_response.json | 145 ++ bitcoin/client_fixtures/invalid_fee_rate.json | 8 + .../rpc_in_warmup_response.json | 8 + bitcoin/client_test.go | 1259 +++++++++++++++++ bitcoin/node.go | 103 ++ bitcoin/types.go | 493 +++++++ bitcoin/utils.go | 70 + configuration/configuration.go | 220 +++ configuration/configuration_test.go | 152 ++ go.mod | 15 + go.sum | 507 +++++++ indexer/indexer.go | 738 ++++++++++ indexer/indexer_test.go | 839 +++++++++++ indexer/wait_table.go | 81 ++ main.go | 203 +++ mocks/indexer/client.go | 119 ++ mocks/services/client.go | 81 ++ mocks/services/indexer.go | 119 ++ rosetta-cli-conf/bitcoin_mainnet.json | 380 +++++ rosetta-cli-conf/bitcoin_testnet.json | 380 +++++ services/account_service.go | 75 + services/account_service_test.go | 106 ++ services/block_service.go | 81 ++ services/block_service_test.go | 120 ++ services/construction_service.go | 788 +++++++++++ services/construction_service_test.go | 399 ++++++ services/errors.go | 193 +++ services/errors_test.go | 40 + services/logger.go | 64 + services/mempool_service.go | 46 + services/mempool_service_test.go | 37 + services/network_service.go | 99 ++ services/network_service_test.go | 126 ++ services/router.go | 71 + services/types.go | 77 + utils/utils.go | 104 ++ zstd-train.sh | 24 + 62 files changed, 10395 insertions(+), 1 deletion(-) create mode 100644 .circleci/config.yml create mode 100644 .dockerignore create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yml create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Dockerfile create mode 100644 LICENSE.txt create mode 100644 Makefile create mode 100644 assets/bitcoin-mainnet.conf create mode 100644 assets/bitcoin-testnet.conf create mode 100644 assets/bitcoin.json create mode 100644 assets/mainnet-transaction.zstd create mode 100644 assets/testnet-transaction.zstd create mode 100644 bitcoin/client.go create mode 100644 bitcoin/client_fixtures/fee_rate.json create mode 100644 bitcoin/client_fixtures/get_block_hash_out_of_range_response.json create mode 100644 bitcoin/client_fixtures/get_block_hash_response.json create mode 100644 bitcoin/client_fixtures/get_block_not_found_response.json create mode 100644 bitcoin/client_fixtures/get_block_response.json create mode 100644 bitcoin/client_fixtures/get_block_response_2.json create mode 100644 bitcoin/client_fixtures/get_blockchain_info_response.json create mode 100644 bitcoin/client_fixtures/get_peer_info_response.json create mode 100644 bitcoin/client_fixtures/invalid_fee_rate.json create mode 100644 bitcoin/client_fixtures/rpc_in_warmup_response.json create mode 100644 bitcoin/client_test.go create mode 100644 bitcoin/node.go create mode 100644 bitcoin/types.go create mode 100644 bitcoin/utils.go create mode 100644 configuration/configuration.go create mode 100644 configuration/configuration_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 indexer/indexer.go create mode 100644 indexer/indexer_test.go create mode 100644 indexer/wait_table.go create mode 100644 main.go create mode 100644 mocks/indexer/client.go create mode 100644 mocks/services/client.go create mode 100644 mocks/services/indexer.go create mode 100644 rosetta-cli-conf/bitcoin_mainnet.json create mode 100644 rosetta-cli-conf/bitcoin_testnet.json create mode 100644 services/account_service.go create mode 100644 services/account_service_test.go create mode 100644 services/block_service.go create mode 100644 services/block_service_test.go create mode 100644 services/construction_service.go create mode 100644 services/construction_service_test.go create mode 100644 services/errors.go create mode 100644 services/errors_test.go create mode 100644 services/logger.go create mode 100644 services/mempool_service.go create mode 100644 services/mempool_service_test.go create mode 100644 services/network_service.go create mode 100644 services/network_service_test.go create mode 100644 services/router.go create mode 100644 services/types.go create mode 100644 utils/utils.go create mode 100755 zstd-train.sh diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..ae50fb8 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,103 @@ +# Copyright 2020 Coinbase, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +version: 2.1 +executors: + default: + docker: + - image: circleci/golang:1.13 + user: root # go directory is owned by root + working_directory: /go/src/github.com/coinbase/rosetta-sdk-go + environment: + - GO111MODULE: "on" + +fast-checkout: &fast-checkout + attach_workspace: + at: /go + +jobs: + setup: + executor: + name: default + steps: + - checkout + - run: make deps + - persist_to_workspace: + root: /go + paths: + - src + - bin + - pkg/mod/cache + test: + executor: + name: default + steps: + - *fast-checkout + - run: make test + lint: + executor: + name: default + steps: + - *fast-checkout + - run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.27.0 + - run: make lint + check-license: + executor: + name: default + steps: + - *fast-checkout + - run: make check-license + check-format: + executor: + name: default + steps: + - *fast-checkout + - run: make check-format + coverage: + executor: + name: default + steps: + - *fast-checkout + - run: make coverage + salus: + machine: true + steps: + - checkout + - run: make salus + +workflows: + version: 2 + build: + jobs: + - setup + - test: + requires: + - setup + - lint: + requires: + - setup + - check-license: + requires: + - setup + - check-format: + requires: + - setup + - coverage: + requires: + - setup + - salus + +notify: + webhooks: + - url: https://coveralls.io/webhook?repo_token=$COVERALLS_TOKEN diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2fab13c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +rosetta-bitcoin +bitcoin-data diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..4e098f8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,19 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: 'bug' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Additional context** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..36014cd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: 'enhancement' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..e4f01e9 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,16 @@ +Fixes # . + +### Motivation + + +### Solution + + +### Open questions + diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..81972fb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +# Copyright 2020 Coinbase, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +version: 2 + +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2fab13c --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +rosetta-bitcoin +bitcoin-data diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..31d6e13 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing to rosetta-bitcoin + +## Code of Conduct + +All interactions with this project follow our [Code of Conduct][code-of-conduct]. +By participating, you are expected to honor this code. Violators can be banned +from further participation in this project, or potentially all Coinbase projects. + +[code-of-conduct]: https://github.com/coinbase/code-of-conduct + +## Bug Reports + +* Ensure your issue [has not already been reported][1]. It may already be fixed! +* Include the steps you carried out to produce the problem. +* Include the behavior you observed along with the behavior you expected, and + why you expected it. +* Include any relevant stack traces or debugging output. + +## Feature Requests + +We welcome feedback with or without pull requests. If you have an idea for how +to improve the project, great! All we ask is that you take the time to write a +clear and concise explanation of what need you are trying to solve. If you have +thoughts on _how_ it can be solved, include those too! + +The best way to see a feature added, however, is to submit a pull request. + +## Pull Requests + +* Before creating your pull request, it's usually worth asking if the code + you're planning on writing will actually be considered for merging. You can + do this by [opening an issue][1] and asking. It may also help give the + maintainers context for when the time comes to review your code. + +* Ensure your [commit messages are well-written][2]. This can double as your + pull request message, so it pays to take the time to write a clear message. + +* Add tests for your feature. You should be able to look at other tests for + examples. If you're unsure, don't hesitate to [open an issue][1] and ask! + +* Submit your pull request! + +## Support Requests + +For security reasons, any communication referencing support tickets for Coinbase +products will be ignored. The request will have its content redacted and will +be locked to prevent further discussion. + +All support requests must be made via [our support team][3]. + +[1]: https://github.com/coinbase/rosetta-bitcoin/issues +[2]: https://medium.com/brigade-engineering/the-secrets-to-great-commit-messages-106fc0a92a25 +[3]: https://support.coinbase.com/customer/en/portal/articles/2288496-how-can-i-contact-coinbase-support- diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8405115 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,89 @@ +# Copyright 2020 Coinbase, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Build bitcoind +FROM ubuntu:18.04 as bitcoind-builder + +RUN mkdir -p /app \ + && chown -R nobody:nogroup /app +WORKDIR /app + +RUN apt-get update && apt-get install -y make gcc g++ autoconf autotools-dev bsdmainutils build-essential git libboost-all-dev \ + libcurl4-openssl-dev libdb++-dev libevent-dev libssl-dev libtool pkg-config python python-pip libzmq3-dev wget + +# VERSION: Bitcoin Core 0.20.1 +RUN git clone https://github.com/bitcoin/bitcoin \ + && cd bitcoin \ + && git checkout 7ff64311bee570874c4f0dfa18f518552188df08 + +RUN cd bitcoin \ + && ./autogen.sh \ + && ./configure --enable-glibc-back-compat --disable-tests --without-miniupnpc --without-gui --with-incompatible-bdb --disable-hardening --disable-zmq --disable-bench --disable-wallet \ + && make + +RUN mv bitcoin/src/bitcoind /app/bitcoind \ + && rm -rf bitcoin + +# Build Rosetta Server Components +FROM ubuntu:18.04 as rosetta-builder + +RUN mkdir -p /app \ + && chown -R nobody:nogroup /app +WORKDIR /app + +RUN apt-get update && apt-get install -y curl make gcc g++ +ENV GOLANG_VERSION 1.15.2 +ENV GOLANG_DOWNLOAD_SHA256 b49fda1ca29a1946d6bb2a5a6982cf07ccd2aba849289508ee0f9918f6bb4552 +ENV GOLANG_DOWNLOAD_URL https://golang.org/dl/go$GOLANG_VERSION.linux-amd64.tar.gz + +RUN curl -fsSL "$GOLANG_DOWNLOAD_URL" -o golang.tar.gz \ + && echo "$GOLANG_DOWNLOAD_SHA256 golang.tar.gz" | sha256sum -c - \ + && tar -C /usr/local -xzf golang.tar.gz \ + && rm golang.tar.gz + +ENV GOPATH /go +ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH +RUN mkdir -p "$GOPATH/src" "$GOPATH/bin" && chmod -R 777 "$GOPATH" + +# Use native remote build context to build in any directory +COPY . src +RUN cd src \ + && go build \ + && cd .. \ + && mv src/rosetta-bitcoin /app/rosetta-bitcoin \ + && mv src/assets/* /app \ + && rm -rf src + +## Build Final Image +FROM ubuntu:18.04 + +RUN apt-get update && apt-get install -y libevent-dev libboost-system-dev libboost-filesystem-dev libboost-test-dev libboost-thread-dev + +RUN mkdir -p /app \ + && chown -R nobody:nogroup /app \ + && mkdir -p /data \ + && chown -R nobody:nogroup /data + +WORKDIR /app + +# Copy binaries from build containers +COPY --from=bitcoind-builder /app/* /app/ + +# Copy configuration files and set permissions +COPY --from=rosetta-builder /app/* /app/ + +# Set permissions for everything added to /app +RUN chmod -R 755 /app/* + +CMD ["/app/rosetta-bitcoin"] diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..5df8419 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2020 Coinbase, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5e2f88a --- /dev/null +++ b/Makefile @@ -0,0 +1,84 @@ +.PHONY: deps build run lint mocks run-mainnet-online run-mainnet-offline run-testnet-online \ + run-testnet-offline check-comments add-license check-license shorten-lines test \ + coverage spellcheck salus build-local coverage-local format check-format + +ADDLICENSE_CMD=go run github.com/google/addlicense +ADDLICENCE_SCRIPT=${ADDLICENSE_CMD} -c "Coinbase, Inc." -l "apache" -v +SPELLCHECK_CMD=go run github.com/client9/misspell/cmd/misspell +GOLINES_CMD=go run github.com/segmentio/golines +GOLINT_CMD=go run golang.org/x/lint/golint +GOIMPORTS_CMD=go run golang.org/x/tools/cmd/goimports +GO_PACKAGES=./services/... ./indexer/... ./bitcoin/... ./configuration/... +GO_FOLDERS=$(shell echo ${GO_PACKAGES} | sed -e "s/\.\///g" | sed -e "s/\/\.\.\.//g") +TEST_SCRIPT=go test ${GO_PACKAGES} +LINT_SETTINGS=golint,misspell,gocyclo,gocritic,whitespace,goconst,gocognit,bodyclose,unconvert,lll,unparam +PWD=$(shell pwd) +NOFILE=100000 + +deps: + go get ./... + +build: + docker build -t rosetta-bitcoin:latest https://github.com/coinbase/rosetta-bitcoin.git + +build-local: + docker build -t rosetta-bitcoin:latest . + +run-mainnet-online: + docker run -d --ulimit "nofile=${NOFILE}:${NOFILE}" -v "${PWD}/bitcoin-data:/data" -e "MODE=ONLINE" -e "NETWORK=MAINNET" -e "PORT=8080" -p 8080:8080 -p 8333:8333 rosetta-bitcoin:latest + +run-mainnet-offline: + docker run -d -e "MODE=OFFLINE" -e "NETWORK=MAINNET" -e "PORT=8081" -p 8081:8081 rosetta-bitcoin:latest + +run-testnet-online: + docker run -d --ulimit "nofile=${NOFILE}:${NOFILE}" -v "${PWD}/bitcoin-data:/data" -e "MODE=ONLINE" -e "NETWORK=TESTNET" -e "PORT=8080" -p 8080:8080 -p 18333:18333 rosetta-bitcoin:latest + +run-testnet-offline: + docker run -d -e "MODE=OFFLINE" -e "NETWORK=TESTNET" -e "PORT=8081" -p 8081:8081 rosetta-bitcoin:latest + +train: + ./zstd-train.sh $(network) transaction $(data-directory) + +check-comments: + ${GOLINT_CMD} -set_exit_status ${GO_FOLDERS} . + +lint: | check-comments + golangci-lint run -v -E ${LINT_SETTINGS},gomnd + +add-license: + ${ADDLICENCE_SCRIPT} . + +check-license: + ${ADDLICENCE_SCRIPT} -check . + +shorten-lines: + ${GOLINES_CMD} -w --shorten-comments ${GO_FOLDERS} . + +format: + gofmt -s -w -l . + ${GOIMPORTS_CMD} -w . + +check-format: + ! gofmt -s -l . | read + ! ${GOIMPORTS_CMD} -l . | read + +test: + ${TEST_SCRIPT} + +coverage: + if [ "${COVERALLS_TOKEN}" ]; then ${TEST_SCRIPT} -coverprofile=c.out -covermode=count; ${GOVERALLS_CMD} -coverprofile=c.out -repotoken ${COVERALLS_TOKEN}; fi + +coverage-local: + ${TEST_SCRIPT} -cover + +salus: + docker run --rm -t -v ${PWD}:/home/repo coinbase/salus + +spellcheck: + ${SPELLCHECK_CMD} -error . + +mocks: + rm -rf mocks; + mockery --dir indexer --all --case underscore --outpkg indexer --output mocks/indexer; + mockery --dir services --all --case underscore --outpkg services --output mocks/services; + ${ADDLICENCE_SCRIPT} .; diff --git a/README.md b/README.md index 62e29cb..2438144 100644 --- a/README.md +++ b/README.md @@ -1 +1,186 @@ -rosetta-bitcoin +

+ + Rosetta + +

+

+ Rosetta Bitcoin +

+

+ + + + + +

+ +

+ROSETTA-BITCOIN IS CONSIDERED ALPHA SOFTWARE. +USE AT YOUR OWN RISK! COINBASE ASSUMES NO RESPONSIBILITY NOR LIABILITY IF THERE IS A BUG IN THIS IMPLEMENTATION. +

+ +## Overview +`rosetta-bitcoin` provides a reference implementation of the Rosetta API for +Bitcoin in Golang. If you haven't heard of the Rosetta API, you can find more +information [here](https://rosetta-api.org). + +## Features +* Rosetta API implementation (both Data API and Construction API) +* UTXO cache for all accounts (accessible using `/account/balance`) +* Stateless, offline, curve-based transaction construction from any SegWit-Bech32 Address + +## Usage +As specified in the [Rosetta API Principles](https://www.rosetta-api.org/docs/automated_deployment.html), +all Rosetta implementations must be deployable via Docker and support running via either an +[`online` or `offline` mode](https://www.rosetta-api.org/docs/node_deployment.html#multiple-modes). + +To build a Docker image from this repository, run the command `make build`. To start +`rosetta-bitcoin`, you can run: +* `make run-mainnet-online` +* `make run-mainnet-offline` +* `make run-testnet-online` +* `make run testnet-offline` + +By default, running these commands will create a data directory at `/bitcoin-data` +and start the `rosetta-bitcoin` server at port `8080`. + +## System Requirements +`rosetta-bitcoin` has been tested on an [AWS c5.2xlarge instance](https://aws.amazon.com/ec2/instance-types/c5). +This instance type has 8 vCPU and 16 GB of RAM. If you use a computer with less than 16 GB of RAM, +it is possible that `rosetta-bitcoin` will exit with an OOM error. + +### Recommended OS Settings +To increase the load `rosetta-bitcoin` can handle, it is recommended to tune your OS +settings to allow for more connections. On a linux-based OS, you can run the following +commands ([source](http://www.tweaked.io/guide/kernel)): +```text +sysctl -w net.ipv4.tcp_tw_reuse=1 +sysctl -w net.core.rmem_max=16777216 +sysctl -w net.core.wmem_max=16777216 +sysctl -w net.ipv4.tcp_max_syn_backlog=10000 +sysctl -w net.core.somaxconn=10000 +sysctl -p (when done) +``` +You should also modify your open file settings to `100000`. This can be done on a linux-based OS +with the command: `ulimit -n 100000`. + +### Optimizations +* Automatically prune bitcoind while indexing blocks +* Reduce sync time with concurrent block indexing +* Use [Zstandard compression](https://github.com/facebook/zstd) to reduce the size of data stored on disk +without needing to write a manual byte-level encoding + +#### Concurrent Block Syncing +To speed up indexing, `rosetta-bitcoin` uses concurrent block processing +with a "wait free" design (using channels instead of sleeps to signal +which threads are unblocked). This allows `rosetta-bitcoin` to fetch +multiple inputs from disk while it waits for inputs that appeared +in recently processed blocks to save to disk. +```text + +----------+ + | bitcoind | + +-----+----+ + | + | + +---------+ fetch block data / unpopulated txs | + | block 1 <------------------------------------+ + +---------+ | + +--> tx 1 | | + | +---------+ | + | | tx 2 | | + | +----+----+ | + | | | + | | +---------+ | + | | | block 2 <-------------------+ + | | +---------+ | + | +-----------> tx 3 +--+ | + | +---------+ | | + +-------------------> tx 4 | | | + | +---------+ | | + | | | + | retrieve previously synced | +---------+ | + | inputs needed for future | | block 3 <--+ + | blocks while waiting for | +---------+ + | populated blocks to save to +---> tx 5 | + | disk +---------+ + +------------------------------------> tx 6 | + | +---------+ + | + | ++------+--------+ +| coin_storage | ++---------------+ +``` + +### Architecture +`rosetta-bitcoin` uses the `syncer`, `storage`, `parser`, and `server` package +from [`rosetta-sdk-go`](https://github.com/coinbase/rosetta-sdk-go) instead +of a new Bitcoin-specific implementation of packages of similar functionality. Below +you can find a high-level overview of how everything fits together: +```text + +------------------------------------------------------------------+ + | | + | +--------------------------------------+ | + | | | | + | | indexer | | + | | | | + | | +--------+ | | + +-------------------+ pruner <----------+ | | + | | +--------+ | | | + +-----v----+ | | | | + | bitcoind | | +------+--------+ | | + +-----+----+ | +--------> block_storage <----+ | | + | | | +---------------+ | | | + | | +---+----+ | | | + +-------------------> syncer | | | | + | +---+----+ | | | + | | +--------------+ | | | + | +--------> coin_storage | | | | + | +------^-------+ | | | + | | | | | + +--------------------------------------+ | + | | | ++-------------------------------------------------------------------------------------------+ | +| | | | | +| +------------------------------------------------------------+ | | | +| | | | | +| | +---------------------+-----------------------+------+ | | +| | | | | | | +| +-------+---------+ +-------+---------+ +-------+-------+ +-----------+----------+ | | +| | account_service | | network_service | | block_service | | construction_service +--------+ +| +-----------------+ +-----------------+ +---------------+ +----------------------+ | +| | +| server | +| | ++-------------------------------------------------------------------------------------------+ +``` + +## Testing with rosetta-cli +To validate `rosetta-bitcoin`, [install `rosetta-cli`](https://github.com/coinbase/rosetta-cli#install) +and run one of the following commands: +* `rosetta-cli check:data --configuration-file rosetta-cli-conf/bitcoin_testnet.json` +* `rosetta-cli check:construction --configuration-file rosetta-cli-conf/bitcoin_testnet.json` +* `rosetta-cli check:data --configuration-file rosetta-cli-conf/bitcoin_mainnet.json` +* `rosetta-cli check:construction --configuration-file rosetta-cli-conf/bitcoin_mainnet.json` + +## Future Work +* Publish benchamrks for sync speed, storage usage, and load testing +* Rosetta API `/mempool/*` implementation +* Add CI test using `rosetta-cli` to run on each PR (likely on a regtest network) +* Add performance mode to use unlimited RAM (implementation currently optimized to use <= 16 GB of RAM) +* Support Multi-Sig Sends + +_Please reach out on our [community](https://community.rosetta-api.org) if you want to tackle anything on this list!_ + +## Development +* `make deps` to install dependencies +* `make test` to run tests +* `make lint` to lint the source code +* `make salus` to check for security concerns +* `make build-local` to build a Docker image from the local context +* `make coverage-local` to generate a coverage report + +## License +This project is available open source under the terms of the [Apache 2.0 License](https://opensource.org/licenses/Apache-2.0). + +© 2020 Coinbase diff --git a/assets/bitcoin-mainnet.conf b/assets/bitcoin-mainnet.conf new file mode 100644 index 0000000..811b92f --- /dev/null +++ b/assets/bitcoin-mainnet.conf @@ -0,0 +1,20 @@ +## +## bitcoin.conf configuration file. Lines beginning with # are comments. +## + +datadir=/data/bitcoind +bind=0.0.0.0 +rpcbind=0.0.0.0 +bantime=15 +rpcallowip=0.0.0.0/0 +rpcthreads=16 +rpcworkqueue=1000 +disablewallet=1 +txindex=0 +port=8333 +rpcport=8332 +rpcuser=rosetta +rpcpassword=rosetta + +# allow manual pruning +prune=1 diff --git a/assets/bitcoin-testnet.conf b/assets/bitcoin-testnet.conf new file mode 100644 index 0000000..dcae0d7 --- /dev/null +++ b/assets/bitcoin-testnet.conf @@ -0,0 +1,22 @@ +## +## bitcoin.conf configuration file. Lines beginning with # are comments. +## +datadir=/data/bitcoind +bantime=15 +rpcallowip=0.0.0.0/0 +rpcthreads=16 +rpcworkqueue=1000 +disablewallet=1 +txindex=0 +rpcuser=rosetta +rpcpassword=rosetta +# allow manual pruning +prune=1 + +testnet=1 + +[test] +port=18333 +bind=0.0.0.0 +rpcport=18332 +rpcbind=0.0.0.0 diff --git a/assets/bitcoin.json b/assets/bitcoin.json new file mode 100644 index 0000000..27d9d22 --- /dev/null +++ b/assets/bitcoin.json @@ -0,0 +1,17 @@ +{ + "network": { + "blockchain": "Bitcoin", + "network": "Mainnet" + }, + "online_url": "http://localhost:8080", + "data_directory": "", + "http_timeout": 10, + "sync_concurrency": 8, + "transaction_concurrency": 16, + "tip_delay": 300, + "disable_memory_limit": false, + "log_configuration": false, + "data": { + "historical_balance_disabled": true + } +} diff --git a/assets/mainnet-transaction.zstd b/assets/mainnet-transaction.zstd new file mode 100644 index 0000000000000000000000000000000000000000..c066f824eb812c03fd07b1ffa51b8cff53066e6b GIT binary patch literal 112640 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 C0YL!( literal 0 HcmV?d00001 diff --git a/assets/testnet-transaction.zstd b/assets/testnet-transaction.zstd new file mode 100644 index 0000000000000000000000000000000000000000..967bde841729a94e7bacd6c57a454c2713af8afc GIT binary patch literal 112640 zcmd4ad5olaniuw}o`YeRV}M<0k!*PxFvdrthCa#D>-QJ`wQK+Uk8I?PANirrz4i6RkA3m;KmFE? zFaE)|HvX$0@`C=vwa@zR7yP$w{K8uO=z|}my`+(L8m(5d(I`5-PM#*ss*(2FS-a@8 z>UP~|_RCh@>nE)uY4oe4%FABa=yvO7-AMDi+e%winiZ{Xr+4-$b>8R~-F{Mby6t9> zH`?)+jc%vg@0V$w^m=)jXKC7MHPU9MDwAfrZg#S&Uo|?NMz7s%G`*Lk>NQF)+pJps zw2|jkGw*ilUL#Grjk?=yHIl4XwECT9tDm)c{eHh{SDkj6cf59|UDS>D4(h$5db_Ou zo)3HNU;Fh3e{pBG-p>!$cecN}Ue()&>;3h5|DA8GZ&&rB!SC`Lum6SZ`tW#XfAg!u z|M;7SC%g5xzA`(vwD|3V!~F2*;N69#v9XDTg?IC^+&S7l{QA3jRqfXY2Os>_)@-&s zv3_&5_3*~*>SBL&^TzV*%ErUHyvlBeQ>f>?0oH;hc_3;-mB_zeJlUk!7q7-U-)jhv%dY+axLFpt>5{JgTHz9 zalZckv%mh}W|8K;!MxSzrM-49Ppe+5mG;Vhztc$ig~!#Pm6X|BvrS_5>L39)QzrBk|nKX-b;#p-OQ?P7BA3?SIatSk(OoA>Gm2) z*KhxiZ`{89pT@gNyPT9zO*;%98ZYBO*yI)tmPOIuR+i9~`6b)y6a3@+WXD_SU z&2H*qHtRG^dacx@>AGBLBT0MRvR}tXZ)SbBtI=w>qrFDQZ;MxJUGh$R{>69V^B27n zMfdQqdboD5w>o!x?{s6XJ9WHudQ@(7n)PbCRm^`pccS0VdsWvd&zp^`uFFnRJO7~)o*vwM%HuSoS4hpiU05Xoe(C=Ue;_TS-acsW_|xt zw9`)MV)T=qIM!-*lFC0Sh2Xp{`i)LrHtM?DaGjH0|8bN}%SI=295Z33&a1jpS7kpb z1lGJxOK-g8hxF5|)vi)O(gASm9ZJW&Rn^VmJCXK#Vr?F8py@a_d?RT$Yi3!U8M8cvk zxU{pT@4sLB3VkwfcKAIdbrt`jZ1#I)vzIv=wJ??Sx`~LEP?_`_9U$>ZXOv>AeekdG3Zc3*o3J>#T0{ z^T$uFoko_-%ZOHa-!m<$BCq_kR-uCH+pPbm}{eIw{JY$LaZa4L>#Qw@T-{ zSyqkQvClgFw20rGq*<@gE9z#ynRW9{zu(GJXSFXRmwgYiT~(DYzR~g~>mq9=%~mGl zHN4X*7r;x&rr+tME<``CQiVz795ub|tl8)mRo>6DR>Lui_t7fkmPQ_B!}URkdw=(+ z*sM?1@`JVSJkIg;!Owp1rS0TSb!%>R>;C-a_~g>;?c1H3<=x)g>gK`W;=R?W!`|^o zCC6u89A3|U_P2jxX>pXA<4aqsRli8@be0dNtKFsj^>l1vtur>6Y|Ir0d3$4bt2rEA z-*x5^CC3$49Jv^k3a_=gu698Bse9r1XOY4s!FJcZXesUbtu&L@<-WceppqPmJkPS> z==#00QfI%;59{h_&GGf`oz)z3N5!rB1L<#1Xu>bB$0Ow2FcxP5Qp?pG%7$KQ@kPmJAK_{!9v zL~dm+c{3gL<+|Oxe0RUzTUcK`cv@9{!)WWtl*481KmA|7{=x4a6rS-vQF`SnAZ0|->~;j_ zB+ENh(Gi4`URfq>#gIgjrD9D}m=IA!+;_fvxSwwyoRx?_{a4QY&ABT3>)(vq^TF>< znq|@G2zK2nD;vEkt>vk9(e9|lI#nyHdcuj2*49eQ|RTS!>x+HGVGqE4F>HUv6Bq zr2pWTRnCtq@ou8@Oo~RK5*?(MtoA{>Ra>jrY$v_K&+3chN`SJ=lXko7Vb?une$iI( z3^IF1f|jR>y6*H96%s+eo%efc)x2=*`dQMK=H(K#z3Zz|=%kfHSh$ALMsb#t8!b7w z%G%9VBIRYOwpxG}^rbOBukE$lQ8E?1T#qQTR8`rN^Q0_4uIq~#T<%{!%gO2;|Gv{w zTI7j`r<`u|65p)MkV`VEvqsh|s;bDOrBY~1+Z_?Kt6UNp1~qJdRds3%&B$y(9}Q3}>N*kIF)~Uyz0V(H}aWg<3jSwOFA_REkAC-UC%c%g`vB z-K6dVhSEw=Sw;1jDd}CuR{KI(sL@ujf+O6eR^96L01Al+rU6hnDnRo#fUDUlnk{fr zDM!_&P_vN<=_Yp-msjHR8U*~JIXflA+f zJMDGE&osO8Do?O#X_@n)Oq6#J8z2RcN0$mi@=RKtx^CpnzT+LS4kV}ZQp76;&ujfmL#Ar#adg-XzgJD4>hlGH0xH>W zwQ~nb=^>(fgVAX!ZCV*{5_(g^HN8YqwmXt%k+%n#Y0#H8w3Y3$4`S^2OAUu+Q+(;l z_nM8*y-**%1d5Z+8c|JUzfJ%J(a$9+aw!x} z>0zn~(;8q0M@u?DEMKc{Q(@7#!icQVfNvyFgQBN&h;!4?RW|%U4Kxfc?`oBke8h-6 z>dRdsBERzKy*MIY`!hok`P!fPh=}~w;R_=2*ZWUf_5h1+Q1O3-kmB6`k7-SQ=q=?trm#v1w za@G|*y%6ub7tleGAJqR{!_n;&4Z{SbSkEXbiF7r13|BfMx@c_PNq1Vu^~OYdak;(H znOYw&rknfwn|E%_%=FULjs0Ty@VvR$tOSOMKyI>5mdJuKXx8g%&dbV!E%i^*mhMDV z-2%)ReRL|fJqjWXO!F#Za~uAr(radoEQJ??UBEXyXRuFrqol=GI#oB7G+N;2C{dc0tRKhmhVed zQ#V1mf_J97Q3)BamKTf7`gyplr=qjg^}2t4b-bF`n{~TdYei36q0^QPqJi%Fp@mUK z*R-LZe=@OoYy19qlCO<7C;RtjR-04Fqtlh~llH-b{`}#}#=&epGkm?iD>k1+g)0xw zVz0_r1k6pf`%RC>l*gCWtc>k?b*b!#fr^1cCU+L_4g+xmN@#vhi0i#xcs3J~w-a*` zXn`bP=4l24398#=E9uo&2&o&WN3rH zZAhkwGTzDjL-0OiU$I|woXVt?H#2iAm^#qI;QujbBYP)>5mpQ(N+TO3vVH+{(QWs% z>s~kSnlc~^z%-hwZv|i$?Xn4$DP~cgkrwi2;r)Z(-cY{PM%i#h*}$ZrD_k_eW2OL# z`?FukC2^4}s>6?Wrp6YJ#?r-pd*S%T`qbUyiSEf!z0iJ`+%2=U?(N~j_~v50sh*mu zaNrF2)3OPs6X`*%4Luvw1T#b1lX#4+qz0AsloX~znMcxquQ6??qU3Vn|3b0etj6>dW1vb> zWdQK3V?5XD=-A32OtgAA`a(?h)CjUmnw#u)@}vZ)mZp@SP^@2XEsu2{mG}Aw{nLpD zt?{K3!=DiARf5Q)|Q5>)p4_|CKaXT!yzAbrnj7>w@-zd6%JMN)FNEE+n@jFMFo^?t((!6ycCV*q3>3vA zrvyc}An#@GDKzhgfQEbz?T)b#Fubb`fYZwmM&YeiS!St_?R5)-743rZ+|aOZhymV9 zI{hGQGt7SQB_juZ}n)H0%hg{_lnZX zpt_a5lF}D0t9pyFGvltg|`e zSM=2#6-cOwq*Uk9Ng`ajq;gTMi$Gt+3o_`w_;KD&=etMk@!3a<_>@y=IW}D{KDtwN zZ{04YR}Sw^j?Ih&8NOshdNH03ZgBf#^G2F3O&1G02dkY2dvoJstGnAv?S-+`yL-15 z(xc&Lho}?3uACa&M4nQ>;a-^0Ai$*sewm?0YBY@|qggH5X7>nx;!@ubL-y$+<276Q zzD{GfDIGm7{;qQjovupAupYP4`)HWFqNjDmL3~@>U>rZwSSP6WG$0P@g5MczoKgR1 zH+u;e=Zr%1Jf~}v$Mc3_-{${yF7`=LTK}jtIugHuwJHhTiKK1**uuTT*>9sBKymvp zUYHrs0O909u}|Hp66tk&8vD4R3XlXL9~}X>nkmC-%mzhN=28M<6?zD(LFZN$Veu5_ zV&9)0=>}a1=&pKrklcE7@^Ehacvm5sxPR=#6yVch4{dpg^Ud>1x zkX&{218t+CYm5?9v%$Et6`iudXb%O~RMbQ3CJ+~0fr_`$g0X;m%nDPq?sB-axNyC% z0Pj`0Uh^{I3&fkCilY)hY`Vw=*qkPr1#qSViNQ93(qw_LLzM952UHvkp%huAk^8qX z+o@5%QVoR9gbHb{sY=0)PT*8ZZiFu^nF4rYa0=#cww|Cm%2<+s zv7l?Ev0&AO4&mrx_!|qL0ioOE#=OYgn5}L|sl@Ac#ge-3UypP=f6I$GFu&zuT(QIL z`|07t?aB4|o%{Qf^C#)lSpRu~sZzWcU=dG)<0_B1w&XN0+nl;xYw zi+))tVk%#tuaZC~gZ*{&rfpTHQl{tI#JtUbW}SREypMD?&y9o(!vP4wSBr4M+^&ze z?g{nMBGY4~$Q)hBj*cj;LC9DH8pDA2jLh|LP3(ffNQep~!P++Ase=cFBv0#sx&bB1 zY7MMjuu9ctp=`nUhF&=fKx&;Zs`Qi7sk_DPiB_)!gd%nO53NLu>b))X1*R1WzCn}= zs=>kwEesobNOXafS7+Vm56*X*pN`LWb~Rr2(_OMt{L+gr%a<;6S+>*m+V085&K~}G z>*mVhN_Bd?vwCuKt<`LmI~(^NOh12F5OJ>lvedZ~8WAzY->(DN0I&s>Fo^f?nFJuF z?5UNu2>NwFcF-`@#3*)!aD*;#S)Kql!AQf`2caSGH5I>6ZfQZ_5)e1|Kny|M4?vs2 zON#K>#nd0rdfey&ZFX=~AhoEHjZz1zHVd=_L#5g3B7Umq;@p=RWl`;ZgWY{p@H^C`mP7*imRu0rXEAkY8yD!3N%T; zoA9q_SP<_h3Ls)W4>Y;aN)&El`rthmn4-SyycT_k&^p~j&7p}vkdzC=pio+92&fS; zIq~bHE4UEeJx54KqCHp>%+;|cx( zWd(4ho8*@~{y+BOVa#QLi^r$`2JL-&0%p9DQ8xwch&tH`0RbLcJEZ{8KaHt|w zmWbpqW~T$z(P{OH1Qkj=>36BUAlf~kIx0+4GmZFXZ1Z{-rEV+ZYHDX?6UuZE!G8Ixbo)L6XKQ7dzXXin`Q0BK3882OJ9Zc;H)U54GM%SS)k}B)QPKc3UlP z86uA%@BL!TCk;@Hn-T+G3aT`Jk|iSM4nT>9cL24?Wy_w96!a7d#Hj(OGC8FVv^z2)JmZO*n=ALo3q+7GN@Idy!RqBy_P{?ttL?WH27^zV^Vp znxv(je?sK2t%o=7Zcnb=-S5oL&z;=9JG-Ms9bdS2*e-APx9+Xw_lD2xdv8DHA6#*l zYTjEYa{Iji3Ftj5Nnczn~q_ppcDv3++L4|2|^P6B+rZ;Io}Y_SgMnmyGN$p+=nHp(9!) zd}-@o{B$CpKHZrndt>W&Z7%A4Gd01y9>_o z&lnHT-@?9)9I$JkwIV!CR4DsNRUbD2@DgQxCy*Zs$B=}Xeu8xrx@5K(p+I{60cM>F zyxNo!U}KCZSLppg1W#^?qz1zR8-W7Ue$bdi!3D6_(zm6wP@qE%f?uj;1Vi#7AgMJ+ z3yHcvN@S}6r|p3&8POC1v%bb0+8ff(S7xN9}rqhMH!rXGsi0vGaHX?KG^QuUprjtHK*=8SeSco zymUK#bhtaSG<|W)N6A&E)FLxu&6%_PUt&CE;)N>N$r3RqN6C(YmnEpqrZbyB&i%*5N zUitJ!0u8TZD)gnT{oeTO{m$mEXrjrwwzI7qL2cF!h8 z7aEWcjn+RrEx-Pw|6*ujgrb8n3Ymd(3Ze=uHWQpXP;c70-yWnF;WH6raU=~6aO(}d z&noe={s1Wj1;L*dcBPPl8lwRk4b(*D3)2cDab-2IhQU*??t!~VL>NA>D;Fi#0R?qlMDi?Cl3u)hj`Y&R*w{( zF2;EsL*1Cup}b`0sVg6+m%`D>X}#MkvZdnDy_3W0!E*a}?Vx=(nQ3(%uJ#t!i{b0_ zowr38-&e19#WhpfQ2LO7;&`dHX&e-y=Hc#?GmY73HswpKRNVSxeSzVV5vpmp${U)I z58tklCS*_h;-+a^-U4c179tP^e3R*Rb*zv|fDRctUyQ7e&?%~_-?gXg2k1G_(?rN9 zz*_-~>r051)mEXzN$C^`Pp@J(JaT!6QF&=4B`e7^fO@@{9pj%`u>S%Py%X;tq z?2DnopS=)NxSihU&J-I{`Sim4t-Xc0xoYy(?#jmY(tf!=m&}hHR>SA(yKlcS%_|;i zJERp1vQVICE)1pND;{xB4l}S_N4HjC)-t2hyUE#XGrIaaV+|ShbxxSf%N@*s$k>v> zL3rdPMOSKYtVnjiFFbm3-Wz$%elPl75DQKn3LNy0M9&Fp0vm;<7HbZmCaBHR

3! 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 eNYKwVdj;5^)03cYi+bnvx+V^3!!tt 1 { + message = messages[1] + } + + // Print debug log if from bitcoindLogger + if identifier == bitcoindLogger { + logger.Debugw(message) + continue + } + + logger.Warnw(message) + } +} + +// StartBitcoind starts a bitcoind daemon in another goroutine +// and logs the results to the console. +func StartBitcoind(ctx context.Context, configPath string, g *errgroup.Group) error { + logger := utils.ExtractLogger(ctx, "bitcoind") + cmd := exec.Command( + "/app/bitcoind", + fmt.Sprintf("--conf=%s", configPath), + ) // #nosec G204 + + stdout, err := cmd.StdoutPipe() + if err != nil { + return err + } + + stderr, err := cmd.StderrPipe() + if err != nil { + return err + } + + g.Go(func() error { + return logPipe(ctx, stdout, bitcoindLogger) + }) + + g.Go(func() error { + return logPipe(ctx, stderr, bitcoindStdErrLogger) + }) + + if err := cmd.Start(); err != nil { + return fmt.Errorf("%w: unable to start bitcoind", err) + } + + g.Go(func() error { + <-ctx.Done() + + logger.Warnw("sending interrupt to bitcoind") + return cmd.Process.Signal(os.Interrupt) + }) + + return cmd.Wait() +} diff --git a/bitcoin/types.go b/bitcoin/types.go new file mode 100644 index 0000000..9891157 --- /dev/null +++ b/bitcoin/types.go @@ -0,0 +1,493 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bitcoin + +import ( + "fmt" + "strings" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/coinbase/rosetta-sdk-go/types" +) + +const ( + // Blockchain is Bitcoin. + Blockchain string = "Bitcoin" + + // MainnetNetwork is the value of the network + // in MainnetNetworkIdentifier. + MainnetNetwork string = "Mainnet" + + // TestnetNetwork is the value of the network + // in TestnetNetworkIdentifier. + TestnetNetwork string = "Testnet3" + + // Decimals is the decimals value + // used in Currency. + Decimals = 8 + + // SatoshisInBitcoin is the number of + // Satoshis in 1 BTC (10^8). + SatoshisInBitcoin = 100000000 + + // InputOpType is used to describe + // INPUT. + InputOpType = "INPUT" + + // OutputOpType is used to describe + // OUTPUT. + OutputOpType = "OUTPUT" + + // CoinbaseOpType is used to describe + // Coinbase. + CoinbaseOpType = "COINBASE" + + // SuccessStatus is the status of all + // Bitcoin operations because anything + // on-chain is considered successful. + SuccessStatus = "SUCCESS" + + // SkippedStatus is the status of all + // operations that are skipped because + // of BIP-30. You can read more about these + // types of operations in BIP-30. + SkippedStatus = "SKIPPED" + + // TransactionHashLength is the length + // of any transaction hash in Bitcoin. + TransactionHashLength = 64 + + // NullData is returned by bitcoind + // as the ScriptPubKey.Type for OP_RETURN + // locking scripts. + NullData = "nulldata" +) + +// Fee estimate constants +// Source: https://bitcoinops.org/en/tools/calc-size/ +const ( + MinFeeRate = float64(0.00001) // nolint:gomnd + TransactionOverhead = 12 // 4 version, 2 segwit flag, 1 vin, 1 vout, 4 lock time + InputSize = 68 // 4 prev index, 32 prev hash, 4 sequence, 1 script size, ~27 script witness + OutputOverhead = 9 // 8 value, 1 script size + P2PKHScriptPubkeySize = 25 // P2PKH size +) + +var ( + // MainnetGenesisBlockIdentifier is the genesis block for mainnet. + MainnetGenesisBlockIdentifier = &types.BlockIdentifier{ + Hash: "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + } + + // MainnetParams are the params for mainnet. + MainnetParams = &chaincfg.MainNetParams + + // MainnetCurrency is the *types.Currency for mainnet. + MainnetCurrency = &types.Currency{ + Symbol: "BTC", + Decimals: Decimals, + } + + // TestnetGenesisBlockIdentifier is the genesis block for testnet. + TestnetGenesisBlockIdentifier = &types.BlockIdentifier{ + Hash: "000000000933ea01ad0ee984209779baaec3ced90fa3f408719526f8d77f4943", + } + + // TestnetParams are the params for testnet. + TestnetParams = &chaincfg.TestNet3Params + + // TestnetCurrency is the *types.Currency for testnet. + TestnetCurrency = &types.Currency{ + Symbol: "tBTC", + Decimals: Decimals, + } + + // OperationTypes are all supported operation.Types. + OperationTypes = []string{ + InputOpType, + OutputOpType, + CoinbaseOpType, + } + + // OperationStatuses are all supported operation.Status. + OperationStatuses = []*types.OperationStatus{ + { + Status: SuccessStatus, + Successful: true, + }, + { + Status: SkippedStatus, + Successful: false, + }, + } +) + +// ScriptPubKey is a script placed on the output operations +// of a Bitcoin transaction that must be satisfied to spend +// the output. +type ScriptPubKey struct { + ASM string `json:"asm"` + Hex string `json:"hex"` + RequiredSigs int64 `json:"reqSigs,omitempty"` + Type string `json:"type"` + Addresses []string `json:"addresses,omitempty"` +} + +// ScriptSig is a script on the input operations of a +// Bitcoin transaction that satisfies the ScriptPubKey +// on an output being spent. +type ScriptSig struct { + ASM string `json:"asm"` + Hex string `json:"hex"` +} + +// BlockchainInfo is information about the Bitcoin network. +// This struct only contains the information necessary for +// this implementation. +type BlockchainInfo struct { + Chain string `json:"chain"` + Blocks int64 `json:"blocks"` + BestBlockHash string `json:"bestblockhash"` +} + +// PeerInfo is a collection of relevant info about a particular peer. +type PeerInfo struct { + Addr string `json:"addr"` + Version int64 `json:"version"` + SubVer string `json:"subver"` + StartingHeight int64 `json:"startingheight"` + RelayTxes bool `json:"relaytxes"` + LastSend int64 `json:"lastsend"` + LastRecv int64 `json:"lastrecv"` + BanScore int64 `json:"banscore"` + SyncedBlocks int64 `json:"synced_blocks"` + SyncedHeaders int64 `json:"synced_headers"` +} + +// Block is a raw Bitcoin block (with verbosity == 2). +type Block struct { + Hash string `json:"hash"` + Height int64 `json:"height"` + PreviousBlockHash string `json:"previousblockhash"` + Time int64 `json:"time"` + MedianTime int64 `json:"mediantime"` + Nonce int64 `json:"nonce"` + MerkleRoot string `json:"merkleroot"` + Version int32 `json:"version"` + Size int64 `json:"size"` + Weight int64 `json:"weight"` + Bits string `json:"bits"` + Difficulty float64 `json:"difficulty"` + + Txs []*Transaction `json:"tx"` +} + +// Metadata returns the metadata for a block. +func (b Block) Metadata() (map[string]interface{}, error) { + m := &BlockMetadata{ + Nonce: b.Nonce, + MerkleRoot: b.MerkleRoot, + Version: b.Version, + Size: b.Size, + Weight: b.Weight, + MedianTime: b.MedianTime, + Bits: b.Bits, + Difficulty: b.Difficulty, + } + + return types.MarshalMap(m) +} + +// BlockMetadata is a collection of useful +// metadata in a block. +type BlockMetadata struct { + Nonce int64 `json:"nonce,omitempty"` + MerkleRoot string `json:"merkleroot,omitempty"` + Version int32 `json:"version,omitempty"` + Size int64 `json:"size,omitempty"` + Weight int64 `json:"weight,omitempty"` + MedianTime int64 `json:"mediantime,omitempty"` + Bits string `json:"bits,omitempty"` + Difficulty float64 `json:"difficulty,omitempty"` +} + +// Transaction is a raw Bitcoin transaction. +type Transaction struct { + Hex string `json:"hex"` + Hash string `json:"txid"` + Size int64 `json:"size"` + Vsize int64 `json:"vsize"` + Version int32 `json:"version"` + Locktime int64 `json:"locktime"` + Weight int64 `json:"weight"` + + Inputs []*Input `json:"vin"` + Outputs []*Output `json:"vout"` +} + +// Metadata returns the metadata for a transaction. +func (t Transaction) Metadata() (map[string]interface{}, error) { + m := &TransactionMetadata{ + Size: t.Size, + Vsize: t.Vsize, + Version: t.Version, + Locktime: t.Locktime, + Weight: t.Weight, + } + + return types.MarshalMap(m) +} + +// TransactionMetadata is a collection of useful +// metadata in a transaction. +type TransactionMetadata struct { + Size int64 `json:"size,omitempty"` + Vsize int64 `json:"vsize,omitempty"` + Version int32 `json:"version,omitempty"` + Locktime int64 `json:"locktime,omitempty"` + Weight int64 `json:"weight,omitempty"` +} + +// Input is a raw input in a Bitcoin transaction. +type Input struct { + TxHash string `json:"txid"` + Vout int64 `json:"vout"` + ScriptSig *ScriptSig `json:"scriptSig"` + Sequence int64 `json:"sequence"` + TxInWitness []string `json:"txinwitness"` + + // Relevant when the input is the coinbase input + Coinbase string `json:"coinbase"` +} + +// Metadata returns the metadata for an input. +func (i Input) Metadata() (map[string]interface{}, error) { + m := &OperationMetadata{ + ScriptSig: i.ScriptSig, + Sequence: i.Sequence, + TxInWitness: i.TxInWitness, + Coinbase: i.Coinbase, + } + + return types.MarshalMap(m) +} + +// Output is a raw output in a Bitcoin transaction. +type Output struct { + Value float64 `json:"value"` + Index int64 `json:"n"` + ScriptPubKey *ScriptPubKey `json:"scriptPubKey"` +} + +// Metadata returns the metadata for an output. +func (o Output) Metadata() (map[string]interface{}, error) { + m := &OperationMetadata{ + ScriptPubKey: o.ScriptPubKey, + } + + return types.MarshalMap(m) +} + +// OperationMetadata is a collection of useful +// metadata from Bitcoin inputs and outputs. +type OperationMetadata struct { + // Coinbase Metadata + Coinbase string `json:"coinbase,omitempty"` + + // Input Metadata + ScriptSig *ScriptSig `json:"scriptsig,omitempty"` + Sequence int64 `json:"sequence,omitempty"` + TxInWitness []string `json:"txinwitness,omitempty"` + + // Output Metadata + ScriptPubKey *ScriptPubKey `json:"scriptPubKey,omitempty"` +} + +// request represents the JSON-RPC request body +type request struct { + JSONRPC string `json:"jsonrpc"` + ID int `json:"id"` + Method string `json:"method"` + Params []interface{} `json:"params"` +} + +func (r request) GetVersion() string { return r.JSONRPC } +func (r request) GetID() int { return r.ID } +func (r request) GetMethod() string { return r.Method } +func (r request) GetParams() []interface{} { return r.Params } + +// Responses + +// jSONRPCResponse represents an interface for generic JSON-RPC responses +type jSONRPCResponse interface { + Err() error +} + +type responseError struct { + Code int64 `json:"code"` + Message string `json:"message"` +} + +// BlockResponse is the response body for `getblock` requests +type blockResponse struct { + Result *Block `json:"result"` + Error *responseError `json:"error"` +} + +func (b blockResponse) Err() error { + if b.Error == nil { + return nil + } + + if b.Error.Code == blockNotFoundErrCode { + return ErrBlockNotFound + } + + return fmt.Errorf( + "%w: error JSON RPC response, code: %d, message: %s", + ErrJSONRPCError, + b.Error.Code, + b.Error.Message, + ) +} + +type pruneBlockchainResponse struct { + Result int64 `json:"result"` + Error *responseError `json:"error"` +} + +func (p pruneBlockchainResponse) Err() error { + if p.Error == nil { + return nil + } + + return fmt.Errorf( + "%w: error JSON RPC response, code: %d, message: %s", + ErrJSONRPCError, + p.Error.Code, + p.Error.Message, + ) +} + +type blockchainInfoResponse struct { + Result *BlockchainInfo `json:"result"` + Error *responseError `json:"error"` +} + +func (b blockchainInfoResponse) Err() error { + if b.Error == nil { + return nil + } + + return fmt.Errorf( + "%w: error JSON RPC response, code: %d, message: %s", + ErrJSONRPCError, + b.Error.Code, + b.Error.Message, + ) +} + +type peerInfoResponse struct { + Result []*PeerInfo `json:"result"` + Error *responseError `json:"error"` +} + +func (p peerInfoResponse) Err() error { + if p.Error == nil { + return nil + } + + return fmt.Errorf( + "%w: error JSON RPC response, code: %d, message: %s", + ErrJSONRPCError, + p.Error.Code, + p.Error.Message, + ) +} + +// blockHashResponse is the response body for `getblockhash` requests +type blockHashResponse struct { + Result string `json:"result"` + Error *responseError `json:"error"` +} + +func (b blockHashResponse) Err() error { + if b.Error == nil { + return nil + } + + return fmt.Errorf( + "%w: error JSON RPC response, code: %d, message: %s", + ErrJSONRPCError, + b.Error.Code, + b.Error.Message, + ) +} + +// sendRawTransactionResponse is the response body for `sendrawtransaction` requests +type sendRawTransactionResponse struct { + Result string `json:"result"` + Error *responseError `json:"error"` +} + +func (s sendRawTransactionResponse) Err() error { + if s.Error == nil { + return nil + } + + return fmt.Errorf( + "%w: error JSON RPC response, code: %d, message: %s", + ErrJSONRPCError, + s.Error.Code, + s.Error.Message, + ) +} + +type suggestedFeeRate struct { + FeeRate float64 `json:"feerate"` +} + +// suggestedFeeRateResponse is the response body for `estimatesmartfee` requests +type suggestedFeeRateResponse struct { + Result *suggestedFeeRate `json:"result"` + Error *responseError `json:"error"` +} + +func (s suggestedFeeRateResponse) Err() error { + if s.Error == nil { + return nil + } + + return fmt.Errorf( + "%w: error JSON RPC response, code: %d, message: %s", + ErrJSONRPCError, + s.Error.Code, + s.Error.Message, + ) +} + +// CoinIdentifier converts a tx hash and vout into +// the canonical CoinIdentifier.Identifier used in +// rosetta-bitcoin. +func CoinIdentifier(hash string, vout int64) string { + return fmt.Sprintf("%s:%d", hash, vout) +} + +// TransactionHash extracts the transaction hash +// from a CoinIdentifier.Identifier. +func TransactionHash(identifier string) string { + vals := strings.Split(identifier, ":") + return vals[0] +} diff --git a/bitcoin/utils.go b/bitcoin/utils.go new file mode 100644 index 0000000..1b9daf7 --- /dev/null +++ b/bitcoin/utils.go @@ -0,0 +1,70 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bitcoin + +import ( + "fmt" + "strconv" + "strings" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcutil" + "github.com/coinbase/rosetta-sdk-go/types" +) + +// ParseCoinIdentifier returns the corresponding hash and index associated +// with a *types.CoinIdentifier. +func ParseCoinIdentifier(coinIdentifier *types.CoinIdentifier) (*chainhash.Hash, uint32, error) { + utxoSpent := strings.Split(coinIdentifier.Identifier, ":") + + outpointHash := utxoSpent[0] + if len(outpointHash) != TransactionHashLength { + return nil, 0, fmt.Errorf("outpoint_hash %s is not length 64", outpointHash) + } + + hash, err := chainhash.NewHashFromStr(outpointHash) + if err != nil { + return nil, 0, fmt.Errorf("%w unable to construct has from string %s", err, outpointHash) + } + + outpointIndex, err := strconv.ParseUint(utxoSpent[1], 10, 32) + if err != nil { + return nil, 0, fmt.Errorf("%w unable to parse outpoint_index", err) + } + + return hash, uint32(outpointIndex), nil +} + +// ParseSingleAddress extracts a single address from a pkscript or +// throws an error. +func ParseSingleAddress( + chainParams *chaincfg.Params, + script []byte, +) (txscript.ScriptClass, btcutil.Address, error) { + class, addresses, nRequired, err := txscript.ExtractPkScriptAddrs(script, chainParams) + if err != nil { + return 0, nil, fmt.Errorf("%w unable to extract script addresses", err) + } + + if nRequired != 1 { + return 0, nil, fmt.Errorf("expecting 1 address, got %d", nRequired) + } + + address := addresses[0] + + return class, address, nil +} diff --git a/configuration/configuration.go b/configuration/configuration.go new file mode 100644 index 0000000..5690077 --- /dev/null +++ b/configuration/configuration.go @@ -0,0 +1,220 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package configuration + +import ( + "errors" + "fmt" + "os" + "path" + "strconv" + "time" + + "github.com/coinbase/rosetta-bitcoin/bitcoin" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/coinbase/rosetta-sdk-go/storage" + "github.com/coinbase/rosetta-sdk-go/types" +) + +// Mode is the setting that determines if +// the implementation is "online" or "offline". +type Mode string + +const ( + // Online is when the implementation is permitted + // to make outbound connections. + Online Mode = "ONLINE" + + // Offline is when the implementation is not permitted + // to make outbound connections. + Offline Mode = "OFFLINE" + + // Mainnet is the Bitcoin Mainnet. + Mainnet string = "MAINNET" + + // Testnet is Bitcoin Testnet3. + Testnet string = "TESTNET" + + // mainnetConfigPath is the path of the Bitcoin + // configuration file for mainnet. + mainnetConfigPath = "/app/bitcoin-mainnet.conf" + + // testnetConfigPath is the path of the Bitcoin + // configuration file for testnet. + testnetConfigPath = "/app/bitcoin-testnet.conf" + + // Zstandard compression dictionaries + transactionNamespace = "transaction" + testnetTransactionDictionary = "/app/testnet-transaction.zstd" + mainnetTransactionDictionary = "/app/mainnet-transaction.zstd" + + mainnetRPCPort = 8332 + testnetRPCPort = 18332 + + // min prune depth is 288: + // https://github.com/bitcoin/bitcoin/blob/ad2952d17a2af419a04256b10b53c7377f826a27/src/validation.h#L84 + pruneDepth = int64(10000) //nolint + + // min prune height (on mainnet): + // https://github.com/bitcoin/bitcoin/blob/62d137ac3b701aae36c1aa3aa93a83fd6357fde6/src/chainparams.cpp#L102 + minPruneHeight = int64(100000) //nolint + + // attempt to prune once an hour + pruneFrequency = 60 * time.Minute + + // DataDirectory is the default location for all + // persistent data. + DataDirectory = "/data" + + bitcoindPath = "bitcoind" + indexerPath = "indexer" + + // allFilePermissions specifies anyone can do anything + // to the file. + allFilePermissions = 0777 + + // ModeEnv is the environment variable read + // to determine mode. + ModeEnv = "MODE" + + // NetworkEnv is the environment variable + // read to determine network. + NetworkEnv = "NETWORK" + + // PortEnv is the environment variable + // read to determine the port for the Rosetta + // implementation. + PortEnv = "PORT" +) + +// PruningConfiguration is the configuration to +// use for pruning in the indexer. +type PruningConfiguration struct { + Frequency time.Duration + Depth int64 + MinHeight int64 +} + +// Configuration determines how +type Configuration struct { + Mode Mode + Network *types.NetworkIdentifier + Params *chaincfg.Params + Currency *types.Currency + GenesisBlockIdentifier *types.BlockIdentifier + Port int + RPCPort int + ConfigPath string + Pruning *PruningConfiguration + IndexerPath string + BitcoindPath string + Compressors []*storage.CompressorEntry +} + +// LoadConfiguration attempts to create a new Configuration +// using the ENVs in the environment. +func LoadConfiguration(baseDirectory string) (*Configuration, error) { + config := &Configuration{} + config.Pruning = &PruningConfiguration{ + Frequency: pruneFrequency, + Depth: pruneDepth, + MinHeight: minPruneHeight, + } + + modeValue := Mode(os.Getenv(ModeEnv)) + switch modeValue { + case Online: + config.Mode = Online + config.IndexerPath = path.Join(baseDirectory, indexerPath) + if err := ensurePathExists(config.IndexerPath); err != nil { + return nil, fmt.Errorf("%w: unable to create indexer path", err) + } + + config.BitcoindPath = path.Join(baseDirectory, bitcoindPath) + if err := ensurePathExists(config.BitcoindPath); err != nil { + return nil, fmt.Errorf("%w: unable to create bitcoind path", err) + } + case Offline: + config.Mode = Offline + case "": + return nil, errors.New("MODE must be populated") + default: + return nil, fmt.Errorf("%s is not a valid mode", modeValue) + } + + networkValue := os.Getenv(NetworkEnv) + switch networkValue { + case Mainnet: + config.Network = &types.NetworkIdentifier{ + Blockchain: bitcoin.Blockchain, + Network: bitcoin.MainnetNetwork, + } + config.GenesisBlockIdentifier = bitcoin.MainnetGenesisBlockIdentifier + config.Params = bitcoin.MainnetParams + config.Currency = bitcoin.MainnetCurrency + config.ConfigPath = mainnetConfigPath + config.RPCPort = mainnetRPCPort + config.Compressors = []*storage.CompressorEntry{ + { + Namespace: transactionNamespace, + DictionaryPath: mainnetTransactionDictionary, + }, + } + case Testnet: + config.Network = &types.NetworkIdentifier{ + Blockchain: bitcoin.Blockchain, + Network: bitcoin.TestnetNetwork, + } + config.GenesisBlockIdentifier = bitcoin.TestnetGenesisBlockIdentifier + config.Params = bitcoin.TestnetParams + config.Currency = bitcoin.TestnetCurrency + config.ConfigPath = testnetConfigPath + config.RPCPort = testnetRPCPort + config.Compressors = []*storage.CompressorEntry{ + { + Namespace: transactionNamespace, + DictionaryPath: testnetTransactionDictionary, + }, + } + case "": + return nil, errors.New("NETWORK must be populated") + default: + return nil, fmt.Errorf("%s is not a valid network", networkValue) + } + + portValue := os.Getenv(PortEnv) + if len(portValue) == 0 { + return nil, errors.New("PORT must be populated") + } + + port, err := strconv.Atoi(portValue) + if err != nil || len(portValue) == 0 || port <= 0 { + return nil, fmt.Errorf("%w: unable to parse port %s", err, portValue) + } + config.Port = port + + return config, nil +} + +// ensurePathsExist directories along +// a path if they do not exist. +func ensurePathExists(path string) error { + if err := os.MkdirAll(path, os.FileMode(allFilePermissions)); err != nil { + return fmt.Errorf("%w: unable to create %s directory", err, path) + } + + return nil +} diff --git a/configuration/configuration_test.go b/configuration/configuration_test.go new file mode 100644 index 0000000..07e6925 --- /dev/null +++ b/configuration/configuration_test.go @@ -0,0 +1,152 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package configuration + +import ( + "errors" + "os" + "path" + "testing" + + "github.com/coinbase/rosetta-bitcoin/bitcoin" + + "github.com/coinbase/rosetta-sdk-go/storage" + "github.com/coinbase/rosetta-sdk-go/types" + "github.com/coinbase/rosetta-sdk-go/utils" + "github.com/stretchr/testify/assert" +) + +func TestLoadConfiguration(t *testing.T) { + tests := map[string]struct { + Mode string + Network string + Port string + + cfg *Configuration + err error + }{ + "no envs set": { + err: errors.New("MODE must be populated"), + }, + "only mode set": { + Mode: string(Online), + err: errors.New("NETWORK must be populated"), + }, + "only mode and network set": { + Mode: string(Online), + Network: Mainnet, + err: errors.New("PORT must be populated"), + }, + "all set (mainnet)": { + Mode: string(Online), + Network: Mainnet, + Port: "1000", + cfg: &Configuration{ + Mode: Online, + Network: &types.NetworkIdentifier{ + Network: bitcoin.MainnetNetwork, + Blockchain: bitcoin.Blockchain, + }, + Params: bitcoin.MainnetParams, + Currency: bitcoin.MainnetCurrency, + GenesisBlockIdentifier: bitcoin.MainnetGenesisBlockIdentifier, + Port: 1000, + RPCPort: mainnetRPCPort, + ConfigPath: mainnetConfigPath, + Pruning: &PruningConfiguration{ + Frequency: pruneFrequency, + Depth: pruneDepth, + MinHeight: minPruneHeight, + }, + Compressors: []*storage.CompressorEntry{ + { + Namespace: transactionNamespace, + DictionaryPath: mainnetTransactionDictionary, + }, + }, + }, + }, + "all set (testnet)": { + Mode: string(Online), + Network: Testnet, + Port: "1000", + cfg: &Configuration{ + Mode: Online, + Network: &types.NetworkIdentifier{ + Network: bitcoin.TestnetNetwork, + Blockchain: bitcoin.Blockchain, + }, + Params: bitcoin.TestnetParams, + Currency: bitcoin.TestnetCurrency, + GenesisBlockIdentifier: bitcoin.TestnetGenesisBlockIdentifier, + Port: 1000, + RPCPort: testnetRPCPort, + ConfigPath: testnetConfigPath, + Pruning: &PruningConfiguration{ + Frequency: pruneFrequency, + Depth: pruneDepth, + MinHeight: minPruneHeight, + }, + Compressors: []*storage.CompressorEntry{ + { + Namespace: transactionNamespace, + DictionaryPath: testnetTransactionDictionary, + }, + }, + }, + }, + "invalid mode": { + Mode: "bad mode", + Network: Testnet, + Port: "1000", + err: errors.New("bad mode is not a valid mode"), + }, + "invalid network": { + Mode: string(Offline), + Network: "bad network", + Port: "1000", + err: errors.New("bad network is not a valid network"), + }, + "invalid port": { + Mode: string(Offline), + Network: Testnet, + Port: "bad port", + err: errors.New("unable to parse port bad port"), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + newDir, err := utils.CreateTempDir() + assert.NoError(t, err) + defer utils.RemoveTempDir(newDir) + + os.Setenv(ModeEnv, test.Mode) + os.Setenv(NetworkEnv, test.Network) + os.Setenv(PortEnv, test.Port) + + cfg, err := LoadConfiguration(newDir) + if test.err != nil { + assert.Nil(t, cfg) + assert.Contains(t, err.Error(), test.err.Error()) + } else { + test.cfg.IndexerPath = path.Join(newDir, "indexer") + test.cfg.BitcoindPath = path.Join(newDir, "bitcoind") + assert.Equal(t, test.cfg, cfg) + assert.NoError(t, err) + } + }) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..eb8b51d --- /dev/null +++ b/go.mod @@ -0,0 +1,15 @@ +module github.com/coinbase/rosetta-bitcoin + +go 1.13 + +require ( + github.com/btcsuite/btcd v0.21.0-beta + github.com/btcsuite/btcutil v1.0.2 + github.com/coinbase/rosetta-sdk-go v0.4.4 + github.com/dgraph-io/badger/v2 v2.2007.2 + github.com/grpc-ecosystem/go-grpc-middleware v1.2.1 + github.com/stretchr/testify v1.6.1 + go.uber.org/zap v1.15.0 + golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 + google.golang.org/appengine v1.6.6 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..be1cfc9 --- /dev/null +++ b/go.sum @@ -0,0 +1,507 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/Azure/azure-pipeline-go v0.2.1/go.mod h1:UGSo8XybXnIGZ3epmeBw7Jdz+HiUVpqIlpz/HKHylF4= +github.com/Azure/azure-pipeline-go v0.2.2/go.mod h1:4rQ/NZncSvGqNkkOsNpOU1tgoNuIlp9AfUH5G1tvCHc= +github.com/Azure/azure-storage-blob-go v0.7.0/go.mod h1:f9YQKtsG1nMisotuTPpO0tjNuEjKRYAcJU8/ydDI++4= +github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= +github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0= +github.com/Azure/go-autorest/autorest/adal v0.8.0/go.mod h1:Z6vX6WXXuyieHAXwMj0S6HY6e6wcHn37qQMBQlvY3lc= +github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA= +github.com/Azure/go-autorest/autorest/date v0.2.0/go.mod h1:vcORJHLJEh643/Ioh9+vPmf1Ij9AEBM5FuBIXLmIy0g= +github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.3.0/go.mod h1:a8FDP3DYzQ4RYfVAxAN3SVSiiO77gL2j2ronKKP0syM= +github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc= +github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/DataDog/zstd v1.4.1 h1:3oxKN3wbHibqx897utPC2LTQU4J+IHWWJO+glkAkpFM= +github.com/DataDog/zstd v1.4.1/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= +github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/StackExchange/wmi v0.0.0-20180116203802-5d049714c4a6/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg= +github.com/VictoriaMetrics/fastcache v1.5.7/go.mod h1:ptDBkNMQI4RtmVo8VS/XwRY6RoTu1dAWCbrk+6WsEM8= +github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E= +github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/allegro/bigcache v1.2.1-0.20190218064605-e24eb225f156/go.mod h1:Cb/ax3seSYIx7SuZdm2G2xzfwmv3TPSk2ucNfQESPXM= +github.com/aristanetworks/goarista v0.0.0-20170210015632-ea17b1a17847/go.mod h1:D/tb0zPVXnP7fmsLZjtdUhSsumbK/ij54UXjjVgMGxQ= +github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/aws/aws-sdk-go v1.25.48/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/btcsuite/btcd v0.0.0-20171128150713-2e60448ffcc6/go.mod h1:Dmm/EzmjnCiweXmzRIAiUWCInVmPgjkzgv5k4tVyXiQ= +github.com/btcsuite/btcd v0.20.1-beta h1:Ik4hyJqN8Jfyv3S4AGBOmyouMsYE3EdYODkMbQjwPGw= +github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= +github.com/btcsuite/btcd v0.21.0-beta h1:At9hIZdJW0s9E/fAz28nrz6AmcNlSVucCH796ZteX1M= +github.com/btcsuite/btcd v0.21.0-beta/go.mod h1:ZSWyehm27aAuS9bvkATT+Xte3hjHZ+MRgMY/8NJ7K94= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f h1:bAs4lUbRJpnnkd9VhRV3jjAVU7DJVjMaK+IsvSeZvFo= +github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= +github.com/btcsuite/btcutil v0.0.0-20190425235716-9e5f4b9a998d/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= +github.com/btcsuite/btcutil v1.0.2 h1:9iZ1Terx9fMIOtq1VrwdqfsATL9MC2l8ZrUY6YZ2uts= +github.com/btcsuite/btcutil v1.0.2/go.mod h1:j9HUFwoQRsZL3V4n+qG+CUnEGHOarIxfC3Le2Yhbcts= +github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd h1:qdGvebPBDuYDPGi1WCPjy1tGyMpmDK8IEapSsszn7HE= +github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= +github.com/btcsuite/goleveldb v1.0.0/go.mod h1:QiK9vBlgftBg6rWQIj6wFzbPfRjiykIEhBH4obrXJ/I= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723 h1:ZA/jbKoGcVAnER6pCHPEkGdZOV7U1oLUedErBHCUMs0= +github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/snappy-go v1.0.0/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= +github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= +github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/cp v0.1.0/go.mod h1:SOGHArjBr4JWaSDEVpWpo/hNg6RoKrls6Oh40hiwW+s= +github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cloudflare/cloudflare-go v0.10.2-0.20190916151808-a80f83b9add9/go.mod h1:1MxXX1Ux4x6mqPmjkUgTP1CdXIBXKX7T+Jk9Gxrmx+U= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/coinbase/rosetta-sdk-go v0.4.4 h1:zTUS4bVlTfD4xq/o6JtsuU+g9sf3+S3Nnn2A24Ycow4= +github.com/coinbase/rosetta-sdk-go v0.4.4/go.mod h1:Luv0AhzZH81eul2hYZ3w0hBGwmFPiexwbntYxihEZck= +github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= +github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= +github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= +github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/dave/dst v0.23.1 h1:2obX6c3RqALrEOp6u01qsqPvwp0t+RpOp9O4Bf9KhXs= +github.com/dave/dst v0.23.1/go.mod h1:LjPcLEauK4jC5hQ1fE/wr05O41zK91Pr4Qs22Ljq7gs= +github.com/dave/gopackages v0.0.0-20170318123100-46e7023ec56e/go.mod h1:i00+b/gKdIDIxuLDFob7ustLAVqhsZRk2qVZrArELGQ= +github.com/dave/jennifer v1.2.0/go.mod h1:fIb+770HOpJ2fmN9EPPKOqm1vMGhB+TwXKMZhrIygKg= +github.com/dave/kerr v0.0.0-20170318121727-bc25dd6abe8e/go.mod h1:qZqlPyPvfsDJt+3wHJ1EvSXDuVjFTK0j2p/ca+gtsb8= +github.com/dave/rebecca v0.9.1/go.mod h1:N6XYdMD/OKw3lkF3ywh8Z6wPGuwNFDNtWYEMFWEmXBA= +github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/deckarep/golang-set v0.0.0-20180603214616-504e848d77ea/go.mod h1:93vsz/8Wt4joVM7c2AVqh+YRMiUSc14yDtF28KmMOgQ= +github.com/decred/dcrd/lru v1.0.0/go.mod h1:mxKOwFd7lFjN2GZYsiz/ecgqR6kkYAl+0pz0tEMk218= +github.com/dgraph-io/badger/v2 v2.2007.2 h1:EjjK0KqwaFMlPin1ajhP943VPENHJdEz1KLIegjaI3k= +github.com/dgraph-io/badger/v2 v2.2007.2/go.mod h1:26P/7fbL4kUZVEVKLAKXkBXKOydDmM2p1e+NhhnBCAE= +github.com/dgraph-io/ristretto v0.0.3-0.20200630154024-f66de99634de/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +github.com/dgraph-io/ristretto v0.0.3 h1:jh22xisGBjrEVnRZ1DVTpBVQm0Xndu8sMl0CWDzSIBI= +github.com/dgraph-io/ristretto v0.0.3/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E= +github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= +github.com/docker/docker v1.4.2-0.20180625184442-8e610b2b55bf/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/dop251/goja v0.0.0-20200721192441-a695b0cdd498/go.mod h1:Mw6PkjjMXWbTj+nnj4s3QPXq1jaT0s5pC0iFD4+BOAA= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/edsrzf/mmap-go v0.0.0-20160512033002-935e0e8a636c/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/ethereum/go-ethereum v1.9.21 h1:8qRlhzrItnmUGdVlBzZLI2Tb46S0RdSNjFwICo781ws= +github.com/ethereum/go-ethereum v1.9.21/go.mod h1:RXAVzbGrSGmDkDnHymruTAIEjUR3E4TX0EOpaj702sI= +github.com/fatih/color v1.3.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.9.0 h1:8xPHl4/q1VyqGIPif1F+1V3Y3lSmrq01EabUW3CoW5s= +github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= +github.com/fatih/structtag v1.2.0 h1:/OdNE99OxoI/PqaW/SuSK9uxxT3f/tcSZgon/ssNSx4= +github.com/fatih/structtag v1.2.0/go.mod h1:mBJUNpUnHmRKrKlQQlmCrh5PuhftFbNv8Ys4/aAZl94= +github.com/fjl/memsize v0.0.0-20180418122429-ca190fb6ffbc/go.mod h1:VvhXpOYNQvB+uIk2RvXzuaQtkQJzzIx6lSBe1xv7hi0= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff/go.mod h1:x7DCsMOv1taUwEWCzT4cmDeAkigA5/QCwUodaVOe8Ww= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-ole/go-ole v1.2.1/go.mod h1:7FAglXiTm7HKlQRDeOQ6ZNUHidzCWXuZWq/1dTyBNF8= +github.com/go-sourcemap/sourcemap v2.1.2+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang/snappy v0.0.2-0.20200707131729-196ae77b8a26 h1:lMm2hD9Fy0ynom5+85/pbdkiYcBqM1JWmhpAXLmy0fw= +github.com/golang/snappy v0.0.2-0.20200707131729-196ae77b8a26/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/addlicense v0.0.0-20200827091314-d1655b921368 h1:Ds6gDZHoviaQM7r7oMx/cG2qwZc3l5u7cg6gTkxOZNE= +github.com/google/addlicense v0.0.0-20200827091314-d1655b921368/go.mod h1:EMjYTRimagHs1FwlIqKyX3wAM0u3rA+McvlIIWmSamA= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0 h1:/QaMHBdZ26BB3SSst0Iwl10Epc+xhTquomWX0oZEB6w= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/pprof v0.0.0-20181127221834-b4f47329b966/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/gorilla/websocket v1.4.1-0.20190629185528-ae1634f6a989/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= +github.com/graph-gophers/graphql-go v0.0.0-20191115155744-f33e81362277/go.mod h1:9CQHMSxwO4MprSdzoIEobiHpoLtHm77vfxsvsIN5Vuc= +github.com/grpc-ecosystem/go-grpc-middleware v1.2.1 h1:V59tBiPuMkySHwJkuq/OYkK0WnOLwCwD3UkTbEMr12U= +github.com/grpc-ecosystem/go-grpc-middleware v1.2.1/go.mod h1:EaizFBKfUKtMIF5iaDEhniwNedqGo9FuLFzppDr3uwI= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/holiman/uint256 v1.1.1/go.mod h1:y4ga/t+u+Xwd7CpDgZESaRcWy0I7XMlTMA25ApIH5Jw= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huin/goupnp v1.0.0/go.mod h1:n9v9KO1tAxYH82qOn+UTIFQDmx5n1Zxd/ClZDMX7Bnc= +github.com/huin/goutil v0.0.0-20170803182201-1ca381bf3150/go.mod h1:PpLOETDnJ0o3iZrZfqZzyLl6l7F3c6L1oWn7OICBi6o= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +github.com/influxdata/influxdb v1.2.3-0.20180221223340-01288bdb0883/go.mod h1:qZna6X/4elxqT3yI9iZYdZrWWdeFOOprn86kgg4+IzY= +github.com/jackpal/go-nat-pmp v1.0.2-0.20160603034137-1fa385a6f458/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89 h1:12K8AlpT0/6QUXSfV0yi4Q0jkbq8NDtIKFtF61AoqV0= +github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k= +github.com/jrick/logrotate v1.0.0 h1:lQ1bL/n9mBNeIXoTUoYRlK4dHuNJVofX9oWqBtPnSzI= +github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= +github.com/julienschmidt/httprouter v1.1.1-0.20170430222011-975b5c4c7c21/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/karalabe/usb v0.0.0-20190919080040-51dc0efba356/go.mod h1:Od972xHfMJowv7NGVDiWVxk2zxnWgjLlJzE+F4F7AGU= +github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23 h1:FOOIBWrEkLgmlgGfMuZT83xIwfPDxEI2OHu6xUmJMFE= +github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs= +github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasjones/reggen v0.0.0-20180717132126-cdb49ff09d77 h1:6xiz3+ZczT3M4+I+JLpcPGG1bQKm8067HktB17EDWEE= +github.com/lucasjones/reggen v0.0.0-20180717132126-cdb49ff09d77/go.mod h1:5ELEyG+X8f+meRWHuqUOewBOhvHkl7M76pdGEansxW4= +github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-colorable v0.1.0/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA= +github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw= +github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-ieproxy v0.0.0-20190610004146-91bb50d98149/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc= +github.com/mattn/go-ieproxy v0.0.0-20190702010315-6dee0af9227d/go.mod h1:31jz6HNzdxOmlERGGEc4v/dMssOfmp2p5bT/okiKFFc= +github.com/mattn/go-isatty v0.0.5-0.20180830101745-3fb116b82035/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= +github.com/mattn/go-isatty v0.0.10/go.mod h1:qgIWMr58cqv1PHHyhnkY9lrL7etaEgOFcMEpPG5Rm84= +github.com/mattn/go-isatty v0.0.11 h1:FxPOTFNqGkuDUGi3H/qkUbQO4ZiBa2brKq5r0l8TGeM= +github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= +github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-runewidth v0.0.3/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/mitchellh/mapstructure v1.3.3 h1:SzB1nHZ2Xi+17FP0zVQBHIZqvwRN9408fJO8h+eeNA8= +github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/naoina/go-stringutil v0.1.0/go.mod h1:XJ2SJL9jCtBh+P9q5btrd/Ylo8XwT/h1USek5+NqSA0= +github.com/naoina/toml v0.1.2-0.20170918210437-9fafd6967416/go.mod h1:NBIhNtsFMo3G2szEBne+bO4gS192HuIYRqfvOWb4i1E= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/olekukonko/tablewriter v0.0.2-0.20190409134802-7e037d187b0c h1:1RHs3tNxjXGHeul8z2t6H2N2TlAqpKe5yryJztRx4Jk= +github.com/olekukonko/tablewriter v0.0.2-0.20190409134802-7e037d187b0c/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= +github.com/onsi/gomega v1.4.1/go.mod h1:C1qb7wdrVGGVU+Z6iS04AVkA3Q65CEZX59MT0QO5uiA= +github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/pborman/uuid v0.0.0-20170112150404-1b00554d8222/go.mod h1:VyrYX9gd7irzKovcSS6BIIEwPRkP2Wm2m9ufcdFSJ34= +github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= +github.com/peterh/liner v1.1.1-0.20190123174540-a2c9a5303de7/go.mod h1:CRroGNssyjTd/qIG2FyxByd2S8JEAZXBl4qUrZf8GS0= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/tsdb v0.6.2-0.20190402121629-4f204dcbc150/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rjeczalik/notify v0.9.1/go.mod h1:rKwnCoCGeuQnwBtTSPL9Dad03Vh2n40ePRrjvIXnJho= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.5.2/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.6.0/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rs/cors v0.0.0-20160617231935-a62a804a8a00/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= +github.com/rs/xhandler v0.0.0-20160618193221-ed27b6fd6521/go.mod h1:RvLn4FgxWubrpZHtQLnOf6EwhN2hEMusxZOhcW9H3UQ= +github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/segmentio/golines v0.0.0-20200824192126-7f30d3046793 h1:rhR7esJSmty+9ST6Gsp7mlQHkpISw2DiYjuFaz3dRDg= +github.com/segmentio/golines v0.0.0-20200824192126-7f30d3046793/go.mod h1:bQSh5qdVR67XiCKbaVvYO41s50c5hQo+3cY/1CQQ3xQ= +github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/shirou/gopsutil v2.20.5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= +github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= +github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= +github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/status-im/keycard-go v0.0.0-20190316090335-8537d3370df4/go.mod h1:RZLeN1LMWmRsyYjvAu+I6Dm9QmlDaIIt+Y+4Kd7Tp+Q= +github.com/steakknife/bloomfilter v0.0.0-20180922174646-6819c0d2a570/go.mod h1:8OR4w3TdeIHIh1g6EMY5p0gVNOovcWC+1vpc7naMuAw= +github.com/steakknife/hamming v0.0.0-20180906055917-c99c65617cd3/go.mod h1:hpGUWaI9xL8pRQCTXQgocU38Qw1g0Us7n5PxxTwTCYU= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/syndtr/goleveldb v1.0.1-0.20200815110645-5c35d600f0ca/go.mod h1:u2MKkTVTVJWe5D1rCvame8WqhBd88EuIwODJZ1VHCPM= +github.com/tidwall/gjson v1.6.0/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls= +github.com/tidwall/gjson v1.6.1 h1:LRbvNuNuvAiISWg6gxLEFuCe72UKy5hDqhxW/8183ws= +github.com/tidwall/gjson v1.6.1/go.mod h1:BaHyNc5bjzYkPqgLq7mdVzeiRtULKULXLgZFKsxEHI0= +github.com/tidwall/match v1.0.1 h1:PnKP62LPNxHKTwvHHZZzdOAOCtsJTjo6dZLCwpKm5xc= +github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tidwall/pretty v1.0.1/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tidwall/pretty v1.0.2 h1:Z7S3cePv9Jwm1KwS0513MRaoUe3S01WPbLNV40pwWZU= +github.com/tidwall/pretty v1.0.2/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= +github.com/tidwall/sjson v1.1.1 h1:7h1vk049Jnd5EH9NyzNiEuwYW4b5qgreBbqRC19AS3U= +github.com/tidwall/sjson v1.1.1/go.mod h1:yvVuSnpEQv5cYIrO+AT6kw4QVfd5SDZoGIS7/5+fZFs= +github.com/tyler-smith/go-bip39 v1.0.1-0.20181017060643-dbb3b84ba2ef/go.mod h1:sJ5fKU0s6JVwZjjcUEX2zFOnvq0ASQ2K9Zr6cf67kNs= +github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= +github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/vmihailenco/msgpack/v4 v4.3.11/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4= +github.com/vmihailenco/msgpack/v5 v5.0.0-beta.1 h1:d71/KA0LhvkrJ/Ok+Wx9qK7bU8meKA1Hk0jpVI5kJjk= +github.com/vmihailenco/msgpack/v5 v5.0.0-beta.1/go.mod h1:xlngVLeyQ/Qi05oQxhQ+oTuqa03RjMwMfk/7/TCs+QI= +github.com/vmihailenco/tagparser v0.1.1 h1:quXMXlA39OCbd2wAdTsGDlK9RkOk6Wuw+x37wVyIuWY= +github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI= +github.com/wsddn/go-ecdh v0.0.0-20161211032359-48726bab9208/go.mod h1:IotVbo4F+mw0EzQ08zFqg7pK3FebNXpaMsRy2RT+Ees= +github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= +github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= +github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= +go.uber.org/atomic v1.6.0 h1:Ezj3JGmsOnG1MoRWQkPBsKLe9DwWD9QeXzTRzzldNVk= +go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= +go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= +go.uber.org/multierr v1.5.0 h1:KCa4XfM8CWFCpxXRGok+Q0SS/0XBhMDbHHGABQLvD2A= +go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= +go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= +go.uber.org/zap v1.15.0 h1:ZZCA22JRF2gQE5FoNmhmrf7jeJJ2uhqDUNRYKm8dvmM= +go.uber.org/zap v1.15.0/go.mod h1:Mb2vm2krFEG5DV0W9qcHBYFtp/Wku1cvYaqPsS/WYfc= +golang.org/x/arch v0.0.0-20180920145803-b19384d3c130/go.mod h1:cYlCBUl1MsqxdiKgmc4uh7TxZfWSFLOGSRR090WDxt8= +golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181127143415-eb0de9b17e85/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200115085410-6d4e4cb37c7d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3 h1:XQyxROzUlZH+WIQwySDgnISgOivlhjIEwaQaJEJrrN0= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b h1:GgiSbuUyC0BlbUmHQBgFqu32eiRR/CEYdjOjOd4zE6Y= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20181011144130-49bb7cea24b1/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200421231249-e086a090c8fd/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344 h1:vGXIOMxbNfDTk/aXCmfdLgkrSV+Z2tcbze+pEc3v5W4= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc h1:zK/HqS5bZxDptfPJNq8v7vJfXtkU7r9TLIoSr1bXaP4= +golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202 h1:VvcQYSHwXgi7W+TpUR6A9g6Up98WAHf3f/ulnJ62IyA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200904194848-62affa334b73 h1:MXfv8rhZWmFeqX3GNZRsd6vOLoaCHjYEX3qkRo3YBUA= +golang.org/x/net v0.0.0-20200904194848-62affa334b73/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 h1:qwRHBd0NqMbJxfbotnDhm2ByMI1Shq4Y6oRJo21SGJA= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180903190138-2b024373dcd9/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191024172528-b4ff53e7a1cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200420163511-1957bb5e6d1f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200824131525-c12d262b63d8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200909081042-eff7692f9009 h1:W0lCpv29Hv0UaM1LXb9QlBHLNP8UFfcKjblhVCWftOM= +golang.org/x/sys v0.0.0-20200909081042-eff7692f9009/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20181127232545-e782529d0ddd/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20191024220359-3d91e92cde03 h1:4gtJXHJ9ud0q8MNSDxJsRU/WH+afypbe4Vk4zq+8qow= +golang.org/x/tools v0.0.0-20191024220359-3d91e92cde03/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa h1:5E4dL8+NgFOgjwbTKz+OOEGGhP+ectTmF842l6KjupQ= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200626171337-aa94e735be7f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729181040-64cdafbe085c/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200731060945-b5fad4ed8dd6/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858 h1:xLt+iB5ksWcZVxqc+g9K41ZHy+6MKWfXCDsjSThnsPA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools/gopls v0.4.4/go.mod h1:zhyGzA+CAtREUwwq/btQxEx2FHnGzDwJvGs5YqdVCbE= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.5 h1:tycE03LOZYQNhDpS27tcQdAzLCVMaj7QT2SXxebnpCM= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6 h1:lMO5rYAqUxkmaj76jAkRUvt5JZgFymx/+Q5Mzfivuhc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200423170343-7949de9c1215/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013 h1:+kGHl1aib/qcwaRi1CbqBZ1rk19r85MNUf8HaBghugY= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.29.1 h1:EC2SB8S04d2r73uptxphDSUG+kTKVgjRPF+N3xpxRB4= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= +gopkg.in/olebedev/go-duktape.v3 v3.0.0-20200619000410-60c24ae608a6/go.mod h1:uAJfkITjFhyEEuUfm7bsmCZRbW5WRq8s9EY8HZ6hCns= +gopkg.in/src-d/go-billy.v4 v4.3.0/go.mod h1:tm33zBoOwxjYHZIE+OV8bxTWFMJLrconzFMd38aARFk= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/urfave/cli.v1 v1.20.0/go.mod h1:vuBzUtMdQeixQj8LVd+/98pzhxNGQoyuPBlsXHOQNO0= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.5 h1:nI5egYTGJakVyOryqLs1cQO5dO0ksin5XXs2pspk75k= +honnef.co/go/tools v0.0.1-2020.1.5/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +mvdan.cc/gofumpt v0.0.0-20200709182408-4fd085cb6d5f/go.mod h1:9VQ397fNXEnF84t90W4r4TRCQK+pg9f8ugVfyj+S26w= +mvdan.cc/gofumpt v0.0.0-20200802201014-ab5a8192947d/go.mod h1:bzrjFmaD6+xqohD3KYP0H2FEuxknnBmyyOxdhLdaIws= +mvdan.cc/xurls/v2 v2.2.0/go.mod h1:EV1RMtya9D6G5DMYPGD8zTQzaHet6Jh8gFlRgGRJeO8= diff --git a/indexer/indexer.go b/indexer/indexer.go new file mode 100644 index 0000000..bdb1471 --- /dev/null +++ b/indexer/indexer.go @@ -0,0 +1,738 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package indexer + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/coinbase/rosetta-bitcoin/bitcoin" + "github.com/coinbase/rosetta-bitcoin/configuration" + "github.com/coinbase/rosetta-bitcoin/services" + "github.com/coinbase/rosetta-bitcoin/utils" + + "github.com/coinbase/rosetta-sdk-go/asserter" + "github.com/coinbase/rosetta-sdk-go/storage" + "github.com/coinbase/rosetta-sdk-go/syncer" + "github.com/coinbase/rosetta-sdk-go/types" + "github.com/dgraph-io/badger/v2" +) + +const ( + // indexPlaceholder is provided to the syncer + // to indicate we should both start from the + // last synced block and that we should sync + // blocks until exit (instead of stopping at + // a particular height). + indexPlaceholder = -1 + + retryDelay = 10 * time.Second + retryLimit = 5 + + nodeWaitSleep = 3 * time.Second + missingTransactionDelay = 200 * time.Millisecond + + // sizeMultiplier is used to multiply the memory + // estimate for pre-fetching blocks. In other words, + // this is the estimated memory overhead for each + // block fetched by the indexer. + sizeMultiplier = 15 + + // BadgerDB options overrides + defaultBlockSize = 1 << 20 // use large blocks so less table indexes (1 MB) + defaultValueThreshold = 0 // put almost everything in value logs (only use table for key) + defaultIndexCacheSize = 5 << 30 // 5 GB +) + +var ( + errMissingTransaction = errors.New("missing transaction") +) + +// Client is used by the indexer to sync blocks. +type Client interface { + NetworkStatus(context.Context) (*types.NetworkStatusResponse, error) + PruneBlockchain(context.Context, int64) (int64, error) + GetRawBlock(context.Context, *types.PartialBlockIdentifier) (*bitcoin.Block, []string, error) + ParseBlock( + context.Context, + *bitcoin.Block, + map[string]*storage.AccountCoin, + ) (*types.Block, error) +} + +var _ syncer.Handler = (*Indexer)(nil) +var _ syncer.Helper = (*Indexer)(nil) +var _ services.Indexer = (*Indexer)(nil) +var _ storage.CoinStorageHelper = (*Indexer)(nil) + +// Indexer caches blocks and provides balance query functionality. +type Indexer struct { + cancel context.CancelFunc + + network *types.NetworkIdentifier + pruningConfig *configuration.PruningConfiguration + + client Client + + asserter *asserter.Asserter + database storage.Database + blockStorage *storage.BlockStorage + coinStorage *storage.CoinStorage + workers []storage.BlockWorker + + waiter *waitTable +} + +// CloseDatabase closes a storage.Database. This should be called +// before exiting. +func (i *Indexer) CloseDatabase(ctx context.Context) { + logger := utils.ExtractLogger(ctx, "") + err := i.database.Close(ctx) + if err != nil { + logger.Fatalw("unable to close indexer database", "error", err) + } + + logger.Infow("database closed successfully") +} + +// defaultBadgerOptions returns a set of badger.Options optimized +// for running a Rosetta implementation. After extensive research +// and profiling, we determined that the bottleneck to high RPC +// load was fetching table indexes from disk on an index cache miss. +// Thus, we increased the default block size to be much larger than +// the Badger default so we have a lot less indexes to store. We also +// ensure all values are stored in value log files (as to minimize +// table bloat at the cost of some performance). +func defaultBadgerOptions(path string) badger.Options { + defaultOps := storage.DefaultBadgerOptions(path) + defaultOps.BlockSize = defaultBlockSize + defaultOps.ValueThreshold = defaultValueThreshold + defaultOps.IndexCacheSize = defaultIndexCacheSize + + return defaultOps +} + +// Initialize returns a new Indexer. +func Initialize( + ctx context.Context, + cancel context.CancelFunc, + config *configuration.Configuration, + client Client, +) (*Indexer, error) { + localStore, err := storage.NewBadgerStorage( + ctx, + config.IndexerPath, + storage.WithCompressorEntries(config.Compressors), + storage.WithCustomSettings(defaultBadgerOptions(config.IndexerPath)), + ) + if err != nil { + return nil, fmt.Errorf("%w: unable to initialize storage", err) + } + + blockStorage := storage.NewBlockStorage(localStore) + asserter, err := asserter.NewClientWithOptions( + config.Network, + config.GenesisBlockIdentifier, + bitcoin.OperationTypes, + bitcoin.OperationStatuses, + services.Errors, + ) + if err != nil { + return nil, fmt.Errorf("%w: unable to initialize asserter", err) + } + + i := &Indexer{ + cancel: cancel, + network: config.Network, + pruningConfig: config.Pruning, + client: client, + database: localStore, + blockStorage: blockStorage, + waiter: newWaitTable(), + asserter: asserter, + } + + coinStorage := storage.NewCoinStorage(localStore, i, asserter) + i.coinStorage = coinStorage + i.workers = []storage.BlockWorker{coinStorage} + + return i, nil +} + +// waitForNode returns once bitcoind is ready to serve +// block queries. +func (i *Indexer) waitForNode(ctx context.Context) error { + logger := utils.ExtractLogger(ctx, "indexer") + for { + _, err := i.client.NetworkStatus(ctx) + if err == nil { + return nil + } + + logger.Infow("waiting for bitcoind...") + if err := utils.ContextSleep(ctx, nodeWaitSleep); err != nil { + return err + } + } +} + +// Sync attempts to index Bitcoin blocks using +// the bitcoin.Client until stopped. +func (i *Indexer) Sync(ctx context.Context) error { + if err := i.waitForNode(ctx); err != nil { + return fmt.Errorf("%w: failed to wait for node", err) + } + + i.blockStorage.Initialize(i.workers) + + startIndex := int64(indexPlaceholder) + head, err := i.blockStorage.GetHeadBlockIdentifier(ctx) + if err == nil { + startIndex = head.Index + 1 + } + + // Load in previous blocks into syncer cache to handle reorgs. + // If previously processed blocks exist in storage, they are fetched. + // Otherwise, none are provided to the cache (the syncer will not attempt + // a reorg if the cache is empty). + pastBlocks := i.blockStorage.CreateBlockCache(ctx) + + syncer := syncer.New( + i.network, + i, + i, + i.cancel, + syncer.WithCacheSize(syncer.DefaultCacheSize), + syncer.WithSizeMultiplier(sizeMultiplier), + syncer.WithPastBlocks(pastBlocks), + ) + + return syncer.Sync(ctx, startIndex, indexPlaceholder) +} + +// Prune attempts to prune blocks in bitcoind every +// pruneFrequency. +func (i *Indexer) Prune(ctx context.Context) error { + logger := utils.ExtractLogger(ctx, "pruner") + + tc := time.NewTicker(i.pruningConfig.Frequency) + defer tc.Stop() + + for { + select { + case <-ctx.Done(): + logger.Warnw("exiting pruner") + return ctx.Err() + case <-tc.C: + head, err := i.blockStorage.GetHeadBlockIdentifier(ctx) + if err != nil { + continue + } + + // Must meet pruning conditions in bitcoin core + // Source: + // https://github.com/bitcoin/bitcoin/blob/a63a26f042134fa80356860c109edb25ac567552/src/rpc/blockchain.cpp#L953-L960 + pruneHeight := head.Index - i.pruningConfig.Depth + if pruneHeight <= i.pruningConfig.MinHeight { + logger.Infow("waiting to prune", "min prune height", i.pruningConfig.MinHeight) + continue + } + + logger.Infow("attempting to prune bitcoind", "prune height", pruneHeight) + prunedHeight, err := i.client.PruneBlockchain(ctx, pruneHeight) + if err != nil { + logger.Warnw( + "unable to prune bitcoind", + "prune height", pruneHeight, + "error", err, + ) + } else { + logger.Infow("pruned bitcoind", "prune height", prunedHeight) + } + } + } +} + +// BlockAdded is called by the syncer when a block is added. +func (i *Indexer) BlockAdded(ctx context.Context, block *types.Block) error { + logger := utils.ExtractLogger(ctx, "indexer") + + err := i.blockStorage.AddBlock(ctx, block) + if err != nil { + return fmt.Errorf( + "%w: unable to add block to storage %s:%d", + err, + block.BlockIdentifier.Hash, + block.BlockIdentifier.Index, + ) + } + + ops := 0 + + // Close channels of all blocks waiting. + i.waiter.Lock() + for _, transaction := range block.Transactions { + ops += len(transaction.Operations) + txHash := transaction.TransactionIdentifier.Hash + val, ok := i.waiter.Get(txHash, true) + if !ok { + continue + } + + if val.channelClosed { + logger.Debugw( + "channel already closed", + "hash", block.BlockIdentifier.Hash, + "index", block.BlockIdentifier.Index, + "channel", txHash, + ) + continue + } + + // Closing channel will cause all listeners to continue + val.channelClosed = true + close(val.channel) + } + + // Look for all remaining waiting transactions associated + // with the next block that have not yet been closed. We should + // abort these waits as they will never be closed by a new transaction. + for txHash, val := range i.waiter.table { + if val.earliestBlock == block.BlockIdentifier.Index+1 && !val.channelClosed { + logger.Debugw( + "aborting channel", + "hash", block.BlockIdentifier.Hash, + "index", block.BlockIdentifier.Index, + "channel", txHash, + ) + val.channelClosed = true + val.aborted = true + close(val.channel) + } + } + i.waiter.Unlock() + + logger.Debugw( + "block added", + "hash", block.BlockIdentifier.Hash, + "index", block.BlockIdentifier.Index, + "transactions", len(block.Transactions), + "ops", ops, + ) + + return nil +} + +// BlockRemoved is called by the syncer when a block is removed. +func (i *Indexer) BlockRemoved( + ctx context.Context, + blockIdentifier *types.BlockIdentifier, +) error { + logger := utils.ExtractLogger(ctx, "indexer") + logger.Debugw( + "block removed", + "hash", blockIdentifier.Hash, + "index", blockIdentifier.Index, + ) + err := i.blockStorage.RemoveBlock(ctx, blockIdentifier) + if err != nil { + return fmt.Errorf( + "%w: unable to remove block from storage %s:%d", + err, + blockIdentifier.Hash, + blockIdentifier.Index, + ) + } + + return nil +} + +// NetworkStatus is called by the syncer to get the current +// network status. +func (i *Indexer) NetworkStatus( + ctx context.Context, + network *types.NetworkIdentifier, +) (*types.NetworkStatusResponse, error) { + return i.client.NetworkStatus(ctx) +} + +func (i *Indexer) findCoin( + ctx context.Context, + btcBlock *bitcoin.Block, + coinIdentifier string, +) (*types.Coin, *types.AccountIdentifier, error) { + for ctx.Err() == nil { + databaseTransaction := i.database.NewDatabaseTransaction(ctx, false) + defer databaseTransaction.Discard(ctx) + + coinHeadBlock, err := i.blockStorage.GetHeadBlockIdentifierTransactional( + ctx, + databaseTransaction, + ) + if errors.Is(err, storage.ErrHeadBlockNotFound) { + if err := utils.ContextSleep(ctx, missingTransactionDelay); err != nil { + return nil, nil, err + } + + continue + } + if err != nil { + return nil, nil, fmt.Errorf( + "%w: unable to get transactional head block identifier", + err, + ) + } + + // Attempt to find coin + coin, owner, err := i.coinStorage.GetCoinTransactional( + ctx, + databaseTransaction, + &types.CoinIdentifier{ + Identifier: coinIdentifier, + }, + ) + if err == nil { + return coin, owner, nil + } + + if !errors.Is(err, storage.ErrCoinNotFound) { + return nil, nil, fmt.Errorf("%w: unable to lookup coin %s", err, coinIdentifier) + } + + // Locking here prevents us from adding sending any done + // signals while we are determining whether or not to add + // to the WaitTable. + i.waiter.Lock() + + // Check to see if head block has increased since + // we created our databaseTransaction. + currHeadBlock, err := i.blockStorage.GetHeadBlockIdentifier(ctx) + if err != nil { + return nil, nil, fmt.Errorf("%w: unable to get head block identifier", err) + } + + // If the block has changed, we try to look up the transaction + // again. + if types.Hash(currHeadBlock) != types.Hash(coinHeadBlock) { + i.waiter.Unlock() + continue + } + + // Put Transaction in WaitTable if doesn't already exist (could be + // multiple listeners) + transactionHash := bitcoin.TransactionHash(coinIdentifier) + val, ok := i.waiter.Get(transactionHash, true) + if !ok { + val = &waitTableEntry{ + channel: make(chan struct{}), + earliestBlock: btcBlock.Height, + } + } + if val.earliestBlock > btcBlock.Height { + val.earliestBlock = btcBlock.Height + } + val.listeners++ + i.waiter.Set(transactionHash, val, true) + i.waiter.Unlock() + + return nil, nil, errMissingTransaction + } + + return nil, nil, ctx.Err() +} + +func (i *Indexer) checkHeaderMatch( + ctx context.Context, + btcBlock *bitcoin.Block, +) error { + headBlock, err := i.blockStorage.GetHeadBlockIdentifier(ctx) + if err != nil && !errors.Is(err, storage.ErrHeadBlockNotFound) { + return fmt.Errorf("%w: unable to lookup head block", err) + } + + // If block we are trying to process is next but it is not connected, we + // should return syncer.ErrOrphanHead to manually trigger a reorg. + if headBlock != nil && + btcBlock.Height == headBlock.Index+1 && + btcBlock.PreviousBlockHash != headBlock.Hash { + return syncer.ErrOrphanHead + } + + return nil +} + +func (i *Indexer) findCoins( + ctx context.Context, + btcBlock *bitcoin.Block, + coins []string, +) (map[string]*storage.AccountCoin, error) { + if err := i.checkHeaderMatch(ctx, btcBlock); err != nil { + return nil, fmt.Errorf("%w: check header match failed", err) + } + + coinMap := map[string]*storage.AccountCoin{} + remainingCoins := []string{} + for _, coinIdentifier := range coins { + coin, owner, err := i.findCoin( + ctx, + btcBlock, + coinIdentifier, + ) + if err == nil { + coinMap[coinIdentifier] = &storage.AccountCoin{ + Account: owner, + Coin: coin, + } + continue + } + + if errors.Is(err, errMissingTransaction) { + remainingCoins = append(remainingCoins, coinIdentifier) + continue + } + + return nil, fmt.Errorf("%w: unable to find coin %s", err, coinIdentifier) + } + + if len(remainingCoins) == 0 { + return coinMap, nil + } + + // Wait for remaining transactions + shouldAbort := false + for _, coinIdentifier := range remainingCoins { + // Wait on Channel + txHash := bitcoin.TransactionHash(coinIdentifier) + entry, ok := i.waiter.Get(txHash, false) + if !ok { + return nil, fmt.Errorf("transaction %s not in waiter", txHash) + } + + select { + case <-entry.channel: + case <-ctx.Done(): + return nil, ctx.Err() + } + + // Delete Transaction from WaitTable if last listener + i.waiter.Lock() + val, ok := i.waiter.Get(txHash, true) + if !ok { + return nil, fmt.Errorf("transaction %s not in waiter", txHash) + } + + // Don't exit right away to make sure + // we remove all closed entries from the + // waiter. + if val.aborted { + shouldAbort = true + } + + val.listeners-- + if val.listeners == 0 { + i.waiter.Delete(txHash, true) + } else { + i.waiter.Set(txHash, val, true) + } + i.waiter.Unlock() + } + + // Wait to exit until we have decremented our listeners + if shouldAbort { + return nil, syncer.ErrOrphanHead + } + + // In the case of a reorg, we may still not be able to find + // the transactions. So, we need to repeat this same process + // recursively until we find the transactions we are looking for. + foundCoins, err := i.findCoins(ctx, btcBlock, remainingCoins) + if err != nil { + return nil, fmt.Errorf("%w: unable to get remaining transactions", err) + } + + for k, v := range foundCoins { + coinMap[k] = v + } + + return coinMap, nil +} + +// Block is called by the syncer to fetch a block. +func (i *Indexer) Block( + ctx context.Context, + network *types.NetworkIdentifier, + blockIdentifier *types.PartialBlockIdentifier, +) (*types.Block, error) { + // get raw block + var btcBlock *bitcoin.Block + var coins []string + var err error + + retries := 0 + for ctx.Err() == nil { + btcBlock, coins, err = i.client.GetRawBlock(ctx, blockIdentifier) + if err == nil { + break + } + + retries++ + if retries > retryLimit { + return nil, fmt.Errorf("%w: unable to get raw block %+v", err, blockIdentifier) + } + + if err := utils.ContextSleep(ctx, retryDelay); err != nil { + return nil, err + } + } + + // determine which coins must be fetched and get from coin storage + coinMap, err := i.findCoins(ctx, btcBlock, coins) + if err != nil { + return nil, fmt.Errorf("%w: unable to find input transactions", err) + } + + // provide to block parsing + block, err := i.client.ParseBlock(ctx, btcBlock, coinMap) + if err != nil { + return nil, fmt.Errorf("%w: unable to parse block %+v", err, blockIdentifier) + } + + // ensure block is valid + if err := i.asserter.Block(block); err != nil { + return nil, fmt.Errorf("%w: block is not valid %+v", err, blockIdentifier) + } + + return block, nil +} + +// GetScriptPubKeys gets the ScriptPubKey for +// a collection of *types.CoinIdentifier. It also +// confirms that the amount provided with each coin +// is valid. +func (i *Indexer) GetScriptPubKeys( + ctx context.Context, + coins []*types.Coin, +) ([]*bitcoin.ScriptPubKey, error) { + databaseTransaction := i.database.NewDatabaseTransaction(ctx, false) + defer databaseTransaction.Discard(ctx) + + scripts := make([]*bitcoin.ScriptPubKey, len(coins)) + for j, coin := range coins { + coinIdentifier := coin.CoinIdentifier + transactionHash, networkIndex, err := bitcoin.ParseCoinIdentifier(coinIdentifier) + if err != nil { + return nil, fmt.Errorf("%w: unable to parse coin identifier", err) + } + + _, transaction, err := i.blockStorage.FindTransaction( + ctx, + &types.TransactionIdentifier{Hash: transactionHash.String()}, + databaseTransaction, + ) + if err != nil || transaction == nil { + return nil, fmt.Errorf( + "%w: unable to find transaction %s", + err, + transactionHash.String(), + ) + } + + for _, op := range transaction.Operations { + if op.Type != bitcoin.OutputOpType { + continue + } + + if *op.OperationIdentifier.NetworkIndex != int64(networkIndex) { + continue + } + + var opMetadata bitcoin.OperationMetadata + if err := types.UnmarshalMap(op.Metadata, &opMetadata); err != nil { + return nil, fmt.Errorf( + "%w: unable to unmarshal operation metadata %+v", + err, + op.Metadata, + ) + } + + if types.Hash(op.Amount.Currency) != types.Hash(coin.Amount.Currency) { + return nil, fmt.Errorf( + "currency expected %s does not match coin %s", + types.PrintStruct(coin.Amount.Currency), + types.PrintStruct(op.Amount.Currency), + ) + } + + addition, err := types.AddValues(op.Amount.Value, coin.Amount.Value) + if err != nil { + return nil, fmt.Errorf("%w: unable to add op amount and coin amount", err) + } + + if addition != "0" { + return nil, fmt.Errorf( + "coin amount does not match expected with difference %s", + addition, + ) + } + + scripts[j] = opMetadata.ScriptPubKey + break + } + + if scripts[j] == nil { + return nil, fmt.Errorf("unable to find script for coin %s", coinIdentifier.Identifier) + } + } + + return scripts, nil +} + +// GetBlockLazy returns a *types.BlockResponse from the indexer's block storage. +// All transactions in a block must be fetched individually. +func (i *Indexer) GetBlockLazy( + ctx context.Context, + blockIdentifier *types.PartialBlockIdentifier, +) (*types.BlockResponse, error) { + return i.blockStorage.GetBlockLazy(ctx, blockIdentifier) +} + +// GetBlockTransaction returns a *types.Transaction if it is in the provided +// *types.BlockIdentifier. +func (i *Indexer) GetBlockTransaction( + ctx context.Context, + blockIdentifier *types.BlockIdentifier, + transactionIdentifier *types.TransactionIdentifier, +) (*types.Transaction, error) { + return i.blockStorage.GetBlockTransaction(ctx, blockIdentifier, transactionIdentifier) +} + +// GetCoins returns all unspent coins for a particular *types.AccountIdentifier. +func (i *Indexer) GetCoins( + ctx context.Context, + accountIdentifier *types.AccountIdentifier, +) ([]*types.Coin, *types.BlockIdentifier, error) { + return i.coinStorage.GetCoins(ctx, accountIdentifier) +} + +// CurrentBlockIdentifier returns the current head block identifier +// and is used to comply with the CoinStorageHelper interface. +func (i *Indexer) CurrentBlockIdentifier( + ctx context.Context, + transaction storage.DatabaseTransaction, +) (*types.BlockIdentifier, error) { + return i.blockStorage.GetHeadBlockIdentifierTransactional(ctx, transaction) +} diff --git a/indexer/indexer_test.go b/indexer/indexer_test.go new file mode 100644 index 0000000..314a2f5 --- /dev/null +++ b/indexer/indexer_test.go @@ -0,0 +1,839 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package indexer + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "math/rand" + "testing" + "time" + + "github.com/coinbase/rosetta-bitcoin/bitcoin" + "github.com/coinbase/rosetta-bitcoin/configuration" + mocks "github.com/coinbase/rosetta-bitcoin/mocks/indexer" + + "github.com/coinbase/rosetta-sdk-go/storage" + "github.com/coinbase/rosetta-sdk-go/types" + "github.com/coinbase/rosetta-sdk-go/utils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func init() { + rand.Seed(time.Now().UTC().UnixNano()) +} + +func getBlockHash(index int64) string { + return fmt.Sprintf("block %d", index) +} + +var ( + index0 = int64(0) +) + +func TestIndexer_Pruning(t *testing.T) { + // Create Indexer + ctx := context.Background() + ctx, cancel := context.WithCancel(context.Background()) + + newDir, err := utils.CreateTempDir() + assert.NoError(t, err) + defer utils.RemoveTempDir(newDir) + + mockClient := &mocks.Client{} + pruneDepth := int64(10) + minHeight := int64(200) + cfg := &configuration.Configuration{ + Network: &types.NetworkIdentifier{ + Network: bitcoin.MainnetNetwork, + Blockchain: bitcoin.Blockchain, + }, + GenesisBlockIdentifier: bitcoin.MainnetGenesisBlockIdentifier, + Pruning: &configuration.PruningConfiguration{ + Frequency: 50 * time.Millisecond, + Depth: pruneDepth, + MinHeight: minHeight, + }, + IndexerPath: newDir, + } + + i, err := Initialize(ctx, cancel, cfg, mockClient) + assert.NoError(t, err) + + // Waiting for bitcoind... + mockClient.On("NetworkStatus", ctx).Return(nil, errors.New("not ready")).Once() + mockClient.On("NetworkStatus", ctx).Return(&types.NetworkStatusResponse{}, nil).Once() + + // Sync to 1000 + mockClient.On("NetworkStatus", ctx).Return(&types.NetworkStatusResponse{ + CurrentBlockIdentifier: &types.BlockIdentifier{ + Index: 1000, + }, + GenesisBlockIdentifier: bitcoin.MainnetGenesisBlockIdentifier, + }, nil) + + // Timeout on first request + mockClient.On( + "PruneBlockchain", + mock.Anything, + mock.Anything, + ).Return( + int64(-1), + errors.New("connection timeout"), + ).Once() + + // Requests after should work + mockClient.On( + "PruneBlockchain", + mock.Anything, + mock.Anything, + ).Return( + int64(100), + nil, + ).Run( + func(args mock.Arguments) { + currBlockResponse, err := i.GetBlockLazy(ctx, nil) + currBlock := currBlockResponse.Block + assert.NoError(t, err) + pruningIndex := args.Get(1).(int64) + assert.True(t, currBlock.BlockIdentifier.Index-pruningIndex >= pruneDepth) + assert.True(t, pruningIndex >= minHeight) + }, + ) + + // Add blocks + waitForCheck := make(chan struct{}) + for i := int64(0); i <= 1000; i++ { + identifier := &types.BlockIdentifier{ + Hash: getBlockHash(i), + Index: i, + } + parentIdentifier := &types.BlockIdentifier{ + Hash: getBlockHash(i - 1), + Index: i - 1, + } + if parentIdentifier.Index < 0 { + parentIdentifier.Index = 0 + parentIdentifier.Hash = getBlockHash(0) + } + + block := &bitcoin.Block{ + Hash: identifier.Hash, + Height: identifier.Index, + PreviousBlockHash: parentIdentifier.Hash, + } + mockClient.On( + "GetRawBlock", + mock.Anything, + &types.PartialBlockIdentifier{Index: &identifier.Index}, + ).Return( + block, + []string{}, + nil, + ).Once() + + blockReturn := &types.Block{ + BlockIdentifier: identifier, + ParentBlockIdentifier: parentIdentifier, + Timestamp: 1599002115110, + } + if i != 200 { + mockClient.On( + "ParseBlock", + mock.Anything, + block, + map[string]*storage.AccountCoin{}, + ).Return( + blockReturn, + nil, + ).Once() + } else { + mockClient.On( + "ParseBlock", + mock.Anything, + block, + map[string]*storage.AccountCoin{}, + ).Return( + blockReturn, + nil, + ).Run(func(args mock.Arguments) { + close(waitForCheck) + }).Once() + } + } + + go func() { + err := i.Sync(ctx) + assert.True(t, errors.Is(err, context.Canceled)) + }() + + go func() { + err := i.Prune(ctx) + assert.True(t, errors.Is(err, context.Canceled)) + }() + + <-waitForCheck + waitForFinish := make(chan struct{}) + go func() { + for { + currBlockResponse, err := i.GetBlockLazy(ctx, nil) + if currBlockResponse == nil { + time.Sleep(1 * time.Second) + continue + } + + currBlock := currBlockResponse.Block + assert.NoError(t, err) + + if currBlock.BlockIdentifier.Index == 1000 { + cancel() + close(waitForFinish) + return + } + time.Sleep(1 * time.Second) + } + }() + + <-waitForFinish + mockClient.AssertExpectations(t) +} + +func TestIndexer_Transactions(t *testing.T) { + // Create Indexer + ctx := context.Background() + ctx, cancel := context.WithCancel(context.Background()) + + newDir, err := utils.CreateTempDir() + assert.NoError(t, err) + defer utils.RemoveTempDir(newDir) + + mockClient := &mocks.Client{} + cfg := &configuration.Configuration{ + Network: &types.NetworkIdentifier{ + Network: bitcoin.MainnetNetwork, + Blockchain: bitcoin.Blockchain, + }, + GenesisBlockIdentifier: bitcoin.MainnetGenesisBlockIdentifier, + IndexerPath: newDir, + } + + i, err := Initialize(ctx, cancel, cfg, mockClient) + assert.NoError(t, err) + + // Sync to 1000 + mockClient.On("NetworkStatus", ctx).Return(&types.NetworkStatusResponse{ + CurrentBlockIdentifier: &types.BlockIdentifier{ + Index: 1000, + }, + GenesisBlockIdentifier: bitcoin.MainnetGenesisBlockIdentifier, + }, nil) + + // Add blocks + waitForCheck := make(chan struct{}) + type coinBankEntry struct { + Script *bitcoin.ScriptPubKey + Coin *types.Coin + Account *types.AccountIdentifier + } + + coinBank := map[string]*coinBankEntry{} + for i := int64(0); i <= 1000; i++ { + identifier := &types.BlockIdentifier{ + Hash: getBlockHash(i), + Index: i, + } + parentIdentifier := &types.BlockIdentifier{ + Hash: getBlockHash(i - 1), + Index: i - 1, + } + if parentIdentifier.Index < 0 { + parentIdentifier.Index = 0 + parentIdentifier.Hash = getBlockHash(0) + } + + transactions := []*types.Transaction{} + for j := 0; j < 5; j++ { + rawHash := fmt.Sprintf("block %d transaction %d", i, j) + hash := fmt.Sprintf("%x", sha256.Sum256([]byte(rawHash))) + coinIdentifier := fmt.Sprintf("%s:%d", hash, index0) + scriptPubKey := &bitcoin.ScriptPubKey{ + ASM: coinIdentifier, + } + marshal, err := types.MarshalMap(scriptPubKey) + assert.NoError(t, err) + tx := &types.Transaction{ + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: hash, + }, + Operations: []*types.Operation{ + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + NetworkIndex: &index0, + }, + Status: bitcoin.SuccessStatus, + Type: bitcoin.OutputOpType, + Account: &types.AccountIdentifier{ + Address: rawHash, + }, + Amount: &types.Amount{ + Value: fmt.Sprintf("%d", rand.Intn(1000)), + Currency: bitcoin.TestnetCurrency, + }, + CoinChange: &types.CoinChange{ + CoinAction: types.CoinCreated, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: coinIdentifier, + }, + }, + Metadata: map[string]interface{}{ + "scriptPubKey": marshal, + }, + }, + }, + } + coinBank[coinIdentifier] = &coinBankEntry{ + Script: scriptPubKey, + Coin: &types.Coin{ + CoinIdentifier: &types.CoinIdentifier{ + Identifier: coinIdentifier, + }, + Amount: tx.Operations[0].Amount, + }, + Account: &types.AccountIdentifier{ + Address: rawHash, + }, + } + + transactions = append(transactions, tx) + } + + block := &bitcoin.Block{ + Hash: identifier.Hash, + Height: identifier.Index, + PreviousBlockHash: parentIdentifier.Hash, + } + + // Require coins that will make the indexer + // wait. + requiredCoins := []string{} + rand := rand.New(rand.NewSource(time.Now().UnixNano())) + for k := i - 1; k >= 0 && k > i-20; k-- { + rawHash := fmt.Sprintf("block %d transaction %d", k, rand.Intn(5)) + hash := fmt.Sprintf("%x", sha256.Sum256([]byte(rawHash))) + requiredCoins = append(requiredCoins, hash+":0") + } + + mockClient.On( + "GetRawBlock", + mock.Anything, + &types.PartialBlockIdentifier{Index: &identifier.Index}, + ).Return( + block, + requiredCoins, + nil, + ).Once() + + blockReturn := &types.Block{ + BlockIdentifier: identifier, + ParentBlockIdentifier: parentIdentifier, + Timestamp: 1599002115110, + Transactions: transactions, + } + + coinMap := map[string]*storage.AccountCoin{} + for _, coinIdentifier := range requiredCoins { + coinMap[coinIdentifier] = &storage.AccountCoin{ + Account: coinBank[coinIdentifier].Account, + Coin: coinBank[coinIdentifier].Coin, + } + } + + if i != 200 { + mockClient.On( + "ParseBlock", + mock.Anything, + block, + coinMap, + ).Return( + blockReturn, + nil, + ).Once() + } else { + mockClient.On("ParseBlock", mock.Anything, block, coinMap).Return(blockReturn, nil).Run(func(args mock.Arguments) { + close(waitForCheck) + }).Once() + } + } + + go func() { + err := i.Sync(ctx) + assert.True(t, errors.Is(err, context.Canceled)) + }() + + <-waitForCheck + waitForFinish := make(chan struct{}) + go func() { + for { + currBlockResponse, err := i.GetBlockLazy(ctx, nil) + if currBlockResponse == nil { + time.Sleep(1 * time.Second) + continue + } + + currBlock := currBlockResponse.Block + assert.NoError(t, err) + + if currBlock.BlockIdentifier.Index == 1000 { + // Ensure ScriptPubKeys are accessible. + allCoins := []*types.Coin{} + expectedPubKeys := []*bitcoin.ScriptPubKey{} + for k, v := range coinBank { + allCoins = append(allCoins, &types.Coin{ + CoinIdentifier: &types.CoinIdentifier{Identifier: k}, + Amount: &types.Amount{ + Value: fmt.Sprintf("-%s", v.Coin.Amount.Value), + Currency: bitcoin.TestnetCurrency, + }, + }) + expectedPubKeys = append(expectedPubKeys, v.Script) + } + + pubKeys, err := i.GetScriptPubKeys(ctx, allCoins) + assert.NoError(t, err) + assert.Equal(t, expectedPubKeys, pubKeys) + + cancel() + close(waitForFinish) + return + } + + time.Sleep(1 * time.Second) + } + }() + + <-waitForFinish + mockClient.AssertExpectations(t) +} + +func TestIndexer_Reorg(t *testing.T) { + // Create Indexer + ctx := context.Background() + ctx, cancel := context.WithCancel(context.Background()) + + newDir, err := utils.CreateTempDir() + assert.NoError(t, err) + defer utils.RemoveTempDir(newDir) + + mockClient := &mocks.Client{} + cfg := &configuration.Configuration{ + Network: &types.NetworkIdentifier{ + Network: bitcoin.MainnetNetwork, + Blockchain: bitcoin.Blockchain, + }, + GenesisBlockIdentifier: bitcoin.MainnetGenesisBlockIdentifier, + IndexerPath: newDir, + } + + i, err := Initialize(ctx, cancel, cfg, mockClient) + assert.NoError(t, err) + + // Sync to 1000 + mockClient.On("NetworkStatus", ctx).Return(&types.NetworkStatusResponse{ + CurrentBlockIdentifier: &types.BlockIdentifier{ + Index: 1000, + }, + GenesisBlockIdentifier: bitcoin.MainnetGenesisBlockIdentifier, + }, nil) + + // Add blocks + waitForCheck := make(chan struct{}) + type coinBankEntry struct { + Script *bitcoin.ScriptPubKey + Coin *types.Coin + Account *types.AccountIdentifier + } + + coinBank := map[string]*coinBankEntry{} + + for i := int64(0); i <= 1000; i++ { + identifier := &types.BlockIdentifier{ + Hash: getBlockHash(i), + Index: i, + } + parentIdentifier := &types.BlockIdentifier{ + Hash: getBlockHash(i - 1), + Index: i - 1, + } + if parentIdentifier.Index < 0 { + parentIdentifier.Index = 0 + parentIdentifier.Hash = getBlockHash(0) + } + + transactions := []*types.Transaction{} + for j := 0; j < 5; j++ { + rawHash := fmt.Sprintf("block %d transaction %d", i, j) + hash := fmt.Sprintf("%x", sha256.Sum256([]byte(rawHash))) + coinIdentifier := fmt.Sprintf("%s:%d", hash, index0) + scriptPubKey := &bitcoin.ScriptPubKey{ + ASM: coinIdentifier, + } + marshal, err := types.MarshalMap(scriptPubKey) + assert.NoError(t, err) + tx := &types.Transaction{ + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: hash, + }, + Operations: []*types.Operation{ + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + NetworkIndex: &index0, + }, + Status: bitcoin.SuccessStatus, + Type: bitcoin.OutputOpType, + Account: &types.AccountIdentifier{ + Address: rawHash, + }, + Amount: &types.Amount{ + Value: fmt.Sprintf("%d", rand.Intn(1000)), + Currency: bitcoin.TestnetCurrency, + }, + CoinChange: &types.CoinChange{ + CoinAction: types.CoinCreated, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: coinIdentifier, + }, + }, + Metadata: map[string]interface{}{ + "scriptPubKey": marshal, + }, + }, + }, + } + coinBank[coinIdentifier] = &coinBankEntry{ + Script: scriptPubKey, + Coin: &types.Coin{ + CoinIdentifier: &types.CoinIdentifier{ + Identifier: coinIdentifier, + }, + Amount: tx.Operations[0].Amount, + }, + Account: &types.AccountIdentifier{ + Address: rawHash, + }, + } + transactions = append(transactions, tx) + } + + block := &bitcoin.Block{ + Hash: identifier.Hash, + Height: identifier.Index, + PreviousBlockHash: parentIdentifier.Hash, + } + + // Require coins that will make the indexer + // wait. + requiredCoins := []string{} + rand := rand.New(rand.NewSource(time.Now().UnixNano())) + for k := i - 1; k >= 0 && k > i-20; k-- { + rawHash := fmt.Sprintf("block %d transaction %d", k, rand.Intn(5)) + hash := fmt.Sprintf("%x", sha256.Sum256([]byte(rawHash))) + requiredCoins = append(requiredCoins, hash+":0") + } + + if i == 400 { + // we will need to call 400 twice + mockClient.On( + "GetRawBlock", + mock.Anything, + &types.PartialBlockIdentifier{Index: &identifier.Index}, + ).Return( + block, + requiredCoins, + nil, + ).Once() + } + + if i == 401 { + // require non-existent coins that will never be + // found to ensure we re-org via abort (with no change + // in block identifiers) + mockClient.On( + "GetRawBlock", + mock.Anything, + &types.PartialBlockIdentifier{Index: &identifier.Index}, + ).Return( + block, + []string{"blah:1", "blah2:2"}, + nil, + ).Once() + } + + mockClient.On( + "GetRawBlock", + mock.Anything, + &types.PartialBlockIdentifier{Index: &identifier.Index}, + ).Return( + block, + requiredCoins, + nil, + ).Once() + + blockReturn := &types.Block{ + BlockIdentifier: identifier, + ParentBlockIdentifier: parentIdentifier, + Timestamp: 1599002115110, + Transactions: transactions, + } + + coinMap := map[string]*storage.AccountCoin{} + for _, coinIdentifier := range requiredCoins { + coinMap[coinIdentifier] = &storage.AccountCoin{ + Account: coinBank[coinIdentifier].Account, + Coin: coinBank[coinIdentifier].Coin, + } + } + + if i == 400 { + mockClient.On( + "ParseBlock", + mock.Anything, + block, + coinMap, + ).Return( + blockReturn, + nil, + ).Once() + } + + if i != 200 { + mockClient.On( + "ParseBlock", + mock.Anything, + block, + coinMap, + ).Return( + blockReturn, + nil, + ).Once() + } else { + mockClient.On("ParseBlock", mock.Anything, block, coinMap).Return(blockReturn, nil).Run(func(args mock.Arguments) { + close(waitForCheck) + }).Once() + } + } + + go func() { + err := i.Sync(ctx) + assert.True(t, errors.Is(err, context.Canceled)) + }() + + <-waitForCheck + waitForFinish := make(chan struct{}) + go func() { + for { + currBlockResponse, err := i.GetBlockLazy(ctx, nil) + if currBlockResponse == nil { + time.Sleep(1 * time.Second) + continue + } + + currBlock := currBlockResponse.Block + assert.NoError(t, err) + + if currBlock.BlockIdentifier.Index == 1000 { + cancel() + close(waitForFinish) + + return + } + + time.Sleep(1 * time.Second) + } + }() + + <-waitForFinish + assert.Len(t, i.waiter.table, 0) + mockClient.AssertExpectations(t) +} + +func TestIndexer_HeaderReorg(t *testing.T) { + // Create Indexer + ctx := context.Background() + ctx, cancel := context.WithCancel(context.Background()) + + newDir, err := utils.CreateTempDir() + assert.NoError(t, err) + defer utils.RemoveTempDir(newDir) + + mockClient := &mocks.Client{} + cfg := &configuration.Configuration{ + Network: &types.NetworkIdentifier{ + Network: bitcoin.MainnetNetwork, + Blockchain: bitcoin.Blockchain, + }, + GenesisBlockIdentifier: bitcoin.MainnetGenesisBlockIdentifier, + IndexerPath: newDir, + } + + i, err := Initialize(ctx, cancel, cfg, mockClient) + assert.NoError(t, err) + + // Sync to 1000 + mockClient.On("NetworkStatus", ctx).Return(&types.NetworkStatusResponse{ + CurrentBlockIdentifier: &types.BlockIdentifier{ + Index: 1000, + }, + GenesisBlockIdentifier: bitcoin.MainnetGenesisBlockIdentifier, + }, nil) + + // Add blocks + waitForCheck := make(chan struct{}) + for i := int64(0); i <= 1000; i++ { + identifier := &types.BlockIdentifier{ + Hash: getBlockHash(i), + Index: i, + } + parentIdentifier := &types.BlockIdentifier{ + Hash: getBlockHash(i - 1), + Index: i - 1, + } + if parentIdentifier.Index < 0 { + parentIdentifier.Index = 0 + parentIdentifier.Hash = getBlockHash(0) + } + + transactions := []*types.Transaction{} + block := &bitcoin.Block{ + Hash: identifier.Hash, + Height: identifier.Index, + PreviousBlockHash: parentIdentifier.Hash, + } + + requiredCoins := []string{} + if i == 400 { + // we will need to call 400 twice + mockClient.On( + "GetRawBlock", + mock.Anything, + &types.PartialBlockIdentifier{Index: &identifier.Index}, + ).Return( + block, + requiredCoins, + nil, + ).Once() + } + + if i == 401 { + // mess up previous block hash to trigger a re-org + mockClient.On( + "GetRawBlock", + mock.Anything, + &types.PartialBlockIdentifier{Index: &identifier.Index}, + ).Return( + &bitcoin.Block{ + Hash: identifier.Hash, + Height: identifier.Index, + PreviousBlockHash: "blah", + }, + []string{}, + nil, + ).After(5 * time.Second).Once() // we delay to ensure we are at tip here + } + + mockClient.On( + "GetRawBlock", + mock.Anything, + &types.PartialBlockIdentifier{Index: &identifier.Index}, + ).Return( + block, + requiredCoins, + nil, + ).Once() + + blockReturn := &types.Block{ + BlockIdentifier: identifier, + ParentBlockIdentifier: parentIdentifier, + Timestamp: 1599002115110, + Transactions: transactions, + } + + coinMap := map[string]*storage.AccountCoin{} + if i == 400 { + mockClient.On( + "ParseBlock", + mock.Anything, + block, + coinMap, + ).Return( + blockReturn, + nil, + ).Once() + } + + if i != 200 { + mockClient.On( + "ParseBlock", + mock.Anything, + block, + coinMap, + ).Return( + blockReturn, + nil, + ).Once() + } else { + mockClient.On("ParseBlock", mock.Anything, block, coinMap).Return(blockReturn, nil).Run(func(args mock.Arguments) { + close(waitForCheck) + }).Once() + } + } + + go func() { + err := i.Sync(ctx) + assert.True(t, errors.Is(err, context.Canceled)) + }() + + <-waitForCheck + waitForFinish := make(chan struct{}) + go func() { + for { + currBlockResponse, err := i.GetBlockLazy(ctx, nil) + if currBlockResponse == nil { + time.Sleep(1 * time.Second) + continue + } + + currBlock := currBlockResponse.Block + assert.NoError(t, err) + + if currBlock.BlockIdentifier.Index == 1000 { + cancel() + close(waitForFinish) + + return + } + + time.Sleep(1 * time.Second) + } + }() + + <-waitForFinish + assert.Len(t, i.waiter.table, 0) + mockClient.AssertExpectations(t) +} diff --git a/indexer/wait_table.go b/indexer/wait_table.go new file mode 100644 index 0000000..7ca7c35 --- /dev/null +++ b/indexer/wait_table.go @@ -0,0 +1,81 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package indexer + +import ( + "sync" +) + +type waitTable struct { + table map[string]*waitTableEntry + + // lock is held when we want to perform multiple + // reads/writes to waiter. + lock sync.Mutex +} + +func newWaitTable() *waitTable { + return &waitTable{ + table: map[string]*waitTableEntry{}, + } +} + +func (t *waitTable) Lock() { + t.lock.Lock() +} + +func (t *waitTable) Unlock() { + t.lock.Unlock() +} + +func (t *waitTable) Get(key string, unsafe bool) (*waitTableEntry, bool) { + if !unsafe { + t.lock.Lock() + defer t.lock.Unlock() + } + + v, ok := t.table[key] + if !ok { + return nil, false + } + + return v, true +} + +func (t *waitTable) Set(key string, value *waitTableEntry, unsafe bool) { + if !unsafe { + t.lock.Lock() + defer t.lock.Unlock() + } + t.table[key] = value +} + +func (t *waitTable) Delete(key string, unsafe bool) { + if !unsafe { + t.lock.Lock() + defer t.lock.Unlock() + } + + delete(t.table, key) +} + +type waitTableEntry struct { + listeners int // need to know when to delete entry (i.e. when no listeners) + channel chan struct{} + + channelClosed bool // needed to know if we should abort (can't read if channel is closed) + aborted bool + earliestBlock int64 +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..0e8f30c --- /dev/null +++ b/main.go @@ -0,0 +1,203 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/coinbase/rosetta-bitcoin/bitcoin" + "github.com/coinbase/rosetta-bitcoin/configuration" + "github.com/coinbase/rosetta-bitcoin/indexer" + "github.com/coinbase/rosetta-bitcoin/services" + "github.com/coinbase/rosetta-bitcoin/utils" + + "github.com/coinbase/rosetta-sdk-go/asserter" + "github.com/coinbase/rosetta-sdk-go/server" + "github.com/coinbase/rosetta-sdk-go/types" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" + "golang.org/x/sync/errgroup" +) + +const ( + // readTimeout is the maximum duration for reading the entire + // request, including the body. + readTimeout = 5 * time.Second + + // writeTimeout is the maximum duration before timing out + // writes of the response. It is reset whenever a new + // request's header is read. + writeTimeout = 15 * time.Second + + // idleTimeout is the maximum amount of time to wait for the + // next request when keep-alives are enabled. + idleTimeout = 30 * time.Second + + // maxHeapUsage is the size of the heap in MB before we manually + // trigger garbage collection. + maxHeapUsage = 8500 // ~8.5 GB +) + +var ( + signalReceived = false +) + +// handleSignals handles OS signals so we can ensure we close database +// correctly. We call multiple sigListeners because we +// may need to cancel more than 1 context. +func handleSignals(ctx context.Context, listeners []context.CancelFunc) { + logger := utils.ExtractLogger(ctx, "signal handler") + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + go func() { + sig := <-sigs + logger.Warnw("received signal", "signal", sig) + signalReceived = true + for _, listener := range listeners { + listener() + } + }() +} + +func startOnlineDependencies( + ctx context.Context, + cancel context.CancelFunc, + cfg *configuration.Configuration, + g *errgroup.Group, +) (*bitcoin.Client, *indexer.Indexer, error) { + client := bitcoin.NewClient( + bitcoin.LocalhostURL(cfg.RPCPort), + cfg.GenesisBlockIdentifier, + cfg.Currency, + ) + + g.Go(func() error { + return bitcoin.StartBitcoind(ctx, cfg.ConfigPath, g) + }) + + i, err := indexer.Initialize(ctx, cancel, cfg, client) + if err != nil { + return nil, nil, fmt.Errorf("%w: unable to initialize indexer", err) + } + + g.Go(func() error { + return i.Sync(ctx) + }) + + g.Go(func() error { + return i.Prune(ctx) + }) + + return client, i, nil +} + +func main() { + loggerRaw, err := zap.NewDevelopment() + if err != nil { + log.Fatalf("can't initialize zap logger: %v", err) + } + + defer func() { + _ = loggerRaw.Sync() + }() + + ctx := context.Background() + ctx = ctxzap.ToContext(ctx, loggerRaw) + ctx, cancel := context.WithCancel(ctx) + go handleSignals(ctx, []context.CancelFunc{cancel}) + + logger := loggerRaw.Sugar().Named("main") + + cfg, err := configuration.LoadConfiguration(configuration.DataDirectory) + if err != nil { + logger.Fatalw("unable to load configuration", "error", err) + } + + logger.Infow("loaded configuration", "configuration", types.PrintStruct(cfg)) + + g, ctx := errgroup.WithContext(ctx) + + g.Go(func() error { + return utils.MonitorMemoryUsage(ctx, maxHeapUsage) + }) + + var i *indexer.Indexer + var client *bitcoin.Client + if cfg.Mode == configuration.Online { + client, i, err = startOnlineDependencies(ctx, cancel, cfg, g) + if err != nil { + logger.Fatalw("unable to start online dependencies", "error", err) + } + } + + // The asserter automatically rejects incorrectly formatted + // requests. + asserter, err := asserter.NewServer( + bitcoin.OperationTypes, + false, + []*types.NetworkIdentifier{cfg.Network}, + ) + if err != nil { + logger.Fatalw("unable to create new server asserter", "error", err) + } + + router := services.NewBlockchainRouter(cfg, client, i, asserter) + loggedRouter := services.LoggerMiddleware(loggerRaw, router) + corsRouter := server.CorsMiddleware(loggedRouter) + server := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.Port), + Handler: corsRouter, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + IdleTimeout: idleTimeout, + } + + g.Go(func() error { + logger.Infow("server listening", "port", cfg.Port) + return server.ListenAndServe() + }) + + g.Go(func() error { + // If we don't shutdown server in errgroup, it will + // never stop because server.ListenAndServe doesn't + // take any context. + <-ctx.Done() + + return server.Shutdown(ctx) + }) + + err = g.Wait() + + // We always want to attempt to close the database, regardless of the error. + // We also want to do this after all indexer goroutines have stopped. + if i != nil { + i.CloseDatabase(ctx) + } + + if signalReceived { + logger.Fatalw("rosetta-bitcoin halted") + } + + if err != nil { + logger.Fatalw("rosetta-bitcoin sync failed", "error", err) + } +} diff --git a/mocks/indexer/client.go b/mocks/indexer/client.go new file mode 100644 index 0000000..d174056 --- /dev/null +++ b/mocks/indexer/client.go @@ -0,0 +1,119 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. + +package indexer + +import ( + context "context" + + bitcoin "github.com/coinbase/rosetta-bitcoin/bitcoin" + + mock "github.com/stretchr/testify/mock" + + storage "github.com/coinbase/rosetta-sdk-go/storage" + + types "github.com/coinbase/rosetta-sdk-go/types" +) + +// Client is an autogenerated mock type for the Client type +type Client struct { + mock.Mock +} + +// GetRawBlock provides a mock function with given fields: _a0, _a1 +func (_m *Client) GetRawBlock(_a0 context.Context, _a1 *types.PartialBlockIdentifier) (*bitcoin.Block, []string, error) { + ret := _m.Called(_a0, _a1) + + var r0 *bitcoin.Block + if rf, ok := ret.Get(0).(func(context.Context, *types.PartialBlockIdentifier) *bitcoin.Block); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*bitcoin.Block) + } + } + + var r1 []string + if rf, ok := ret.Get(1).(func(context.Context, *types.PartialBlockIdentifier) []string); ok { + r1 = rf(_a0, _a1) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).([]string) + } + } + + var r2 error + if rf, ok := ret.Get(2).(func(context.Context, *types.PartialBlockIdentifier) error); ok { + r2 = rf(_a0, _a1) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// NetworkStatus provides a mock function with given fields: _a0 +func (_m *Client) NetworkStatus(_a0 context.Context) (*types.NetworkStatusResponse, error) { + ret := _m.Called(_a0) + + var r0 *types.NetworkStatusResponse + if rf, ok := ret.Get(0).(func(context.Context) *types.NetworkStatusResponse); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.NetworkStatusResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ParseBlock provides a mock function with given fields: _a0, _a1, _a2 +func (_m *Client) ParseBlock(_a0 context.Context, _a1 *bitcoin.Block, _a2 map[string]*storage.AccountCoin) (*types.Block, error) { + ret := _m.Called(_a0, _a1, _a2) + + var r0 *types.Block + if rf, ok := ret.Get(0).(func(context.Context, *bitcoin.Block, map[string]*storage.AccountCoin) *types.Block); ok { + r0 = rf(_a0, _a1, _a2) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.Block) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *bitcoin.Block, map[string]*storage.AccountCoin) error); ok { + r1 = rf(_a0, _a1, _a2) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// PruneBlockchain provides a mock function with given fields: _a0, _a1 +func (_m *Client) PruneBlockchain(_a0 context.Context, _a1 int64) (int64, error) { + ret := _m.Called(_a0, _a1) + + var r0 int64 + if rf, ok := ret.Get(0).(func(context.Context, int64) int64); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Get(0).(int64) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/mocks/services/client.go b/mocks/services/client.go new file mode 100644 index 0000000..d6aa25d --- /dev/null +++ b/mocks/services/client.go @@ -0,0 +1,81 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. + +package services + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + + types "github.com/coinbase/rosetta-sdk-go/types" +) + +// Client is an autogenerated mock type for the Client type +type Client struct { + mock.Mock +} + +// NetworkStatus provides a mock function with given fields: _a0 +func (_m *Client) NetworkStatus(_a0 context.Context) (*types.NetworkStatusResponse, error) { + ret := _m.Called(_a0) + + var r0 *types.NetworkStatusResponse + if rf, ok := ret.Get(0).(func(context.Context) *types.NetworkStatusResponse); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.NetworkStatusResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SendRawTransaction provides a mock function with given fields: _a0, _a1 +func (_m *Client) SendRawTransaction(_a0 context.Context, _a1 string) (string, error) { + ret := _m.Called(_a0, _a1) + + var r0 string + if rf, ok := ret.Get(0).(func(context.Context, string) string); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Get(0).(string) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SuggestedFeeRate provides a mock function with given fields: _a0, _a1 +func (_m *Client) SuggestedFeeRate(_a0 context.Context, _a1 int64) (float64, error) { + ret := _m.Called(_a0, _a1) + + var r0 float64 + if rf, ok := ret.Get(0).(func(context.Context, int64) float64); ok { + r0 = rf(_a0, _a1) + } else { + r0 = ret.Get(0).(float64) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/mocks/services/indexer.go b/mocks/services/indexer.go new file mode 100644 index 0000000..a3c4711 --- /dev/null +++ b/mocks/services/indexer.go @@ -0,0 +1,119 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. + +package services + +import ( + context "context" + + bitcoin "github.com/coinbase/rosetta-bitcoin/bitcoin" + + mock "github.com/stretchr/testify/mock" + + types "github.com/coinbase/rosetta-sdk-go/types" +) + +// Indexer is an autogenerated mock type for the Indexer type +type Indexer struct { + mock.Mock +} + +// GetBlockLazy provides a mock function with given fields: _a0, _a1 +func (_m *Indexer) GetBlockLazy(_a0 context.Context, _a1 *types.PartialBlockIdentifier) (*types.BlockResponse, error) { + ret := _m.Called(_a0, _a1) + + var r0 *types.BlockResponse + if rf, ok := ret.Get(0).(func(context.Context, *types.PartialBlockIdentifier) *types.BlockResponse); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.BlockResponse) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *types.PartialBlockIdentifier) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetBlockTransaction provides a mock function with given fields: _a0, _a1, _a2 +func (_m *Indexer) GetBlockTransaction(_a0 context.Context, _a1 *types.BlockIdentifier, _a2 *types.TransactionIdentifier) (*types.Transaction, error) { + ret := _m.Called(_a0, _a1, _a2) + + var r0 *types.Transaction + if rf, ok := ret.Get(0).(func(context.Context, *types.BlockIdentifier, *types.TransactionIdentifier) *types.Transaction); ok { + r0 = rf(_a0, _a1, _a2) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.Transaction) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *types.BlockIdentifier, *types.TransactionIdentifier) error); ok { + r1 = rf(_a0, _a1, _a2) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetCoins provides a mock function with given fields: _a0, _a1 +func (_m *Indexer) GetCoins(_a0 context.Context, _a1 *types.AccountIdentifier) ([]*types.Coin, *types.BlockIdentifier, error) { + ret := _m.Called(_a0, _a1) + + var r0 []*types.Coin + if rf, ok := ret.Get(0).(func(context.Context, *types.AccountIdentifier) []*types.Coin); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*types.Coin) + } + } + + var r1 *types.BlockIdentifier + if rf, ok := ret.Get(1).(func(context.Context, *types.AccountIdentifier) *types.BlockIdentifier); ok { + r1 = rf(_a0, _a1) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*types.BlockIdentifier) + } + } + + var r2 error + if rf, ok := ret.Get(2).(func(context.Context, *types.AccountIdentifier) error); ok { + r2 = rf(_a0, _a1) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// GetScriptPubKeys provides a mock function with given fields: _a0, _a1 +func (_m *Indexer) GetScriptPubKeys(_a0 context.Context, _a1 []*types.Coin) ([]*bitcoin.ScriptPubKey, error) { + ret := _m.Called(_a0, _a1) + + var r0 []*bitcoin.ScriptPubKey + if rf, ok := ret.Get(0).(func(context.Context, []*types.Coin) []*bitcoin.ScriptPubKey); ok { + r0 = rf(_a0, _a1) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*bitcoin.ScriptPubKey) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, []*types.Coin) error); ok { + r1 = rf(_a0, _a1) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} diff --git a/rosetta-cli-conf/bitcoin_mainnet.json b/rosetta-cli-conf/bitcoin_mainnet.json new file mode 100644 index 0000000..be17d0a --- /dev/null +++ b/rosetta-cli-conf/bitcoin_mainnet.json @@ -0,0 +1,380 @@ +{ + "network": { + "blockchain": "Bitcoin", + "network": "Mainnet" + }, + "data_directory": "bitcoin-mainnet-data", + "http_timeout": 300, + "max_retries": 5, + "retry_elapsed_time": 0, + "max_online_connections": 0, + "max_sync_concurrency": 0, + "tip_delay": 1800, + "log_configuration": false, + "construction": { + "max_offline_connections": 0, + "stale_depth": 0, + "broadcast_limit": 0, + "ignore_broadcast_failures": false, + "clear_broadcasts": false, + "broadcast_behind_tip": false, + "block_broadcast_limit": 0, + "rebroadcast_all": false, + "workflows": [ + { + "name": "request_funds", + "concurrency": 1, + "scenarios": [ + { + "name": "find_account", + "actions": [ + { + "input": "{\"symbol\":\"BTC\", \"decimals\":8}", + "type": "set_variable", + "output_path": "currency" + }, + { + "input": "{\"minimum_balance\":{\"value\": \"0\", \"currency\": {{currency}}}, \"create_limit\":1}", + "type": "find_balance", + "output_path": "random_account" + } + ] + }, + { + "name": "request", + "actions": [ + { + "input": "{\"account_identifier\": {{random_account.account_identifier}}, \"minimum_balance\":{\"value\": \"1000000\", \"currency\": {{currency}}}}", + "type": "find_balance", + "output_path": "loaded_account" + } + ] + } + ] + }, + { + "name": "create_account", + "concurrency": 1, + "scenarios": [ + { + "name": "create_account", + "actions": [ + { + "input": "{\"network\":\"Mainnet\", \"blockchain\":\"Bitcoin\"}", + "type": "set_variable", + "output_path": "network" + }, + { + "input": "{\"curve_type\": \"secp256k1\"}", + "type": "generate_key", + "output_path": "key" + }, + { + "input": "{\"network_identifier\": {{network}}, \"public_key\": {{key.public_key}}}", + "type": "derive", + "output_path": "account" + }, + { + "input": "{\"account_identifier\": {{account.account_identifier}}, \"keypair\": {{key}}}", + "type": "save_account" + } + ] + } + ] + }, + { + "name": "transfer", + "concurrency": 10, + "scenarios": [ + { + "name": "transfer_dry_run", + "actions": [ + { + "input": "{\"network\":\"Mainnet\", \"blockchain\":\"Bitcoin\"}", + "type": "set_variable", + "output_path": "transfer_dry_run.network" + }, + { + "input": "{\"symbol\":\"BTC\", \"decimals\":8}", + "type": "set_variable", + "output_path": "currency" + }, + { + "input": "\"600\"", + "type": "set_variable", + "output_path": "dust_amount" + }, + { + "input": "\"1200\"", + "type": "set_variable", + "output_path": "max_fee_amount" + }, + { + "input": "{\"operation\":\"addition\", \"left_value\": {{dust_amount}}, \"right_value\": {{max_fee_amount}}}", + "type": "math", + "output_path": "send_buffer" + }, + { + "input": "\"2400\"", + "type": "set_variable", + "output_path": "reserved_amount" + }, + { + "input": "{\"require_coin\":true, \"minimum_balance\":{\"value\": {{reserved_amount}}, \"currency\": {{currency}}}}", + "type": "find_balance", + "output_path": "sender" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": {{sender.balance.value}}, \"right_value\": {{send_buffer}}}", + "type": "math", + "output_path": "available_amount" + }, + { + "input": "{\"minimum\": {{dust_amount}}, \"maximum\": {{available_amount}}}", + "type": "random_number", + "output_path": "recipient_amount" + }, + { + "input": "{\"recipient_amount\":{{recipient_amount}}}", + "type": "print_message" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": {{sender.balance.value}}, \"right_value\": {{recipient_amount}}}", + "type": "math", + "output_path": "total_change_amount" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": {{total_change_amount}}, \"right_value\": {{max_fee_amount}}}", + "type": "math", + "output_path": "change_amount" + }, + { + "input": "{\"change_amount\":{{change_amount}}}", + "type": "print_message" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": \"0\", \"right_value\":{{sender.balance.value}}}", + "type": "math", + "output_path": "sender_amount" + }, + { + "input": "{\"not_account_identifier\":[{{sender.account_identifier}}], \"not_coins\":[{{sender.coin}}], \"minimum_balance\":{\"value\": \"0\", \"currency\": {{currency}}}, \"create_limit\": 100, \"create_probability\": 50}", + "type": "find_balance", + "output_path": "recipient" + }, + { + "input": "\"1\"", + "type": "set_variable", + "output_path": "transfer_dry_run.confirmation_depth" + }, + { + "input": "\"true\"", + "type": "set_variable", + "output_path": "transfer_dry_run.dry_run" + }, + { + "input": "[{\"operation_identifier\":{\"index\":0},\"type\":\"INPUT\",\"account\":{{sender.account_identifier}},\"amount\":{\"value\":{{sender_amount}},\"currency\":{{currency}}}, \"coin_change\":{\"coin_action\":\"coin_spent\", \"coin_identifier\":{{sender.coin}}}},{\"operation_identifier\":{\"index\":1},\"type\":\"OUTPUT\",\"account\":{{recipient.account_identifier}},\"amount\":{\"value\":{{recipient_amount}},\"currency\":{{currency}}}}, {\"operation_identifier\":{\"index\":2},\"type\":\"OUTPUT\",\"account\":{{sender.account_identifier}},\"amount\":{\"value\":{{change_amount}},\"currency\":{{currency}}}}]", + "type": "set_variable", + "output_path": "transfer_dry_run.operations" + }, + { + "input": "{{transfer_dry_run.operations}}", + "type": "print_message" + } + ] + }, + { + "name": "transfer", + "actions": [ + { + "input": "{\"currency\":{{currency}}, \"amounts\":{{transfer_dry_run.suggested_fee}}}", + "type": "find_currency_amount", + "output_path": "suggested_fee" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": {{total_change_amount}}, \"right_value\": {{suggested_fee.value}}}", + "type": "math", + "output_path": "change_amount" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": {{change_amount}}, \"right_value\": {{dust_amount}}}", + "type": "math", + "output_path": "change_minus_dust" + }, + { + "input": "{{change_minus_dust}}", + "type": "assert" + }, + { + "input": "{\"network\":\"Mainnet\", \"blockchain\":\"Bitcoin\"}", + "type": "set_variable", + "output_path": "transfer.network" + }, + { + "input": "\"1\"", + "type": "set_variable", + "output_path": "transfer.confirmation_depth" + }, + { + "input": "[{\"operation_identifier\":{\"index\":0},\"type\":\"INPUT\",\"account\":{{sender.account_identifier}},\"amount\":{\"value\":{{sender_amount}},\"currency\":{{currency}}}, \"coin_change\":{\"coin_action\":\"coin_spent\", \"coin_identifier\":{{sender.coin}}}},{\"operation_identifier\":{\"index\":1},\"type\":\"OUTPUT\",\"account\":{{recipient.account_identifier}},\"amount\":{\"value\":{{recipient_amount}},\"currency\":{{currency}}}}, {\"operation_identifier\":{\"index\":2},\"type\":\"OUTPUT\",\"account\":{{sender.account_identifier}},\"amount\":{\"value\":{{change_amount}},\"currency\":{{currency}}}}]", + "type": "set_variable", + "output_path": "transfer.operations" + }, + { + "input": "{{transfer.operations}}", + "type": "print_message" + } + ] + } + ] + }, + { + "name": "return_funds", + "concurrency": 10, + "scenarios": [ + { + "name": "transfer_dry_run", + "actions": [ + { + "input": "{\"network\":\"Mainnet\", \"blockchain\":\"Bitcoin\"}", + "type": "set_variable", + "output_path": "transfer_dry_run.network" + }, + { + "input": "{\"symbol\":\"BTC\", \"decimals\":8}", + "type": "set_variable", + "output_path": "currency" + }, + { + "input": "\"1200\"", + "type": "set_variable", + "output_path": "max_fee_amount" + }, + { + "input": "\"1800\"", + "type": "set_variable", + "output_path": "reserved_amount" + }, + { + "input": "{\"require_coin\":true, \"minimum_balance\":{\"value\": {{reserved_amount}}, \"currency\": {{currency}}}}", + "type": "find_balance", + "output_path": "sender" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": {{sender.balance.value}}, \"right_value\": {{max_fee_amount}}}", + "type": "math", + "output_path": "recipient_amount" + }, + { + "input": "{\"recipient_amount\":{{recipient_amount}}}", + "type": "print_message" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": \"0\", \"right_value\":{{sender.balance.value}}}", + "type": "math", + "output_path": "sender_amount" + }, + { + "input": "\"1\"", + "type": "set_variable", + "output_path": "transfer_dry_run.confirmation_depth" + }, + { + "input": "\"true\"", + "type": "set_variable", + "output_path": "transfer_dry_run.dry_run" + }, + { + "input": "{\"address\": \"mkHS9ne12qx9pS9VojpwU5xtRd4T7X7ZUt\"}", + "type": "set_variable", + "output_path": "recipient" + }, + { + "input": "[{\"operation_identifier\":{\"index\":0},\"type\":\"INPUT\",\"account\":{{sender.account_identifier}},\"amount\":{\"value\":{{sender_amount}},\"currency\":{{currency}}}, \"coin_change\":{\"coin_action\":\"coin_spent\", \"coin_identifier\":{{sender.coin}}}},{\"operation_identifier\":{\"index\":1},\"type\":\"OUTPUT\",\"account\":{{recipient}},\"amount\":{\"value\":{{recipient_amount}},\"currency\":{{currency}}}}]", + "type": "set_variable", + "output_path": "transfer_dry_run.operations" + }, + { + "input": "{{transfer_dry_run.operations}}", + "type": "print_message" + } + ] + }, + { + "name": "transfer", + "actions": [ + { + "input": "{\"currency\":{{currency}}, \"amounts\":{{transfer_dry_run.suggested_fee}}}", + "type": "find_currency_amount", + "output_path": "suggested_fee" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": {{sender.balance.value}}, \"right_value\": {{suggested_fee.value}}}", + "type": "math", + "output_path": "recipient_amount" + }, + { + "input": "\"600\"", + "type": "set_variable", + "output_path": "dust_amount" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": {{recipient_amount}}, \"right_value\": {{dust_amount}}}", + "type": "math", + "output_path": "recipient_minus_dust" + }, + { + "input": "{{recipient_minus_dust}}", + "type": "assert" + }, + { + "input": "{\"network\":\"Mainnet\", \"blockchain\":\"Bitcoin\"}", + "type": "set_variable", + "output_path": "transfer.network" + }, + { + "input": "\"1\"", + "type": "set_variable", + "output_path": "transfer.confirmation_depth" + }, + { + "input": "[{\"operation_identifier\":{\"index\":0},\"type\":\"INPUT\",\"account\":{{sender.account_identifier}},\"amount\":{\"value\":{{sender_amount}},\"currency\":{{currency}}}, \"coin_change\":{\"coin_action\":\"coin_spent\", \"coin_identifier\":{{sender.coin}}}},{\"operation_identifier\":{\"index\":1},\"type\":\"OUTPUT\",\"account\":{{recipient}},\"amount\":{\"value\":{{recipient_amount}},\"currency\":{{currency}}}}]", + "type": "set_variable", + "output_path": "transfer.operations" + }, + { + "input": "{{transfer.operations}}", + "type": "print_message" + } + ] + } + ] + } + ], + "end_conditions": { + "create_account": 10, + "transfer": 10 + } + }, + "data": { + "active_reconciliation_concurrency": 0, + "inactive_reconciliation_concurrency": 0, + "inactive_reconciliation_frequency": 0, + "log_blocks": false, + "log_transactions": false, + "log_balance_changes": false, + "log_reconciliations": false, + "ignore_reconciliation_error": false, + "exempt_accounts": "", + "bootstrap_balances": "", + "interesting_accounts": "", + "reconciliation_disabled": false, + "inactive_discrepency_search_disabled": false, + "balance_tracking_disabled": false, + "coin_tracking_disabled": false, + "end_conditions": { + "reconciliation_coverage": 0.95 + }, + "results_output_file": "" + } +} diff --git a/rosetta-cli-conf/bitcoin_testnet.json b/rosetta-cli-conf/bitcoin_testnet.json new file mode 100644 index 0000000..90ceee5 --- /dev/null +++ b/rosetta-cli-conf/bitcoin_testnet.json @@ -0,0 +1,380 @@ +{ + "network": { + "blockchain": "Bitcoin", + "network": "Testnet3" + }, + "data_directory": "bitcoin-testnet-data", + "http_timeout": 300, + "max_retries": 5, + "retry_elapsed_time": 0, + "max_online_connections": 0, + "max_sync_concurrency": 0, + "tip_delay": 1800, + "log_configuration": false, + "construction": { + "max_offline_connections": 0, + "stale_depth": 0, + "broadcast_limit": 0, + "ignore_broadcast_failures": false, + "clear_broadcasts": false, + "broadcast_behind_tip": false, + "block_broadcast_limit": 0, + "rebroadcast_all": false, + "workflows": [ + { + "name": "request_funds", + "concurrency": 1, + "scenarios": [ + { + "name": "find_account", + "actions": [ + { + "input": "{\"symbol\":\"tBTC\", \"decimals\":8}", + "type": "set_variable", + "output_path": "currency" + }, + { + "input": "{\"minimum_balance\":{\"value\": \"0\", \"currency\": {{currency}}}, \"create_limit\":1}", + "type": "find_balance", + "output_path": "random_account" + } + ] + }, + { + "name": "request", + "actions": [ + { + "input": "{\"account_identifier\": {{random_account.account_identifier}}, \"minimum_balance\":{\"value\": \"1000000\", \"currency\": {{currency}}}}", + "type": "find_balance", + "output_path": "loaded_account" + } + ] + } + ] + }, + { + "name": "create_account", + "concurrency": 1, + "scenarios": [ + { + "name": "create_account", + "actions": [ + { + "input": "{\"network\":\"Testnet3\", \"blockchain\":\"Bitcoin\"}", + "type": "set_variable", + "output_path": "network" + }, + { + "input": "{\"curve_type\": \"secp256k1\"}", + "type": "generate_key", + "output_path": "key" + }, + { + "input": "{\"network_identifier\": {{network}}, \"public_key\": {{key.public_key}}}", + "type": "derive", + "output_path": "account" + }, + { + "input": "{\"account_identifier\": {{account.account_identifier}}, \"keypair\": {{key}}}", + "type": "save_account" + } + ] + } + ] + }, + { + "name": "transfer", + "concurrency": 10, + "scenarios": [ + { + "name": "transfer_dry_run", + "actions": [ + { + "input": "{\"network\":\"Testnet3\", \"blockchain\":\"Bitcoin\"}", + "type": "set_variable", + "output_path": "transfer_dry_run.network" + }, + { + "input": "{\"symbol\":\"tBTC\", \"decimals\":8}", + "type": "set_variable", + "output_path": "currency" + }, + { + "input": "\"600\"", + "type": "set_variable", + "output_path": "dust_amount" + }, + { + "input": "\"1200\"", + "type": "set_variable", + "output_path": "max_fee_amount" + }, + { + "input": "{\"operation\":\"addition\", \"left_value\": {{dust_amount}}, \"right_value\": {{max_fee_amount}}}", + "type": "math", + "output_path": "send_buffer" + }, + { + "input": "\"2400\"", + "type": "set_variable", + "output_path": "reserved_amount" + }, + { + "input": "{\"require_coin\":true, \"minimum_balance\":{\"value\": {{reserved_amount}}, \"currency\": {{currency}}}}", + "type": "find_balance", + "output_path": "sender" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": {{sender.balance.value}}, \"right_value\": {{send_buffer}}}", + "type": "math", + "output_path": "available_amount" + }, + { + "input": "{\"minimum\": {{dust_amount}}, \"maximum\": {{available_amount}}}", + "type": "random_number", + "output_path": "recipient_amount" + }, + { + "input": "{\"recipient_amount\":{{recipient_amount}}}", + "type": "print_message" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": {{sender.balance.value}}, \"right_value\": {{recipient_amount}}}", + "type": "math", + "output_path": "total_change_amount" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": {{total_change_amount}}, \"right_value\": {{max_fee_amount}}}", + "type": "math", + "output_path": "change_amount" + }, + { + "input": "{\"change_amount\":{{change_amount}}}", + "type": "print_message" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": \"0\", \"right_value\":{{sender.balance.value}}}", + "type": "math", + "output_path": "sender_amount" + }, + { + "input": "{\"not_account_identifier\":[{{sender.account_identifier}}], \"not_coins\":[{{sender.coin}}], \"minimum_balance\":{\"value\": \"0\", \"currency\": {{currency}}}, \"create_limit\": 100, \"create_probability\": 50}", + "type": "find_balance", + "output_path": "recipient" + }, + { + "input": "\"1\"", + "type": "set_variable", + "output_path": "transfer_dry_run.confirmation_depth" + }, + { + "input": "\"true\"", + "type": "set_variable", + "output_path": "transfer_dry_run.dry_run" + }, + { + "input": "[{\"operation_identifier\":{\"index\":0},\"type\":\"INPUT\",\"account\":{{sender.account_identifier}},\"amount\":{\"value\":{{sender_amount}},\"currency\":{{currency}}}, \"coin_change\":{\"coin_action\":\"coin_spent\", \"coin_identifier\":{{sender.coin}}}},{\"operation_identifier\":{\"index\":1},\"type\":\"OUTPUT\",\"account\":{{recipient.account_identifier}},\"amount\":{\"value\":{{recipient_amount}},\"currency\":{{currency}}}}, {\"operation_identifier\":{\"index\":2},\"type\":\"OUTPUT\",\"account\":{{sender.account_identifier}},\"amount\":{\"value\":{{change_amount}},\"currency\":{{currency}}}}]", + "type": "set_variable", + "output_path": "transfer_dry_run.operations" + }, + { + "input": "{{transfer_dry_run.operations}}", + "type": "print_message" + } + ] + }, + { + "name": "transfer", + "actions": [ + { + "input": "{\"currency\":{{currency}}, \"amounts\":{{transfer_dry_run.suggested_fee}}}", + "type": "find_currency_amount", + "output_path": "suggested_fee" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": {{total_change_amount}}, \"right_value\": {{suggested_fee.value}}}", + "type": "math", + "output_path": "change_amount" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": {{change_amount}}, \"right_value\": {{dust_amount}}}", + "type": "math", + "output_path": "change_minus_dust" + }, + { + "input": "{{change_minus_dust}}", + "type": "assert" + }, + { + "input": "{\"network\":\"Testnet3\", \"blockchain\":\"Bitcoin\"}", + "type": "set_variable", + "output_path": "transfer.network" + }, + { + "input": "\"1\"", + "type": "set_variable", + "output_path": "transfer.confirmation_depth" + }, + { + "input": "[{\"operation_identifier\":{\"index\":0},\"type\":\"INPUT\",\"account\":{{sender.account_identifier}},\"amount\":{\"value\":{{sender_amount}},\"currency\":{{currency}}}, \"coin_change\":{\"coin_action\":\"coin_spent\", \"coin_identifier\":{{sender.coin}}}},{\"operation_identifier\":{\"index\":1},\"type\":\"OUTPUT\",\"account\":{{recipient.account_identifier}},\"amount\":{\"value\":{{recipient_amount}},\"currency\":{{currency}}}}, {\"operation_identifier\":{\"index\":2},\"type\":\"OUTPUT\",\"account\":{{sender.account_identifier}},\"amount\":{\"value\":{{change_amount}},\"currency\":{{currency}}}}]", + "type": "set_variable", + "output_path": "transfer.operations" + }, + { + "input": "{{transfer.operations}}", + "type": "print_message" + } + ] + } + ] + }, + { + "name": "return_funds", + "concurrency": 10, + "scenarios": [ + { + "name": "transfer_dry_run", + "actions": [ + { + "input": "{\"network\":\"Testnet3\", \"blockchain\":\"Bitcoin\"}", + "type": "set_variable", + "output_path": "transfer_dry_run.network" + }, + { + "input": "{\"symbol\":\"tBTC\", \"decimals\":8}", + "type": "set_variable", + "output_path": "currency" + }, + { + "input": "\"1200\"", + "type": "set_variable", + "output_path": "max_fee_amount" + }, + { + "input": "\"1800\"", + "type": "set_variable", + "output_path": "reserved_amount" + }, + { + "input": "{\"require_coin\":true, \"minimum_balance\":{\"value\": {{reserved_amount}}, \"currency\": {{currency}}}}", + "type": "find_balance", + "output_path": "sender" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": {{sender.balance.value}}, \"right_value\": {{max_fee_amount}}}", + "type": "math", + "output_path": "recipient_amount" + }, + { + "input": "{\"recipient_amount\":{{recipient_amount}}}", + "type": "print_message" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": \"0\", \"right_value\":{{sender.balance.value}}}", + "type": "math", + "output_path": "sender_amount" + }, + { + "input": "\"1\"", + "type": "set_variable", + "output_path": "transfer_dry_run.confirmation_depth" + }, + { + "input": "\"true\"", + "type": "set_variable", + "output_path": "transfer_dry_run.dry_run" + }, + { + "input": "{\"address\": \"mkHS9ne12qx9pS9VojpwU5xtRd4T7X7ZUt\"}", + "type": "set_variable", + "output_path": "recipient" + }, + { + "input": "[{\"operation_identifier\":{\"index\":0},\"type\":\"INPUT\",\"account\":{{sender.account_identifier}},\"amount\":{\"value\":{{sender_amount}},\"currency\":{{currency}}}, \"coin_change\":{\"coin_action\":\"coin_spent\", \"coin_identifier\":{{sender.coin}}}},{\"operation_identifier\":{\"index\":1},\"type\":\"OUTPUT\",\"account\":{{recipient}},\"amount\":{\"value\":{{recipient_amount}},\"currency\":{{currency}}}}]", + "type": "set_variable", + "output_path": "transfer_dry_run.operations" + }, + { + "input": "{{transfer_dry_run.operations}}", + "type": "print_message" + } + ] + }, + { + "name": "transfer", + "actions": [ + { + "input": "{\"currency\":{{currency}}, \"amounts\":{{transfer_dry_run.suggested_fee}}}", + "type": "find_currency_amount", + "output_path": "suggested_fee" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": {{sender.balance.value}}, \"right_value\": {{suggested_fee.value}}}", + "type": "math", + "output_path": "recipient_amount" + }, + { + "input": "\"600\"", + "type": "set_variable", + "output_path": "dust_amount" + }, + { + "input": "{\"operation\":\"subtraction\", \"left_value\": {{recipient_amount}}, \"right_value\": {{dust_amount}}}", + "type": "math", + "output_path": "recipient_minus_dust" + }, + { + "input": "{{recipient_minus_dust}}", + "type": "assert" + }, + { + "input": "{\"network\":\"Testnet3\", \"blockchain\":\"Bitcoin\"}", + "type": "set_variable", + "output_path": "transfer.network" + }, + { + "input": "\"1\"", + "type": "set_variable", + "output_path": "transfer.confirmation_depth" + }, + { + "input": "[{\"operation_identifier\":{\"index\":0},\"type\":\"INPUT\",\"account\":{{sender.account_identifier}},\"amount\":{\"value\":{{sender_amount}},\"currency\":{{currency}}}, \"coin_change\":{\"coin_action\":\"coin_spent\", \"coin_identifier\":{{sender.coin}}}},{\"operation_identifier\":{\"index\":1},\"type\":\"OUTPUT\",\"account\":{{recipient}},\"amount\":{\"value\":{{recipient_amount}},\"currency\":{{currency}}}}]", + "type": "set_variable", + "output_path": "transfer.operations" + }, + { + "input": "{{transfer.operations}}", + "type": "print_message" + } + ] + } + ] + } + ], + "end_conditions": { + "create_account": 10, + "transfer": 10 + } + }, + "data": { + "active_reconciliation_concurrency": 0, + "inactive_reconciliation_concurrency": 0, + "inactive_reconciliation_frequency": 0, + "log_blocks": false, + "log_transactions": false, + "log_balance_changes": false, + "log_reconciliations": false, + "ignore_reconciliation_error": false, + "exempt_accounts": "", + "bootstrap_balances": "", + "interesting_accounts": "", + "reconciliation_disabled": false, + "inactive_discrepency_search_disabled": false, + "balance_tracking_disabled": false, + "coin_tracking_disabled": false, + "end_conditions": { + "reconciliation_coverage": 0.95 + }, + "results_output_file": "" + } +} diff --git a/services/account_service.go b/services/account_service.go new file mode 100644 index 0000000..04a786f --- /dev/null +++ b/services/account_service.go @@ -0,0 +1,75 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package services + +import ( + "context" + + "github.com/coinbase/rosetta-bitcoin/configuration" + + "github.com/coinbase/rosetta-sdk-go/server" + "github.com/coinbase/rosetta-sdk-go/types" +) + +// AccountAPIService implements the server.AccountAPIServicer interface. +type AccountAPIService struct { + config *configuration.Configuration + i Indexer +} + +// NewAccountAPIService returns a new *AccountAPIService. +func NewAccountAPIService( + config *configuration.Configuration, + i Indexer, +) server.AccountAPIServicer { + return &AccountAPIService{ + config: config, + i: i, + } +} + +// AccountBalance implements /account/balance. +func (s *AccountAPIService) AccountBalance( + ctx context.Context, + request *types.AccountBalanceRequest, +) (*types.AccountBalanceResponse, *types.Error) { + if s.config.Mode != configuration.Online { + return nil, wrapErr(ErrUnavailableOffline, nil) + } + + coins, block, err := s.i.GetCoins(ctx, request.AccountIdentifier) + if err != nil { + return nil, wrapErr(ErrUnableToGetCoins, err) + } + + balance := "0" + for _, coin := range coins { + balance, err = types.AddValues(balance, coin.Amount.Value) + if err != nil { + return nil, wrapErr(ErrUnableToParseIntermediateResult, err) + } + } + + return &types.AccountBalanceResponse{ + BlockIdentifier: block, + Coins: coins, + Balances: []*types.Amount{ + { + Value: balance, + Currency: s.config.Currency, + }, + }, + }, nil +} diff --git a/services/account_service_test.go b/services/account_service_test.go new file mode 100644 index 0000000..f50076f --- /dev/null +++ b/services/account_service_test.go @@ -0,0 +1,106 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package services + +import ( + "context" + "testing" + + "github.com/coinbase/rosetta-bitcoin/bitcoin" + "github.com/coinbase/rosetta-bitcoin/configuration" + mocks "github.com/coinbase/rosetta-bitcoin/mocks/services" + + "github.com/coinbase/rosetta-sdk-go/types" + "github.com/stretchr/testify/assert" +) + +func TestAccountBalance_Offline(t *testing.T) { + cfg := &configuration.Configuration{ + Mode: configuration.Offline, + } + mockIndexer := &mocks.Indexer{} + servicer := NewAccountAPIService(cfg, mockIndexer) + ctx := context.Background() + + bal, err := servicer.AccountBalance(ctx, &types.AccountBalanceRequest{}) + assert.Nil(t, bal) + assert.Equal(t, ErrUnavailableOffline.Code, err.Code) + + mockIndexer.AssertExpectations(t) +} + +func TestAccountBalance_Online(t *testing.T) { + cfg := &configuration.Configuration{ + Mode: configuration.Online, + Currency: bitcoin.MainnetCurrency, + } + mockIndexer := &mocks.Indexer{} + servicer := NewAccountAPIService(cfg, mockIndexer) + ctx := context.Background() + + account := &types.AccountIdentifier{ + Address: "hello", + } + + coins := []*types.Coin{ + { + Amount: &types.Amount{ + Value: "10", + }, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "coin 1", + }, + }, + { + Amount: &types.Amount{ + Value: "15", + }, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "coin 2", + }, + }, + { + Amount: &types.Amount{ + Value: "0", + }, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "coin 3", + }, + }, + } + block := &types.BlockIdentifier{ + Index: 1000, + Hash: "block 1000", + } + mockIndexer.On("GetCoins", ctx, account).Return(coins, block, nil).Once() + + bal, err := servicer.AccountBalance(ctx, &types.AccountBalanceRequest{ + AccountIdentifier: account, + }) + assert.Nil(t, err) + + assert.Equal(t, &types.AccountBalanceResponse{ + BlockIdentifier: block, + Coins: coins, + Balances: []*types.Amount{ + { + Value: "25", + Currency: bitcoin.MainnetCurrency, + }, + }, + }, bal) + + mockIndexer.AssertExpectations(t) +} diff --git a/services/block_service.go b/services/block_service.go new file mode 100644 index 0000000..7eb76ed --- /dev/null +++ b/services/block_service.go @@ -0,0 +1,81 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package services + +import ( + "context" + + "github.com/coinbase/rosetta-bitcoin/configuration" + + "github.com/coinbase/rosetta-sdk-go/server" + "github.com/coinbase/rosetta-sdk-go/types" +) + +// BlockAPIService implements the server.BlockAPIServicer interface. +type BlockAPIService struct { + config *configuration.Configuration + i Indexer +} + +// NewBlockAPIService creates a new instance of a BlockAPIService. +func NewBlockAPIService( + config *configuration.Configuration, + i Indexer, +) server.BlockAPIServicer { + return &BlockAPIService{ + config: config, + i: i, + } +} + +// Block implements the /block endpoint. +func (s *BlockAPIService) Block( + ctx context.Context, + request *types.BlockRequest, +) (*types.BlockResponse, *types.Error) { + if s.config.Mode != configuration.Online { + return nil, wrapErr(ErrUnavailableOffline, nil) + } + + blockResponse, err := s.i.GetBlockLazy(ctx, request.BlockIdentifier) + if err != nil { + return nil, wrapErr(ErrBlockNotFound, err) + } + + return blockResponse, nil +} + +// BlockTransaction implements the /block/transaction endpoint. +func (s *BlockAPIService) BlockTransaction( + ctx context.Context, + request *types.BlockTransactionRequest, +) (*types.BlockTransactionResponse, *types.Error) { + if s.config.Mode != configuration.Online { + return nil, wrapErr(ErrUnavailableOffline, nil) + } + + transaction, err := s.i.GetBlockTransaction( + ctx, + request.BlockIdentifier, + request.TransactionIdentifier, + ) + if err != nil { + return nil, wrapErr(ErrTransactionNotFound, err) + } + + return &types.BlockTransactionResponse{ + Transaction: transaction, + }, nil +} diff --git a/services/block_service_test.go b/services/block_service_test.go new file mode 100644 index 0000000..9510e9f --- /dev/null +++ b/services/block_service_test.go @@ -0,0 +1,120 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package services + +import ( + "context" + "testing" + + "github.com/coinbase/rosetta-bitcoin/configuration" + mocks "github.com/coinbase/rosetta-bitcoin/mocks/services" + + "github.com/coinbase/rosetta-sdk-go/types" + "github.com/stretchr/testify/assert" +) + +func TestBlockService_Offline(t *testing.T) { + cfg := &configuration.Configuration{ + Mode: configuration.Offline, + } + mockIndexer := &mocks.Indexer{} + servicer := NewBlockAPIService(cfg, mockIndexer) + ctx := context.Background() + + block, err := servicer.Block(ctx, &types.BlockRequest{}) + assert.Nil(t, block) + assert.Equal(t, ErrUnavailableOffline.Code, err.Code) + assert.Equal(t, ErrUnavailableOffline.Message, err.Message) + + blockTransaction, err := servicer.BlockTransaction(ctx, &types.BlockTransactionRequest{}) + assert.Nil(t, blockTransaction) + assert.Equal(t, ErrUnavailableOffline.Code, err.Code) + assert.Equal(t, ErrUnavailableOffline.Message, err.Message) + + mockIndexer.AssertExpectations(t) +} + +func TestBlockService_Online(t *testing.T) { + cfg := &configuration.Configuration{ + Mode: configuration.Online, + } + mockIndexer := &mocks.Indexer{} + servicer := NewBlockAPIService(cfg, mockIndexer) + ctx := context.Background() + + block := &types.Block{ + BlockIdentifier: &types.BlockIdentifier{ + Index: 100, + Hash: "block 100", + }, + } + + blockResponse := &types.BlockResponse{ + Block: block, + OtherTransactions: []*types.TransactionIdentifier{ + { + Hash: "tx1", + }, + }, + } + + transaction := &types.Transaction{ + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: "tx1", + }, + } + + t.Run("nil identifier", func(t *testing.T) { + mockIndexer.On( + "GetBlockLazy", + ctx, + (*types.PartialBlockIdentifier)(nil), + ).Return( + blockResponse, + nil, + ).Once() + b, err := servicer.Block(ctx, &types.BlockRequest{}) + assert.Nil(t, err) + assert.Equal(t, blockResponse, b) + }) + + t.Run("populated identifier", func(t *testing.T) { + pbIdentifier := types.ConstructPartialBlockIdentifier(block.BlockIdentifier) + mockIndexer.On("GetBlockLazy", ctx, pbIdentifier).Return(blockResponse, nil).Once() + b, err := servicer.Block(ctx, &types.BlockRequest{ + BlockIdentifier: pbIdentifier, + }) + assert.Nil(t, err) + assert.Equal(t, blockResponse, b) + + mockIndexer.On( + "GetBlockTransaction", + ctx, + blockResponse.Block.BlockIdentifier, + transaction.TransactionIdentifier, + ).Return( + transaction, + nil, + ).Once() + blockTransaction, err := servicer.BlockTransaction(ctx, &types.BlockTransactionRequest{ + BlockIdentifier: blockResponse.Block.BlockIdentifier, + TransactionIdentifier: transaction.TransactionIdentifier, + }) + assert.Nil(t, err) + assert.Equal(t, transaction, blockTransaction.Transaction) + }) + + mockIndexer.AssertExpectations(t) +} diff --git a/services/construction_service.go b/services/construction_service.go new file mode 100644 index 0000000..21ca739 --- /dev/null +++ b/services/construction_service.go @@ -0,0 +1,788 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package services + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math/big" + "strconv" + + "github.com/coinbase/rosetta-bitcoin/bitcoin" + "github.com/coinbase/rosetta-bitcoin/configuration" + + "github.com/btcsuite/btcd/btcec" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcutil" + "github.com/coinbase/rosetta-sdk-go/parser" + "github.com/coinbase/rosetta-sdk-go/server" + "github.com/coinbase/rosetta-sdk-go/types" +) + +const ( + // bytesInKB is the number of bytes in a KB. In Bitcoin, this is + // considered to be 1000. + bytesInKb = float64(1000) // nolint:gomnd + + // defaultConfirmationTarget is the number of blocks we would + // like our transaction to be included by. + defaultConfirmationTarget = int64(2) // nolint:gomnd +) + +// ConstructionAPIService implements the server.ConstructionAPIServicer interface. +type ConstructionAPIService struct { + config *configuration.Configuration + client Client + i Indexer +} + +// NewConstructionAPIService creates a new instance of a ConstructionAPIService. +func NewConstructionAPIService( + config *configuration.Configuration, + client Client, + i Indexer, +) server.ConstructionAPIServicer { + return &ConstructionAPIService{ + config: config, + client: client, + i: i, + } +} + +// ConstructionDerive implements the /construction/derive endpoint. +func (s *ConstructionAPIService) ConstructionDerive( + ctx context.Context, + request *types.ConstructionDeriveRequest, +) (*types.ConstructionDeriveResponse, *types.Error) { + addr, err := btcutil.NewAddressWitnessPubKeyHash( + btcutil.Hash160(request.PublicKey.Bytes), + s.config.Params, + ) + if err != nil { + return nil, wrapErr(ErrUnableToDerive, err) + } + + return &types.ConstructionDeriveResponse{ + AccountIdentifier: &types.AccountIdentifier{ + Address: addr.EncodeAddress(), + }, + }, nil +} + +// estimateSize returns the estimated size of a transaction in vBytes. +func (s *ConstructionAPIService) estimateSize(operations []*types.Operation) float64 { + size := bitcoin.TransactionOverhead + for _, operation := range operations { + switch operation.Type { + case bitcoin.InputOpType: + size += bitcoin.InputSize + case bitcoin.OutputOpType: + size += bitcoin.OutputOverhead + addr, err := btcutil.DecodeAddress(operation.Account.Address, s.config.Params) + if err != nil { + size += bitcoin.P2PKHScriptPubkeySize + continue + } + + script, err := txscript.PayToAddrScript(addr) + if err != nil { + size += bitcoin.P2PKHScriptPubkeySize + continue + } + + size += len(script) + } + } + + return float64(size) +} + +// ConstructionPreprocess implements the /construction/preprocess +// endpoint. +func (s *ConstructionAPIService) ConstructionPreprocess( + ctx context.Context, + request *types.ConstructionPreprocessRequest, +) (*types.ConstructionPreprocessResponse, *types.Error) { + descriptions := &parser.Descriptions{ + OperationDescriptions: []*parser.OperationDescription{ + { + Type: bitcoin.InputOpType, + Account: &parser.AccountDescription{ + Exists: true, + }, + Amount: &parser.AmountDescription{ + Exists: true, + Sign: parser.NegativeAmountSign, + Currency: s.config.Currency, + }, + CoinAction: types.CoinSpent, + AllowRepeats: true, + }, + }, + } + + matches, err := parser.MatchOperations(descriptions, request.Operations) + if err != nil { + return nil, wrapErr(ErrUnclearIntent, err) + } + + coins := make([]*types.Coin, len(matches[0].Operations)) + for i, input := range matches[0].Operations { + if input.CoinChange == nil { + return nil, wrapErr(ErrUnclearIntent, errors.New("CoinChange cannot be nil")) + } + + coins[i] = &types.Coin{ + CoinIdentifier: input.CoinChange.CoinIdentifier, + Amount: input.Amount, + } + } + + options, err := types.MarshalMap(&preprocessOptions{ + Coins: coins, + EstimatedSize: s.estimateSize(request.Operations), + FeeMultiplier: request.SuggestedFeeMultiplier, + }) + if err != nil { + return nil, wrapErr(ErrUnableToParseIntermediateResult, err) + } + + return &types.ConstructionPreprocessResponse{ + Options: options, + }, nil +} + +// ConstructionMetadata implements the /construction/metadata endpoint. +func (s *ConstructionAPIService) ConstructionMetadata( + ctx context.Context, + request *types.ConstructionMetadataRequest, +) (*types.ConstructionMetadataResponse, *types.Error) { + if s.config.Mode != configuration.Online { + return nil, wrapErr(ErrUnavailableOffline, nil) + } + + var options preprocessOptions + if err := types.UnmarshalMap(request.Options, &options); err != nil { + return nil, wrapErr(ErrUnableToParseIntermediateResult, err) + } + + // Determine feePerKB and ensure it is not below the minimum fee + // relay rate. + feePerKB, err := s.client.SuggestedFeeRate(ctx, defaultConfirmationTarget) + if err != nil { + return nil, wrapErr(ErrCouldNotGetFeeRate, err) + } + if options.FeeMultiplier != nil { + feePerKB *= *options.FeeMultiplier + } + if feePerKB < bitcoin.MinFeeRate { + feePerKB = bitcoin.MinFeeRate + } + + // Calculated the estimated fee in Satoshis + satoshisPerB := (feePerKB * float64(bitcoin.SatoshisInBitcoin)) / bytesInKb + estimatedFee := satoshisPerB * options.EstimatedSize + suggestedFee := &types.Amount{ + Value: fmt.Sprintf("%d", int64(estimatedFee)), + Currency: s.config.Currency, + } + + scripts, err := s.i.GetScriptPubKeys(ctx, options.Coins) + if err != nil { + return nil, wrapErr(ErrScriptPubKeysMissing, err) + } + + metadata, err := types.MarshalMap(&constructionMetadata{ScriptPubKeys: scripts}) + if err != nil { + return nil, wrapErr(ErrUnableToParseIntermediateResult, err) + } + + return &types.ConstructionMetadataResponse{ + Metadata: metadata, + SuggestedFee: []*types.Amount{suggestedFee}, + }, nil +} + +// ConstructionPayloads implements the /construction/payloads endpoint. +func (s *ConstructionAPIService) ConstructionPayloads( + ctx context.Context, + request *types.ConstructionPayloadsRequest, +) (*types.ConstructionPayloadsResponse, *types.Error) { + descriptions := &parser.Descriptions{ + OperationDescriptions: []*parser.OperationDescription{ + { + Type: bitcoin.InputOpType, + Account: &parser.AccountDescription{ + Exists: true, + }, + Amount: &parser.AmountDescription{ + Exists: true, + Sign: parser.NegativeAmountSign, + Currency: s.config.Currency, + }, + AllowRepeats: true, + CoinAction: types.CoinSpent, + }, + { + Type: bitcoin.OutputOpType, + Account: &parser.AccountDescription{ + Exists: true, + }, + Amount: &parser.AmountDescription{ + Exists: true, + Sign: parser.PositiveAmountSign, + Currency: s.config.Currency, + }, + AllowRepeats: true, + }, + }, + ErrUnmatched: true, + } + + matches, err := parser.MatchOperations(descriptions, request.Operations) + if err != nil { + return nil, wrapErr(ErrUnclearIntent, err) + } + + tx := wire.NewMsgTx(wire.TxVersion) + for _, input := range matches[0].Operations { + if input.CoinChange == nil { + return nil, wrapErr(ErrUnclearIntent, errors.New("CoinChange cannot be nil")) + } + + transactionHash, index, err := bitcoin.ParseCoinIdentifier(input.CoinChange.CoinIdentifier) + if err != nil { + return nil, wrapErr(ErrInvalidCoin, err) + } + + tx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: wire.OutPoint{ + Hash: *transactionHash, + Index: index, + }, + SignatureScript: nil, + Sequence: wire.MaxTxInSequenceNum, + }) + } + + for i, output := range matches[1].Operations { + addr, err := btcutil.DecodeAddress(output.Account.Address, s.config.Params) + if err != nil { + return nil, wrapErr(ErrUnableToDecodeAddress, fmt.Errorf( + "%w unable to decode address %s", + err, + output.Account.Address, + ), + ) + } + + pkScript, err := txscript.PayToAddrScript(addr) + if err != nil { + return nil, wrapErr( + ErrUnableToDecodeAddress, + fmt.Errorf("%w unable to construct payToAddrScript", err), + ) + } + + tx.AddTxOut(&wire.TxOut{ + Value: matches[1].Amounts[i].Int64(), + PkScript: pkScript, + }) + } + + // Create Signing Payloads (must be done after entire tx is constructed + // or hash will not be correct). + inputAmounts := make([]string, len(tx.TxIn)) + inputAddresses := make([]string, len(tx.TxIn)) + payloads := make([]*types.SigningPayload, len(tx.TxIn)) + var metadata constructionMetadata + if err := types.UnmarshalMap(request.Metadata, &metadata); err != nil { + return nil, wrapErr(ErrUnableToParseIntermediateResult, err) + } + + for i := range tx.TxIn { + address := matches[0].Operations[i].Account.Address + script, err := hex.DecodeString(metadata.ScriptPubKeys[i].Hex) + if err != nil { + return nil, wrapErr(ErrUnableToDecodeScriptPubKey, err) + } + + class, _, err := bitcoin.ParseSingleAddress(s.config.Params, script) + if err != nil { + return nil, wrapErr( + ErrUnableToDecodeAddress, + fmt.Errorf("%w unable to parse address for utxo %d", err, i), + ) + } + + inputAddresses[i] = address + inputAmounts[i] = matches[0].Amounts[i].String() + absAmount := new(big.Int).Abs(matches[0].Amounts[i]).Int64() + + switch class { + case txscript.WitnessV0PubKeyHashTy: + hash, err := txscript.CalcWitnessSigHash( + script, + txscript.NewTxSigHashes(tx), + txscript.SigHashAll, + tx, + i, + absAmount, + ) + if err != nil { + return nil, wrapErr(ErrUnableToCalculateSignatureHash, err) + } + + payloads[i] = &types.SigningPayload{ + AccountIdentifier: &types.AccountIdentifier{ + Address: address, + }, + Bytes: hash, + SignatureType: types.Ecdsa, + } + default: + return nil, wrapErr( + ErrUnsupportedScriptType, + fmt.Errorf("unupported script type: %s", class), + ) + } + } + + buf := bytes.NewBuffer(make([]byte, 0, tx.SerializeSize())) + if err := tx.Serialize(buf); err != nil { + return nil, wrapErr(ErrUnableToParseIntermediateResult, err) + } + + rawTx, err := json.Marshal(&unsignedTransaction{ + Transaction: hex.EncodeToString(buf.Bytes()), + ScriptPubKeys: metadata.ScriptPubKeys, + InputAmounts: inputAmounts, + InputAddresses: inputAddresses, + }) + if err != nil { + return nil, wrapErr(ErrUnableToParseIntermediateResult, err) + } + + return &types.ConstructionPayloadsResponse{ + UnsignedTransaction: hex.EncodeToString(rawTx), + Payloads: payloads, + }, nil +} + +func normalizeSignature(signature []byte) []byte { + sig := btcec.Signature{ // signature is in form of R || S + R: new(big.Int).SetBytes(signature[:32]), + S: new(big.Int).SetBytes(signature[32:64]), + } + + return append(sig.Serialize(), byte(txscript.SigHashAll)) +} + +// ConstructionCombine implements the /construction/combine +// endpoint. +func (s *ConstructionAPIService) ConstructionCombine( + ctx context.Context, + request *types.ConstructionCombineRequest, +) (*types.ConstructionCombineResponse, *types.Error) { + decodedTx, err := hex.DecodeString(request.UnsignedTransaction) + if err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w transaction cannot be decoded", err), + ) + } + + var unsigned unsignedTransaction + if err := json.Unmarshal(decodedTx, &unsigned); err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w unable to unmarshal bitcoin transaction", err), + ) + } + + decodedCoreTx, err := hex.DecodeString(unsigned.Transaction) + if err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w transaction cannot be decoded", err), + ) + } + + var tx wire.MsgTx + if err := tx.Deserialize(bytes.NewReader(decodedCoreTx)); err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w unable to deserialize tx", err), + ) + } + + for i := range tx.TxIn { + decodedScript, err := hex.DecodeString(unsigned.ScriptPubKeys[i].Hex) + if err != nil { + return nil, wrapErr(ErrUnableToDecodeScriptPubKey, err) + } + + class, _, err := bitcoin.ParseSingleAddress(s.config.Params, decodedScript) + if err != nil { + return nil, wrapErr( + ErrUnableToDecodeAddress, + fmt.Errorf("%w unable to parse address for script", err), + ) + } + + pkData := request.Signatures[i].PublicKey.Bytes + fullsig := normalizeSignature(request.Signatures[i].Bytes) + + switch class { + case txscript.WitnessV0PubKeyHashTy: + tx.TxIn[i].Witness = wire.TxWitness{fullsig, pkData} + default: + return nil, wrapErr( + ErrUnsupportedScriptType, + fmt.Errorf("unupported script type: %s", class), + ) + } + } + + buf := bytes.NewBuffer(make([]byte, 0, tx.SerializeSize())) + if err := tx.Serialize(buf); err != nil { + return nil, wrapErr(ErrUnableToParseIntermediateResult, fmt.Errorf("%w serialize tx", err)) + } + + rawTx, err := json.Marshal(&signedTransaction{ + Transaction: hex.EncodeToString(buf.Bytes()), + InputAmounts: unsigned.InputAmounts, + }) + if err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w unable to serialize signed tx", err), + ) + } + + return &types.ConstructionCombineResponse{ + SignedTransaction: hex.EncodeToString(rawTx), + }, nil +} + +// ConstructionHash implements the /construction/hash endpoint. +func (s *ConstructionAPIService) ConstructionHash( + ctx context.Context, + request *types.ConstructionHashRequest, +) (*types.TransactionIdentifierResponse, *types.Error) { + decodedTx, err := hex.DecodeString(request.SignedTransaction) + if err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w signed transaction cannot be decoded", err), + ) + } + + var signed signedTransaction + if err := json.Unmarshal(decodedTx, &signed); err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w unable to unmarshal signed bitcoin transaction", err), + ) + } + + bytesTx, err := hex.DecodeString(signed.Transaction) + if err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w unable to decode hex transaction", err), + ) + } + + tx, err := btcutil.NewTxFromBytes(bytesTx) + if err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w unable to parse transaction", err), + ) + } + + return &types.TransactionIdentifierResponse{ + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: tx.Hash().String(), + }, + }, nil +} + +func (s *ConstructionAPIService) parseUnsignedTransaction( + request *types.ConstructionParseRequest, +) (*types.ConstructionParseResponse, *types.Error) { + decodedTx, err := hex.DecodeString(request.Transaction) + if err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w transaction cannot be decoded", err), + ) + } + + var unsigned unsignedTransaction + if err := json.Unmarshal(decodedTx, &unsigned); err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w unable to unmarshal bitcoin transaction", err), + ) + } + + decodedCoreTx, err := hex.DecodeString(unsigned.Transaction) + if err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w transaction cannot be decoded", err), + ) + } + + var tx wire.MsgTx + if err := tx.Deserialize(bytes.NewReader(decodedCoreTx)); err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w unable to deserialize tx", err), + ) + } + + ops := []*types.Operation{} + for i, input := range tx.TxIn { + networkIndex := int64(i) + ops = append(ops, &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: int64(len(ops)), + NetworkIndex: &networkIndex, + }, + Type: bitcoin.InputOpType, + Account: &types.AccountIdentifier{ + Address: unsigned.InputAddresses[i], + }, + Amount: &types.Amount{ + Value: unsigned.InputAmounts[i], + Currency: s.config.Currency, + }, + CoinChange: &types.CoinChange{ + CoinAction: types.CoinSpent, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: fmt.Sprintf( + "%s:%d", + input.PreviousOutPoint.Hash.String(), + input.PreviousOutPoint.Index, + ), + }, + }, + }) + } + + for i, output := range tx.TxOut { + networkIndex := int64(i) + _, addr, err := bitcoin.ParseSingleAddress(s.config.Params, output.PkScript) + if err != nil { + return nil, wrapErr( + ErrUnableToDecodeAddress, + fmt.Errorf("%w unable to parse output address", err), + ) + } + + ops = append(ops, &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: int64(len(ops)), + NetworkIndex: &networkIndex, + }, + Type: bitcoin.OutputOpType, + Account: &types.AccountIdentifier{ + Address: addr.String(), + }, + Amount: &types.Amount{ + Value: strconv.FormatInt(output.Value, 10), + Currency: s.config.Currency, + }, + }) + } + + return &types.ConstructionParseResponse{ + Operations: ops, + AccountIdentifierSigners: []*types.AccountIdentifier{}, + }, nil +} + +func (s *ConstructionAPIService) parseSignedTransaction( + request *types.ConstructionParseRequest, +) (*types.ConstructionParseResponse, *types.Error) { + decodedTx, err := hex.DecodeString(request.Transaction) + if err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w signed transaction cannot be decoded", err), + ) + } + + var signed signedTransaction + if err := json.Unmarshal(decodedTx, &signed); err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w unable to unmarshal signed bitcoin transaction", err), + ) + } + + serializedTx, err := hex.DecodeString(signed.Transaction) + if err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w unable to decode hex transaction", err), + ) + } + + var tx wire.MsgTx + if err := tx.Deserialize(bytes.NewReader(serializedTx)); err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w unable to decode msgTx", err), + ) + } + + ops := []*types.Operation{} + signers := []*types.AccountIdentifier{} + for i, input := range tx.TxIn { + pkScript, err := txscript.ComputePkScript(input.SignatureScript, input.Witness) + if err != nil { + return nil, wrapErr( + ErrUnableToComputePkScript, + fmt.Errorf("%w: unable to compute pk script", err), + ) + } + + _, addr, err := bitcoin.ParseSingleAddress(s.config.Params, pkScript.Script()) + if err != nil { + return nil, wrapErr( + ErrUnableToDecodeAddress, + fmt.Errorf("%w unable to decode address", err), + ) + } + + networkIndex := int64(i) + signers = append(signers, &types.AccountIdentifier{ + Address: addr.EncodeAddress(), + }) + ops = append(ops, &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: int64(len(ops)), + NetworkIndex: &networkIndex, + }, + Type: bitcoin.InputOpType, + Account: &types.AccountIdentifier{ + Address: addr.EncodeAddress(), + }, + Amount: &types.Amount{ + Value: signed.InputAmounts[i], + Currency: s.config.Currency, + }, + CoinChange: &types.CoinChange{ + CoinAction: types.CoinSpent, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: fmt.Sprintf( + "%s:%d", + input.PreviousOutPoint.Hash.String(), + input.PreviousOutPoint.Index, + ), + }, + }, + }) + } + + for i, output := range tx.TxOut { + networkIndex := int64(i) + _, addr, err := bitcoin.ParseSingleAddress(s.config.Params, output.PkScript) + if err != nil { + return nil, wrapErr( + ErrUnableToDecodeAddress, + fmt.Errorf("%w unable to parse output address", err), + ) + } + + ops = append(ops, &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: int64(len(ops)), + NetworkIndex: &networkIndex, + }, + Type: bitcoin.OutputOpType, + Account: &types.AccountIdentifier{ + Address: addr.String(), + }, + Amount: &types.Amount{ + Value: strconv.FormatInt(output.Value, 10), + Currency: s.config.Currency, + }, + }) + } + + return &types.ConstructionParseResponse{ + Operations: ops, + AccountIdentifierSigners: signers, + }, nil +} + +// ConstructionParse implements the /construction/parse endpoint. +func (s *ConstructionAPIService) ConstructionParse( + ctx context.Context, + request *types.ConstructionParseRequest, +) (*types.ConstructionParseResponse, *types.Error) { + if request.Signed { + return s.parseSignedTransaction(request) + } + + return s.parseUnsignedTransaction(request) +} + +// ConstructionSubmit implements the /construction/submit endpoint. +func (s *ConstructionAPIService) ConstructionSubmit( + ctx context.Context, + request *types.ConstructionSubmitRequest, +) (*types.TransactionIdentifierResponse, *types.Error) { + if s.config.Mode != configuration.Online { + return nil, wrapErr(ErrUnavailableOffline, nil) + } + + decodedTx, err := hex.DecodeString(request.SignedTransaction) + if err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w signed transaction cannot be decoded", err), + ) + } + + var signed signedTransaction + if err := json.Unmarshal(decodedTx, &signed); err != nil { + return nil, wrapErr( + ErrUnableToParseIntermediateResult, + fmt.Errorf("%w unable to unmarshal signed bitcoin transaction", err), + ) + } + + txHash, err := s.client.SendRawTransaction(ctx, signed.Transaction) + if err != nil { + return nil, wrapErr(ErrBitcoind, fmt.Errorf("%w unable to submit transaction", err)) + } + + return &types.TransactionIdentifierResponse{ + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: txHash, + }, + }, nil +} diff --git a/services/construction_service_test.go b/services/construction_service_test.go new file mode 100644 index 0000000..e07fd26 --- /dev/null +++ b/services/construction_service_test.go @@ -0,0 +1,399 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package services + +import ( + "context" + "encoding/hex" + "testing" + + "github.com/coinbase/rosetta-bitcoin/bitcoin" + "github.com/coinbase/rosetta-bitcoin/configuration" + mocks "github.com/coinbase/rosetta-bitcoin/mocks/services" + + "github.com/coinbase/rosetta-sdk-go/types" + "github.com/stretchr/testify/assert" +) + +func forceHexDecode(t *testing.T, s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + t.Fatalf("could not decode hex %s", s) + } + + return b +} + +func forceMarshalMap(t *testing.T, i interface{}) map[string]interface{} { + m, err := types.MarshalMap(i) + if err != nil { + t.Fatalf("could not marshal map %s", types.PrintStruct(i)) + } + + return m +} + +func TestConstructionService(t *testing.T) { + networkIdentifier = &types.NetworkIdentifier{ + Network: bitcoin.TestnetNetwork, + Blockchain: bitcoin.Blockchain, + } + + cfg := &configuration.Configuration{ + Mode: configuration.Online, + Network: networkIdentifier, + Params: bitcoin.TestnetParams, + Currency: bitcoin.TestnetCurrency, + } + + mockIndexer := &mocks.Indexer{} + mockClient := &mocks.Client{} + servicer := NewConstructionAPIService(cfg, mockClient, mockIndexer) + ctx := context.Background() + + // Test Derive + publicKey := &types.PublicKey{ + Bytes: forceHexDecode( + t, + "0325c9a4252789b31dbb3454ec647e9516e7c596bcde2bd5da71a60fab8644e438", + ), + CurveType: types.Secp256k1, + } + deriveResponse, err := servicer.ConstructionDerive(ctx, &types.ConstructionDeriveRequest{ + NetworkIdentifier: networkIdentifier, + PublicKey: publicKey, + }) + assert.Nil(t, err) + assert.Equal(t, &types.ConstructionDeriveResponse{ + AccountIdentifier: &types.AccountIdentifier{ + Address: "tb1qcqzmqzkswhfshzd8kedhmtvgnxax48z4fklhvm", + }, + }, deriveResponse) + + // Test Preprocess + ops := []*types.Operation{ + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + }, + Type: bitcoin.InputOpType, + Account: &types.AccountIdentifier{ + Address: "tb1qcqzmqzkswhfshzd8kedhmtvgnxax48z4fklhvm", + }, + Amount: &types.Amount{ + Value: "-1000000", + Currency: bitcoin.TestnetCurrency, + }, + CoinChange: &types.CoinChange{ + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "b14157a5c50503c8cd202a173613dd27e0027343c3d50cf85852dd020bf59c7f:1", + }, + CoinAction: types.CoinSpent, + }, + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + }, + Type: bitcoin.OutputOpType, + Account: &types.AccountIdentifier{ + Address: "tb1q3r8xjf0c2yazxnq9ey3wayelygfjxpfqjvj5v7", + }, + Amount: &types.Amount{ + Value: "954843", + Currency: bitcoin.TestnetCurrency, + }, + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 2, + }, + Type: bitcoin.OutputOpType, + Account: &types.AccountIdentifier{ + Address: "tb1qjsrjvk2ug872pdypp33fjxke62y7awpgefr6ua", + }, + Amount: &types.Amount{ + Value: "44657", + Currency: bitcoin.TestnetCurrency, + }, + }, + } + feeMultiplier := float64(0.75) + preprocessResponse, err := servicer.ConstructionPreprocess( + ctx, + &types.ConstructionPreprocessRequest{ + NetworkIdentifier: networkIdentifier, + Operations: ops, + SuggestedFeeMultiplier: &feeMultiplier, + }, + ) + assert.Nil(t, err) + options := &preprocessOptions{ + Coins: []*types.Coin{ + { + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "b14157a5c50503c8cd202a173613dd27e0027343c3d50cf85852dd020bf59c7f:1", + }, + Amount: &types.Amount{ + Value: "-1000000", + Currency: bitcoin.TestnetCurrency, + }, + }, + }, + EstimatedSize: 142, + FeeMultiplier: &feeMultiplier, + } + assert.Equal(t, &types.ConstructionPreprocessResponse{ + Options: forceMarshalMap(t, options), + }, preprocessResponse) + + // Test Metadata + metadata := &constructionMetadata{ + ScriptPubKeys: []*bitcoin.ScriptPubKey{ + { + ASM: "0 c005b00ad075d30b89a7b65b7dad8899ba6a9c55", + Hex: "0014c005b00ad075d30b89a7b65b7dad8899ba6a9c55", + RequiredSigs: 1, + Type: "witness_v0_keyhash", + Addresses: []string{ + "tb1qcqzmqzkswhfshzd8kedhmtvgnxax48z4fklhvm", + }, + }, + }, + } + + // Normal Fee + mockIndexer.On( + "GetScriptPubKeys", + ctx, + options.Coins, + ).Return( + metadata.ScriptPubKeys, + nil, + ).Once() + mockClient.On( + "SuggestedFeeRate", + ctx, + defaultConfirmationTarget, + ).Return( + bitcoin.MinFeeRate*10, + nil, + ).Once() + metadataResponse, err := servicer.ConstructionMetadata(ctx, &types.ConstructionMetadataRequest{ + NetworkIdentifier: networkIdentifier, + Options: forceMarshalMap(t, options), + }) + assert.Nil(t, err) + assert.Equal(t, &types.ConstructionMetadataResponse{ + Metadata: forceMarshalMap(t, metadata), + SuggestedFee: []*types.Amount{ + { + Value: "1065", // 1,420 * 0.75 + Currency: bitcoin.TestnetCurrency, + }, + }, + }, metadataResponse) + + // Low Fee + mockIndexer.On( + "GetScriptPubKeys", + ctx, + options.Coins, + ).Return( + metadata.ScriptPubKeys, + nil, + ).Once() + mockClient.On( + "SuggestedFeeRate", + ctx, + defaultConfirmationTarget, + ).Return( + bitcoin.MinFeeRate, + nil, + ).Once() + metadataResponse, err = servicer.ConstructionMetadata(ctx, &types.ConstructionMetadataRequest{ + NetworkIdentifier: networkIdentifier, + Options: forceMarshalMap(t, options), + }) + assert.Nil(t, err) + assert.Equal(t, &types.ConstructionMetadataResponse{ + Metadata: forceMarshalMap(t, metadata), + SuggestedFee: []*types.Amount{ + { + Value: "142", // we don't go below minimum fee rate + Currency: bitcoin.TestnetCurrency, + }, + }, + }, metadataResponse) + + // Test Payloads + unsignedRaw := "7b227472616e73616374696f6e223a2230313030303030303031376639636635306230326464353235386638306364356333343337333032653032376464313333363137326132306364633830333035633561353537343162313031303030303030303066666666666666663032646239313065303030303030303030303136303031343838636536393235663835313361323334633035633932326565393333663232313332333035323037316165303030303030303030303030313630303134393430373236353935633431666361306234383130633632393931616439643238396565623832383030303030303030222c227363726970745075624b657973223a5b7b2261736d223a22302063303035623030616430373564333062383961376236356237646164383839396261366139633535222c22686578223a223030313463303035623030616430373564333062383961376236356237646164383839396261366139633535222c2272657153696773223a312c2274797065223a227769746e6573735f76305f6b657968617368222c22616464726573736573223a5b227462317163717a6d717a6b7377686673687a64386b6564686d7476676e78617834387a34666b6c68766d225d7d5d2c22696e7075745f616d6f756e7473223a5b222d31303030303030225d2c22696e7075745f616464726573736573223a5b227462317163717a6d717a6b7377686673687a64386b6564686d7476676e78617834387a34666b6c68766d225d7d" // nolint + payloadsResponse, err := servicer.ConstructionPayloads(ctx, &types.ConstructionPayloadsRequest{ + NetworkIdentifier: networkIdentifier, + Operations: ops, + Metadata: forceMarshalMap(t, metadata), + }) + val0 := int64(0) + val1 := int64(1) + parseOps := []*types.Operation{ + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + NetworkIndex: &val0, + }, + Type: bitcoin.InputOpType, + Account: &types.AccountIdentifier{ + Address: "tb1qcqzmqzkswhfshzd8kedhmtvgnxax48z4fklhvm", + }, + Amount: &types.Amount{ + Value: "-1000000", + Currency: bitcoin.TestnetCurrency, + }, + CoinChange: &types.CoinChange{ + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "b14157a5c50503c8cd202a173613dd27e0027343c3d50cf85852dd020bf59c7f:1", + }, + CoinAction: types.CoinSpent, + }, + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + NetworkIndex: &val0, + }, + Type: bitcoin.OutputOpType, + Account: &types.AccountIdentifier{ + Address: "tb1q3r8xjf0c2yazxnq9ey3wayelygfjxpfqjvj5v7", + }, + Amount: &types.Amount{ + Value: "954843", + Currency: bitcoin.TestnetCurrency, + }, + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 2, + NetworkIndex: &val1, + }, + Type: bitcoin.OutputOpType, + Account: &types.AccountIdentifier{ + Address: "tb1qjsrjvk2ug872pdypp33fjxke62y7awpgefr6ua", + }, + Amount: &types.Amount{ + Value: "44657", + Currency: bitcoin.TestnetCurrency, + }, + }, + } + + assert.Nil(t, err) + signingPayload := &types.SigningPayload{ + Bytes: forceHexDecode( + t, + "7b98f8b77fa6ef34044f320073118033afdffbd3fd3f8423889d9e5953ff4a30", + ), + AccountIdentifier: &types.AccountIdentifier{ + Address: "tb1qcqzmqzkswhfshzd8kedhmtvgnxax48z4fklhvm", + }, + SignatureType: types.Ecdsa, + } + assert.Equal(t, &types.ConstructionPayloadsResponse{ + UnsignedTransaction: unsignedRaw, + Payloads: []*types.SigningPayload{signingPayload}, + }, payloadsResponse) + + // Test Parse Unsigned + parseUnsignedResponse, err := servicer.ConstructionParse(ctx, &types.ConstructionParseRequest{ + NetworkIdentifier: networkIdentifier, + Signed: false, + Transaction: unsignedRaw, + }) + assert.Nil(t, err) + assert.Equal(t, &types.ConstructionParseResponse{ + Operations: parseOps, + AccountIdentifierSigners: []*types.AccountIdentifier{}, + }, parseUnsignedResponse) + + // Test Combine + signedRaw := "7b227472616e73616374696f6e223a22303130303030303030303031303137663963663530623032646435323538663830636435633334333733303265303237646431333336313732613230636463383033303563356135353734316231303130303030303030306666666666666666303264623931306530303030303030303030313630303134383863653639323566383531336132333463303563393232656539333366323231333233303532303731616530303030303030303030303031363030313439343037323635393563343166636130623438313063363239393161643964323839656562383238303234373330343430323230323538373665633862396635316433343361356135366163353439633063383238303035656634356562653964613136366462363435633039313537323233663032323034636430386237323738613838383961383131333539313562636531306431656633626239326232313766383161306465376537396666623364666436616335303132313033323563396134323532373839623331646262333435346563363437653935313665376335393662636465326264356461373161363066616238363434653433383030303030303030222c22696e7075745f616d6f756e7473223a5b222d31303030303030225d7d" // nolint + combineResponse, err := servicer.ConstructionCombine(ctx, &types.ConstructionCombineRequest{ + NetworkIdentifier: networkIdentifier, + UnsignedTransaction: unsignedRaw, + Signatures: []*types.Signature{ + { + Bytes: forceHexDecode( + t, + "25876ec8b9f51d343a5a56ac549c0c828005ef45ebe9da166db645c09157223f4cd08b7278a8889a81135915bce10d1ef3bb92b217f81a0de7e79ffb3dfd6ac5", // nolint + ), + SigningPayload: signingPayload, + PublicKey: publicKey, + SignatureType: types.Ecdsa, + }, + }, + }) + assert.Nil(t, err) + assert.Equal(t, &types.ConstructionCombineResponse{ + SignedTransaction: signedRaw, + }, combineResponse) + + // Test Parse Signed + parseSignedResponse, err := servicer.ConstructionParse(ctx, &types.ConstructionParseRequest{ + NetworkIdentifier: networkIdentifier, + Signed: true, + Transaction: signedRaw, + }) + assert.Nil(t, err) + assert.Equal(t, &types.ConstructionParseResponse{ + Operations: parseOps, + AccountIdentifierSigners: []*types.AccountIdentifier{ + {Address: "tb1qcqzmqzkswhfshzd8kedhmtvgnxax48z4fklhvm"}, + }, + }, parseSignedResponse) + + // Test Hash + transactionIdentifier := &types.TransactionIdentifier{ + Hash: "6d87ad0e26025128f5a8357fa423b340cbcffb9703f79f432f5520fca59cd20b", + } + hashResponse, err := servicer.ConstructionHash(ctx, &types.ConstructionHashRequest{ + NetworkIdentifier: networkIdentifier, + SignedTransaction: signedRaw, + }) + assert.Nil(t, err) + assert.Equal(t, &types.TransactionIdentifierResponse{ + TransactionIdentifier: transactionIdentifier, + }, hashResponse) + + // Test Submit + bitcoinTransaction := "010000000001017f9cf50b02dd5258f80cd5c3437302e027dd1336172a20cdc80305c5a55741b10100000000ffffffff02db910e000000000016001488ce6925f8513a234c05c922ee933f221323052071ae000000000000160014940726595c41fca0b4810c62991ad9d289eeb82802473044022025876ec8b9f51d343a5a56ac549c0c828005ef45ebe9da166db645c09157223f02204cd08b7278a8889a81135915bce10d1ef3bb92b217f81a0de7e79ffb3dfd6ac501210325c9a4252789b31dbb3454ec647e9516e7c596bcde2bd5da71a60fab8644e43800000000" // nolint + mockClient.On( + "SendRawTransaction", + ctx, + bitcoinTransaction, + ).Return( + transactionIdentifier.Hash, + nil, + ) + submitResponse, err := servicer.ConstructionSubmit(ctx, &types.ConstructionSubmitRequest{ + NetworkIdentifier: networkIdentifier, + SignedTransaction: signedRaw, + }) + assert.Nil(t, err) + assert.Equal(t, &types.TransactionIdentifierResponse{ + TransactionIdentifier: transactionIdentifier, + }, submitResponse) + + mockClient.AssertExpectations(t) + mockIndexer.AssertExpectations(t) +} diff --git a/services/errors.go b/services/errors.go new file mode 100644 index 0000000..85542be --- /dev/null +++ b/services/errors.go @@ -0,0 +1,193 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package services + +import ( + "github.com/coinbase/rosetta-sdk-go/types" +) + +var ( + // Errors contains all errors that could be returned + // by this Rosetta implementation. + Errors = []*types.Error{ + ErrUnimplemented, + ErrUnavailableOffline, + ErrNotReady, + ErrBitcoind, + ErrBlockNotFound, + ErrUnableToDerive, + ErrUnclearIntent, + ErrUnableToParseIntermediateResult, + ErrScriptPubKeysMissing, + ErrInvalidCoin, + ErrUnableToDecodeAddress, + ErrUnableToDecodeScriptPubKey, + ErrUnableToCalculateSignatureHash, + ErrUnsupportedScriptType, + ErrUnableToComputePkScript, + ErrUnableToGetCoins, + ErrTransactionNotFound, + ErrCouldNotGetFeeRate, + } + + // ErrUnimplemented is returned when an endpoint + // is called that is not implemented. + ErrUnimplemented = &types.Error{ + Code: 0, //nolint + Message: "Endpoint not implemented", + } + + // ErrUnavailableOffline is returned when an endpoint + // is called that is not available offline. + ErrUnavailableOffline = &types.Error{ + Code: 1, //nolint + Message: "Endpoint unavailable offline", + } + + // ErrNotReady is returned when bitcoind is not + // yet ready to serve queries. + ErrNotReady = &types.Error{ + Code: 2, //nolint + Message: "Bitcoind is not ready", + } + + // ErrBitcoind is returned when bitcoind + // errors on a request. + ErrBitcoind = &types.Error{ + Code: 3, //nolint + Message: "Bitcoind error", + } + + // ErrBlockNotFound is returned when a block + // is not available in the indexer. + ErrBlockNotFound = &types.Error{ + Code: 4, //nolint + Message: "Block not found", + } + + // ErrUnableToDerive is returned when an address + // cannot be derived from a provided public key. + ErrUnableToDerive = &types.Error{ + Code: 5, //nolint + Message: "Unable to derive address", + } + + // ErrUnclearIntent is returned when operations + // provided in /construction/preprocess or /construction/payloads + // are not valid. + ErrUnclearIntent = &types.Error{ + Code: 6, //nolint + Message: "Unable to parse intent", + } + + // ErrUnableToParseIntermediateResult is returned + // when a data structure passed between Construction + // API calls is not valid. + ErrUnableToParseIntermediateResult = &types.Error{ + Code: 7, //nolint + Message: "Unable to parse intermediate result", + } + + // ErrScriptPubKeysMissing is returned when + // the indexer cannot populate the required + // bitcoin.ScriptPubKeys to construct a transaction. + ErrScriptPubKeysMissing = &types.Error{ + Code: 8, //nolint + Message: "Missing ScriptPubKeys", + } + + // ErrInvalidCoin is returned when a *types.Coin + // cannot be parsed during construction. + ErrInvalidCoin = &types.Error{ + Code: 9, //nolint + Message: "Coin is invalid", + } + + // ErrUnableToDecodeAddress is returned when an address + // cannot be parsed during construction. + ErrUnableToDecodeAddress = &types.Error{ + Code: 10, //nolint + Message: "Unable to decode address", + } + + // ErrUnableToDecodeScriptPubKey is returned when a + // bitcoin.ScriptPubKey cannot be parsed during construction. + ErrUnableToDecodeScriptPubKey = &types.Error{ + Code: 11, //nolint + Message: "Unable to decode ScriptPubKey", + } + + // ErrUnableToCalculateSignatureHash is returned + // when some payload to sign cannot be generated. + ErrUnableToCalculateSignatureHash = &types.Error{ + Code: 12, //nolint + Message: "Unable to calculate signature hash", + } + + // ErrUnsupportedScriptType is returned when + // trying to sign an input with an unsupported + // script type. + ErrUnsupportedScriptType = &types.Error{ + Code: 13, //nolint + Message: "Script type is not supported", + } + + // ErrUnableToComputePkScript is returned + // when trying to compute the PkScript in + // ConsructionParse. + ErrUnableToComputePkScript = &types.Error{ + Code: 14, //nolint + Message: "Unable to compute PK script", + } + + // ErrUnableToGetCoins is returned by the indexer + // when it is not possible to get the coins + // owned by a *types.AccountIdentifier. + ErrUnableToGetCoins = &types.Error{ + Code: 15, //nolint + Message: "Unable to get coins", + } + + // ErrTransactionNotFound is returned by the indexer + // when it is not possible to find a transaction. + ErrTransactionNotFound = &types.Error{ + Code: 16, // nolint + Message: "Transaction not found", + } + + // ErrCouldNotGetFeeRate is returned when the fetch + // to get the suggested fee rate fails. + ErrCouldNotGetFeeRate = &types.Error{ + Code: 17, // nolint + Message: "Could not get suggested fee rate", + } +) + +// wrapErr adds details to the types.Error provided. We use a function +// to do this so that we don't accidentially overrwrite the standard +// errors. +func wrapErr(rErr *types.Error, err error) *types.Error { + newErr := &types.Error{ + Code: rErr.Code, + Message: rErr.Message, + } + if err != nil { + newErr.Details = map[string]interface{}{ + "context": err.Error(), + } + } + + return newErr +} diff --git a/services/errors_test.go b/services/errors_test.go new file mode 100644 index 0000000..12f7cc8 --- /dev/null +++ b/services/errors_test.go @@ -0,0 +1,40 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package services + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestErrors(t *testing.T) { + for i := 0; i < len(Errors); i++ { + assert.Equal(t, int32(i), Errors[i].Code) + } +} + +func TestWrapErr(t *testing.T) { + err := errors.New("testing") + typedErr := wrapErr(ErrUnclearIntent, err) + + assert.Equal(t, ErrUnclearIntent.Code, typedErr.Code) + assert.Equal(t, ErrUnclearIntent.Message, typedErr.Message) + assert.Equal(t, err.Error(), typedErr.Details["context"]) + + // Assert we don't overwrite our reference. + assert.Nil(t, ErrUnclearIntent.Details) +} diff --git a/services/logger.go b/services/logger.go new file mode 100644 index 0000000..e6e293e --- /dev/null +++ b/services/logger.go @@ -0,0 +1,64 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package services + +import ( + "net/http" + "time" + + "go.uber.org/zap" +) + +// StatusRecorder is used to surface the status +// code of a HTTP response. We must use this wrapping +// because the status code is not exposed by the +// http.ResponseWriter in the http.HandlerFunc. +// +// Inspired by: +// https://stackoverflow.com/questions/53272536/how-do-i-get-response-statuscode-in-golang-middleware +type StatusRecorder struct { + http.ResponseWriter + Code int +} + +// NewStatusRecorder returns a new *StatusRecorder. +func NewStatusRecorder(w http.ResponseWriter) *StatusRecorder { + return &StatusRecorder{w, http.StatusOK} +} + +// WriteHeader stores the status code of a response. +func (r *StatusRecorder) WriteHeader(code int) { + r.Code = code + r.ResponseWriter.WriteHeader(code) +} + +// LoggerMiddleware is a simple logger middleware that prints the requests in +// an ad-hoc fashion to the stdlib's log. +func LoggerMiddleware(loggerRaw *zap.Logger, inner http.Handler) http.Handler { + logger := loggerRaw.Sugar().Named("server") + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + recorder := NewStatusRecorder(w) + + inner.ServeHTTP(recorder, r) + + logger.Debugw( + r.Method, + "code", recorder.Code, + "uri", r.RequestURI, + "time", time.Since(start), + ) + }) +} diff --git a/services/mempool_service.go b/services/mempool_service.go new file mode 100644 index 0000000..f5d232b --- /dev/null +++ b/services/mempool_service.go @@ -0,0 +1,46 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package services + +import ( + "context" + + "github.com/coinbase/rosetta-sdk-go/server" + "github.com/coinbase/rosetta-sdk-go/types" +) + +// MempoolAPIService implements the server.MempoolAPIServicer interface. +type MempoolAPIService struct{} + +// NewMempoolAPIService creates a new instance of a MempoolAPIService. +func NewMempoolAPIService() server.MempoolAPIServicer { + return &MempoolAPIService{} +} + +// Mempool implements the /mempool endpoint. +func (s *MempoolAPIService) Mempool( + ctx context.Context, + request *types.NetworkRequest, +) (*types.MempoolResponse, *types.Error) { + return nil, wrapErr(ErrUnimplemented, nil) +} + +// MempoolTransaction implements the /mempool/transaction endpoint. +func (s *MempoolAPIService) MempoolTransaction( + ctx context.Context, + request *types.MempoolTransactionRequest, +) (*types.MempoolTransactionResponse, *types.Error) { + return nil, wrapErr(ErrUnimplemented, nil) +} diff --git a/services/mempool_service_test.go b/services/mempool_service_test.go new file mode 100644 index 0000000..d1e6a4b --- /dev/null +++ b/services/mempool_service_test.go @@ -0,0 +1,37 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package services + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMempoolEndpoints(t *testing.T) { + servicer := NewMempoolAPIService() + ctx := context.Background() + + mem, err := servicer.Mempool(ctx, nil) + assert.Nil(t, mem) + assert.Equal(t, ErrUnimplemented.Code, err.Code) + assert.Equal(t, ErrUnimplemented.Message, err.Message) + + memTransaction, err := servicer.MempoolTransaction(ctx, nil) + assert.Nil(t, memTransaction) + assert.Equal(t, ErrUnimplemented.Code, err.Code) + assert.Equal(t, ErrUnimplemented.Message, err.Message) +} diff --git a/services/network_service.go b/services/network_service.go new file mode 100644 index 0000000..647ed45 --- /dev/null +++ b/services/network_service.go @@ -0,0 +1,99 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package services + +import ( + "context" + + "github.com/coinbase/rosetta-bitcoin/bitcoin" + "github.com/coinbase/rosetta-bitcoin/configuration" + + "github.com/coinbase/rosetta-sdk-go/server" + "github.com/coinbase/rosetta-sdk-go/types" +) + +// NetworkAPIService implements the server.NetworkAPIServicer interface. +type NetworkAPIService struct { + config *configuration.Configuration + client Client + i Indexer +} + +// NewNetworkAPIService creates a new instance of a NetworkAPIService. +func NewNetworkAPIService( + config *configuration.Configuration, + client Client, + i Indexer, +) server.NetworkAPIServicer { + return &NetworkAPIService{ + config: config, + client: client, + i: i, + } +} + +// NetworkList implements the /network/list endpoint +func (s *NetworkAPIService) NetworkList( + ctx context.Context, + request *types.MetadataRequest, +) (*types.NetworkListResponse, *types.Error) { + return &types.NetworkListResponse{ + NetworkIdentifiers: []*types.NetworkIdentifier{ + s.config.Network, + }, + }, nil +} + +// NetworkStatus implements the /network/status endpoint. +func (s *NetworkAPIService) NetworkStatus( + ctx context.Context, + request *types.NetworkRequest, +) (*types.NetworkStatusResponse, *types.Error) { + if s.config.Mode != configuration.Online { + return nil, wrapErr(ErrUnavailableOffline, nil) + } + + rawStatus, err := s.client.NetworkStatus(ctx) + if err != nil { + return nil, wrapErr(ErrBitcoind, err) + } + + cachedBlockResponse, err := s.i.GetBlockLazy(ctx, nil) + if err != nil { + return nil, wrapErr(ErrNotReady, nil) + } + + rawStatus.CurrentBlockIdentifier = cachedBlockResponse.Block.BlockIdentifier + + return rawStatus, nil +} + +// NetworkOptions implements the /network/options endpoint. +func (s *NetworkAPIService) NetworkOptions( + ctx context.Context, + request *types.NetworkRequest, +) (*types.NetworkOptionsResponse, *types.Error) { + return &types.NetworkOptionsResponse{ + Version: &types.Version{ + RosettaVersion: "1.4.2", + NodeVersion: "0.0.1", + }, + Allow: &types.Allow{ + OperationStatuses: bitcoin.OperationStatuses, + OperationTypes: bitcoin.OperationTypes, + Errors: Errors, + }, + }, nil +} diff --git a/services/network_service_test.go b/services/network_service_test.go new file mode 100644 index 0000000..86b36f7 --- /dev/null +++ b/services/network_service_test.go @@ -0,0 +1,126 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package services + +import ( + "context" + "testing" + + "github.com/coinbase/rosetta-bitcoin/bitcoin" + "github.com/coinbase/rosetta-bitcoin/configuration" + mocks "github.com/coinbase/rosetta-bitcoin/mocks/services" + + "github.com/coinbase/rosetta-sdk-go/types" + "github.com/stretchr/testify/assert" +) + +var ( + defaultNetworkOptions = &types.NetworkOptionsResponse{ + Version: &types.Version{ + RosettaVersion: "1.4.2", + NodeVersion: "0.0.1", + }, + Allow: &types.Allow{ + OperationStatuses: bitcoin.OperationStatuses, + OperationTypes: bitcoin.OperationTypes, + Errors: Errors, + }, + } + + networkIdentifier = &types.NetworkIdentifier{ + Network: bitcoin.MainnetNetwork, + Blockchain: bitcoin.Blockchain, + } +) + +func TestNetworkEndpoints_Offline(t *testing.T) { + cfg := &configuration.Configuration{ + Mode: configuration.Offline, + Network: networkIdentifier, + } + mockIndexer := &mocks.Indexer{} + mockClient := &mocks.Client{} + servicer := NewNetworkAPIService(cfg, mockClient, mockIndexer) + ctx := context.Background() + + networkList, err := servicer.NetworkList(ctx, nil) + assert.Nil(t, err) + assert.Equal(t, []*types.NetworkIdentifier{ + networkIdentifier, + }, networkList.NetworkIdentifiers) + + networkStatus, err := servicer.NetworkStatus(ctx, nil) + assert.Nil(t, networkStatus) + assert.Equal(t, ErrUnavailableOffline.Code, err.Code) + assert.Equal(t, ErrUnavailableOffline.Message, err.Message) + + networkOptions, err := servicer.NetworkOptions(ctx, nil) + assert.Nil(t, err) + assert.Equal(t, defaultNetworkOptions, networkOptions) + + mockIndexer.AssertExpectations(t) + mockClient.AssertExpectations(t) +} + +func TestNetworkEndpoints_Online(t *testing.T) { + cfg := &configuration.Configuration{ + Mode: configuration.Online, + Network: networkIdentifier, + } + mockIndexer := &mocks.Indexer{} + mockClient := &mocks.Client{} + servicer := NewNetworkAPIService(cfg, mockClient, mockIndexer) + ctx := context.Background() + + networkList, err := servicer.NetworkList(ctx, nil) + assert.Nil(t, err) + assert.Equal(t, []*types.NetworkIdentifier{ + networkIdentifier, + }, networkList.NetworkIdentifiers) + + rawStatus := &types.NetworkStatusResponse{ + GenesisBlockIdentifier: bitcoin.MainnetGenesisBlockIdentifier, + } + blockResponse := &types.BlockResponse{ + Block: &types.Block{ + BlockIdentifier: &types.BlockIdentifier{ + Index: 100, + Hash: "block 100", + }, + }, + } + mockClient.On("NetworkStatus", ctx).Return(rawStatus, nil) + mockIndexer.On( + "GetBlockLazy", + ctx, + (*types.PartialBlockIdentifier)(nil), + ).Return( + blockResponse, + nil, + ) + networkStatus, err := servicer.NetworkStatus(ctx, nil) + assert.Nil(t, err) + assert.Equal(t, &types.NetworkStatusResponse{ + GenesisBlockIdentifier: bitcoin.MainnetGenesisBlockIdentifier, + CurrentBlockIdentifier: blockResponse.Block.BlockIdentifier, + }, networkStatus) + + networkOptions, err := servicer.NetworkOptions(ctx, nil) + assert.Nil(t, err) + assert.Equal(t, defaultNetworkOptions, networkOptions) + + mockIndexer.AssertExpectations(t) + mockClient.AssertExpectations(t) +} diff --git a/services/router.go b/services/router.go new file mode 100644 index 0000000..bd0e6cd --- /dev/null +++ b/services/router.go @@ -0,0 +1,71 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package services + +import ( + "net/http" + + "github.com/coinbase/rosetta-bitcoin/configuration" + + "github.com/coinbase/rosetta-sdk-go/asserter" + "github.com/coinbase/rosetta-sdk-go/server" +) + +// NewBlockchainRouter creates a Mux http.Handler from a collection +// of server controllers. +func NewBlockchainRouter( + config *configuration.Configuration, + client Client, + i Indexer, + asserter *asserter.Asserter, +) http.Handler { + networkAPIService := NewNetworkAPIService(config, client, i) + networkAPIController := server.NewNetworkAPIController( + networkAPIService, + asserter, + ) + + blockAPIService := NewBlockAPIService(config, i) + blockAPIController := server.NewBlockAPIController( + blockAPIService, + asserter, + ) + + accountAPIService := NewAccountAPIService(config, i) + accountAPIController := server.NewAccountAPIController( + accountAPIService, + asserter, + ) + + constructionAPIService := NewConstructionAPIService(config, client, i) + constructionAPIController := server.NewConstructionAPIController( + constructionAPIService, + asserter, + ) + + mempoolAPIService := NewMempoolAPIService() + mempoolAPIController := server.NewMempoolAPIController( + mempoolAPIService, + asserter, + ) + + return server.NewRouter( + networkAPIController, + blockAPIController, + accountAPIController, + constructionAPIController, + mempoolAPIController, + ) +} diff --git a/services/types.go b/services/types.go new file mode 100644 index 0000000..7d8b8af --- /dev/null +++ b/services/types.go @@ -0,0 +1,77 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package services + +import ( + "context" + + "github.com/coinbase/rosetta-bitcoin/bitcoin" + + "github.com/coinbase/rosetta-sdk-go/types" +) + +// Client is used by the servicers to get Peer information +// and to submit transactions. +type Client interface { + NetworkStatus(context.Context) (*types.NetworkStatusResponse, error) + SendRawTransaction(context.Context, string) (string, error) + SuggestedFeeRate(context.Context, int64) (float64, error) +} + +// Indexer is used by the servicers to get block and account data. +type Indexer interface { + GetBlockLazy(context.Context, *types.PartialBlockIdentifier) (*types.BlockResponse, error) + GetBlockTransaction( + context.Context, + *types.BlockIdentifier, + *types.TransactionIdentifier, + ) (*types.Transaction, error) + GetCoins( + context.Context, + *types.AccountIdentifier, + ) ([]*types.Coin, *types.BlockIdentifier, error) + GetScriptPubKeys( + context.Context, + []*types.Coin, + ) ([]*bitcoin.ScriptPubKey, error) +} + +type unsignedTransaction struct { + Transaction string `json:"transaction"` + ScriptPubKeys []*bitcoin.ScriptPubKey `json:"scriptPubKeys"` + InputAmounts []string `json:"input_amounts"` + InputAddresses []string `json:"input_addresses"` +} + +type preprocessOptions struct { + Coins []*types.Coin `json:"coins"` + EstimatedSize float64 `json:"estimated_size"` + FeeMultiplier *float64 `json:"fee_multiplier,omitempty"` +} + +type constructionMetadata struct { + ScriptPubKeys []*bitcoin.ScriptPubKey `json:"script_pub_keys"` +} + +type signedTransaction struct { + Transaction string `json:"transaction"` + InputAmounts []string `json:"input_amounts"` +} + +// ParseOperationMetadata is returned from +// ConstructionParse. +type ParseOperationMetadata struct { + ScriptPubKey *bitcoin.ScriptPubKey `json:"scriptPubKey"` +} diff --git a/utils/utils.go b/utils/utils.go new file mode 100644 index 0000000..a283023 --- /dev/null +++ b/utils/utils.go @@ -0,0 +1,104 @@ +// Copyright 2020 Coinbase, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package utils + +import ( + "context" + "runtime" + "time" + + sdkUtils "github.com/coinbase/rosetta-sdk-go/utils" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +const ( + // monitorMemorySleep is how long we should sleep + // between checking memory stats. + monitorMemorySleep = 50 * time.Millisecond +) + +// ExtractLogger returns a sugared logger with the origin +// tag added. +func ExtractLogger(ctx context.Context, origin string) *zap.SugaredLogger { + logger := ctxzap.Extract(ctx) + if len(origin) > 0 { + logger = logger.Named(origin) + } + + return logger.Sugar() +} + +// ContextSleep sleeps for the provided duration and returns +// an error if context is canceled. +func ContextSleep(ctx context.Context, duration time.Duration) error { + timer := time.NewTimer(duration) + defer timer.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + + case <-timer.C: + return nil + } + } +} + +// MonitorMemoryUsage periodically logs memory usage +// stats and triggers garbage collection when heap allocations +// surpass maxHeapUsage. +func MonitorMemoryUsage( + ctx context.Context, + maxHeapUsage int, +) error { + logger := ExtractLogger(ctx, "memory") + + maxHeap := float64(0) + garbageCollections := uint32(0) + for ctx.Err() == nil { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + heapAlloc := float64(m.HeapAlloc) + if heapAlloc > maxHeap { + maxHeap = heapAlloc + } + + heapAllocMB := sdkUtils.BtoMb(heapAlloc) + if heapAllocMB > float64(maxHeapUsage) { + runtime.GC() + } + + if m.NumGC > garbageCollections { + garbageCollections = m.NumGC + logger.Debugw( + "stats", + "heap (MB)", heapAllocMB, + "max heap (MB)", sdkUtils.BtoMb(maxHeap), + "stack (MB)", sdkUtils.BtoMb(float64(m.StackInuse)), + "system (MB)", sdkUtils.BtoMb(float64(m.Sys)), + "garbage collections", m.NumGC, + ) + } + + if err := ContextSleep(ctx, monitorMemorySleep); err != nil { + return err + } + } + + return ctx.Err() +} diff --git a/zstd-train.sh b/zstd-train.sh new file mode 100755 index 0000000..5737e4c --- /dev/null +++ b/zstd-train.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Copyright 2020 Coinbase, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +NETWORK=$1; +NAMESPACE=$2; +DATA_DIRECTORY=$3; +MAX_ITEMS=150000; + +DATA_PATH="${DATA_DIRECTORY}/indexer"; +DICT_PATH="assets/${NETWORK}-${NAMESPACE}.zstd"; + +rosetta-cli utils:train-zstd "${NAMESPACE}" "${DATA_PATH}" "${DICT_PATH}" "${MAX_ITEMS}" "${DICT_PATH}";