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 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}";