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..5743f02 100644 --- a/README.md +++ b/README.md @@ -1 +1,189 @@ -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) +``` +_We have not tested `rosetta-bitcoin` with `net.ipv4.tcp_tw_recycle` and do not recommend +enabling it._ + +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..40ea180 --- /dev/null +++ b/assets/bitcoin-mainnet.conf @@ -0,0 +1,26 @@ +## +## bitcoin.conf configuration file. Lines beginning with # are comments. +## + +# DO NOT USE THIS CONFIGURATION FILE IF YOU PLAN TO EXPOSE +# BITCOIND'S RPC PORT PUBLICALLY (THESE INSECURE CREDENTIALS +# COULD LEAD TO AN ATTACK). ROSETTA-BITCOIN USES THE RPC PORT +# FOR INDEXING AND TRANSACTION BROADCAST BUT NEVER PROVIDES THE +# CALLER ACCESS TO BITCOIND'S RPC PORT. + +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..6dac1f9 --- /dev/null +++ b/assets/bitcoin-testnet.conf @@ -0,0 +1,29 @@ +## +## bitcoin.conf configuration file. Lines beginning with # are comments. +## + +# DO NOT USE THIS CONFIGURATION FILE IF YOU PLAN TO EXPOSE +# BITCOIND'S RPC PORT PUBLICALLY (THESE INSECURE CREDENTIALS +# COULD LEAD TO AN ATTACK). ROSETTA-BITCOIN USES THE RPC PORT +# FOR INDEXING AND TRANSACTION BROADCAST BUT NEVER PROVIDES THE +# CALLER ACCESS TO BITCOIND'S RPC PORT. + +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 0000000..c066f82 Binary files /dev/null and b/assets/mainnet-transaction.zstd differ diff --git a/assets/testnet-transaction.zstd b/assets/testnet-transaction.zstd new file mode 100644 index 0000000..967bde8 Binary files /dev/null and b/assets/testnet-transaction.zstd differ diff --git a/bitcoin/client.go b/bitcoin/client.go new file mode 100644 index 0000000..6ac38cc --- /dev/null +++ b/bitcoin/client.go @@ -0,0 +1,834 @@ +// 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 ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net" + "net/http" + "strconv" + "time" + + bitcoinUtils "github.com/coinbase/rosetta-bitcoin/utils" + + "github.com/btcsuite/btcutil" + "github.com/coinbase/rosetta-sdk-go/storage" + "github.com/coinbase/rosetta-sdk-go/types" + "github.com/coinbase/rosetta-sdk-go/utils" +) + +const ( + // genesisBlockIndex is the height of the block we consider to be the + // genesis block of the bitcoin blockchain for polling + genesisBlockIndex = 0 + + // requestID is the JSON-RPC request ID we use for making requests. + // We don't need unique request IDs because we're processing all of + // our requests synchronously. + requestID = 1 + + // jSONRPCVersion is the JSON-RPC version we use for making requests + jSONRPCVersion = "2.0" + + // blockVerbosity represents the verbose level used when fetching blocks + // * 0 returns the hex representation + // * 1 returns the JSON representation + // * 2 returns the JSON representation with included Transaction data + blockVerbosity = 2 +) + +type requestMethod string + +const ( + // https://bitcoin.org/en/developer-reference#getblock + requestMethodGetBlock requestMethod = "getblock" + + // https://bitcoin.org/en/developer-reference#getblockhash + requestMethodGetBlockHash requestMethod = "getblockhash" + + // https://bitcoin.org/en/developer-reference#getblockchaininfo + requestMethodGetBlockchainInfo requestMethod = "getblockchaininfo" + + // https://developer.bitcoin.org/reference/rpc/getpeerinfo.html + requestMethodGetPeerInfo requestMethod = "getpeerinfo" + + // https://developer.bitcoin.org/reference/rpc/pruneblockchain.html + requestMethodPruneBlockchain requestMethod = "pruneblockchain" + + // https://developer.bitcoin.org/reference/rpc/sendrawtransaction.html + requestMethodSendRawTransaction requestMethod = "sendrawtransaction" + + // https://developer.bitcoin.org/reference/rpc/estimatesmartfee.html + requestMethodEstimateSmartFee requestMethod = "estimatesmartfee" + + // blockNotFoundErrCode is the RPC error code when a block cannot be found + blockNotFoundErrCode = -5 +) + +const ( + defaultTimeout = 100 * time.Second + dialTimeout = 5 * time.Second + + // timeMultiplier is used to multiply the time + // returned in Bitcoin blocks to be milliseconds. + timeMultiplier = 1000 + + // rpc credentials are fixed in rosetta-bitcoin + // because we never expose access to the raw bitcoind + // endpoints (that could be used perform an attack, like + // changing our peers). + rpcUsername = "rosetta" + rpcPassword = "rosetta" +) + +var ( + // ErrBlockNotFound is returned by when the requested block + // cannot be found by the node + ErrBlockNotFound = errors.New("unable to find block") + + // ErrJSONRPCError is returned when receiving an error from a JSON-RPC response + ErrJSONRPCError = errors.New("JSON-RPC error") +) + +// Client is used to fetch blocks from bitcoind and +// to parse Bitcoin block data into Rosetta types. +// +// We opted not to use existing Bitcoin RPC libraries +// because they don't allow providing context +// in each request. +type Client struct { + baseURL string + + genesisBlockIdentifier *types.BlockIdentifier + currency *types.Currency + + httpClient *http.Client +} + +// LocalhostURL returns the URL to use +// for a client that is running at localhost. +func LocalhostURL(rpcPort int) string { + return fmt.Sprintf("http://localhost:%d", rpcPort) +} + +// NewClient creates a new Bitcoin client. +func NewClient( + baseURL string, + genesisBlockIdentifier *types.BlockIdentifier, + currency *types.Currency, +) *Client { + return &Client{ + baseURL: baseURL, + genesisBlockIdentifier: genesisBlockIdentifier, + currency: currency, + httpClient: newHTTPClient(defaultTimeout), + } +} + +// newHTTPClient returns a new HTTP client +func newHTTPClient(timeout time.Duration) *http.Client { + var netTransport = &http.Transport{ + Dial: (&net.Dialer{ + Timeout: dialTimeout, + }).Dial, + } + + httpClient := &http.Client{ + Timeout: timeout, + Transport: netTransport, + } + + return httpClient +} + +// NetworkStatus returns the *types.NetworkStatusResponse for +// bitcoind. +func (b *Client) NetworkStatus(ctx context.Context) (*types.NetworkStatusResponse, error) { + rawBlock, err := b.getBlock(ctx, nil) + if err != nil { + return nil, fmt.Errorf("%w: unable to get current block", err) + } + + currentBlock, err := b.parseBlockData(rawBlock) + if err != nil { + return nil, fmt.Errorf("%w: unable to parse current block", err) + } + + peers, err := b.GetPeers(ctx) + if err != nil { + return nil, err + } + + return &types.NetworkStatusResponse{ + CurrentBlockIdentifier: currentBlock.BlockIdentifier, + CurrentBlockTimestamp: currentBlock.Timestamp, + GenesisBlockIdentifier: b.genesisBlockIdentifier, + Peers: peers, + }, nil +} + +// GetPeers fetches the list of peer nodes +func (b *Client) GetPeers(ctx context.Context) ([]*types.Peer, error) { + info, err := b.getPeerInfo(ctx) + if err != nil { + return nil, err + } + + peers := make([]*types.Peer, len(info)) + for i, peerInfo := range info { + metadata, err := types.MarshalMap(peerInfo) + if err != nil { + return nil, fmt.Errorf("%w: unable to marshal peer info", err) + } + + peers[i] = &types.Peer{ + PeerID: peerInfo.Addr, + Metadata: metadata, + } + } + + return peers, nil +} + +// GetRawBlock fetches a block (block) by *types.PartialBlockIdentifier. +func (b *Client) GetRawBlock( + ctx context.Context, + identifier *types.PartialBlockIdentifier, +) (*Block, []string, error) { + block, err := b.getBlock(ctx, identifier) + if err != nil { + return nil, nil, err + } + + coins := []string{} + blockTxHashes := []string{} + for txIndex, tx := range block.Txs { + blockTxHashes = append(blockTxHashes, tx.Hash) + for inputIndex, input := range tx.Inputs { + txHash, vout, ok := b.getInputTxHash(input, txIndex, inputIndex) + if !ok { + continue + } + + // If any transactions spent in the same block they are created, don't include them + // in previousTxHashes to fetch. + if !utils.ContainsString(blockTxHashes, txHash) { + coins = append(coins, CoinIdentifier(txHash, vout)) + } + } + } + + return block, coins, nil +} + +// ParseBlock returns a parsed bitcoin block given a raw bitcoin +// block and a map of transactions containing inputs. +func (b *Client) ParseBlock( + ctx context.Context, + block *Block, + coins map[string]*storage.AccountCoin, +) (*types.Block, error) { + rblock, err := b.parseBlockData(block) + if err != nil { + return nil, err + } + + txs, err := b.parseTransactions(ctx, block, coins) + if err != nil { + return nil, err + } + + rblock.Transactions = txs + + return rblock, nil +} + +// SendRawTransaction submits a serialized transaction +// to bitcoind. +func (b *Client) SendRawTransaction( + ctx context.Context, + serializedTx string, +) (string, error) { + // Parameters: + // 1. hextring + // 2. maxfeerate (0 means accept any fee) + params := []interface{}{serializedTx, 0} + + response := &sendRawTransactionResponse{} + if err := b.post(ctx, requestMethodSendRawTransaction, params, response); err != nil { + return "", fmt.Errorf("%w: error submitting raw transaction", err) + } + + return response.Result, nil +} + +// SuggestedFeeRate estimates the approximate fee per vKB needed +// to get a transaction in a block within conf_target. +func (b *Client) SuggestedFeeRate( + ctx context.Context, + confTarget int64, +) (float64, error) { + // Parameters: + // 1. conf_target (confirmation target in blocks) + params := []interface{}{confTarget} + + response := &suggestedFeeRateResponse{} + if err := b.post(ctx, requestMethodEstimateSmartFee, params, response); err != nil { + return -1, fmt.Errorf("%w: error getting fee estimate", err) + } + + return response.Result.FeeRate, nil +} + +// PruneBlockchain prunes up to the provided height. +// https://bitcoincore.org/en/doc/0.20.0/rpc/blockchain/pruneblockchain +func (b *Client) PruneBlockchain( + ctx context.Context, + height int64, +) (int64, error) { + // Parameters: + // 1. Height + // https://developer.bitcoin.org/reference/rpc/pruneblockchain.html#argument-1-height + params := []interface{}{height} + + response := &pruneBlockchainResponse{} + if err := b.post(ctx, requestMethodPruneBlockchain, params, response); err != nil { + return -1, fmt.Errorf("%w: error pruning blockchain", err) + } + + return response.Result, nil +} + +// getPeerInfo performs the `getpeerinfo` JSON-RPC request +func (b *Client) getPeerInfo( + ctx context.Context, +) ([]*PeerInfo, error) { + params := []interface{}{} + response := &peerInfoResponse{} + if err := b.post(ctx, requestMethodGetPeerInfo, params, response); err != nil { + return nil, fmt.Errorf("%w: error posting to JSON-RPC", err) + } + + return response.Result, nil +} + +// getBlock returns a Block for the specified identifier +func (b *Client) getBlock( + ctx context.Context, + identifier *types.PartialBlockIdentifier, +) (*Block, error) { + hash, err := b.getBlockHash(ctx, identifier) + if err != nil { + return nil, fmt.Errorf("%w: error getting block hash by identifier", err) + } + + // Parameters: + // 1. Block hash (string, required) + // 2. Verbosity (integer, optional, default=1) + // https://bitcoin.org/en/developer-reference#getblock + params := []interface{}{hash, blockVerbosity} + + response := &blockResponse{} + if err := b.post(ctx, requestMethodGetBlock, params, response); err != nil { + return nil, fmt.Errorf("%w: error fetching block by hash %s", err, hash) + } + + return response.Result, nil +} + +// getBlockchainInfo performs the `getblockchaininfo` JSON-RPC request +func (b *Client) getBlockchainInfo( + ctx context.Context, +) (*BlockchainInfo, error) { + params := []interface{}{} + response := &blockchainInfoResponse{} + if err := b.post(ctx, requestMethodGetBlockchainInfo, params, response); err != nil { + return nil, fmt.Errorf("%w: unbale to get blockchain info", err) + } + + return response.Result, nil +} + +// getBlockHash returns the hash for a specified block identifier. +// If the identifier includes a hash it will return that hash. +// If the identifier only includes an index, if will fetch the hash that corresponds to +// that block height from the node. +func (b *Client) getBlockHash( + ctx context.Context, + identifier *types.PartialBlockIdentifier, +) (string, error) { + // Lookup best block if no PartialBlockIdentifier provided. + if identifier == nil || (identifier.Hash == nil && identifier.Index == nil) { + info, err := b.getBlockchainInfo(ctx) + if err != nil { + return "", fmt.Errorf("%w: unable to get blockchain info", err) + } + + return info.BestBlockHash, nil + } + + if identifier.Hash != nil { + return *identifier.Hash, nil + } + + return b.getHashFromIndex(ctx, *identifier.Index) +} + +// parseBlock returns a *types.Block from a Block +func (b *Client) parseBlockData(block *Block) (*types.Block, error) { + if block == nil { + return nil, errors.New("error parsing nil block") + } + + blockIndex := block.Height + previousBlockIndex := blockIndex - 1 + previousBlockHash := block.PreviousBlockHash + + // the genesis block's predecessor is itself + if blockIndex == genesisBlockIndex { + previousBlockIndex = genesisBlockIndex + previousBlockHash = block.Hash + } + + metadata, err := block.Metadata() + if err != nil { + return nil, fmt.Errorf("%w: unable to create block metadata", err) + } + + return &types.Block{ + BlockIdentifier: &types.BlockIdentifier{ + Hash: block.Hash, + Index: blockIndex, + }, + ParentBlockIdentifier: &types.BlockIdentifier{ + Hash: previousBlockHash, + Index: previousBlockIndex, + }, + Timestamp: block.Time * timeMultiplier, + Metadata: metadata, + }, nil +} + +// getHashFromIndex performs the `getblockhash` JSON-RPC request for the specified +// block index, and returns the hash. +// https://bitcoin.org/en/developer-reference#getblockhash +func (b *Client) getHashFromIndex( + ctx context.Context, + index int64, +) (string, error) { + // Parameters: + // 1. Block height (numeric, required) + // https://bitcoin.org/en/developer-reference#getblockhash + params := []interface{}{index} + + response := &blockHashResponse{} + if err := b.post(ctx, requestMethodGetBlockHash, params, response); err != nil { + return "", fmt.Errorf( + "%w: error fetching block hash by index: %d", + err, + index, + ) + } + + return response.Result, nil +} + +// skipTransactionOperations is used to skip operations on transactions that +// contain duplicate UTXOs (which are no longer possible after BIP-30). This +// function mirrors the behavior of a similar commit in bitcoin-core. +// +// Source: https://github.com/bitcoin/bitcoin/commit/ab91bf39b7c11e9c86bb2043c24f0f377f1cf514 +func skipTransactionOperations(blockNumber int64, blockHash string, transactionHash string) bool { + if blockNumber == 91842 && blockHash == "00000000000a4d0a398161ffc163c503763b1f4360639393e0e4c8e300e0caec" && + transactionHash == "d5d27987d2a3dfc724e359870c6644b40e497bdc0589a033220fe15429d88599" { + return true + } + + if blockNumber == 91880 && blockHash == "00000000000743f190a18c5577a3c2d2a1f610ae9601ac046a38084ccb7cd721" && + transactionHash == "e3bf3d07d4b0375638d5f1db5255fe07ba2c4cb067cd81b84ee974b6585fb468" { + return true + } + + return false +} + +// parseTransactions returns the transactions for a specified `Block` +func (b *Client) parseTransactions( + ctx context.Context, + block *Block, + coins map[string]*storage.AccountCoin, +) ([]*types.Transaction, error) { + logger := bitcoinUtils.ExtractLogger(ctx, "client") + + if block == nil { + return nil, errors.New("error parsing nil block") + } + + txs := make([]*types.Transaction, len(block.Txs)) + + for index, transaction := range block.Txs { + txOps, err := b.parseTxOperations(transaction, index, coins) + if err != nil { + return nil, fmt.Errorf("%w: error parsing transaction operations", err) + } + + if skipTransactionOperations(block.Height, block.Hash, transaction.Hash) { + logger.Warnw( + "skipping transaction", + "block index", block.Height, + "block hash", block.Hash, + "transaction hash", transaction.Hash, + ) + + for _, op := range txOps { + op.Status = SkippedStatus + } + } + + metadata, err := transaction.Metadata() + if err != nil { + return nil, fmt.Errorf("%w: unable to get metadata for transaction", err) + } + + tx := &types.Transaction{ + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: transaction.Hash, + }, + Operations: txOps, + Metadata: metadata, + } + + txs[index] = tx + + // In some cases, a transaction will spent an output + // from the same block. + for _, op := range tx.Operations { + if op.CoinChange == nil { + continue + } + + if op.CoinChange.CoinAction != types.CoinCreated { + continue + } + + coins[op.CoinChange.CoinIdentifier.Identifier] = &storage.AccountCoin{ + Coin: &types.Coin{ + CoinIdentifier: op.CoinChange.CoinIdentifier, + Amount: op.Amount, + }, + Account: op.Account, + } + } + } + + return txs, nil +} + +// parseTransactions returns the transaction operations for a specified transaction. +// It uses a map of previous transactions to properly hydrate the input operations. +func (b *Client) parseTxOperations( + tx *Transaction, + txIndex int, + coins map[string]*storage.AccountCoin, +) ([]*types.Operation, error) { + txOps := []*types.Operation{} + + for networkIndex, input := range tx.Inputs { + if bitcoinIsCoinbaseInput(input, txIndex, networkIndex) { + txOp, err := b.coinbaseTxOperation(input, int64(len(txOps)), int64(networkIndex)) + if err != nil { + return nil, err + } + + txOps = append(txOps, txOp) + break + } + + // Fetch the *storage.AccountCoin the input is associated with + accountCoin, ok := coins[CoinIdentifier(input.TxHash, input.Vout)] + if !ok { + return nil, fmt.Errorf( + "error finding previous tx: %s, for tx: %s, input index: %d", + input.TxHash, + tx.Hash, + networkIndex, + ) + } + + // Parse the input transaction operation + txOp, err := b.parseInputTransactionOperation( + input, + int64(len(txOps)), + int64(networkIndex), + accountCoin, + ) + if err != nil { + return nil, fmt.Errorf("%w: error parsing tx input", err) + } + + txOps = append(txOps, txOp) + } + + for networkIndex, output := range tx.Outputs { + txOp, err := b.parseOutputTransactionOperation( + output, + tx.Hash, + int64(len(txOps)), + int64(networkIndex), + ) + if err != nil { + return nil, fmt.Errorf( + "%w: error parsing tx output, hash: %s, index: %d", + err, + tx.Hash, + networkIndex, + ) + } + + txOps = append(txOps, txOp) + } + + return txOps, nil +} + +// parseOutputTransactionOperation returns the types.Operation for the specified +// `bitcoinOutput` transaction output. +func (b *Client) parseOutputTransactionOperation( + output *Output, + txHash string, + index int64, + networkIndex int64, +) (*types.Operation, error) { + amount, err := b.parseAmount(output.Value) + if err != nil { + return nil, fmt.Errorf( + "%w: error parsing output value, hash: %s, index: %d", + err, + txHash, + index, + ) + } + + metadata, err := output.Metadata() + if err != nil { + return nil, fmt.Errorf("%w: unable to get output metadata", err) + } + + coinChange := &types.CoinChange{ + CoinIdentifier: &types.CoinIdentifier{ + Identifier: fmt.Sprintf("%s:%d", txHash, networkIndex), + }, + CoinAction: types.CoinCreated, + } + + // If we are unable to parse the output account (i.e. bitcoind + // returns a blank/nonstandard ScriptPubKey), we create an address as the + // concatenation of the tx hash and index. + // + // Example: 4852fe372ff7534c16713b3146bbc1e86379c70bea4d5c02fb1fa0112980a081:1 + // on testnet + account := b.parseOutputAccount(output.ScriptPubKey) + if len(account.Address) == 0 { + account.Address = fmt.Sprintf("%s:%d", txHash, networkIndex) + } + + // If this is an OP_RETURN locking script, + // we don't create a coin because it is provably unspendable. + if output.ScriptPubKey.Type == NullData { + coinChange = nil + } + + return &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: index, + NetworkIndex: &networkIndex, + }, + Type: OutputOpType, + Status: SuccessStatus, + Account: account, + Amount: &types.Amount{ + Value: strconv.FormatInt(int64(amount), 10), + Currency: b.currency, + }, + CoinChange: coinChange, + Metadata: metadata, + }, nil +} + +// getInputTxHash returns the transaction hash corresponding to an inputs previous +// output. If the input is a coinbase input, then no previous transaction is associated +// with the input. +func (b *Client) getInputTxHash( + input *Input, + txIndex int, + inputIndex int, +) (string, int64, bool) { + if bitcoinIsCoinbaseInput(input, txIndex, inputIndex) { + return "", -1, false + } + + return input.TxHash, input.Vout, true +} + +// bitcoinIsCoinbaseInput returns whether the specified input is +// the coinbase input. The coinbase input is always the first input in the first +// transaction, and does not contain a previous transaction hash. +func bitcoinIsCoinbaseInput(input *Input, txIndex int, inputIndex int) bool { + return txIndex == 0 && inputIndex == 0 && input.TxHash == "" && input.Coinbase != "" +} + +// parseInputTransactionOperation returns the types.Operation for the specified +// Input transaction input. +func (b *Client) parseInputTransactionOperation( + input *Input, + index int64, + networkIndex int64, + accountCoin *storage.AccountCoin, +) (*types.Operation, error) { + metadata, err := input.Metadata() + if err != nil { + return nil, fmt.Errorf("%w: unable to get input metadata", err) + } + + newValue, err := types.NegateValue(accountCoin.Coin.Amount.Value) + if err != nil { + return nil, fmt.Errorf("%w: unable to negate previous output", err) + } + + return &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: index, + NetworkIndex: &networkIndex, + }, + Type: InputOpType, + Status: SuccessStatus, + Account: accountCoin.Account, + Amount: &types.Amount{ + Value: newValue, + Currency: b.currency, + }, + CoinChange: &types.CoinChange{ + CoinIdentifier: &types.CoinIdentifier{ + Identifier: fmt.Sprintf("%s:%d", input.TxHash, input.Vout), + }, + CoinAction: types.CoinSpent, + }, + Metadata: metadata, + }, nil +} + +// parseAmount returns the atomic value of the specified amount. +// https://godoc.org/github.com/btcsuite/btcutil#NewAmount +func (b *Client) parseAmount(amount float64) (uint64, error) { + atomicAmount, err := btcutil.NewAmount(amount) + if err != nil { + return uint64(0), fmt.Errorf("%w: error parsing amount", err) + } + + if atomicAmount < 0 { + return uint64(0), fmt.Errorf("error unexpected negative amount: %d", atomicAmount) + } + + return uint64(atomicAmount), nil +} + +// parseOutputAccount parses a bitcoinScriptPubKey and returns an account +// identifier. The account identifier's address corresponds to the first +// address encoded in the script. +func (b *Client) parseOutputAccount( + scriptPubKey *ScriptPubKey, +) *types.AccountIdentifier { + if len(scriptPubKey.Addresses) != 1 { + return &types.AccountIdentifier{Address: scriptPubKey.Hex} + } + + return &types.AccountIdentifier{Address: scriptPubKey.Addresses[0]} +} + +// coinbaseTxOperation constructs a transaction operation for the coinbase input. +// This reflects an input that does not correspond to a previous output. +func (b *Client) coinbaseTxOperation( + input *Input, + index int64, + networkIndex int64, +) (*types.Operation, error) { + metadata, err := input.Metadata() + if err != nil { + return nil, fmt.Errorf("%w: unable to get input metadata", err) + } + + return &types.Operation{ + OperationIdentifier: &types.OperationIdentifier{ + Index: index, + NetworkIndex: &networkIndex, + }, + Type: CoinbaseOpType, + Status: SuccessStatus, + Metadata: metadata, + }, nil +} + +// post makes a HTTP request to a Bitcoin node +func (b *Client) post( + ctx context.Context, + method requestMethod, + params []interface{}, + response jSONRPCResponse, +) error { + rpcRequest := &request{ + JSONRPC: jSONRPCVersion, + ID: requestID, + Method: string(method), + Params: params, + } + + requestBody, err := json.Marshal(rpcRequest) + if err != nil { + return fmt.Errorf("%w: error marshalling RPC request", err) + } + + req, err := http.NewRequest(http.MethodPost, b.baseURL, bytes.NewReader(requestBody)) + if err != nil { + return fmt.Errorf("%w: error constructing request", err) + } + + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth(rpcUsername, rpcPassword) + + // Perform the post request + res, err := b.httpClient.Do(req.WithContext(ctx)) + if err != nil { + return fmt.Errorf("%w: error posting to rpc-api", err) + } + defer res.Body.Close() + + // We expect JSON-RPC responses to return `200 OK` statuses + if res.StatusCode != http.StatusOK { + val, _ := ioutil.ReadAll(res.Body) + return fmt.Errorf("invalid response: %s %s", res.Status, string(val)) + } + + if err = json.NewDecoder(res.Body).Decode(response); err != nil { + return fmt.Errorf("%w: error decoding response body", err) + } + + // Handle errors that are returned in JSON-RPC responses with `200 OK` statuses + return response.Err() +} diff --git a/bitcoin/client_fixtures/fee_rate.json b/bitcoin/client_fixtures/fee_rate.json new file mode 100644 index 0000000..ea2ca18 --- /dev/null +++ b/bitcoin/client_fixtures/fee_rate.json @@ -0,0 +1,8 @@ +{ + "result": { + "feerate": 0.00001, + "blocks": 2 + }, + "error": null, + "id": "curltest" +} diff --git a/bitcoin/client_fixtures/get_block_hash_out_of_range_response.json b/bitcoin/client_fixtures/get_block_hash_out_of_range_response.json new file mode 100644 index 0000000..880fa9b --- /dev/null +++ b/bitcoin/client_fixtures/get_block_hash_out_of_range_response.json @@ -0,0 +1,8 @@ +{ + "result": null, + "error": { + "code": -8, + "message": "Block height out of range" + }, + "id": "curltext" +} \ No newline at end of file diff --git a/bitcoin/client_fixtures/get_block_hash_response.json b/bitcoin/client_fixtures/get_block_hash_response.json new file mode 100644 index 0000000..2acfbeb --- /dev/null +++ b/bitcoin/client_fixtures/get_block_hash_response.json @@ -0,0 +1,5 @@ +{ + "result": "00000000c937983704a73af28acdec37b049d214adbda81d7e2a3dd146f6ed09", + "error": null, + "id": "curltext" +} diff --git a/bitcoin/client_fixtures/get_block_not_found_response.json b/bitcoin/client_fixtures/get_block_not_found_response.json new file mode 100644 index 0000000..001e2a3 --- /dev/null +++ b/bitcoin/client_fixtures/get_block_not_found_response.json @@ -0,0 +1,8 @@ +{ + "result": null, + "error": { + "code": -5, + "message": "Block not found" + }, + "id": 1 +} \ No newline at end of file diff --git a/bitcoin/client_fixtures/get_block_response.json b/bitcoin/client_fixtures/get_block_response.json new file mode 100644 index 0000000..1acbab4 --- /dev/null +++ b/bitcoin/client_fixtures/get_block_response.json @@ -0,0 +1,88 @@ +{ + "result": { + "hash": "00000000c937983704a73af28acdec37b049d214adbda81d7e2a3dd146f6ed09", + "confirmations": 643039, + "strippedsize": 216, + "size": 216, + "weight": 864, + "height": 1000, + "version": 1, + "versionHex": "00000001", + "merkleroot": "fe28050b93faea61fa88c4c630f0e1f0a1c24d0082dd0e10d369e13212128f33", + "tx": [ + { + "txid": "fe28050b93faea61fa88c4c630f0e1f0a1c24d0082dd0e10d369e13212128f33", + "hash": "fe28050b93faea61fa88c4c630f0e1f0a1c24d0082dd0e10d369e13212128f33", + "version": 1, + "size": 135, + "vsize": 135, + "weight": 540, + "locktime": 0, + "vin": [ + { + "coinbase": "04ffff001d02fd04", + "sequence": 4294967295 + } + ], + "vout": [ + { + "value": 50, + "n": 0, + "scriptPubKey": { + "asm": "04f5eeb2b10c944c6b9fbcfff94c35bdeecd93df977882babc7f3a2cf7f5c81d3b09a68db7f0e04f21de5d4230e75e6dbe7ad16eefe0d4325a62067dc6f369446a OP_CHECKSIG", + "hex": "4104f5eeb2b10c944c6b9fbcfff94c35bdeecd93df977882babc7f3a2cf7f5c81d3b09a68db7f0e04f21de5d4230e75e6dbe7ad16eefe0d4325a62067dc6f369446aac", + "type": "pubkey" + } + } + ], + "hex": "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff0804ffff001d02fd04ffffffff0100f2052a01000000434104f5eeb2b10c944c6b9fbcfff94c35bdeecd93df977882babc7f3a2cf7f5c81d3b09a68db7f0e04f21de5d4230e75e6dbe7ad16eefe0d4325a62067dc6f369446aac00000000" + }, + { + "txid": "4852fe372ff7534c16713b3146bbc1e86379c70bea4d5c02fb1fa0112980a081", + "hash": "4852fe372ff7534c16713b3146bbc1e86379c70bea4d5c02fb1fa0112980a081", + "version": 1, + "size": 1408, + "vsize": 1408, + "weight": 5632, + "locktime": 0, + "vin": [], + "vout": [ + { + "value": 0.0381, + "n": 0, + "scriptPubKey": { + "asm": "OP_DUP OP_HASH160 45db0b779c0b9fa207f12a8218c94fc77aff5045 OP_EQUALVERIFY OP_CHECKSIG", + "hex": "76a91445db0b779c0b9fa207f12a8218c94fc77aff504588ac", + "reqSigs": 1, + "type": "pubkeyhash", + "addresses": [ + "mmtKKnjqTPdkBnBMbNt5Yu2SCwpMaEshEL" + ] + } + }, + { + "value": 0.5, + "n": 1, + "scriptPubKey": { + "asm": "", + "hex": "", + "type": "nonstandard" + } + } + ], + "hex": "01000000081cefd96060ecb1c4fbe675ad8a4f8bdc61d634c52b3a1c4116dee23749fe80ff000000009300493046022100866859c21f306538152e83f115bcfbf59ab4bb34887a88c03483a5dff9895f96022100a6dfd83caa609bf0516debc2bf65c3df91813a4842650a1858b3f61cfa8af249014730440220296d4b818bb037d0f83f9f7111665f49532dfdcbec1e6b784526e9ac4046eaa602204acf3a5cb2695e8404d80bf49ab04828bcbe6fc31d25a2844ced7a8d24afbdff01ffffffff1cefd96060ecb1c4fbe675ad8a4f8bdc61d634c52b3a1c4116dee23749fe80ff020000009400483045022100e87899175991aa008176cb553c6f2badbb5b741f328c9845fcab89f8b18cae2302200acce689896dc82933015e7230e5230d5cff8a1ffe82d334d60162ac2c5b0c9601493046022100994ad29d1e7b03e41731a4316e5f4992f0d9b6e2efc40a1ccd2c949b461175c502210099b69fdc2db00fbba214f16e286f6a49e2d8a0d5ffc6409d87796add475478d601ffffffff1e4a6d2d280ea06680d6cf8788ac90344a9c67cca9b06005bbd6d3f6945c8272010000009500493046022100a27400ba52fd842ce07398a1de102f710a10c5599545e6c95798934352c2e4df022100f6383b0b14c9f64b6718139f55b6b9494374755b86bae7d63f5d3e583b57255a01493046022100fdf543292f34e1eeb1703b264965339ec4a450ec47585009c606b3edbc5b617b022100a5fbb1c8de8aaaa582988cdb23622838e38de90bebcaab3928d949aa502a65d401ffffffff1e4a6d2d280ea06680d6cf8788ac90344a9c67cca9b06005bbd6d3f6945c8272020000009400493046022100ac626ac3051f875145b4fe4cfe089ea895aac73f65ab837b1ac30f5d875874fa022100bc03e79fa4b7eb707fb735b95ff6613ca33adeaf3a0607cdcead4cfd3b51729801483045022100b720b04a5c5e2f61b7df0fcf334ab6fea167b7aaede5695d3f7c6973496adbf1022043328c4cc1cdc3e5db7bb895ccc37133e960b2fd3ece98350f774596badb387201ffffffff23a8733e349c97d6cd90f520fdd084ba15ce0a395aad03cd51370602bb9e5db3010000004a00483045022100e8556b72c5e9c0da7371913a45861a61c5df434dfd962de7b23848e1a28c86ca02205d41ceda00136267281be0974be132ac4cda1459fe2090ce455619d8b91045e901ffffffff6856d609b881e875a5ee141c235e2a82f6b039f2b9babe82333677a5570285a6000000006a473044022040a1c631554b8b210fbdf2a73f191b2851afb51d5171fb53502a3a040a38d2c0022040d11cf6e7b41fe1b66c3d08f6ada1aee07a047cb77f242b8ecc63812c832c9a012102bcfad931b502761e452962a5976c79158a0f6d307ad31b739611dac6a297c256ffffffff6856d609b881e875a5ee141c235e2a82f6b039f2b9babe82333677a5570285a601000000930048304502205b109df098f7e932fbf71a45869c3f80323974a826ee2770789eae178a21bfc8022100c0e75615e53ee4b6e32b9bb5faa36ac539e9c05fa2ae6b6de5d09c08455c8b9601483045022009fb7d27375c47bea23b24818634df6a54ecf72d52e0c1268fb2a2c84f1885de022100e0ed4f15d62e7f537da0d0f1863498f9c7c0c0a4e00e4679588c8d1a9eb20bb801ffffffffa563c3722b7b39481836d5edfc1461f97335d5d1e9a23ade13680d0e2c1c371f030000006c493046022100ecc38ae2b1565643dc3c0dad5e961a5f0ea09cab28d024f92fa05c922924157e022100ebc166edf6fbe4004c72bfe8cf40130263f98ddff728c8e67b113dbd621906a601210211a4ed241174708c07206601b44a4c1c29e5ad8b1f731c50ca7e1d4b2a06dc1fffffffff02d0223a00000000001976a91445db0b779c0b9fa207f12a8218c94fc77aff504588ac80f0fa02000000000000000000" + } + ], + "time": 1232346882, + "mediantime": 1232344831, + "nonce": 2595206198, + "bits": "1d00ffff", + "difficulty": 1, + "chainwork": "000000000000000000000000000000000000000000000000000003e903e903e9", + "nTx": 1, + "previousblockhash": "0000000008e647742775a230787d66fdf92c46a48c896bfbc85cdc8acc67e87d", + "nextblockhash": "00000000a2887344f8db859e372e7e4bc26b23b9de340f725afbf2edb265b4c6" + }, + "error": null, + "id": "curltest" +} diff --git a/bitcoin/client_fixtures/get_block_response_2.json b/bitcoin/client_fixtures/get_block_response_2.json new file mode 100644 index 0000000..8b0fe89 --- /dev/null +++ b/bitcoin/client_fixtures/get_block_response_2.json @@ -0,0 +1,174 @@ +{ + "result": { + "hash": "000000000003ba27aa200b1cecaad478d2b00432346c3f1f3986da1afd33e506", + "confirmations": 544053, + "strippedsize": 957, + "size": 957, + "weight": 3828, + "height": 100000, + "version": 1, + "versionHex": "00000001", + "merkleroot": "f3e94742aca4b5ef85488dc37c06c3282295ffec960994b2c0d5ac2a25a95766", + "tx": [ + { + "txid": "8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87", + "hash": "8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87", + "version": 1, + "size": 135, + "vsize": 135, + "weight": 540, + "locktime": 0, + "vin": [ + { + "coinbase": "044c86041b020602", + "sequence": 4294967295 + } + ], + "vout": [ + { + "value": 15.89351625, + "n": 0, + "scriptPubKey": { + "asm": "OP_HASH160 228f554bbf766d6f9cc828de1126e3d35d15e5fe OP_EQUAL", + "hex": "a914228f554bbf766d6f9cc828de1126e3d35d15e5fe87", + "reqSigs": 1, + "type": "scripthash", + "addresses": [ + "34qkc2iac6RsyxZVfyE2S5U5WcRsbg2dpK" + ] + } + }, + { + "value": 0, + "n": 1, + "scriptPubKey": { + "asm": "OP_RETURN aa21a9ed10109f4b82aa3ed7ec9d02a2a90246478b3308c8b85daf62fe501d58d05727a4", + "hex": "6a24aa21a9ed10109f4b82aa3ed7ec9d02a2a90246478b3308c8b85daf62fe501d58d05727a4", + "type": "nulldata" + } + } + ], + "hex": "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff08044c86041b020602ffffffff0100f2052a010000004341041b0e8c2567c12536aa13357b79a073dc4444acb83c4ec7a0e2f99dd7457516c5817242da796924ca4e99947d087fedf9ce467cb9f7c6287078f801df276fdf84ac00000000" + }, + { + "txid": "fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4", + "hash": "fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4", + "version": 1, + "size": 259, + "vsize": 259, + "weight": 1036, + "locktime": 0, + "vin": [ + { + "txid": "87a157f3fd88ac7907c05fc55e271dc4acdc5605d187d646604ca8c0e9382e03", + "vout": 0, + "scriptSig": { + "asm": "3046022100c352d3dd993a981beba4a63ad15c209275ca9470abfcd57da93b58e4eb5dce82022100840792bc1f456062819f15d33ee7055cf7b5ee1af1ebcc6028d9cdb1c3af7748[ALL] 04f46db5e9d61a9dc27b8d64ad23e7383a4e6ca164593c2527c038c0857eb67ee8e825dca65046b82c9331586c82e0fd1f633f25f87c161bc6f8a630121df2b3d3", + "hex": "493046022100c352d3dd993a981beba4a63ad15c209275ca9470abfcd57da93b58e4eb5dce82022100840792bc1f456062819f15d33ee7055cf7b5ee1af1ebcc6028d9cdb1c3af7748014104f46db5e9d61a9dc27b8d64ad23e7383a4e6ca164593c2527c038c0857eb67ee8e825dca65046b82c9331586c82e0fd1f633f25f87c161bc6f8a630121df2b3d3" + }, + "sequence": 4294967295 + } + ], + "vout": [ + { + "value": 5.56, + "n": 0, + "scriptPubKey": { + "asm": "OP_DUP OP_HASH160 c398efa9c392ba6013c5e04ee729755ef7f58b32 OP_EQUALVERIFY OP_CHECKSIG", + "hex": "76a914c398efa9c392ba6013c5e04ee729755ef7f58b3288ac", + "reqSigs": 1, + "type": "pubkeyhash", + "addresses": [ + "1JqDybm2nWTENrHvMyafbSXXtTk5Uv5QAn" + ] + } + }, + { + "value": 44.44, + "n": 1, + "scriptPubKey": { + "asm": "OP_DUP OP_HASH160 948c765a6914d43f2a7ac177da2c2f6b52de3d7c OP_EQUALVERIFY OP_CHECKSIG", + "hex": "76a914948c765a6914d43f2a7ac177da2c2f6b52de3d7c88ac", + "reqSigs": 1, + "type": "pubkeyhash", + "addresses": [ + "1EYTGtG4LnFfiMvjJdsU7GMGCQvsRSjYhx" + ] + } + } + ], + "hex": "0100000001032e38e9c0a84c6046d687d10556dcacc41d275ec55fc00779ac88fdf357a187000000008c493046022100c352d3dd993a981beba4a63ad15c209275ca9470abfcd57da93b58e4eb5dce82022100840792bc1f456062819f15d33ee7055cf7b5ee1af1ebcc6028d9cdb1c3af7748014104f46db5e9d61a9dc27b8d64ad23e7383a4e6ca164593c2527c038c0857eb67ee8e825dca65046b82c9331586c82e0fd1f633f25f87c161bc6f8a630121df2b3d3ffffffff0200e32321000000001976a914c398efa9c392ba6013c5e04ee729755ef7f58b3288ac000fe208010000001976a914948c765a6914d43f2a7ac177da2c2f6b52de3d7c88ac00000000" + }, + { + "txid": "fake", + "hash": "fake", + "version": 2, + "size": 421, + "vsize": 612, + "weight": 129992, + "locktime": 10, + "vin": [ + { + "txid": "503e4e9824282eb06f1a328484e2b367b5f4f93a405d6e7b97261bafabfb53d5", + "vout": 0, + "scriptSig": { + "asm": "00142b2296c588ec413cebd19c3cbc04ea830ead6e78", + "hex": "1600142b2296c588ec413cebd19c3cbc04ea830ead6e78" + }, + "txinwitness": [ + "304402205f39ccbab38b644acea0776d18cb63ce3e37428cbac06dc23b59c61607aef69102206b8610827e9cb853ea0ba38983662034bd3575cc1ab118fb66d6a98066fa0bed01", + "0304c01563d46e38264283b99bb352b46e69bf132431f102d4bd9a9d8dab075e7f" + ], + "sequence": 4294967295 + }, + { + "txid": "503e4e9824282eb06f1a328484e2b367b5f4f93a405d6e7b97261bafabfb53d5", + "vout": 1, + "scriptSig": { + "asm": "00142b2296c588ec413cebd19c3cbc04ea830ead6e78", + "hex": "1600142b2296c588ec413cebd19c3cbc04ea830ead6e78" + }, + "sequence": 4294967295 + }, + { + "txid": "fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4", + "vout": 0, + "scriptSig": { + "asm": "3046022100c352d3dd993a981beba4a63ad15c209275ca9470abfcd57da93b58e4eb5dce82022100840792bc1f456062819f15d33ee7055cf7b5ee1af1ebcc6028d9cdb1c3af7748[ALL] 04f46db5e9d61a9dc27b8d64ad23e7383a4e6ca164593c2527c038c0857eb67ee8e825dca65046b82c9331586c82e0fd1f633f25f87c161bc6f8a630121df2b3d3", + "hex": "493046022100c352d3dd993a981beba4a63ad15c209275ca9470abfcd57da93b58e4eb5dce82022100840792bc1f456062819f15d33ee7055cf7b5ee1af1ebcc6028d9cdb1c3af7748014104f46db5e9d61a9dc27b8d64ad23e7383a4e6ca164593c2527c038c0857eb67ee8e825dca65046b82c9331586c82e0fd1f633f25f87c161bc6f8a630121df2b3d3" + }, + "sequence": 4294967295 + } + ], + "vout": [ + { + "value": 200.56, + "n": 0, + "scriptPubKey": { + "asm": "OP_DUP OP_HASH160 c398efa9c392ba6013c5e04ee729755ef7f58b32 OP_EQUALVERIFY OP_CHECKSIG", + "hex": "76a914c398efa9c392ba6013c5e04ee729755ef7f58b3288ac", + "reqSigs": 1, + "type": "pubkeyhash", + "addresses": [ + "1JqDybm2nWTENrHvMyafbSXXtTk5Uv5QAn", + "1EYTGtG4LnFfiMvjJdsU7GMGCQvsRSjYhx" + ] + } + } + ], + "hex": "fake hex" + } + ], + "time": 1293623863, + "mediantime": 1293622620, + "nonce": 274148111, + "bits": "1b04864c", + "difficulty": 14484.1623612254, + "chainwork": "0000000000000000000000000000000000000000000000000644cb7f5234089e", + "nTx": 4, + "previousblockhash": "000000000002d01c1fccc21636b607dfd930d31d01c3a62104612a1719011250", + "nextblockhash": "00000000000080b66c911bd5ba14a74260057311eaeb1982802f7010f1a9f090" + }, + "error": null, + "id": "curltest" +} diff --git a/bitcoin/client_fixtures/get_blockchain_info_response.json b/bitcoin/client_fixtures/get_blockchain_info_response.json new file mode 100644 index 0000000..fe138d8 --- /dev/null +++ b/bitcoin/client_fixtures/get_blockchain_info_response.json @@ -0,0 +1,45 @@ +{ + "result": { + "chain": "main", + "blocks": 1000, + "headers": 1000, + "bestblockhash": "00000000c937983704a73af28acdec37b049d214adbda81d7e2a3dd146f6ed09", + "difficulty": 16947802333946.61, + "mediantime": 1597603357, + "verificationprogress": 0.9999978065942465, + "initialblockdownload": false, + "chainwork": "0000000000000000000000000000000000000000127a25606c744d562654d78c", + "size_on_disk": 333786409564, + "pruned": false, + "softforks": { + "bip34": { + "type": "buried", + "active": true, + "height": 227931 + }, + "bip66": { + "type": "buried", + "active": true, + "height": 363725 + }, + "bip65": { + "type": "buried", + "active": true, + "height": 388381 + }, + "csv": { + "type": "buried", + "active": true, + "height": 419328 + }, + "segwit": { + "type": "buried", + "active": true, + "height": 481824 + } + }, + "warnings": "" + }, + "error": null, + "id": "curltest" +} diff --git a/bitcoin/client_fixtures/get_peer_info_response.json b/bitcoin/client_fixtures/get_peer_info_response.json new file mode 100644 index 0000000..71c7d01 --- /dev/null +++ b/bitcoin/client_fixtures/get_peer_info_response.json @@ -0,0 +1,145 @@ +{ + "result": [ + { + "id": 4, + "addr": "77.93.223.9:8333", + "addrlocal": "34.234.104.214:28956", + "addrbind": "172.19.0.2:52608", + "services": "000000000000000d", + "servicesnames": [ + "NETWORK", + "BLOOM", + "WITNESS" + ], + "relaytxes": true, + "lastsend": 1597606676, + "lastrecv": 1597606677, + "bytessent": 29042690, + "bytesrecv": 144225864, + "conntime": 1597353296, + "timeoffset": -2, + "pingtime": 0.100113, + "minping": 0.09692, + "version": 70015, + "subver": "/Satoshi:0.14.2/", + "inbound": false, + "addnode": false, + "startingheight": 643579, + "banscore": 0, + "synced_headers": 644046, + "synced_blocks": 644046, + "inflight": [], + "whitelisted": false, + "permissions": [], + "minfeefilter": 0.00001, + "bytessent_per_msg": { + "addr": 62140, + "feefilter": 32, + "getaddr": 24, + "getblocktxn": 774, + "getdata": 8198251, + "getheaders": 13689, + "headers": 21836, + "inv": 20160356, + "notfound": 6524, + "ping": 67552, + "pong": 67584, + "sendcmpct": 132, + "sendheaders": 24, + "tx": 443622, + "verack": 24, + "version": 126 + }, + "bytesrecv_per_msg": { + "*other*": 231, + "addr": 68292, + "blocktxn": 1070719, + "cmpctblock": 995040, + "feefilter": 32, + "getdata": 22504, + "getheaders": 1053, + "headers": 39150, + "inv": 16723092, + "notfound": 41549, + "ping": 67584, + "pong": 67552, + "sendcmpct": 66, + "sendheaders": 24, + "tx": 125128826, + "verack": 24, + "version": 126 + } + }, + { + "id": 6, + "addr": "172.105.93.179:8333", + "addrlocal": "34.234.104.214:37443", + "addrbind": "172.19.0.2:33074", + "services": "000000000000040d", + "servicesnames": [ + "NETWORK", + "BLOOM", + "WITNESS", + "NETWORK_LIMITED" + ], + "relaytxes": true, + "lastsend": 1597606678, + "lastrecv": 1597606676, + "bytessent": 33137375, + "bytesrecv": 98074000, + "conntime": 1597353319, + "timeoffset": 0, + "pingtime": 0.091882, + "minping": 0.091026, + "version": 70015, + "subver": "/Satoshi:0.18.1/", + "inbound": false, + "addnode": false, + "startingheight": 643579, + "banscore": 0, + "synced_headers": 644046, + "synced_blocks": 644046, + "inflight": [], + "whitelisted": false, + "permissions": [], + "minfeefilter": 0.00001, + "bytessent_per_msg": { + "addr": 61685, + "feefilter": 32, + "getaddr": 24, + "getdata": 5604084, + "getheaders": 1053, + "headers": 26606, + "inv": 22199034, + "notfound": 14167, + "ping": 67552, + "pong": 67552, + "sendcmpct": 132, + "sendheaders": 24, + "tx": 5095280, + "verack": 24, + "version": 126 + }, + "bytesrecv_per_msg": { + "*other*": 1401, + "addr": 76492, + "cmpctblock": 655743, + "feefilter": 32, + "getdata": 392811, + "getheaders": 1053, + "headers": 37679, + "inv": 14752875, + "notfound": 18254, + "ping": 67552, + "pong": 67552, + "sendcmpct": 66, + "sendheaders": 24, + "tx": 82002316, + "verack": 24, + "version": 126 + } + } + ], + "error": null, + "id": "curltest" +} diff --git a/bitcoin/client_fixtures/invalid_fee_rate.json b/bitcoin/client_fixtures/invalid_fee_rate.json new file mode 100644 index 0000000..a7bb198 --- /dev/null +++ b/bitcoin/client_fixtures/invalid_fee_rate.json @@ -0,0 +1,8 @@ +{ + "result": null, + "error": { + "code": -8, + "message": "Invalid conf_target, must be between 1 - 1008" + }, + "id": "curltest" +} diff --git a/bitcoin/client_fixtures/rpc_in_warmup_response.json b/bitcoin/client_fixtures/rpc_in_warmup_response.json new file mode 100644 index 0000000..da91c04 --- /dev/null +++ b/bitcoin/client_fixtures/rpc_in_warmup_response.json @@ -0,0 +1,8 @@ +{ + "result": null, + "error": { + "code": -28, + "message": "rpc in warmup" + }, + "id": 1 +} diff --git a/bitcoin/client_test.go b/bitcoin/client_test.go new file mode 100644 index 0000000..b2f8278 --- /dev/null +++ b/bitcoin/client_test.go @@ -0,0 +1,1259 @@ +// 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 ( + "context" + "errors" + "fmt" + "io/ioutil" + "log" + "net/http" + "net/http/httptest" + "testing" + + "github.com/coinbase/rosetta-sdk-go/storage" + "github.com/coinbase/rosetta-sdk-go/types" + "github.com/stretchr/testify/assert" +) + +const ( + url = "/" +) + +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 +} + +var ( + blockIdentifier1000 = &types.BlockIdentifier{ + Hash: "00000000c937983704a73af28acdec37b049d214adbda81d7e2a3dd146f6ed09", + Index: 1000, + } + + block1000 = &Block{ + Hash: "00000000c937983704a73af28acdec37b049d214adbda81d7e2a3dd146f6ed09", + Height: 1000, + PreviousBlockHash: "0000000008e647742775a230787d66fdf92c46a48c896bfbc85cdc8acc67e87d", + Time: 1232346882, + Size: 216, + Weight: 864, + Version: 1, + MerkleRoot: "fe28050b93faea61fa88c4c630f0e1f0a1c24d0082dd0e10d369e13212128f33", + MedianTime: 1232344831, + Nonce: 2595206198, + Bits: "1d00ffff", + Difficulty: 1, + Txs: []*Transaction{ + { + Hex: "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff0804ffff001d02fd04ffffffff0100f2052a01000000434104f5eeb2b10c944c6b9fbcfff94c35bdeecd93df977882babc7f3a2cf7f5c81d3b09a68db7f0e04f21de5d4230e75e6dbe7ad16eefe0d4325a62067dc6f369446aac00000000", // nolint + Hash: "fe28050b93faea61fa88c4c630f0e1f0a1c24d0082dd0e10d369e13212128f33", + Size: 135, + Vsize: 135, + Version: 1, + Locktime: 0, + Weight: 540, + Inputs: []*Input{ + { + Coinbase: "04ffff001d02fd04", + Sequence: 4294967295, + }, + }, + Outputs: []*Output{ + { + Value: 50, + Index: 0, + ScriptPubKey: &ScriptPubKey{ + ASM: "04f5eeb2b10c944c6b9fbcfff94c35bdeecd93df977882babc7f3a2cf7f5c81d3b09a68db7f0e04f21de5d4230e75e6dbe7ad16eefe0d4325a62067dc6f369446a OP_CHECKSIG", // nolint + Hex: "4104f5eeb2b10c944c6b9fbcfff94c35bdeecd93df977882babc7f3a2cf7f5c81d3b09a68db7f0e04f21de5d4230e75e6dbe7ad16eefe0d4325a62067dc6f369446aac", // nolint + Type: "pubkey", + }, + }, + }, + }, + { + Hex: "01000000081cefd96060ecb1c4fbe675ad8a4f8bdc61d634c52b3a1c4116dee23749fe80ff000000009300493046022100866859c21f306538152e83f115bcfbf59ab4bb34887a88c03483a5dff9895f96022100a6dfd83caa609bf0516debc2bf65c3df91813a4842650a1858b3f61cfa8af249014730440220296d4b818bb037d0f83f9f7111665f49532dfdcbec1e6b784526e9ac4046eaa602204acf3a5cb2695e8404d80bf49ab04828bcbe6fc31d25a2844ced7a8d24afbdff01ffffffff1cefd96060ecb1c4fbe675ad8a4f8bdc61d634c52b3a1c4116dee23749fe80ff020000009400483045022100e87899175991aa008176cb553c6f2badbb5b741f328c9845fcab89f8b18cae2302200acce689896dc82933015e7230e5230d5cff8a1ffe82d334d60162ac2c5b0c9601493046022100994ad29d1e7b03e41731a4316e5f4992f0d9b6e2efc40a1ccd2c949b461175c502210099b69fdc2db00fbba214f16e286f6a49e2d8a0d5ffc6409d87796add475478d601ffffffff1e4a6d2d280ea06680d6cf8788ac90344a9c67cca9b06005bbd6d3f6945c8272010000009500493046022100a27400ba52fd842ce07398a1de102f710a10c5599545e6c95798934352c2e4df022100f6383b0b14c9f64b6718139f55b6b9494374755b86bae7d63f5d3e583b57255a01493046022100fdf543292f34e1eeb1703b264965339ec4a450ec47585009c606b3edbc5b617b022100a5fbb1c8de8aaaa582988cdb23622838e38de90bebcaab3928d949aa502a65d401ffffffff1e4a6d2d280ea06680d6cf8788ac90344a9c67cca9b06005bbd6d3f6945c8272020000009400493046022100ac626ac3051f875145b4fe4cfe089ea895aac73f65ab837b1ac30f5d875874fa022100bc03e79fa4b7eb707fb735b95ff6613ca33adeaf3a0607cdcead4cfd3b51729801483045022100b720b04a5c5e2f61b7df0fcf334ab6fea167b7aaede5695d3f7c6973496adbf1022043328c4cc1cdc3e5db7bb895ccc37133e960b2fd3ece98350f774596badb387201ffffffff23a8733e349c97d6cd90f520fdd084ba15ce0a395aad03cd51370602bb9e5db3010000004a00483045022100e8556b72c5e9c0da7371913a45861a61c5df434dfd962de7b23848e1a28c86ca02205d41ceda00136267281be0974be132ac4cda1459fe2090ce455619d8b91045e901ffffffff6856d609b881e875a5ee141c235e2a82f6b039f2b9babe82333677a5570285a6000000006a473044022040a1c631554b8b210fbdf2a73f191b2851afb51d5171fb53502a3a040a38d2c0022040d11cf6e7b41fe1b66c3d08f6ada1aee07a047cb77f242b8ecc63812c832c9a012102bcfad931b502761e452962a5976c79158a0f6d307ad31b739611dac6a297c256ffffffff6856d609b881e875a5ee141c235e2a82f6b039f2b9babe82333677a5570285a601000000930048304502205b109df098f7e932fbf71a45869c3f80323974a826ee2770789eae178a21bfc8022100c0e75615e53ee4b6e32b9bb5faa36ac539e9c05fa2ae6b6de5d09c08455c8b9601483045022009fb7d27375c47bea23b24818634df6a54ecf72d52e0c1268fb2a2c84f1885de022100e0ed4f15d62e7f537da0d0f1863498f9c7c0c0a4e00e4679588c8d1a9eb20bb801ffffffffa563c3722b7b39481836d5edfc1461f97335d5d1e9a23ade13680d0e2c1c371f030000006c493046022100ecc38ae2b1565643dc3c0dad5e961a5f0ea09cab28d024f92fa05c922924157e022100ebc166edf6fbe4004c72bfe8cf40130263f98ddff728c8e67b113dbd621906a601210211a4ed241174708c07206601b44a4c1c29e5ad8b1f731c50ca7e1d4b2a06dc1fffffffff02d0223a00000000001976a91445db0b779c0b9fa207f12a8218c94fc77aff504588ac80f0fa02000000000000000000", // nolint + Hash: "4852fe372ff7534c16713b3146bbc1e86379c70bea4d5c02fb1fa0112980a081", + Size: 1408, + Vsize: 1408, + Version: 1, + Locktime: 0, + Weight: 5632, + Inputs: []*Input{}, // all we care about in this test is the outputs + Outputs: []*Output{ + { + Value: 0.0381, + Index: 0, + ScriptPubKey: &ScriptPubKey{ + ASM: "OP_DUP OP_HASH160 45db0b779c0b9fa207f12a8218c94fc77aff5045 OP_EQUALVERIFY OP_CHECKSIG", + Hex: "76a91445db0b779c0b9fa207f12a8218c94fc77aff504588ac", + RequiredSigs: 1, + Type: "pubkeyhash", + Addresses: []string{ + "mmtKKnjqTPdkBnBMbNt5Yu2SCwpMaEshEL", + }, + }, + }, + { + Value: 0.5, + Index: 1, + ScriptPubKey: &ScriptPubKey{ + ASM: "", + Hex: "", + Type: "nonstandard", + }, + }, + }, + }, + }, + } + + blockIdentifier100000 = &types.BlockIdentifier{ + Hash: "000000000003ba27aa200b1cecaad478d2b00432346c3f1f3986da1afd33e506", + Index: 100000, + } + + block100000 = &Block{ + Hash: "000000000003ba27aa200b1cecaad478d2b00432346c3f1f3986da1afd33e506", + Height: 100000, + PreviousBlockHash: "000000000002d01c1fccc21636b607dfd930d31d01c3a62104612a1719011250", + Time: 1293623863, + Size: 957, + Weight: 3828, + Version: 1, + MerkleRoot: "f3e94742aca4b5ef85488dc37c06c3282295ffec960994b2c0d5ac2a25a95766", + MedianTime: 1293622620, + Nonce: 274148111, + Bits: "1b04864c", + Difficulty: 14484.1623612254, + Txs: []*Transaction{ + { + Hex: "01000000010000000000000000000000000000000000000000000000000000000000000000ffffffff08044c86041b020602ffffffff0100f2052a010000004341041b0e8c2567c12536aa13357b79a073dc4444acb83c4ec7a0e2f99dd7457516c5817242da796924ca4e99947d087fedf9ce467cb9f7c6287078f801df276fdf84ac00000000", // nolint + Hash: "8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87", + Size: 135, + Vsize: 135, + Version: 1, + Locktime: 0, + Weight: 540, + Inputs: []*Input{ + { + Coinbase: "044c86041b020602", + Sequence: 4294967295, + }, + }, + Outputs: []*Output{ + { + Value: 15.89351625, + Index: 0, + ScriptPubKey: &ScriptPubKey{ + ASM: "OP_HASH160 228f554bbf766d6f9cc828de1126e3d35d15e5fe OP_EQUAL", + Hex: "a914228f554bbf766d6f9cc828de1126e3d35d15e5fe87", + RequiredSigs: 1, + Type: "scripthash", + Addresses: []string{ + "34qkc2iac6RsyxZVfyE2S5U5WcRsbg2dpK", + }, + }, + }, + { + Value: 0, + Index: 1, + ScriptPubKey: &ScriptPubKey{ + ASM: "OP_RETURN aa21a9ed10109f4b82aa3ed7ec9d02a2a90246478b3308c8b85daf62fe501d58d05727a4", + Hex: "6a24aa21a9ed10109f4b82aa3ed7ec9d02a2a90246478b3308c8b85daf62fe501d58d05727a4", + Type: "nulldata", + }, + }, + }, + }, + { + Hex: "0100000001032e38e9c0a84c6046d687d10556dcacc41d275ec55fc00779ac88fdf357a187000000008c493046022100c352d3dd993a981beba4a63ad15c209275ca9470abfcd57da93b58e4eb5dce82022100840792bc1f456062819f15d33ee7055cf7b5ee1af1ebcc6028d9cdb1c3af7748014104f46db5e9d61a9dc27b8d64ad23e7383a4e6ca164593c2527c038c0857eb67ee8e825dca65046b82c9331586c82e0fd1f633f25f87c161bc6f8a630121df2b3d3ffffffff0200e32321000000001976a914c398efa9c392ba6013c5e04ee729755ef7f58b3288ac000fe208010000001976a914948c765a6914d43f2a7ac177da2c2f6b52de3d7c88ac00000000", // nolint + Hash: "fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4", + Size: 259, + Vsize: 259, + Version: 1, + Locktime: 0, + Weight: 1036, + Inputs: []*Input{ + { + TxHash: "87a157f3fd88ac7907c05fc55e271dc4acdc5605d187d646604ca8c0e9382e03", + Vout: 0, + ScriptSig: &ScriptSig{ + ASM: "3046022100c352d3dd993a981beba4a63ad15c209275ca9470abfcd57da93b58e4eb5dce82022100840792bc1f456062819f15d33ee7055cf7b5ee1af1ebcc6028d9cdb1c3af7748[ALL] 04f46db5e9d61a9dc27b8d64ad23e7383a4e6ca164593c2527c038c0857eb67ee8e825dca65046b82c9331586c82e0fd1f633f25f87c161bc6f8a630121df2b3d3", // nolint + Hex: "493046022100c352d3dd993a981beba4a63ad15c209275ca9470abfcd57da93b58e4eb5dce82022100840792bc1f456062819f15d33ee7055cf7b5ee1af1ebcc6028d9cdb1c3af7748014104f46db5e9d61a9dc27b8d64ad23e7383a4e6ca164593c2527c038c0857eb67ee8e825dca65046b82c9331586c82e0fd1f633f25f87c161bc6f8a630121df2b3d3", // nolint + }, + Sequence: 4294967295, + }, + }, + Outputs: []*Output{ + { + Value: 5.56, + Index: 0, + ScriptPubKey: &ScriptPubKey{ + ASM: "OP_DUP OP_HASH160 c398efa9c392ba6013c5e04ee729755ef7f58b32 OP_EQUALVERIFY OP_CHECKSIG", + Hex: "76a914c398efa9c392ba6013c5e04ee729755ef7f58b3288ac", + RequiredSigs: 1, + Type: "pubkeyhash", + Addresses: []string{ + "1JqDybm2nWTENrHvMyafbSXXtTk5Uv5QAn", + }, + }, + }, + { + Value: 44.44, + Index: 1, + ScriptPubKey: &ScriptPubKey{ + ASM: "OP_DUP OP_HASH160 948c765a6914d43f2a7ac177da2c2f6b52de3d7c OP_EQUALVERIFY OP_CHECKSIG", + Hex: "76a914948c765a6914d43f2a7ac177da2c2f6b52de3d7c88ac", + RequiredSigs: 1, + Type: "pubkeyhash", + Addresses: []string{ + "1EYTGtG4LnFfiMvjJdsU7GMGCQvsRSjYhx", + }, + }, + }, + }, + }, + { + Hash: "fake", + Hex: "fake hex", + Version: 2, + Size: 421, + Vsize: 612, + Weight: 129992, + Locktime: 10, + Inputs: []*Input{ + { + TxHash: "503e4e9824282eb06f1a328484e2b367b5f4f93a405d6e7b97261bafabfb53d5", + Vout: 0, + ScriptSig: &ScriptSig{ + ASM: "00142b2296c588ec413cebd19c3cbc04ea830ead6e78", + Hex: "1600142b2296c588ec413cebd19c3cbc04ea830ead6e78", + }, + TxInWitness: []string{ + "304402205f39ccbab38b644acea0776d18cb63ce3e37428cbac06dc23b59c61607aef69102206b8610827e9cb853ea0ba38983662034bd3575cc1ab118fb66d6a98066fa0bed01", // nolint + "0304c01563d46e38264283b99bb352b46e69bf132431f102d4bd9a9d8dab075e7f", + }, + Sequence: 4294967295, + }, + { + TxHash: "503e4e9824282eb06f1a328484e2b367b5f4f93a405d6e7b97261bafabfb53d5", + Vout: 1, + ScriptSig: &ScriptSig{ + ASM: "00142b2296c588ec413cebd19c3cbc04ea830ead6e78", + Hex: "1600142b2296c588ec413cebd19c3cbc04ea830ead6e78", + }, + Sequence: 4294967295, + }, + { + TxHash: "fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4", + Vout: 0, + ScriptSig: &ScriptSig{ + ASM: "3046022100c352d3dd993a981beba4a63ad15c209275ca9470abfcd57da93b58e4eb5dce82022100840792bc1f456062819f15d33ee7055cf7b5ee1af1ebcc6028d9cdb1c3af7748[ALL] 04f46db5e9d61a9dc27b8d64ad23e7383a4e6ca164593c2527c038c0857eb67ee8e825dca65046b82c9331586c82e0fd1f633f25f87c161bc6f8a630121df2b3d3", // nolint + Hex: "493046022100c352d3dd993a981beba4a63ad15c209275ca9470abfcd57da93b58e4eb5dce82022100840792bc1f456062819f15d33ee7055cf7b5ee1af1ebcc6028d9cdb1c3af7748014104f46db5e9d61a9dc27b8d64ad23e7383a4e6ca164593c2527c038c0857eb67ee8e825dca65046b82c9331586c82e0fd1f633f25f87c161bc6f8a630121df2b3d3", // nolint + }, + Sequence: 4294967295, + }, + }, + Outputs: []*Output{ + { + Value: 200.56, + Index: 0, + ScriptPubKey: &ScriptPubKey{ + ASM: "OP_DUP OP_HASH160 c398efa9c392ba6013c5e04ee729755ef7f58b32 OP_EQUALVERIFY OP_CHECKSIG", + Hex: "76a914c398efa9c392ba6013c5e04ee729755ef7f58b3288ac", + RequiredSigs: 1, + Type: "pubkeyhash", + Addresses: []string{ + "1JqDybm2nWTENrHvMyafbSXXtTk5Uv5QAn", + "1EYTGtG4LnFfiMvjJdsU7GMGCQvsRSjYhx", + }, + }, + }, + }, + }, + }, + } +) + +func TestNetworkStatus(t *testing.T) { + tests := map[string]struct { + responses []responseFixture + + expectedStatus *types.NetworkStatusResponse + expectedError error + }{ + "successful": { + responses: []responseFixture{ + { + status: http.StatusOK, + body: loadFixture("get_blockchain_info_response.json"), + url: url, + }, + { + status: http.StatusOK, + body: loadFixture("get_block_response.json"), + url: url, + }, + { + status: http.StatusOK, + body: loadFixture("get_peer_info_response.json"), + url: url, + }, + }, + expectedStatus: &types.NetworkStatusResponse{ + CurrentBlockIdentifier: blockIdentifier1000, + CurrentBlockTimestamp: block1000.Time * 1000, + GenesisBlockIdentifier: MainnetGenesisBlockIdentifier, + Peers: []*types.Peer{ + { + PeerID: "77.93.223.9:8333", + Metadata: forceMarshalMap(t, &PeerInfo{ + Addr: "77.93.223.9:8333", + Version: 70015, + SubVer: "/Satoshi:0.14.2/", + StartingHeight: 643579, + RelayTxes: true, + LastSend: 1597606676, + LastRecv: 1597606677, + BanScore: 0, + SyncedHeaders: 644046, + SyncedBlocks: 644046, + }), + }, + { + PeerID: "172.105.93.179:8333", + Metadata: forceMarshalMap(t, &PeerInfo{ + Addr: "172.105.93.179:8333", + RelayTxes: true, + LastSend: 1597606678, + LastRecv: 1597606676, + Version: 70015, + SubVer: "/Satoshi:0.18.1/", + StartingHeight: 643579, + BanScore: 0, + SyncedHeaders: 644046, + SyncedBlocks: 644046, + }), + }, + }, + }, + }, + "blockchain warming up error": { + responses: []responseFixture{ + { + status: http.StatusOK, + body: loadFixture("rpc_in_warmup_response.json"), + url: url, + }, + }, + expectedError: errors.New("rpc in warmup"), + }, + "blockchain info error": { + responses: []responseFixture{ + { + status: http.StatusInternalServerError, + body: "{}", + url: url, + }, + }, + expectedError: errors.New("invalid response: 500 Internal Server Error"), + }, + "peer info not accessible": { + responses: []responseFixture{ + { + status: http.StatusOK, + body: loadFixture("get_blockchain_info_response.json"), + url: url, + }, + { + status: http.StatusOK, + body: loadFixture("get_block_response.json"), + url: url, + }, + { + status: http.StatusInternalServerError, + body: "{}", + url: url, + }, + }, + expectedError: errors.New("invalid response: 500 Internal Server Error"), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var ( + assert = assert.New(t) + ) + + responses := make(chan responseFixture, len(test.responses)) + for _, response := range test.responses { + responses <- response + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := <-responses + assert.Equal("application/json", r.Header.Get("Content-Type")) + assert.Equal("POST", r.Method) + assert.Equal(response.url, r.URL.RequestURI()) + + w.WriteHeader(response.status) + fmt.Fprintln(w, response.body) + })) + + client := NewClient(ts.URL, MainnetGenesisBlockIdentifier, MainnetCurrency) + status, err := client.NetworkStatus(context.Background()) + if test.expectedError != nil { + assert.Contains(err.Error(), test.expectedError.Error()) + } else { + assert.NoError(err) + assert.Equal(test.expectedStatus, status) + } + }) + } +} + +func TestGetRawBlock(t *testing.T) { + tests := map[string]struct { + blockIdentifier *types.PartialBlockIdentifier + responses []responseFixture + + expectedBlock *Block + expectedCoins []string + expectedError error + }{ + "lookup by hash": { + blockIdentifier: &types.PartialBlockIdentifier{ + Hash: &blockIdentifier1000.Hash, + }, + responses: []responseFixture{ + { + status: http.StatusOK, + body: loadFixture("get_block_response.json"), + url: url, + }, + }, + expectedBlock: block1000, + expectedCoins: []string{}, + }, + "lookup by hash 2": { + blockIdentifier: &types.PartialBlockIdentifier{ + Hash: &blockIdentifier100000.Hash, + }, + responses: []responseFixture{ + { + status: http.StatusOK, + body: loadFixture("get_block_response_2.json"), + url: url, + }, + }, + expectedBlock: block100000, + expectedCoins: []string{ + "87a157f3fd88ac7907c05fc55e271dc4acdc5605d187d646604ca8c0e9382e03:0", + "503e4e9824282eb06f1a328484e2b367b5f4f93a405d6e7b97261bafabfb53d5:0", + "503e4e9824282eb06f1a328484e2b367b5f4f93a405d6e7b97261bafabfb53d5:1", + }, + }, + "lookup by hash (get block api error)": { + blockIdentifier: &types.PartialBlockIdentifier{ + Hash: &blockIdentifier1000.Hash, + }, + responses: []responseFixture{ + { + status: http.StatusOK, + body: loadFixture("get_block_not_found_response.json"), + url: url, + }, + }, + expectedError: ErrBlockNotFound, + }, + "lookup by hash (get block internal error)": { + blockIdentifier: &types.PartialBlockIdentifier{ + Hash: &blockIdentifier1000.Hash, + }, + responses: []responseFixture{ + { + status: http.StatusInternalServerError, + body: "{}", + url: url, + }, + }, + expectedBlock: nil, + expectedError: errors.New("invalid response: 500 Internal Server Error"), + }, + "lookup by index": { + blockIdentifier: &types.PartialBlockIdentifier{ + Index: &blockIdentifier1000.Index, + }, + responses: []responseFixture{ + { + status: http.StatusOK, + body: loadFixture("get_block_hash_response.json"), + url: url, + }, + { + status: http.StatusOK, + body: loadFixture("get_block_response.json"), + url: url, + }, + }, + expectedBlock: block1000, + expectedCoins: []string{}, + }, + "lookup by index (out of range)": { + blockIdentifier: &types.PartialBlockIdentifier{ + Index: &blockIdentifier1000.Index, + }, + responses: []responseFixture{ + { + status: http.StatusOK, + body: loadFixture("get_block_hash_out_of_range_response.json"), + url: url, + }, + }, + expectedError: errors.New("height out of range"), + }, + "current block lookup": { + responses: []responseFixture{ + { + status: http.StatusOK, + body: loadFixture("get_blockchain_info_response.json"), + url: url, + }, + { + status: http.StatusOK, + body: loadFixture("get_block_response.json"), + url: url, + }, + }, + expectedBlock: block1000, + expectedCoins: []string{}, + }, + "current block lookup (can't get current info)": { + responses: []responseFixture{ + { + status: http.StatusOK, + body: loadFixture("rpc_in_warmup_response.json"), + url: url, + }, + }, + expectedError: errors.New("unable to get blockchain info"), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var ( + assert = assert.New(t) + ) + + responses := make(chan responseFixture, len(test.responses)) + for _, response := range test.responses { + responses <- response + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := <-responses + assert.Equal("application/json", r.Header.Get("Content-Type")) + assert.Equal("POST", r.Method) + assert.Equal(response.url, r.URL.RequestURI()) + + w.WriteHeader(response.status) + fmt.Fprintln(w, response.body) + })) + + client := NewClient(ts.URL, MainnetGenesisBlockIdentifier, MainnetCurrency) + block, coins, err := client.GetRawBlock(context.Background(), test.blockIdentifier) + if test.expectedError != nil { + assert.Contains(err.Error(), test.expectedError.Error()) + } else { + assert.NoError(err) + assert.Equal(test.expectedBlock, block) + assert.Equal(test.expectedCoins, coins) + } + }) + } +} + +func int64Pointer(v int64) *int64 { + return &v +} + +func mustMarshalMap(v interface{}) map[string]interface{} { + m, _ := types.MarshalMap(v) + return m +} + +func TestParseBlock(t *testing.T) { + tests := map[string]struct { + block *Block + coins map[string]*storage.AccountCoin + + expectedBlock *types.Block + expectedError error + }{ + "no fetched transactions": { + block: block1000, + coins: map[string]*storage.AccountCoin{}, + expectedBlock: &types.Block{ + BlockIdentifier: blockIdentifier1000, + ParentBlockIdentifier: &types.BlockIdentifier{ + Hash: "0000000008e647742775a230787d66fdf92c46a48c896bfbc85cdc8acc67e87d", + Index: 999, + }, + Timestamp: 1232346882000, + Transactions: []*types.Transaction{ + { + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: "fe28050b93faea61fa88c4c630f0e1f0a1c24d0082dd0e10d369e13212128f33", + }, + Operations: []*types.Operation{ + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + NetworkIndex: int64Pointer(0), + }, + Type: CoinbaseOpType, + Status: SuccessStatus, + Metadata: mustMarshalMap(&OperationMetadata{ + Coinbase: "04ffff001d02fd04", + Sequence: 4294967295, + }), + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + NetworkIndex: int64Pointer(0), + }, + Type: OutputOpType, + Status: SuccessStatus, + Account: &types.AccountIdentifier{ + Address: "4104f5eeb2b10c944c6b9fbcfff94c35bdeecd93df977882babc7f3a2cf7f5c81d3b09a68db7f0e04f21de5d4230e75e6dbe7ad16eefe0d4325a62067dc6f369446aac", // nolint + }, + Amount: &types.Amount{ + Value: "5000000000", + Currency: MainnetCurrency, + }, + CoinChange: &types.CoinChange{ + CoinAction: types.CoinCreated, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "fe28050b93faea61fa88c4c630f0e1f0a1c24d0082dd0e10d369e13212128f33:0", + }, + }, + Metadata: mustMarshalMap(&OperationMetadata{ + ScriptPubKey: &ScriptPubKey{ + ASM: "04f5eeb2b10c944c6b9fbcfff94c35bdeecd93df977882babc7f3a2cf7f5c81d3b09a68db7f0e04f21de5d4230e75e6dbe7ad16eefe0d4325a62067dc6f369446a OP_CHECKSIG", // nolint + Hex: "4104f5eeb2b10c944c6b9fbcfff94c35bdeecd93df977882babc7f3a2cf7f5c81d3b09a68db7f0e04f21de5d4230e75e6dbe7ad16eefe0d4325a62067dc6f369446aac", // nolint + Type: "pubkey", + }, + }), + }, + }, + Metadata: mustMarshalMap(&TransactionMetadata{ + Size: 135, + Version: 1, + Vsize: 135, + Weight: 540, + }), + }, + { + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: "4852fe372ff7534c16713b3146bbc1e86379c70bea4d5c02fb1fa0112980a081", + }, + Operations: []*types.Operation{ + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + NetworkIndex: int64Pointer(0), + }, + Type: OutputOpType, + Status: SuccessStatus, + Account: &types.AccountIdentifier{ + Address: "mmtKKnjqTPdkBnBMbNt5Yu2SCwpMaEshEL", // nolint + }, + Amount: &types.Amount{ + Value: "3810000", + Currency: MainnetCurrency, + }, + CoinChange: &types.CoinChange{ + CoinAction: types.CoinCreated, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "4852fe372ff7534c16713b3146bbc1e86379c70bea4d5c02fb1fa0112980a081:0", + }, + }, + Metadata: mustMarshalMap(&OperationMetadata{ + ScriptPubKey: &ScriptPubKey{ + ASM: "OP_DUP OP_HASH160 45db0b779c0b9fa207f12a8218c94fc77aff5045 OP_EQUALVERIFY OP_CHECKSIG", // nolint + Hex: "76a91445db0b779c0b9fa207f12a8218c94fc77aff504588ac", // nolint + Type: "pubkeyhash", + RequiredSigs: 1, + Addresses: []string{ + "mmtKKnjqTPdkBnBMbNt5Yu2SCwpMaEshEL", + }, + }, + }), + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + NetworkIndex: int64Pointer(1), + }, + Type: OutputOpType, + Status: SuccessStatus, + Account: &types.AccountIdentifier{ + Address: "4852fe372ff7534c16713b3146bbc1e86379c70bea4d5c02fb1fa0112980a081:1", + }, + Amount: &types.Amount{ + Value: "50000000", + Currency: MainnetCurrency, + }, + CoinChange: &types.CoinChange{ + CoinAction: types.CoinCreated, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "4852fe372ff7534c16713b3146bbc1e86379c70bea4d5c02fb1fa0112980a081:1", + }, + }, + Metadata: mustMarshalMap(&OperationMetadata{ + ScriptPubKey: &ScriptPubKey{ + ASM: "", + Hex: "", + Type: "nonstandard", + }, + }), + }, + }, + Metadata: mustMarshalMap(&TransactionMetadata{ + Size: 1408, + Version: 1, + Vsize: 1408, + Weight: 5632, + }), + }, + }, + Metadata: mustMarshalMap(&BlockMetadata{ + Size: 216, + Weight: 864, + Version: 1, + MerkleRoot: "fe28050b93faea61fa88c4c630f0e1f0a1c24d0082dd0e10d369e13212128f33", + MedianTime: 1232344831, + Nonce: 2595206198, + Bits: "1d00ffff", + Difficulty: 1, + }), + }, + }, + "block 100000": { + block: block100000, + coins: map[string]*storage.AccountCoin{ + "87a157f3fd88ac7907c05fc55e271dc4acdc5605d187d646604ca8c0e9382e03:0": { + Account: &types.AccountIdentifier{ + Address: "1BNwxHGaFbeUBitpjy2AsKpJ29Ybxntqvb", + }, + Coin: &types.Coin{ + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "87a157f3fd88ac7907c05fc55e271dc4acdc5605d187d646604ca8c0e9382e03:0", + }, + Amount: &types.Amount{ + Value: "5000000000", + Currency: MainnetCurrency, + }, + }, + }, + "503e4e9824282eb06f1a328484e2b367b5f4f93a405d6e7b97261bafabfb53d5:0": { + Account: &types.AccountIdentifier{ + Address: "3FfQGY7jqsADC7uTVqF3vKQzeNPiBPTqt4", + }, + Coin: &types.Coin{ + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "503e4e9824282eb06f1a328484e2b367b5f4f93a405d6e7b97261bafabfb53d5:0", + }, + Amount: &types.Amount{ + Value: "3467607", + Currency: MainnetCurrency, + }, + }, + }, + "503e4e9824282eb06f1a328484e2b367b5f4f93a405d6e7b97261bafabfb53d5:1": { + Account: &types.AccountIdentifier{ + Address: "1NdvAyRJLdK5EXs7DV3ebYb5wffdCZk1pD", + }, + Coin: &types.Coin{ + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "503e4e9824282eb06f1a328484e2b367b5f4f93a405d6e7b97261bafabfb53d5:1", + }, + Amount: &types.Amount{ + Value: "0", + Currency: MainnetCurrency, + }, + }, + }, + }, + expectedBlock: &types.Block{ + BlockIdentifier: blockIdentifier100000, + ParentBlockIdentifier: &types.BlockIdentifier{ + Hash: "000000000002d01c1fccc21636b607dfd930d31d01c3a62104612a1719011250", + Index: 99999, + }, + Timestamp: 1293623863000, + Transactions: []*types.Transaction{ + { + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: "8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87", + }, + Operations: []*types.Operation{ + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + NetworkIndex: int64Pointer(0), + }, + Type: CoinbaseOpType, + Status: SuccessStatus, + Metadata: mustMarshalMap(&OperationMetadata{ + Coinbase: "044c86041b020602", + Sequence: 4294967295, + }), + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + NetworkIndex: int64Pointer(0), + }, + Type: OutputOpType, + Status: SuccessStatus, + Account: &types.AccountIdentifier{ + Address: "34qkc2iac6RsyxZVfyE2S5U5WcRsbg2dpK", + }, + Amount: &types.Amount{ + Value: "1589351625", + Currency: MainnetCurrency, + }, + CoinChange: &types.CoinChange{ + CoinAction: types.CoinCreated, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "8c14f0db3df150123e6f3dbbf30f8b955a8249b62ac1d1ff16284aefa3d06d87:0", + }, + }, + Metadata: mustMarshalMap(&OperationMetadata{ + ScriptPubKey: &ScriptPubKey{ + ASM: "OP_HASH160 228f554bbf766d6f9cc828de1126e3d35d15e5fe OP_EQUAL", + Hex: "a914228f554bbf766d6f9cc828de1126e3d35d15e5fe87", + RequiredSigs: 1, + Type: "scripthash", + Addresses: []string{ + "34qkc2iac6RsyxZVfyE2S5U5WcRsbg2dpK", + }, + }, + }), + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 2, + NetworkIndex: int64Pointer(1), + }, + Type: OutputOpType, + Status: SuccessStatus, + Account: &types.AccountIdentifier{ + Address: "6a24aa21a9ed10109f4b82aa3ed7ec9d02a2a90246478b3308c8b85daf62fe501d58d05727a4", + }, + Amount: &types.Amount{ + Value: "0", + Currency: MainnetCurrency, + }, + Metadata: mustMarshalMap(&OperationMetadata{ + ScriptPubKey: &ScriptPubKey{ + ASM: "OP_RETURN aa21a9ed10109f4b82aa3ed7ec9d02a2a90246478b3308c8b85daf62fe501d58d05727a4", + Hex: "6a24aa21a9ed10109f4b82aa3ed7ec9d02a2a90246478b3308c8b85daf62fe501d58d05727a4", + Type: "nulldata", + }, + }), + }, + }, + Metadata: mustMarshalMap(&TransactionMetadata{ + Size: 135, + Version: 1, + Vsize: 135, + Weight: 540, + }), + }, + { + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: "fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4", + }, + Operations: []*types.Operation{ + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + NetworkIndex: int64Pointer(0), + }, + Type: InputOpType, + Status: SuccessStatus, + Amount: &types.Amount{ + Value: "-5000000000", + Currency: MainnetCurrency, + }, + Account: &types.AccountIdentifier{ + Address: "1BNwxHGaFbeUBitpjy2AsKpJ29Ybxntqvb", + }, + CoinChange: &types.CoinChange{ + CoinAction: types.CoinSpent, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "87a157f3fd88ac7907c05fc55e271dc4acdc5605d187d646604ca8c0e9382e03:0", + }, + }, + Metadata: mustMarshalMap(&OperationMetadata{ + ScriptSig: &ScriptSig{ + ASM: "3046022100c352d3dd993a981beba4a63ad15c209275ca9470abfcd57da93b58e4eb5dce82022100840792bc1f456062819f15d33ee7055cf7b5ee1af1ebcc6028d9cdb1c3af7748[ALL] 04f46db5e9d61a9dc27b8d64ad23e7383a4e6ca164593c2527c038c0857eb67ee8e825dca65046b82c9331586c82e0fd1f633f25f87c161bc6f8a630121df2b3d3", // nolint + Hex: "493046022100c352d3dd993a981beba4a63ad15c209275ca9470abfcd57da93b58e4eb5dce82022100840792bc1f456062819f15d33ee7055cf7b5ee1af1ebcc6028d9cdb1c3af7748014104f46db5e9d61a9dc27b8d64ad23e7383a4e6ca164593c2527c038c0857eb67ee8e825dca65046b82c9331586c82e0fd1f633f25f87c161bc6f8a630121df2b3d3", // nolint + }, + Sequence: 4294967295, + }), + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + NetworkIndex: int64Pointer(0), + }, + Type: OutputOpType, + Status: SuccessStatus, + Account: &types.AccountIdentifier{ + Address: "1JqDybm2nWTENrHvMyafbSXXtTk5Uv5QAn", + }, + Amount: &types.Amount{ + Value: "556000000", + Currency: MainnetCurrency, + }, + CoinChange: &types.CoinChange{ + CoinAction: types.CoinCreated, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4:0", + }, + }, + Metadata: mustMarshalMap(&OperationMetadata{ + ScriptPubKey: &ScriptPubKey{ + ASM: "OP_DUP OP_HASH160 c398efa9c392ba6013c5e04ee729755ef7f58b32 OP_EQUALVERIFY OP_CHECKSIG", + Hex: "76a914c398efa9c392ba6013c5e04ee729755ef7f58b3288ac", + RequiredSigs: 1, + Type: "pubkeyhash", + Addresses: []string{ + "1JqDybm2nWTENrHvMyafbSXXtTk5Uv5QAn", + }, + }, + }), + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 2, + NetworkIndex: int64Pointer(1), + }, + Type: OutputOpType, + Status: SuccessStatus, + Account: &types.AccountIdentifier{ + Address: "1EYTGtG4LnFfiMvjJdsU7GMGCQvsRSjYhx", + }, + Amount: &types.Amount{ + Value: "4444000000", + Currency: MainnetCurrency, + }, + CoinChange: &types.CoinChange{ + CoinAction: types.CoinCreated, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4:1", + }, + }, + Metadata: mustMarshalMap(&OperationMetadata{ + ScriptPubKey: &ScriptPubKey{ + ASM: "OP_DUP OP_HASH160 948c765a6914d43f2a7ac177da2c2f6b52de3d7c OP_EQUALVERIFY OP_CHECKSIG", + Hex: "76a914948c765a6914d43f2a7ac177da2c2f6b52de3d7c88ac", + RequiredSigs: 1, + Type: "pubkeyhash", + Addresses: []string{ + "1EYTGtG4LnFfiMvjJdsU7GMGCQvsRSjYhx", + }, + }, + }), + }, + }, + Metadata: mustMarshalMap(&TransactionMetadata{ + Size: 259, + Version: 1, + Vsize: 259, + Weight: 1036, + }), + }, + { + TransactionIdentifier: &types.TransactionIdentifier{ + Hash: "fake", + }, + Operations: []*types.Operation{ + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 0, + NetworkIndex: int64Pointer(0), + }, + Type: InputOpType, + Status: SuccessStatus, + Amount: &types.Amount{ + Value: "-3467607", + Currency: MainnetCurrency, + }, + Account: &types.AccountIdentifier{ + Address: "3FfQGY7jqsADC7uTVqF3vKQzeNPiBPTqt4", + }, + CoinChange: &types.CoinChange{ + CoinAction: types.CoinSpent, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "503e4e9824282eb06f1a328484e2b367b5f4f93a405d6e7b97261bafabfb53d5:0", + }, + }, + Metadata: mustMarshalMap(&OperationMetadata{ + ScriptSig: &ScriptSig{ + ASM: "00142b2296c588ec413cebd19c3cbc04ea830ead6e78", + Hex: "1600142b2296c588ec413cebd19c3cbc04ea830ead6e78", + }, + TxInWitness: []string{ + "304402205f39ccbab38b644acea0776d18cb63ce3e37428cbac06dc23b59c61607aef69102206b8610827e9cb853ea0ba38983662034bd3575cc1ab118fb66d6a98066fa0bed01", // nolint + "0304c01563d46e38264283b99bb352b46e69bf132431f102d4bd9a9d8dab075e7f", + }, + Sequence: 4294967295, + }), + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 1, + NetworkIndex: int64Pointer(1), + }, + Type: InputOpType, + Status: SuccessStatus, + Amount: &types.Amount{ + Value: "0", + Currency: MainnetCurrency, + }, + Account: &types.AccountIdentifier{ + Address: "1NdvAyRJLdK5EXs7DV3ebYb5wffdCZk1pD", + }, + CoinChange: &types.CoinChange{ + CoinAction: types.CoinSpent, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "503e4e9824282eb06f1a328484e2b367b5f4f93a405d6e7b97261bafabfb53d5:1", + }, + }, + Metadata: mustMarshalMap(&OperationMetadata{ + ScriptSig: &ScriptSig{ + ASM: "00142b2296c588ec413cebd19c3cbc04ea830ead6e78", + Hex: "1600142b2296c588ec413cebd19c3cbc04ea830ead6e78", + }, + Sequence: 4294967295, + }), + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 2, + NetworkIndex: int64Pointer(2), + }, + Type: InputOpType, + Status: SuccessStatus, + Amount: &types.Amount{ + Value: "-556000000", + Currency: MainnetCurrency, + }, + Account: &types.AccountIdentifier{ + Address: "1JqDybm2nWTENrHvMyafbSXXtTk5Uv5QAn", + }, + CoinChange: &types.CoinChange{ + CoinAction: types.CoinSpent, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "fff2525b8931402dd09222c50775608f75787bd2b87e56995a7bdd30f79702c4:0", + }, + }, + Metadata: mustMarshalMap(&OperationMetadata{ + ScriptSig: &ScriptSig{ + ASM: "3046022100c352d3dd993a981beba4a63ad15c209275ca9470abfcd57da93b58e4eb5dce82022100840792bc1f456062819f15d33ee7055cf7b5ee1af1ebcc6028d9cdb1c3af7748[ALL] 04f46db5e9d61a9dc27b8d64ad23e7383a4e6ca164593c2527c038c0857eb67ee8e825dca65046b82c9331586c82e0fd1f633f25f87c161bc6f8a630121df2b3d3", // nolint + Hex: "493046022100c352d3dd993a981beba4a63ad15c209275ca9470abfcd57da93b58e4eb5dce82022100840792bc1f456062819f15d33ee7055cf7b5ee1af1ebcc6028d9cdb1c3af7748014104f46db5e9d61a9dc27b8d64ad23e7383a4e6ca164593c2527c038c0857eb67ee8e825dca65046b82c9331586c82e0fd1f633f25f87c161bc6f8a630121df2b3d3", // nolint + }, + Sequence: 4294967295, + }), + }, + { + OperationIdentifier: &types.OperationIdentifier{ + Index: 3, + NetworkIndex: int64Pointer(0), + }, + Type: OutputOpType, + Status: SuccessStatus, + Account: &types.AccountIdentifier{ + Address: "76a914c398efa9c392ba6013c5e04ee729755ef7f58b3288ac", + }, + Amount: &types.Amount{ + Value: "20056000000", + Currency: MainnetCurrency, + }, + CoinChange: &types.CoinChange{ + CoinAction: types.CoinCreated, + CoinIdentifier: &types.CoinIdentifier{ + Identifier: "fake:0", + }, + }, + Metadata: mustMarshalMap(&OperationMetadata{ + ScriptPubKey: &ScriptPubKey{ + ASM: "OP_DUP OP_HASH160 c398efa9c392ba6013c5e04ee729755ef7f58b32 OP_EQUALVERIFY OP_CHECKSIG", + Hex: "76a914c398efa9c392ba6013c5e04ee729755ef7f58b3288ac", + RequiredSigs: 1, + Type: "pubkeyhash", + Addresses: []string{ + "1JqDybm2nWTENrHvMyafbSXXtTk5Uv5QAn", + "1EYTGtG4LnFfiMvjJdsU7GMGCQvsRSjYhx", + }, + }, + }), + }, + }, + Metadata: mustMarshalMap(&TransactionMetadata{ + Size: 421, + Version: 2, + Vsize: 612, + Weight: 129992, + Locktime: 10, + }), + }, + }, + Metadata: mustMarshalMap(&BlockMetadata{ + Size: 957, + Weight: 3828, + Version: 1, + MerkleRoot: "f3e94742aca4b5ef85488dc37c06c3282295ffec960994b2c0d5ac2a25a95766", + MedianTime: 1293622620, + Nonce: 274148111, + Bits: "1b04864c", + Difficulty: 14484.1623612254, + }), + }, + }, + "missing transactions": { + block: block100000, + coins: map[string]*storage.AccountCoin{}, + expectedError: errors.New("error finding previous tx"), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var ( + assert = assert.New(t) + ) + + client := NewClient("", MainnetGenesisBlockIdentifier, MainnetCurrency) + block, err := client.ParseBlock(context.Background(), test.block, test.coins) + if test.expectedError != nil { + assert.Contains(err.Error(), test.expectedError.Error()) + } else { + assert.NoError(err) + assert.Equal(test.expectedBlock, block) + } + }) + } +} + +func TestSuggestedFeeRate(t *testing.T) { + tests := map[string]struct { + responses []responseFixture + + expectedRate float64 + expectedError error + }{ + "successful": { + responses: []responseFixture{ + { + status: http.StatusOK, + body: loadFixture("fee_rate.json"), + url: url, + }, + }, + expectedRate: float64(0.00001), + }, + "invalid range error": { + responses: []responseFixture{ + { + status: http.StatusOK, + body: loadFixture("invalid_fee_rate.json"), + url: url, + }, + }, + expectedError: errors.New("error getting fee estimate"), + }, + "500 error": { + responses: []responseFixture{ + { + status: http.StatusInternalServerError, + body: "{}", + url: url, + }, + }, + expectedError: errors.New("invalid response: 500 Internal Server Error"), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var ( + assert = assert.New(t) + ) + + responses := make(chan responseFixture, len(test.responses)) + for _, response := range test.responses { + responses <- response + } + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + response := <-responses + assert.Equal("application/json", r.Header.Get("Content-Type")) + assert.Equal("POST", r.Method) + assert.Equal(response.url, r.URL.RequestURI()) + + w.WriteHeader(response.status) + fmt.Fprintln(w, response.body) + })) + + client := NewClient(ts.URL, MainnetGenesisBlockIdentifier, MainnetCurrency) + rate, err := client.SuggestedFeeRate(context.Background(), 1) + if test.expectedError != nil { + assert.Contains(err.Error(), test.expectedError.Error()) + } else { + assert.NoError(err) + assert.Equal(test.expectedRate, rate) + } + }) + } +} + +// loadFixture takes a file name and returns the response fixture. +func loadFixture(fileName string) string { + content, err := ioutil.ReadFile(fmt.Sprintf("client_fixtures/%s", fileName)) + if err != nil { + log.Fatal(err) + } + return string(content) +} + +type responseFixture struct { + status int + body string + url string +} diff --git a/bitcoin/node.go b/bitcoin/node.go new file mode 100644 index 0000000..7782525 --- /dev/null +++ b/bitcoin/node.go @@ -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. + +package bitcoin + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "github.com/coinbase/rosetta-bitcoin/utils" + + "golang.org/x/sync/errgroup" +) + +const ( + bitcoindLogger = "bitcoind" + bitcoindStdErrLogger = "bitcoind stderr" +) + +func logPipe(ctx context.Context, pipe io.ReadCloser, identifier string) error { + logger := utils.ExtractLogger(ctx, identifier) + reader := bufio.NewReader(pipe) + for { + str, err := reader.ReadString('\n') + if err != nil { + logger.Warnw("closing logger", "error", err) + return err + } + + message := strings.Replace(str, "\n", "", -1) + messages := strings.SplitAfterN(message, " ", 2) + + // Trim the timestamp from the log if it exists + if len(messages) > 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..43e8d12 --- /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, false) + 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, false) + 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, false) + 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, true) + 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, false) + 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, false) + } else { + i.waiter.Set(txHash, val, false) + } + 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..1c97cfc --- /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, safe bool) (*waitTableEntry, bool) { + if safe { + 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, safe bool) { + if safe { + t.lock.Lock() + defer t.lock.Unlock() + } + t.table[key] = value +} + +func (t *waitTable) Delete(key string, safe bool) { + if safe { + 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}";