diff --git a/clients/horizon/client.go b/clients/horizon/client.go index 813514497f..726ab842e9 100644 --- a/clients/horizon/client.go +++ b/clients/horizon/client.go @@ -8,6 +8,7 @@ import ( "net/http" "net/url" "strconv" + "strings" "github.com/stellar/go/support/errors" "github.com/stellar/go/xdr" @@ -24,9 +25,28 @@ func (c *Client) HomeDomainForAccount(aid string) (string, error) { return a.HomeDomain, nil } +// fixURL removes trailing slash from Client.URL. This will prevent situation when +// http.Client does not follow redirects. +func (c *Client) fixURL() { + c.URL = strings.TrimRight(c.URL, "/") +} + +// Root loads the root endpoint of horizon +func (c *Client) Root() (root Root, err error) { + c.fixURLOnce.Do(c.fixURL) + resp, err := c.HTTP.Get(c.URL) + if err != nil { + return + } + + err = decodeResponse(resp, &root) + return +} + // LoadAccount loads the account state from horizon. err can be either error // object or horizon.Error object. func (c *Client) LoadAccount(accountID string) (account Account, err error) { + c.fixURLOnce.Do(c.fixURL) resp, err := c.HTTP.Get(c.URL + "/accounts/" + accountID) if err != nil { return @@ -39,7 +59,7 @@ func (c *Client) LoadAccount(accountID string) (account Account, err error) { // LoadAccountOffers loads the account offers from horizon. err can be either // error object or horizon.Error object. func (c *Client) LoadAccountOffers(accountID string, params ...interface{}) (offers OffersPage, err error) { - + c.fixURLOnce.Do(c.fixURL) endpoint := "" query := url.Values{} @@ -115,6 +135,7 @@ func (c *Client) SequenceForAccount( // LoadOrderBook loads order book for given selling and buying assets. func (c *Client) LoadOrderBook(selling Asset, buying Asset, params ...interface{}) (orderBook OrderBookSummary, err error) { + c.fixURLOnce.Do(c.fixURL) query := url.Values{} query.Add("selling_asset_type", selling.Type) @@ -237,6 +258,7 @@ func (c *Client) stream(ctx context.Context, baseURL string, cursor *Cursor, han // StreamLedgers streams incoming ledgers. Use context.WithCancel to stop streaming or // context.Background() if you want to stream indefinitely. func (c *Client) StreamLedgers(ctx context.Context, cursor *Cursor, handler LedgerHandler) (err error) { + c.fixURLOnce.Do(c.fixURL) url := fmt.Sprintf("%s/ledgers", c.URL) return c.stream(ctx, url, cursor, func(data []byte) error { var ledger Ledger @@ -252,6 +274,7 @@ func (c *Client) StreamLedgers(ctx context.Context, cursor *Cursor, handler Ledg // StreamPayments streams incoming payments. Use context.WithCancel to stop streaming or // context.Background() if you want to stream indefinitely. func (c *Client) StreamPayments(ctx context.Context, accountID string, cursor *Cursor, handler PaymentHandler) (err error) { + c.fixURLOnce.Do(c.fixURL) url := fmt.Sprintf("%s/accounts/%s/payments", c.URL, accountID) return c.stream(ctx, url, cursor, func(data []byte) error { var payment Payment @@ -267,6 +290,7 @@ func (c *Client) StreamPayments(ctx context.Context, accountID string, cursor *C // StreamTransactions streams incoming transactions. Use context.WithCancel to stop streaming or // context.Background() if you want to stream indefinitely. func (c *Client) StreamTransactions(ctx context.Context, accountID string, cursor *Cursor, handler TransactionHandler) (err error) { + c.fixURLOnce.Do(c.fixURL) url := fmt.Sprintf("%s/accounts/%s/transactions", c.URL, accountID) return c.stream(ctx, url, cursor, func(data []byte) error { var transaction Transaction @@ -280,9 +304,8 @@ func (c *Client) StreamTransactions(ctx context.Context, accountID string, curso } // SubmitTransaction submits a transaction to the network. err can be either error object or horizon.Error object. -func (c *Client) SubmitTransaction( - transactionEnvelopeXdr string, -) (response TransactionSuccess, err error) { +func (c *Client) SubmitTransaction(transactionEnvelopeXdr string) (response TransactionSuccess, err error) { + c.fixURLOnce.Do(c.fixURL) v := url.Values{} v.Set("tx", transactionEnvelopeXdr) @@ -297,5 +320,12 @@ func (c *Client) SubmitTransaction( return } + // WARNING! Do not remove this code. If you include two trailing slashes (`//`) at the end of Client.URL + // and developers changed Client.HTTP to not follow redirects, this will return empty response and no error! + if resp.StatusCode != http.StatusOK { + err = errors.New("Invalid response code") + return + } + return } diff --git a/clients/horizon/main.go b/clients/horizon/main.go index 996dfb0fe0..620433193e 100644 --- a/clients/horizon/main.go +++ b/clients/horizon/main.go @@ -9,6 +9,7 @@ package horizon import ( "net/http" "net/url" + "sync" "github.com/stellar/go/build" "github.com/stellar/go/support/errors" @@ -64,9 +65,12 @@ type Client struct { // HTTP client to make requests with HTTP HTTP + + fixURLOnce sync.Once } type ClientInterface interface { + Root() (Root, error) LoadAccount(accountID string) (Account, error) LoadAccountOffers(accountID string, params ...interface{}) (offers OffersPage, err error) LoadMemo(p *Payment) error diff --git a/clients/horizon/mocks.go b/clients/horizon/mocks.go index 93e657d115..13637121a5 100644 --- a/clients/horizon/mocks.go +++ b/clients/horizon/mocks.go @@ -10,6 +10,12 @@ type MockClient struct { mock.Mock } +// Root is a mocking a method +func (m *MockClient) Root() (Root, error) { + a := m.Called() + return a.Get(0).(Root), a.Error(1) +} + // LoadAccount is a mocking a method func (m *MockClient) LoadAccount(accountID string) (Account, error) { a := m.Called(accountID) diff --git a/clients/horizon/responses.go b/clients/horizon/responses.go index 7652543331..516d443c0b 100644 --- a/clients/horizon/responses.go +++ b/clients/horizon/responses.go @@ -15,6 +15,28 @@ type Problem struct { Extras map[string]json.RawMessage `json:"extras,omitempty"` } +type Root struct { + Links struct { + Account Link `json:"account"` + AccountTransactions Link `json:"account_transactions"` + Friendbot Link `json:"friendbot"` + Metrics Link `json:"metrics"` + OrderBook Link `json:"order_book"` + Self Link `json:"self"` + Transaction Link `json:"transaction"` + Transactions Link `json:"transactions"` + } `json:"_links"` + + HorizonVersion string `json:"horizon_version"` + StellarCoreVersion string `json:"core_version"` + HorizonSequence int32 `json:"history_latest_ledger"` + HistoryElderSequence int32 `json:"history_elder_ledger"` + CoreSequence int32 `json:"core_latest_ledger"` + CoreElderSequence int32 `json:"core_elder_ledger"` + NetworkPassphrase string `json:"network_passphrase"` + ProtocolVersion int32 `json:"protocol_version"` +} + type Account struct { Links struct { Self Link `json:"self"` @@ -47,6 +69,16 @@ func (a Account) GetNativeBalance() string { return "0" } +func (a Account) GetCreditBalance(code, issuer string) string { + for _, balance := range a.Balances { + if balance.Asset.Code == code && balance.Asset.Issuer == issuer { + return balance.Balance + } + } + + return "0" +} + // MustGetData returns decoded value for a given key. If the key does // not exist, empty slice will be returned. If there is an error // decoding a value, it will panic. @@ -192,6 +224,10 @@ type Payment struct { } `json:"transaction"` } `json:"_links"` + // create_account fields + Account string `json:"account"` + StartingBalance string `json:"starting_balance"` + // payment/path_payment fields From string `json:"from"` To string `json:"to"` diff --git a/glide.lock b/glide.lock index 08f1dc0d6f..c7229e9d2d 100644 --- a/glide.lock +++ b/glide.lock @@ -1,5 +1,5 @@ -hash: d706a6b1bff95df93546374ff7ef6ecf0b11cf60d054c67569f46df10e477cf3 -updated: 2017-10-05T09:54:13.340235406-07:00 +hash: 558db3af33103976806c9452a4823d13971090678f683b57a793836d58c74d9b +updated: 2017-10-11T15:07:25.91120115+02:00 imports: - name: bitbucket.org/ww/goautoneg version: 75cd24fc2f2c2a2088577d12123ddee5f54e0675 @@ -43,6 +43,28 @@ imports: - private/waiter - service/s3 - service/sts +- name: github.com/btcsuite/btcd + version: 4803a8291c92a1d2d41041b942a9a9e37deab065 + subpackages: + - btcec + - btcjson + - chaincfg + - chaincfg/chainhash + - rpcclient + - wire +- name: github.com/btcsuite/btclog + version: 84c8d2346e9fc8c7b947e243b9c24e6df9fd206a +- name: github.com/btcsuite/btcutil + version: 501929d3d046174c3d39f0ea54ece471aa17238c + subpackages: + - base58 + - bech32 +- name: github.com/btcsuite/go-socks + version: 4720035b7bfd2a9bb130b1c184f8bbe41b6f0d0f + subpackages: + - socks +- name: github.com/btcsuite/websocket + version: 31079b6807923eb23992c421b114992b95131b55 - name: github.com/BurntSushi/toml version: 99064174e013895bbd9b025c31100bd1d9b590ca repo: https://github.com/BurntSushi/toml @@ -54,6 +76,30 @@ imports: repo: https://github.com/davecgh/go-spew subpackages: - spew +- name: github.com/ethereum/go-ethereum + version: ad444752311b1a318a7933562749b4586d4469e9 + subpackages: + - common + - common/hexutil + - common/math + - core/types + - crypto + - crypto/secp256k1 + - crypto/sha3 + - ethclient + - log + - params + - rlp + - rpc + - trie +- name: github.com/facebookgo/inject + version: cc1aa653e50f6a9893bcaef89e673e5b24e1e97b +- name: github.com/facebookgo/structtag + version: 217e25fb96916cc60332e399c9aa63f5c422ceed +- name: github.com/FactomProject/basen + version: fe3947df716ebfda9847eb1b9a48f9592e06478c +- name: github.com/FactomProject/btcutilecc + version: d3a63a5752ecf3fbc06bd97365da752111c263df - name: github.com/fatih/structs version: a720dfa8df582c51dee1b36feabb906bde1588bd repo: https://github.com/fatih/structs @@ -72,6 +118,10 @@ imports: - name: github.com/go-errors/errors version: a41850380601eeb43f4350f7d17c6bbd8944aaf8 repo: https://github.com/go-errors/errors +- name: github.com/go-chi/chi + version: 25354a53cca531cb2efd3f1d3c565d90ff04d802 + subpackages: + - middleware - name: github.com/go-ini/ini version: 6f66b0e091edb3c7b380f7c4f0f884274d550b67 repo: https://github.com/go-ini/ini @@ -86,6 +136,8 @@ imports: repo: https://github.com/golang/groupcache subpackages: - lru +- name: github.com/go-stack/stack + version: 817915b46b97fd7bb80e8ab6b69f01a53ac3eebf - name: github.com/google/go-querystring version: 9235644dd9e52eeae6fa48efd539fdc351a0af53 repo: https://github.com/google/go-querystring @@ -94,6 +146,10 @@ imports: - name: github.com/guregu/null version: 79c5bd36b615db4c06132321189f579c8a5fca98 repo: https://github.com/guregu/null +- name: github.com/haltingstate/secp256k1-go + version: 572209b26df66a638573023571b5ce80983214eb + subpackages: + - secp256k1-go2 - name: github.com/howeyc/gopass version: bf9dde6d0d2c004a008c27aaee91170c786f6db8 repo: https://github.com/howeyc/gopass @@ -224,6 +280,10 @@ imports: - name: github.com/rcrowley/go-metrics version: a5cfc242a56ba7fa70b785f678d6214837bf93b9 repo: https://github.com/rcrowley/go-metrics +- name: github.com/r3labs/sse + version: 2f36fb519619e8589fbef5095fbe113c14e7d090 +- name: github.com/rcrowley/go-metrics + version: a5cfc242a56ba7fa70b785f678d6214837bf93b9 - name: github.com/rs/cors version: a62a804a8a009876ca59105f7899938a1349f4b3 repo: https://github.com/rs/cors @@ -290,6 +350,8 @@ imports: - name: github.com/tylerb/graceful version: 7116c7a8115899e80197cd9e0b97998c0f97ed8e repo: https://github.com/tylerb/graceful +- name: github.com/tyler-smith/go-bip32 + version: 2c9cfd17756470a0b7c3e4b7954bae7d11035504 - name: github.com/valyala/bytebufferpool version: e746df99fe4a3986f4d4f79e13c1e0117ce9c2f7 repo: https://github.com/valyala/bytebufferpool @@ -344,6 +406,7 @@ imports: version: 1f22c0103821b9390939b6776727195525381532 repo: https://go.googlesource.com/crypto subpackages: + - ripemd160 - ssh/terminal - name: golang.org/x/net version: 9bc2a3340c92c17a20edcd0080e93851ed58f5d5 @@ -358,10 +421,13 @@ imports: - lex/httplex - netutil - publicsuffix + - websocket - name: golang.org/x/sys version: 1f5e250e1174502017917628cc48b52fdc25b531 subpackages: - unix +- name: gopkg.in/fatih/set.v0 + version: 27c40922c40b43fe04554d8223a402af3ea333f3 - name: gopkg.in/gavv/httpexpect.v1 version: 40724cf1e4a08b670b14a02deff693a91f3aa9a0 repo: https://gopkg.in/gavv/httpexpect.v1 @@ -371,9 +437,12 @@ imports: - name: gopkg.in/tylerb/graceful.v1 version: 50a48b6e73fcc75b45e22c05b79629a67c79e938 repo: https://gopkg.in/tylerb/graceful.v1 -- name: gopkg.in/yaml.v2 - version: 7ad95dd0798a40da1ccdff6dff35fd177b5edf40 - repo: https://gopkg.in/yaml.v2 +- name: gopkg.in/karalabe/cookiejar.v2 + version: 8dcd6a7f4951f6ff3ee9cbb919a06d8925822e57 + subpackages: + - collections/prque +- name: gopkg.in/natefinch/npipe.v2 + version: c1b8fa8bdccecb0b8db834ee0b92fdbcfa606dd6 testImports: - name: golang.org/x/text version: 1cbadb444a806fd9430d14ad08967ed91da4fa0a @@ -393,3 +462,6 @@ testImports: - language - runes - transform +- name: gopkg.in/yaml.v2 + version: 7ad95dd0798a40da1ccdff6dff35fd177b5edf40 + repo: https://gopkg.in/yaml.v2 diff --git a/glide.yaml b/glide.yaml index 9967c60a7f..0576c3046c 100644 --- a/glide.yaml +++ b/glide.yaml @@ -286,3 +286,15 @@ import: - package: gopkg.in/yaml.v2 version: 7ad95dd0798a40da1ccdff6dff35fd177b5edf40 repo: https://gopkg.in/yaml.v2 +- package: github.com/go-chi/chi + version: ~3.1.5 +- package: github.com/tyler-smith/go-bip32 +- package: github.com/btcsuite/btcd + subpackages: + - rpcclient +- package: github.com/btcsuite/btclog +- package: github.com/btcsuite/btcutil +- package: github.com/btcsuite/go-socks + subpackages: + - socks +- package: github.com/btcsuite/websocket diff --git a/services/bifrost/README.md b/services/bifrost/README.md new file mode 100644 index 0000000000..a3e99ba9e8 --- /dev/null +++ b/services/bifrost/README.md @@ -0,0 +1,103 @@ +# bifrost + +> In Norse mythology, Bifröst (/ˈbɪvrɒst/ or sometimes Bilröst or Bivrost) is a burning rainbow bridge that reaches between Midgard (Earth) and Asgard, the realm of the gods. [**Wikipedia**](https://en.wikipedia.org/wiki/Bifr%C3%B6st) + +Bifrost is highly available and secure Bitcoin/Ethereum → Stellar bridge. It allows users to move BTC/ETH to Stellar network and then trade them to other tokens or participate in ICOs (initial coin offering). + +It solves many problems connected to moving tokens to Stellar network: + +* Security: + * Developers don’t have access to users’ Stellar keys. + * No Bitcoin/Ethereum private keys for receiving accounts uploaded to any application machine. +* High availability: + * Can be deployed to multiple availability zones (one or more machines can fail). + * Handles high load. +* Easy to use: + * Simple process for users. + * Easy installation for developers. + +Use Bifrost with it's [JS SDK](https://github.com/stellar/bifrost-js-sdk). + +We are releasing the **alpha version** of this software. We encourage our community of developers to test and improve it. + +## How it works + +1. User opens the web app implemented using [Bifrost JS SDK](https://github.com/stellar/bifrost-js-sdk). +1. User is presented with her public and private Stellar keys where Bitcoin/Ethereum will be sent. +1. User selects what cryptocurrency she wants to move to Stellar network. +1. A receiving Bitcoin/Etherem address is generated. +1. User sends funds in Bitcoin/Ethereum network. +1. Bifrost listens to Bitcoin and Ethereum network events. When payment arrives it creates a Stellar [account](https://www.stellar.org/developers/guides/concepts/accounts.html) for the user. +1. User creates a [trust line](https://www.stellar.org/developers/guides/concepts/assets.html) to BTC/ETH issued by Bifrost account. +1. Bifrost sends corresponding amount of BTC/ETH to user's Stellar account. +1. As an optional step, web application can also exchange BTC/ETH to other token in Stellar network at a given rate. + +## Demo + +We use Ethereum Ropsten test network for this demo because it's faster that Bitcoin testnet. + +https://bifrost.stellar.org/ + +1. First you need some ETH on Ethereum testnet. +1. Create an account at https://www.myetherwallet.com/, then switch Network (top-right dropdown) to "Ropsten (MyEtherWallet)". Write down/copy your Ethereum address somewhere. +1. Use http://faucet.ropsten.be:3001/ to send 3 ETH to your testnet account. +1. Now open Bifrost demo: https://bifrost.stellar.org/ It will display address where to send your test ETH. +1. Go back to MyEtherWallet and send ETH to the address displayed by Bifrost. Sometimes Ropsten network is overloaded so monitor the Etherscan to check if your tx was included in a block. If not, increated gas price (this can be done in "Send Offline" tab). +1. Switch back to Bifrost demo and check the progress. + +## Config + +* `port` - bifrost server listening port +* `using_proxy` (default `false`) - set to `true` if bifrost lives behind a proxy or load balancer +* `bitcoin` + * `master_public_key` - master public key for bitcoin keys derivation (read more in [BIP-0032](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki)) + * `rpc_server` - URL of [bitcoin-core](https://github.com/bitcoin/bitcoin) >= 0.15.0 RPC server + * `rpc_user` (default empty) - username for RPC server (if any) + * `rpc_pass` (default empty) - password for RPC server (if any) + * `testnet` (default `false`) - set to `true` if you're testing bifrost in ethereum + * `minimum_value_btc` - minimum transaction value in BTC that will be accepted by Bifrost, everything below will be ignored. +* `ethereum` + * `master_public_key` - master public key for bitcoin keys derivation (read more in [BIP-0032](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki)) + * `rpc_server` - URL of [geth](https://github.com/ethereum/go-ethereum) >= 1.7.1 RPC server + * `network_id` - network ID (`3` - Ropsten testnet, `1` - live Ethereum network) + * `minimum_value_eth` - minimum transaction value in ETH that will be accepted by Bifrost, everything below will be ignored. +* `stellar` + * `issuer_public_key` - public key of the assets issuer or hot wallet, + * `signer_secret_key` - issuer's secret key if only one instance of Bifrost is deployed OR [channel](https://www.stellar.org/developers/guides/channels.html)'s secret key if more than one instance of Bifrost is deployed. Signer's sequence number will be consumed in transaction's sequence number. + * `horizon` - URL to [horizon](https://github.com/stellar/go/tree/master/services/horizon) server + * `network_passphrase` - Stellar network passphrase (`Public Global Stellar Network ; September 2015` for production network, `Test SDF Network ; September 2015` for test network) +* `database` + * `type` - currently the only supported database type is: `postgres` + * `dsn` - data source name for postgres connection (`postgres://user:password@host/dbname?sslmode=sslmode` - [more info](https://godoc.org/github.com/lib/pq#hdr-Connection_String_Parameters)) + +## Deployment + +There are two ways you can deploy Bifrost: +* **Simple/single-instance deployment** - Bifrost will live on a single instance along with geth, bitcoin-core and database. This is the easiest way to deploy Bifrost but if this instance goes down, so does Bifrost and all the other components. +* **High-availability deployment** - Each of the components is deployed to at least 2 instances so even if random instances go down, Bifrost will still be online accepting payments. + +We recommend the latter. + +### High-availability deployment + +Here's the proposed architecture diagram of high-availability deployment: + +![Architecture](./images/architecture.png) + +## Going to production + +* Remember that everyone with master public key and **any** child private key can recover your **master** private key. Do not share your master public key and obviously any private keys. Treat your master public key as if it was a private key. Read more in BIP-0032 [Security](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#security) section. +* Make sure "Sell [your token] for BTC" and/or "Sell [your token] for ETH" exist in Stellar production network. If not, you can create an offer by sending a transaction with `manage_offer` operation. +* Make sure you don't use account from `stellar.issuer_secret_key` anywhere else than bifrost. Otherwise, sequence numbers will go out of sync and bifrost will stop working. It's good idea to create a new signer on issuing account. +* Check if public master key correct. Use CLI tool (`bifrost check-keys`) to generate a few addresses and ensure you have corresponding private keys! You should probably send test transactions to some of these addresses and check if you can withdraw funds. +* Make sure `using_proxy` variable is set to correct value. Otherwise you will see your proxy IP instead of users' IPs in logs. +* Make sure you're not connecting to testnets. +* Deploy at least 2 bifrost, bitcoin-core, geth, stellar-core and horizon servers. Use multi-AZ database. +* Do not use SDF's horizon servers. There is no SLA and we cannot guarantee it will handle your load. +* Make sure bifrost <-> bitcoin-core and bifrost <-> geth connections are not public or are encrypted (mitm attacks). +* It's a good idea to set "Authorization required" and "Authorization revocable" [flags](https://www.stellar.org/developers/guides/concepts/accounts.html#flags) on issuing account during ICO stage to remove trustlines to accounts with lost keys. You need to set `stellar.needs_authorize` in config to `true` if you want to use this feature. +* Monitor bifrost logs and react to all WARN and ERROR entries. +* Make sure you are using geth >= 1.7.1 and bitcoin-core >= 0.15.0. +* Increase horizon rate limiting to handle expected load. +* Make sure you configured minimum accepted value for Bitcoin and Ethereum transactions to the value you really want. +* Make sure you start from a fresh Bifrost DB in production. If Bifrost was running, you stopped bitcoin-core or geth and then started it again then all the Bitcoin and Ethereum blocks mined during that period will be processed which can take a lot of time. diff --git a/services/bifrost/bifrost.cfg b/services/bifrost/bifrost.cfg new file mode 100644 index 0000000000..326c8072bd --- /dev/null +++ b/services/bifrost/bifrost.cfg @@ -0,0 +1,29 @@ +port = 8000 +using_proxy = false +access-control-allow-origin-header = "*" + +[bitcoin] +master_public_key = "xpub6DxSCdWu6jKqr4isjo7bsPeDD6s3J4YVQV1JSHZg12Eagdqnf7XX4fxqyW2sLhUoFWutL7tAELU2LiGZrEXtjVbvYptvTX5Eoa4Mamdjm9u" +rpc_server = "localhost:18332" +rpc_user = "user" +rpc_pass = "password" +testnet = true +minimum_value_btc = "0.0001" + +[ethereum] +master_public_key = "xpub6DxSCdWu6jKqr4isjo7bsPeDD6s3J4YVQV1JSHZg12Eagdqnf7XX4fxqyW2sLhUoFWutL7tAELU2LiGZrEXtjVbvYptvTX5Eoa4Mamdjm9u" +rpc_server = "localhost:8545" +network_id = "3" +minimum_value_eth = "0.00001" + +[stellar] +issuer_public_key = "GDGVTKSEXWB4VFTBDWCBJVJZLIY6R3766EHBZFIGK2N7EQHVV5UTA63C" +signer_secret_key = "SAGC33ER53WGBISR5LQ4RJIBFG5UHXWNGTLG4KJRC737VYXNDGWLO54B" +token_asset_code = "TOKE" +needs_authorize = true +horizon = "https://horizon-testnet.stellar.org" +network_passphrase = "Test SDF Network ; September 2015" + +[database] +type="postgres" +dsn="postgres://root@localhost/bifrost?sslmode=[sslmode]" diff --git a/services/bifrost/bitcoin/address_generator.go b/services/bifrost/bitcoin/address_generator.go new file mode 100644 index 0000000000..4c14ce9986 --- /dev/null +++ b/services/bifrost/bitcoin/address_generator.go @@ -0,0 +1,39 @@ +package bitcoin + +import ( + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcutil" + "github.com/stellar/go/support/errors" + "github.com/tyler-smith/go-bip32" +) + +func NewAddressGenerator(masterPublicKeyString string, chainParams *chaincfg.Params) (*AddressGenerator, error) { + deserializedMasterPublicKey, err := bip32.B58Deserialize(masterPublicKeyString) + if err != nil { + return nil, errors.Wrap(err, "Error deserializing master public key") + } + + if deserializedMasterPublicKey.IsPrivate { + return nil, errors.New("Key is not a master public key") + } + + return &AddressGenerator{deserializedMasterPublicKey, chainParams}, nil +} + +func (g *AddressGenerator) Generate(index uint32) (string, error) { + if g.masterPublicKey == nil { + return "", errors.New("No master public key set") + } + + accountKey, err := g.masterPublicKey.NewChildKey(index) + if err != nil { + return "", errors.Wrap(err, "Error creating new child key") + } + + address, err := btcutil.NewAddressPubKey(accountKey.Key, g.chainParams) + if err != nil { + return "", errors.Wrap(err, "Error creating address for new child key") + } + + return address.AddressPubKeyHash().EncodeAddress(), nil +} diff --git a/services/bifrost/bitcoin/address_generator_test.go b/services/bifrost/bitcoin/address_generator_test.go new file mode 100644 index 0000000000..bedebd9762 --- /dev/null +++ b/services/bifrost/bitcoin/address_generator_test.go @@ -0,0 +1,54 @@ +package bitcoin + +import ( + "testing" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/stretchr/testify/assert" +) + +func TestAddressGenerator(t *testing.T) { + // Generated using https://iancoleman.github.io/bip39/ + // Root key: + // xprv9s21ZrQH143K2Cfj4mDZBcEecBmJmawReGwwoAou2zZzG45bM6cFPJSvobVTCB55L6Ld2y8RzC61CpvadeAnhws3CHsMFhNjozBKGNgucYm + // Derivation Path m/44'/0'/0'/0: + // xprvA1y8DJefYknMwXkdUrSk57z26Z3Fjr3rVpk8NzQKRQWjy3ogV43qr4eqTuF1rg5rrw28mqbDHfWsmoBbeDPcQ34teNgDyohSu6oyodoJ6Bu + // xpub6ExUcpBZP8LfA1q6asykSFvkeask9Jmhs3fjBNovyk3iqr8q2bN6PryKKCvLLkMs1u2667wJnoM5LRQc3JcsGbQAhjUqJavxhtdk363GbP2 + generator, err := NewAddressGenerator("xpub6ExUcpBZP8LfA1q6asykSFvkeask9Jmhs3fjBNovyk3iqr8q2bN6PryKKCvLLkMs1u2667wJnoM5LRQc3JcsGbQAhjUqJavxhtdk363GbP2", &chaincfg.MainNetParams) + assert.NoError(t, err) + + expectedChildren := []struct { + index uint32 + address string + }{ + {0, "1Q74qRud8bXUn6FMtXWZwJa5pj56s3mdyf"}, + {1, "1CSauQLNjb3RVQN34bDZAnmKuHScsP3xuC"}, + {2, "17HCcV6BseYXaZaBXAPZqtCGQTJB9ZKsYS"}, + {3, "1MLEi1UXggrJP9ArUbxNPE9N6JUMnXErxb"}, + {4, "1cwGtdn8kqGakhXji1qDAnFjp58zN5qTn"}, + {5, "13X3CERUszAkQ2YG8yJ3eDQ8w2ATosRJWk"}, + {6, "16sgaW7RPaebPNB1umpNMxiJLjhRnNsJWY"}, + {7, "1D8xepkjsM6hfA56E1j3NWP2zcyrTMsrQM"}, + {8, "1DAEFQpKEqchA7caGKQBRacexcGJWvjXfP"}, + {9, "1N3nPpuLiZtDxuM9F3qtbTNJun3kSwC83C"}, + + {100, "14C4sYrxXMCN17gUK2BMjuHSgmsp4X1oYu"}, + {101, "1G8unQbMMSrGh9SHwyUCVVGu5NTjAEPRYY"}, + {102, "1HeyVCFJr95VGJwJAuUSfBenCwk1jSjjsQ"}, + {103, "18hSmMYJ43AHrE1x5Q9gHjaEMJmbwaUQQo"}, + {104, "18sVLpqDyz4dfmBy6bwNw9yYJme8ybQxeh"}, + {105, "1EjPpuUU2Mh2vgmQgdmQvF6TqkR3YJEypn"}, + {106, "17zJ3LxbZFVpNANXeJfHvCGSsytfMYMeVh"}, + {107, "1555pj7ZWw2Qmv7chn1ziJgDYkaauw9BLD"}, + {108, "1KUaZb5Znqu8XF7AV7phhGDuVPvosJeoa"}, + {109, "144w7WJhkpm9M9k9xYxQdmyNxgPiY33L6v"}, + + {1000, "1JGxd9xgBpYp4z7XHS9ezonfUTEuSoQv7y"}, + } + + for _, child := range expectedChildren { + address, err := generator.Generate(child.index) + assert.NoError(t, err) + assert.Equal(t, child.address, address) + } +} diff --git a/services/bifrost/bitcoin/listener.go b/services/bifrost/bitcoin/listener.go new file mode 100644 index 0000000000..e1c044a798 --- /dev/null +++ b/services/bifrost/bitcoin/listener.go @@ -0,0 +1,181 @@ +package bitcoin + +import ( + "strings" + "time" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/stellar/go/services/bifrost/common" + "github.com/stellar/go/support/errors" + "github.com/stellar/go/support/log" +) + +func (l *Listener) Start() error { + l.log = common.CreateLogger("BitcoinListener") + l.log.Info("BitcoinListener starting") + + genesisBlockHash, err := l.Client.GetBlockHash(0) + if err != nil { + return errors.Wrap(err, "Error getting genesis block") + } + + if l.Testnet { + l.chainParams = &chaincfg.TestNet3Params + } else { + l.chainParams = &chaincfg.MainNetParams + } + + if !genesisBlockHash.IsEqual(l.chainParams.GenesisHash) { + return errors.New("Invalid genesis hash") + } + + blockNumber, err := l.Storage.GetBitcoinBlockToProcess() + if err != nil { + err = errors.Wrap(err, "Error getting bitcoin block to process from DB") + l.log.Error(err) + return err + } + + if blockNumber == 0 { + blockNumberTmp, err := l.Client.GetBlockCount() + if err != nil { + err = errors.Wrap(err, "Error getting the block count from bitcoin-core") + l.log.Error(err) + return err + } + blockNumber = uint64(blockNumberTmp) + } + + go l.processBlocks(blockNumber) + return nil +} + +func (l *Listener) processBlocks(blockNumber uint64) { + l.log.Infof("Starting from block %d", blockNumber) + + // Time when last new block has been seen + lastBlockSeen := time.Now() + missingBlockWarningLogged := false + + for { + block, err := l.getBlock(blockNumber) + if err != nil { + l.log.WithFields(log.F{"err": err, "blockNumber": blockNumber}).Error("Error getting block") + time.Sleep(time.Second) + continue + } + + // Block doesn't exist yet + if block == nil { + if time.Since(lastBlockSeen) > 20*time.Minute && !missingBlockWarningLogged { + l.log.Warn("No new block in more than 20 minutes") + missingBlockWarningLogged = true + } + + time.Sleep(time.Second) + continue + } + + // Reset counter when new block appears + lastBlockSeen = time.Now() + missingBlockWarningLogged = false + + err = l.processBlock(block) + if err != nil { + l.log.WithFields(log.F{"err": err, "blockHash": block.Header.BlockHash().String()}).Error("Error processing block") + time.Sleep(time.Second) + continue + } + + // Persist block number + err = l.Storage.SaveLastProcessedBitcoinBlock(blockNumber) + if err != nil { + l.log.WithField("err", err).Error("Error saving last processed block") + time.Sleep(time.Second) + // We continue to the next block. + // The idea behind this is if there was a problem with this single query we want to + // continue processing because it's safe to reprocess blocks and we don't want a downtime. + } + + blockNumber++ + } +} + +// getBlock returns (nil, nil) if block has not been found (not exists yet) +func (l *Listener) getBlock(blockNumber uint64) (*wire.MsgBlock, error) { + blockHeight := int64(blockNumber) + blockHash, err := l.Client.GetBlockHash(blockHeight) + if err != nil { + if strings.Contains(err.Error(), "Block height out of range") { + // Block does not exist yet + return nil, nil + } + err = errors.Wrap(err, "Error getting block hash from bitcoin-core") + l.log.WithField("blockHeight", blockHeight).Error(err) + return nil, err + } + + block, err := l.Client.GetBlock(blockHash) + if err != nil { + err = errors.Wrap(err, "Error getting block from bitcoin-core") + l.log.WithField("blockHash", blockHash.String()).Error(err) + return nil, err + } + + return block, nil +} + +func (l *Listener) processBlock(block *wire.MsgBlock) error { + transactions := block.Transactions + + localLog := l.log.WithFields(log.F{ + "blockHash": block.Header.BlockHash().String(), + "blockTime": block.Header.Timestamp, + "transactions": len(transactions), + }) + localLog.Info("Processing block") + + for _, transaction := range transactions { + transactionLog := localLog.WithField("transactionHash", transaction.TxHash().String()) + + for index, output := range transaction.TxOut { + class, addresses, _, err := txscript.ExtractPkScriptAddrs(output.PkScript, l.chainParams) + if err != nil { + // txscript.ExtractPkScriptAddrs returns error on non-standard scripts + // so this can be Warn. + transactionLog.WithField("err", err).Warn("Error extracting addresses") + continue + } + + // We only support P2PK and P2PKH addresses + if class != txscript.PubKeyTy && class != txscript.PubKeyHashTy { + transactionLog.WithField("class", class).Debug("Unsupported addresses class") + continue + } + + // Paranoid. We access address[0] later. + if len(addresses) != 1 { + transactionLog.WithField("addresses", addresses).Error("Invalid addresses length") + continue + } + + handlerTransaction := Transaction{ + Hash: transaction.TxHash().String(), + TxOutIndex: index, + ValueSat: output.Value, + To: addresses[0].EncodeAddress(), + } + + err = l.TransactionHandler(handlerTransaction) + if err != nil { + return errors.Wrap(err, "Error processing transaction") + } + } + } + + localLog.Info("Processed block") + + return nil +} diff --git a/services/bifrost/bitcoin/main.go b/services/bifrost/bitcoin/main.go new file mode 100644 index 0000000000..aa27c7151e --- /dev/null +++ b/services/bifrost/bitcoin/main.go @@ -0,0 +1,87 @@ +package bitcoin + +import ( + "math/big" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/stellar/go/support/errors" + "github.com/stellar/go/support/log" + "github.com/tyler-smith/go-bip32" +) + +var ( + eight = big.NewInt(8) + ten = big.NewInt(10) + // satInBtc = 10^8 + satInBtc = new(big.Rat).SetInt(new(big.Int).Exp(ten, eight, nil)) +) + +// Listener listens for transactions using bitcoin-core RPC. It calls TransactionHandler for each new +// transactions. It will reprocess the block if TransactionHandler returns error. It will +// start from the block number returned from Storage.GetBitcoinBlockToProcess or the latest block +// if it returned 0. Transactions can be processed more than once, it's TransactionHandler +// responsibility to ignore duplicates. +// Listener tracks only P2PKH payments. +// You can run multiple Listeners if Storage is implemented correctly. +type Listener struct { + Enabled bool + Client Client `inject:""` + Storage Storage `inject:""` + TransactionHandler TransactionHandler + Testnet bool + + chainParams *chaincfg.Params + log *log.Entry +} + +type Client interface { + GetBlockCount() (int64, error) + GetBlockHash(blockHeight int64) (*chainhash.Hash, error) + GetBlock(blockHash *chainhash.Hash) (*wire.MsgBlock, error) +} + +// Storage is an interface that must be implemented by an object using +// persistent storage. +type Storage interface { + // GetBitcoinBlockToProcess gets the number of Bitcoin block to process. `0` means the + // processing should start from the current block. + GetBitcoinBlockToProcess() (uint64, error) + // SaveLastProcessedBitcoinBlock should update the number of the last processed Bitcoin + // block. It should only update the block if block > current block in atomic transaction. + SaveLastProcessedBitcoinBlock(block uint64) error +} + +type TransactionHandler func(transaction Transaction) error + +type Transaction struct { + Hash string + TxOutIndex int + // Value in sats + ValueSat int64 + To string +} + +type AddressGenerator struct { + masterPublicKey *bip32.Key + chainParams *chaincfg.Params +} + +func BtcToSat(btc string) (int64, error) { + valueRat := new(big.Rat) + _, ok := valueRat.SetString(btc) + if !ok { + return 0, errors.New("Could not convert to *big.Rat") + } + + // Calculate value in satoshi + valueRat.Mul(valueRat, satInBtc) + + // Ensure denominator is equal `1` + if valueRat.Denom().Cmp(big.NewInt(1)) != 0 { + return 0, errors.New("Invalid precision, is value smaller than 1 satoshi?") + } + + return valueRat.Num().Int64(), nil +} diff --git a/services/bifrost/bitcoin/main_test.go b/services/bifrost/bitcoin/main_test.go new file mode 100644 index 0000000000..fb1c65ee28 --- /dev/null +++ b/services/bifrost/bitcoin/main_test.go @@ -0,0 +1,40 @@ +package bitcoin + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBtcToSat(t *testing.T) { + tests := []struct { + amount string + expectedAmount int64 + expectedError string + }{ + {"", 0, "Could not convert to *big.Rat"}, + {"test", 0, "Could not convert to *big.Rat"}, + {"0.000000001", 0, "Invalid precision"}, + {"1.234567891", 0, "Invalid precision"}, + + {"0", 0, ""}, + {"0.00", 0, ""}, + {"1", 100000000, ""}, + {"1.00", 100000000, ""}, + {"1.23456789", 123456789, ""}, + {"1.234567890", 123456789, ""}, + {"0.00000001", 1, ""}, + {"21000000.12345678", 2100000012345678, ""}, + } + + for _, test := range tests { + returnedAmount, err := BtcToSat(test.amount) + if test.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), test.expectedError) + } else { + assert.NoError(t, err) + assert.Equal(t, test.expectedAmount, returnedAmount) + } + } +} diff --git a/services/bifrost/bitcoin/transaction.go b/services/bifrost/bitcoin/transaction.go new file mode 100644 index 0000000000..8a4ed98c1b --- /dev/null +++ b/services/bifrost/bitcoin/transaction.go @@ -0,0 +1,13 @@ +package bitcoin + +import ( + "math/big" + + "github.com/stellar/go/services/bifrost/common" +) + +func (t Transaction) ValueToStellar() string { + valueSat := new(big.Int).SetInt64(t.ValueSat) + valueBtc := new(big.Rat).Quo(new(big.Rat).SetInt(valueSat), satInBtc) + return valueBtc.FloatString(common.StellarAmountPrecision) +} diff --git a/services/bifrost/bitcoin/transaction_test.go b/services/bifrost/bitcoin/transaction_test.go new file mode 100644 index 0000000000..33d88a97bd --- /dev/null +++ b/services/bifrost/bitcoin/transaction_test.go @@ -0,0 +1,29 @@ +package bitcoin + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTransactionAmount(t *testing.T) { + tests := []struct { + amount int64 + expectedStellarAmount string + }{ + {1, "0.0000000"}, + {4, "0.0000000"}, + {5, "0.0000001"}, + {10, "0.0000001"}, + {12345674, "0.1234567"}, + {12345678, "0.1234568"}, + {100000000, "1.0000000"}, + {2100000000000000, "21000000.0000000"}, + } + + for _, test := range tests { + transaction := Transaction{ValueSat: test.amount} + amount := transaction.ValueToStellar() + assert.Equal(t, test.expectedStellarAmount, amount) + } +} diff --git a/services/bifrost/common/main.go b/services/bifrost/common/main.go new file mode 100644 index 0000000000..588d4561ef --- /dev/null +++ b/services/bifrost/common/main.go @@ -0,0 +1,11 @@ +package common + +import ( + "github.com/stellar/go/support/log" +) + +const StellarAmountPrecision = 7 + +func CreateLogger(serviceName string) *log.Entry { + return log.DefaultLogger.WithField("service", serviceName) +} diff --git a/services/bifrost/config/main.go b/services/bifrost/config/main.go new file mode 100644 index 0000000000..6a36053086 --- /dev/null +++ b/services/bifrost/config/main.go @@ -0,0 +1,52 @@ +package config + +type Config struct { + Port int `valid:"required"` + UsingProxy bool `valid:"optional" toml:"using_proxy"` + Bitcoin *bitcoinConfig `valid:"optional" toml:"bitcoin"` + Ethereum *ethereumConfig `valid:"optional" toml:"ethereum"` + AccessControlAllowOriginHeader string `valid:"optional" toml:"access-control-allow-origin-header"` + + Stellar struct { + Horizon string `valid:"required" toml:"horizon"` + NetworkPassphrase string `valid:"required" toml:"network_passphrase"` + // TokenAssetCode is asset code of token that will be purchased using BTC or ETH. + TokenAssetCode string `valid:"required" toml:"token_asset_code"` + // NeedsAuthorize should be set to true if issuers's authorization required flag is set. + NeedsAuthorize bool `valid:"optional" toml:"needs_authorize"` + // IssuerPublicKey is public key of the assets issuer or hot wallet. + IssuerPublicKey string `valid:"required" toml:"issuer_public_key"` + // SignerSecretKey is: + // * Issuer's secret key if only one instance of Bifrost is deployed. + // * Channel's secret key if more than one instance of Bifrost is deployed. + // https://www.stellar.org/developers/guides/channels.html + // Signer's sequence number will be consumed in transaction's sequence number. + SignerSecretKey string `valid:"required" toml:"signer_secret_key"` + } `valid:"required" toml:"stellar"` + Database struct { + Type string `valid:"matches(^postgres$)"` + DSN string `valid:"required"` + } `valid:"required"` +} + +type bitcoinConfig struct { + MasterPublicKey string `valid:"required" toml:"master_public_key"` + // Minimum value of transaction accepted by Bifrost in BTC. + // Everything below will be ignored. + MinimumValueBtc string `valid:"required" toml:"minimum_value_btc"` + // Host only + RpcServer string `valid:"required" toml:"rpc_server"` + RpcUser string `valid:"optional" toml:"rpc_user"` + RpcPass string `valid:"optional" toml:"rpc_pass"` + Testnet bool `valid:"optional" toml:"testnet"` +} + +type ethereumConfig struct { + NetworkID string `valid:"required,int" toml:"network_id"` + MasterPublicKey string `valid:"required" toml:"master_public_key"` + // Minimum value of transaction accepted by Bifrost in ETH. + // Everything below will be ignored. + MinimumValueEth string `valid:"required" toml:"minimum_value_eth"` + // Host only + RpcServer string `valid:"required" toml:"rpc_server"` +} diff --git a/services/bifrost/database/main.go b/services/bifrost/database/main.go new file mode 100644 index 0000000000..4cd2b37e80 --- /dev/null +++ b/services/bifrost/database/main.go @@ -0,0 +1,65 @@ +package database + +import ( + "time" + + "github.com/stellar/go/support/db" + "github.com/stellar/go/support/errors" +) + +type Chain string + +// Scan implements database/sql.Scanner interface +func (s *Chain) Scan(src interface{}) error { + value, ok := src.([]byte) + if !ok { + return errors.New("Cannot convert value to Chain") + } + *s = Chain(value) + return nil +} + +const ( + ChainBitcoin Chain = "bitcoin" + ChainEthereum Chain = "ethereum" +) + +type Database interface { + // CreateAddressAssociation creates Bitcoin/Ethereum-Stellar association. `addressIndex` + // is the chain (Bitcoin/Ethereum) address derivation index (BIP-32). + CreateAddressAssociation(chain Chain, stellarAddress, address string, addressIndex uint32) error + // GetAssociationByChainAddress searches for previously saved Bitcoin/Ethereum-Stellar association. + // Should return nil if not found. + GetAssociationByChainAddress(chain Chain, address string) (*AddressAssociation, error) + // GetAssociationByStellarPublicKey searches for previously saved Bitcoin/Ethereum-Stellar association. + // Should return nil if not found. + GetAssociationByStellarPublicKey(stellarPublicKey string) (*AddressAssociation, error) + // AddProcessedTransaction adds a transaction to database as processed. This + // should return `true` and no error if transaction processing has already started/finished. + AddProcessedTransaction(chain Chain, transactionID, receivingAddress string) (alreadyProcessing bool, err error) + // IncrementAddressIndex returns the current value of index used for `chain` key + // derivation and then increments it. This operation must be atomic so this function + // should never return the same value more than once. + IncrementAddressIndex(chain Chain) (uint32, error) + + // ResetBlockCounters changes last processed bitcoin and ethereum block to default value. + // Used in stress tests. + ResetBlockCounters() error + + // AddRecoveryTransaction inserts recovery account ID and transaction envelope + AddRecoveryTransaction(sourceAccount string, txEnvelope string) error +} + +type PostgresDatabase struct { + session *db.Session +} + +type AddressAssociation struct { + // Chain is the name of the payment origin chain + Chain Chain `db:"chain"` + // BIP-44 + AddressIndex uint32 `db:"address_index"` + Address string `db:"address"` + StellarPublicKey string `db:"stellar_public_key"` + CreatedAt time.Time `db:"created_at"` +} diff --git a/services/bifrost/database/migrations/01_init.sql b/services/bifrost/database/migrations/01_init.sql new file mode 100644 index 0000000000..8dbe761a92 --- /dev/null +++ b/services/bifrost/database/migrations/01_init.sql @@ -0,0 +1,74 @@ +CREATE TYPE chain AS ENUM ('bitcoin', 'ethereum'); + +CREATE TABLE address_association ( + chain chain NOT NULL, + address_index bigint NOT NULL, + /* bitcoin 34 characters */ + /* ethereum 42 characters */ + address varchar(42) NOT NULL UNIQUE, + stellar_public_key varchar(56) NOT NULL UNIQUE, + created_at timestamp NOT NULL, + PRIMARY KEY (chain, address_index, address, stellar_public_key), + CONSTRAINT valid_address_index CHECK (address_index >= 0) +); + +CREATE TABLE key_value_store ( + key varchar(255) NOT NULL, + value varchar(255) NOT NULL, + PRIMARY KEY (key) +); + +INSERT INTO key_value_store (key, value) VALUES ('ethereum_address_index', '0'); +INSERT INTO key_value_store (key, value) VALUES ('ethereum_last_block', '0'); + +INSERT INTO key_value_store (key, value) VALUES ('bitcoin_address_index', '0'); +INSERT INTO key_value_store (key, value) VALUES ('bitcoin_last_block', '0'); + +CREATE TABLE processed_transaction ( + chain chain NOT NULL, + /* Ethereum: "0x"+hash (so 64+2) */ + transaction_id varchar(66) NOT NULL, + /* bitcoin 34 characters */ + /* ethereum 42 characters */ + receiving_address varchar(42) NOT NULL, + created_at timestamp NOT NULL, + PRIMARY KEY (chain, transaction_id) +); + +/* If using DB storage for the queue not AWS FIFO */ +CREATE TABLE transactions_queue ( + id bigserial, + /* Ethereum: "0x"+hash (so 64+2) */ + transaction_id varchar(66) NOT NULL, + asset_code varchar(3) NOT NULL, + /* Amount in the base unit of currency (BTC or ETH). */ + /* ethereum: 100000000 in year 2128 + 7 decimal precision in Stellar + dot */ + /* bitcoin: 21000000 + 7 decimal precision in Stellar + dot */ + amount varchar(20) NOT NULL, + stellar_public_key varchar(56) NOT NULL, + pooled boolean NOT NULL DEFAULT false, + PRIMARY KEY (id), + UNIQUE (transaction_id, asset_code), + CONSTRAINT valid_asset_code CHECK (char_length(asset_code) = 3), + CONSTRAINT valid_stellar_public_key CHECK (char_length(stellar_public_key) = 56) +); + +CREATE TYPE event AS ENUM ('transaction_received', 'account_created', 'account_credited'); + +CREATE TABLE broadcasted_event ( + id bigserial, + /* bitcoin 34 characters */ + /* ethereum 42 characters */ + address varchar(42) NOT NULL, + event event NOT NULL, + data varchar(255) NOT NULL, + PRIMARY KEY (id), + UNIQUE (address, event) +); + +CREATE TABLE recovery_transaction ( + source varchar(56) NOT NULL, + envelope_xdr text NOT NULL +); + +CREATE INDEX source_index ON recovery_transaction (source); diff --git a/services/bifrost/database/mock.go b/services/bifrost/database/mock.go new file mode 100644 index 0000000000..f6c73391df --- /dev/null +++ b/services/bifrost/database/mock.go @@ -0,0 +1,48 @@ +package database + +import ( + "github.com/stretchr/testify/mock" +) + +// MockDatabase is a mockable database. +type MockDatabase struct { + mock.Mock +} + +func (m *MockDatabase) CreateAddressAssociation(chain Chain, stellarAddress, address string, addressIndex uint32) error { + a := m.Called(chain, stellarAddress, address, addressIndex) + return a.Error(0) +} + +func (m *MockDatabase) GetAssociationByChainAddress(chain Chain, address string) (*AddressAssociation, error) { + a := m.Called(chain, address) + if a.Get(0) == nil { + return nil, a.Error(1) + } + return a.Get(0).(*AddressAssociation), a.Error(1) +} + +func (m *MockDatabase) GetAssociationByStellarPublicKey(stellarPublicKey string) (*AddressAssociation, error) { + a := m.Called(stellarPublicKey) + return a.Get(0).(*AddressAssociation), a.Error(1) +} + +func (m *MockDatabase) AddProcessedTransaction(chain Chain, transactionID, receivingAddress string) (alreadyProcessing bool, err error) { + a := m.Called(chain, transactionID, receivingAddress) + return a.Get(0).(bool), a.Error(1) +} + +func (m *MockDatabase) IncrementAddressIndex(chain Chain) (uint32, error) { + a := m.Called(chain) + return a.Get(0).(uint32), a.Error(1) +} + +func (m *MockDatabase) ResetBlockCounters() error { + a := m.Called() + return a.Error(0) +} + +func (m *MockDatabase) AddRecoveryTransaction(sourceAccount string, txEnvelope string) error { + a := m.Called(sourceAccount, txEnvelope) + return a.Error(0) +} diff --git a/services/bifrost/database/postgres.go b/services/bifrost/database/postgres.go new file mode 100644 index 0000000000..8f3b4558d0 --- /dev/null +++ b/services/bifrost/database/postgres.go @@ -0,0 +1,440 @@ +package database + +import ( + "database/sql" + "strconv" + "strings" + "time" + + "github.com/stellar/go/services/bifrost/queue" + "github.com/stellar/go/services/bifrost/sse" + "github.com/stellar/go/support/db" + "github.com/stellar/go/support/errors" +) + +const ( + ethereumAddressIndexKey = "ethereum_address_index" + ethereumLastBlockKey = "ethereum_last_block" + + bitcoinAddressIndexKey = "bitcoin_address_index" + bitcoinLastBlockKey = "bitcoin_last_block" + + addressAssociationTableName = "address_association" + broadcastedEventTableName = "broadcasted_event" + keyValueStoreTableName = "key_value_store" + processedTransactionTableName = "processed_transaction" + transactionsQueueTableName = "transactions_queue" + recoveryTransactionTableName = "recovery_transaction" +) + +type keyValueStoreRow struct { + Key string `db:"key"` + Value string `db:"value"` +} + +type broadcastedEventRow struct { + ID int64 `db:"id"` + Address string `db:"address"` + Event string `db:"event"` + Data string `db:"data"` +} + +func (b *broadcastedEventRow) toSSE() sse.Event { + return sse.Event{ + Address: b.Address, + Event: sse.AddressEvent(b.Event), + Data: b.Data, + } +} + +type transactionsQueueRow struct { + TransactionID string `db:"transaction_id"` + AssetCode queue.AssetCode `db:"asset_code"` + Amount string `db:"amount"` + StellarPublicKey string `db:"stellar_public_key"` +} + +type processedTransactionRow struct { + Chain Chain `db:"chain"` + TransactionID string `db:"transaction_id"` + ReceivingAddress string `db:"receiving_address"` + CreatedAt time.Time `db:"created_at"` +} + +type recoveryTransactionRow struct { + Source string `db:"source"` + EnvelopeXDR string `db:"envelope_xdr"` +} + +func fromQueueTransaction(tx queue.Transaction) *transactionsQueueRow { + return &transactionsQueueRow{ + TransactionID: tx.TransactionID, + AssetCode: tx.AssetCode, + Amount: tx.Amount, + StellarPublicKey: tx.StellarPublicKey, + } +} + +func isDuplicateError(err error) bool { + return strings.Contains(err.Error(), "duplicate key value violates unique constraint") +} + +func (r *transactionsQueueRow) toQueueTransaction() *queue.Transaction { + return &queue.Transaction{ + TransactionID: r.TransactionID, + AssetCode: r.AssetCode, + Amount: r.Amount, + StellarPublicKey: r.StellarPublicKey, + } +} + +func (d *PostgresDatabase) Open(dsn string) error { + var err error + d.session, err = db.Open("postgres", dsn) + if err != nil { + return err + } + + return nil +} + +func (d *PostgresDatabase) getTable(name string, session *db.Session) *db.Table { + if session == nil { + session = d.session + } + + return &db.Table{ + Name: name, + Session: session, + } +} + +func (d *PostgresDatabase) CreateAddressAssociation(chain Chain, stellarAddress, address string, addressIndex uint32) error { + addressAssociationTable := d.getTable(addressAssociationTableName, nil) + + association := &AddressAssociation{ + Chain: chain, + AddressIndex: addressIndex, + Address: address, + StellarPublicKey: stellarAddress, + CreatedAt: time.Now(), + } + + _, err := addressAssociationTable.Insert(association).Exec() + return err +} + +func (d *PostgresDatabase) GetAssociationByChainAddress(chain Chain, address string) (*AddressAssociation, error) { + addressAssociationTable := d.getTable(addressAssociationTableName, nil) + row := &AddressAssociation{} + where := map[string]interface{}{"address": address, "chain": chain} + err := addressAssociationTable.Get(row, where).Exec() + if err != nil { + switch errors.Cause(err) { + case sql.ErrNoRows: + return nil, nil + default: + return nil, errors.Wrap(err, "Error getting addressAssociation from DB") + } + } + + return row, nil +} + +func (d *PostgresDatabase) GetAssociationByStellarPublicKey(stellarPublicKey string) (*AddressAssociation, error) { + addressAssociationTable := d.getTable(addressAssociationTableName, nil) + row := &AddressAssociation{} + where := map[string]interface{}{"stellar_public_key": stellarPublicKey} + err := addressAssociationTable.Get(row, where).Exec() + if err != nil { + switch errors.Cause(err) { + case sql.ErrNoRows: + return nil, nil + default: + return nil, errors.Wrap(err, "Error getting addressAssociation from DB") + } + } + + return row, nil +} + +func (d *PostgresDatabase) AddProcessedTransaction(chain Chain, transactionID, receivingAddress string) (bool, error) { + processedTransactionTable := d.getTable(processedTransactionTableName, nil) + processedTransaction := processedTransactionRow{chain, transactionID, receivingAddress, time.Now()} + _, err := processedTransactionTable.Insert(processedTransaction).Exec() + if err != nil && isDuplicateError(err) { + return true, nil + } + return false, err +} + +func (d *PostgresDatabase) IncrementAddressIndex(chain Chain) (uint32, error) { + var key string + switch chain { + case ChainBitcoin: + key = bitcoinAddressIndexKey + case ChainEthereum: + key = ethereumAddressIndexKey + default: + return 0, errors.New("Invalid chain") + } + + row := keyValueStoreRow{} + + session := d.session.Clone() + keyValueStore := d.getTable(keyValueStoreTableName, session) + + err := session.Begin() + if err != nil { + return 0, errors.Wrap(err, "Error starting a new transaction") + } + defer session.Rollback() + + err = keyValueStore.Get(&row, map[string]interface{}{"key": key}).Suffix("FOR UPDATE").Exec() + if err != nil { + return 0, errors.Wrap(err, "Error getting `"+key+"` from DB") + } + + // TODO check for overflows - should we create a new account 1'? + index, err := strconv.ParseUint(row.Value, 10, 32) + if err != nil { + return 0, errors.Wrap(err, "Error converting `"+key+"` value to uint32") + } + + index++ + + // TODO: something's wrong with db.Table.Update(). Setting the first argument does not work as expected. + _, err = keyValueStore.Update(nil, map[string]interface{}{"key": key}).Set("value", index).Exec() + if err != nil { + return 0, errors.Wrap(err, "Error updating `"+key+"`") + } + + err = session.Commit() + if err != nil { + return 0, errors.Wrap(err, "Error commiting a transaction") + } + + return uint32(index), nil +} + +func (d *PostgresDatabase) ResetBlockCounters() error { + keyValueStore := d.getTable(keyValueStoreTableName, nil) + + _, err := keyValueStore.Update(nil, map[string]interface{}{"key": bitcoinLastBlockKey}).Set("value", 0).Exec() + if err != nil { + return errors.Wrap(err, "Error reseting `bitcoinLastBlockKey`") + } + + _, err = keyValueStore.Update(nil, map[string]interface{}{"key": ethereumLastBlockKey}).Set("value", 0).Exec() + if err != nil { + return errors.Wrap(err, "Error reseting `ethereumLastBlockKey`") + } + + return nil +} + +func (d *PostgresDatabase) GetEthereumBlockToProcess() (uint64, error) { + return d.getBlockToProcess(ethereumLastBlockKey) +} + +func (d *PostgresDatabase) SaveLastProcessedEthereumBlock(block uint64) error { + return d.saveLastProcessedBlock(ethereumLastBlockKey, block) +} + +func (d *PostgresDatabase) GetBitcoinBlockToProcess() (uint64, error) { + return d.getBlockToProcess(bitcoinLastBlockKey) +} + +func (d *PostgresDatabase) SaveLastProcessedBitcoinBlock(block uint64) error { + return d.saveLastProcessedBlock(bitcoinLastBlockKey, block) +} + +func (d *PostgresDatabase) getBlockToProcess(key string) (uint64, error) { + keyValueStore := d.getTable(keyValueStoreTableName, nil) + row := keyValueStoreRow{} + + err := keyValueStore.Get(&row, map[string]interface{}{"key": key}).Exec() + if err != nil { + return 0, errors.Wrap(err, "Error getting `"+key+"` from DB") + } + + block, err := strconv.ParseUint(row.Value, 10, 64) + if err != nil { + return 0, errors.Wrap(err, "Error converting `"+key+"` value to uint64") + } + + // If set, `block` is the last processed block so we need to start processing from the next one. + if block > 0 { + block++ + } + return block, nil +} + +func (d *PostgresDatabase) saveLastProcessedBlock(key string, block uint64) error { + row := keyValueStoreRow{} + + session := d.session.Clone() + keyValueStore := d.getTable(keyValueStoreTableName, session) + + err := session.Begin() + if err != nil { + return errors.Wrap(err, "Error starting a new transaction") + } + defer session.Rollback() + + err = keyValueStore.Get(&row, map[string]interface{}{"key": key}).Suffix("FOR UPDATE").Exec() + if err != nil { + return errors.Wrap(err, "Error getting `"+key+"` from DB") + } + + lastBlock, err := strconv.ParseUint(row.Value, 10, 64) + if err != nil { + return errors.Wrap(err, "Error converting `"+key+"` value to uint32") + } + + if block > lastBlock { + // TODO: something's wrong with db.Table.Update(). Setting the first argument does not work as expected. + _, err = keyValueStore.Update(nil, map[string]interface{}{"key": key}).Set("value", block).Exec() + if err != nil { + return errors.Wrap(err, "Error updating `"+key+"`") + } + } + + err = session.Commit() + if err != nil { + return errors.Wrap(err, "Error commiting a transaction") + } + + return nil +} + +// QueueAdd implements queue.Queue interface. If element already exists in a queue, it should +// return nil. +func (d *PostgresDatabase) QueueAdd(tx queue.Transaction) error { + transactionsQueueTable := d.getTable(transactionsQueueTableName, nil) + transactionQueue := fromQueueTransaction(tx) + _, err := transactionsQueueTable.Insert(transactionQueue).Exec() + if err != nil { + if isDuplicateError(err) { + return nil + } + } + return err +} + +// QueuePool receives and removes the head of this queue. Returns nil if no elements found. +// QueuePool implements queue.Queue interface. +func (d *PostgresDatabase) QueuePool() (*queue.Transaction, error) { + row := transactionsQueueRow{} + + session := d.session.Clone() + transactionsQueueTable := d.getTable(transactionsQueueTableName, session) + + err := session.Begin() + if err != nil { + return nil, errors.Wrap(err, "Error starting a new transaction") + } + defer session.Rollback() + + err = transactionsQueueTable.Get(&row, map[string]interface{}{"pooled": false}).OrderBy("id ASC").Suffix("FOR UPDATE").Exec() + if err != nil { + switch errors.Cause(err) { + case sql.ErrNoRows: + return nil, nil + default: + return nil, errors.Wrap(err, "Error getting transaction from a queue") + } + } + + // TODO: something's wrong with db.Table.Update(). Setting the first argument does not work as expected. + where := map[string]interface{}{"transaction_id": row.TransactionID, "asset_code": row.AssetCode} + _, err = transactionsQueueTable.Update(nil, where).Set("pooled", true).Exec() + if err != nil { + return nil, errors.Wrap(err, "Error setting transaction as pooled in a queue") + } + + err = session.Commit() + if err != nil { + return nil, errors.Wrap(err, "Error commiting a transaction") + } + + return row.toQueueTransaction(), nil +} + +// AddEvent adds a new server-sent event to the storage. +func (d *PostgresDatabase) AddEvent(event sse.Event) error { + broadcastedEventTable := d.getTable(broadcastedEventTableName, nil) + _, err := broadcastedEventTable.Insert(event).Exec() + if err != nil { + if isDuplicateError(err) { + return nil + } + } + return err +} + +// GetEventsSinceID returns all events since `id`. Used to load and publish +// all broadcasted events. +// It returns the last event ID, list of events or error. +// If `id` is equal `-1`: +// * it should return the last event ID and empty list if at least one +// event has been broadcasted. +// * it should return `0` if no events have been broadcasted. +func (d *PostgresDatabase) GetEventsSinceID(id int64) (int64, []sse.Event, error) { + if id == -1 { + lastID, err := d.getEventsLastID() + return lastID, nil, err + } + + broadcastedEventTable := d.getTable(broadcastedEventTableName, nil) + rows := []broadcastedEventRow{} + err := broadcastedEventTable.Select(&rows, "id > ?", id).Exec() + if err != nil { + switch errors.Cause(err) { + case sql.ErrNoRows: + return 0, nil, nil + default: + return 0, nil, errors.Wrap(err, "Error getting broadcastedEvent's from DB") + } + } + + var lastID int64 + returnRows := make([]sse.Event, len(rows)) + if len(rows) > 0 { + lastID = rows[len(rows)-1].ID + for i, event := range rows { + returnRows[i] = event.toSSE() + } + } + + return lastID, returnRows, nil +} + +// Returns the last event ID from broadcasted_event table. +func (d *PostgresDatabase) getEventsLastID() (int64, error) { + row := struct { + ID int64 `db:"id"` + }{} + broadcastedEventTable := d.getTable(broadcastedEventTableName, nil) + // TODO: `1=1`. We should be able to get row without WHERE clause. + // When it's set to nil: `pq: syntax error at or near "ORDER""` + err := broadcastedEventTable.Get(&row, "1=1").OrderBy("id DESC").Exec() + if err != nil { + switch errors.Cause(err) { + case sql.ErrNoRows: + return 0, nil + default: + return 0, errors.Wrap(err, "Error getting events last ID") + } + } + + return row.ID, nil +} + +func (d *PostgresDatabase) AddRecoveryTransaction(sourceAccount string, txEnvelope string) error { + recoveryTransactionTable := d.getTable(recoveryTransactionTableName, nil) + recoveryTransaction := recoveryTransactionRow{Source: sourceAccount, EnvelopeXDR: txEnvelope} + + _, err := recoveryTransactionTable.Insert(recoveryTransaction).Exec() + return err +} diff --git a/services/bifrost/ethereum/address_generator.go b/services/bifrost/ethereum/address_generator.go new file mode 100644 index 0000000000..e0d3a7b426 --- /dev/null +++ b/services/bifrost/ethereum/address_generator.go @@ -0,0 +1,40 @@ +package ethereum + +import ( + ethereumCommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/haltingstate/secp256k1-go" + "github.com/stellar/go/support/errors" + "github.com/tyler-smith/go-bip32" +) + +func NewAddressGenerator(masterPublicKeyString string) (*AddressGenerator, error) { + deserializedMasterPublicKey, err := bip32.B58Deserialize(masterPublicKeyString) + if err != nil { + return nil, errors.Wrap(err, "Error deserializing master public key") + } + + if deserializedMasterPublicKey.IsPrivate { + return nil, errors.New("Key is not a master public key") + } + + return &AddressGenerator{deserializedMasterPublicKey}, nil +} + +func (g *AddressGenerator) Generate(index uint32) (string, error) { + if g.masterPublicKey == nil { + return "", errors.New("No master public key set") + } + + accountKey, err := g.masterPublicKey.NewChildKey(index) + if err != nil { + return "", errors.Wrap(err, "Error creating new child key") + } + + uncompressed := secp256k1.UncompressPubkey(accountKey.Key) + uncompressed = uncompressed[1:] + + keccak := crypto.Keccak256(uncompressed) + address := ethereumCommon.BytesToAddress(keccak[12:]).Hex() // Encode lower 160 bits/20 bytes + return address, nil +} diff --git a/services/bifrost/ethereum/address_generator_test.go b/services/bifrost/ethereum/address_generator_test.go new file mode 100644 index 0000000000..b7e8e20040 --- /dev/null +++ b/services/bifrost/ethereum/address_generator_test.go @@ -0,0 +1,53 @@ +package ethereum + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAddressGenerator(t *testing.T) { + // Generated using https://iancoleman.github.io/bip39/ + // Root key: + // xprv9s21ZrQH143K2Cfj4mDZBcEecBmJmawReGwwoAou2zZzG45bM6cFPJSvobVTCB55L6Ld2y8RzC61CpvadeAnhws3CHsMFhNjozBKGNgucYm + // Derivation Path m/44'/60'/0'/0: + // xprv9zy5o7z1GMmYdaeQdmabWFhUf52Ytbpe3G5hduA4SghboqWe7aDGWseN8BJy1GU72wPjkCbBE1hvbXYqpCecAYdaivxjNnBoSNxwYD4wHpW + // xpub6DxSCdWu6jKqr4isjo7bsPeDD6s3J4YVQV1JSHZg12Eagdqnf7XX4fxqyW2sLhUoFWutL7tAELU2LiGZrEXtjVbvYptvTX5Eoa4Mamdjm9u + generator, err := NewAddressGenerator("xpub6DxSCdWu6jKqr4isjo7bsPeDD6s3J4YVQV1JSHZg12Eagdqnf7XX4fxqyW2sLhUoFWutL7tAELU2LiGZrEXtjVbvYptvTX5Eoa4Mamdjm9u") + assert.NoError(t, err) + + expectedChildren := []struct { + index uint32 + address string + }{ + {0, "0x044d22459b0Ce2eBa60B47ee411F8B6a8f91dF52"}, + {1, "0xc881d34F83001A0c96C422594ea9EBE0c0114973"}, + {2, "0x61203C142Fe744499D819ca5d36753F4461e174D"}, + {3, "0x80D3ee1268DC1A2d1b9E73D49050083E75Ef7c2D"}, + {4, "0x314d1281f7cf78E5EC28DB62194Ef80a91f13b61"}, + {5, "0xD63eC9c99459BB2D9688CC71Eb849fDA142d55C5"}, + {6, "0xd977D20405c549a36A50Be06AE3B754155Fb3dDa"}, + {7, "0xC0dbAe13052CD4F4B9B674496a72Fc02d05aF442"}, + {8, "0x9A44d3447821885Ea60eb708c0EB0e50493Add0F"}, + {9, "0x82ae892Dfe0bED4c4b83780a00F4723D71c19b1D"}, + + {100, "0x7aB27448C69aD3e10A754899151d006285DD0f60"}, + {101, "0xBCdcD65F4Db02CBc99FfbC3Bc045e4BC180f302f"}, + {102, "0x3A0A72A9644DCE86Df1C16Fdeb665a574009d9c4"}, + {103, "0xA21666e6BDF9F58EB422098fbe0850e440E9f53C"}, + {104, "0x208cDC0DF05321E4d3Ae2b6473cdf0c928e2fCd0"}, + {105, "0x30C70532a99f7Ea1186a476A22601966945a2624"}, + {106, "0xc345cA1D1347B6A7a532AFd19ccf30228e438D67"}, + {107, "0x35F58b0A6104E78998ca295C17F37Fc545Ed1E1c"}, + {108, "0x0079022194FF43188c3f3E571c503C15bAb4E3F3"}, + {109, "0x0f7203D9Aa1395D37CCee478E4E24e2bfDe54879"}, + + {1000, "0x02D2DEbeA9A27F964aBEcE49a5d64062637Bd6C5"}, + } + + for _, child := range expectedChildren { + address, err := generator.Generate(child.index) + assert.NoError(t, err) + assert.Equal(t, child.address, address) + } +} diff --git a/services/bifrost/ethereum/listener.go b/services/bifrost/ethereum/listener.go new file mode 100644 index 0000000000..4f0921c34d --- /dev/null +++ b/services/bifrost/ethereum/listener.go @@ -0,0 +1,152 @@ +package ethereum + +import ( + "context" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/stellar/go/services/bifrost/common" + "github.com/stellar/go/support/errors" + "github.com/stellar/go/support/log" +) + +func (l *Listener) Start(rpcServer string) error { + l.log = common.CreateLogger("EthereumListener") + l.log.Info("EthereumListener starting") + + blockNumber, err := l.Storage.GetEthereumBlockToProcess() + if err != nil { + err = errors.Wrap(err, "Error getting ethereum block to process from DB") + l.log.Error(err) + return err + } + + // Check if connected to correct network + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(5*time.Second)) + defer cancel() + id, err := l.Client.NetworkID(ctx) + if err != nil { + err = errors.Wrap(err, "Error getting ethereum network ID") + l.log.Error(err) + return err + } + + if id.String() != l.NetworkID { + return errors.Errorf("Invalid network ID (have=%s, want=%s)", id.String(), l.NetworkID) + } + + go l.processBlocks(blockNumber) + return nil +} + +func (l *Listener) processBlocks(blockNumber uint64) { + if blockNumber == 0 { + l.log.Info("Starting from the latest block") + } else { + l.log.Infof("Starting from block %d", blockNumber) + } + + // Time when last new block has been seen + lastBlockSeen := time.Now() + noBlockWarningLogged := false + + for { + block, err := l.getBlock(blockNumber) + if err != nil { + l.log.WithFields(log.F{"err": err, "blockNumber": blockNumber}).Error("Error getting block") + time.Sleep(1 * time.Second) + continue + } + + // Block doesn't exist yet + if block == nil { + if time.Since(lastBlockSeen) > 3*time.Minute && !noBlockWarningLogged { + l.log.Warn("No new block in more than 3 minutes") + noBlockWarningLogged = true + } + + time.Sleep(1 * time.Second) + continue + } + + // Reset counter when new block appears + lastBlockSeen = time.Now() + noBlockWarningLogged = false + + err = l.processBlock(block) + if err != nil { + l.log.WithFields(log.F{"err": err, "blockNumber": block.NumberU64()}).Error("Error processing block") + time.Sleep(1 * time.Second) + continue + } + + // Persist block number + err = l.Storage.SaveLastProcessedEthereumBlock(blockNumber) + if err != nil { + l.log.WithField("err", err).Error("Error saving last processed block") + time.Sleep(1 * time.Second) + // We continue to the next block + } + + blockNumber = block.NumberU64() + 1 + } +} + +// getBlock returns (nil, nil) if block has not been found (not exists yet) +func (l *Listener) getBlock(blockNumber uint64) (*types.Block, error) { + var blockNumberInt *big.Int + if blockNumber > 0 { + blockNumberInt = big.NewInt(int64(blockNumber)) + } + + d := time.Now().Add(5 * time.Second) + ctx, cancel := context.WithDeadline(context.Background(), d) + defer cancel() + + block, err := l.Client.BlockByNumber(ctx, blockNumberInt) + if err != nil { + if err.Error() == "not found" { + return nil, nil + } + err = errors.Wrap(err, "Error getting block from geth") + l.log.WithField("block", blockNumberInt.String()).Error(err) + return nil, err + } + + return block, nil +} + +func (l *Listener) processBlock(block *types.Block) error { + transactions := block.Transactions() + blockTime := time.Unix(block.Time().Int64(), 0) + + localLog := l.log.WithFields(log.F{ + "blockNumber": block.NumberU64(), + "blockTime": blockTime, + "transactions": len(transactions), + }) + localLog.Info("Processing block") + + for _, transaction := range transactions { + to := transaction.To() + if to == nil { + // Contract creation + continue + } + + tx := Transaction{ + Hash: transaction.Hash().Hex(), + ValueWei: transaction.Value(), + To: to.Hex(), + } + err := l.TransactionHandler(tx) + if err != nil { + return errors.Wrap(err, "Error processing transaction") + } + } + + localLog.Info("Processed block") + + return nil +} diff --git a/services/bifrost/ethereum/main.go b/services/bifrost/ethereum/main.go new file mode 100644 index 0000000000..c34bd37a89 --- /dev/null +++ b/services/bifrost/ethereum/main.go @@ -0,0 +1,83 @@ +package ethereum + +import ( + "context" + "math/big" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/stellar/go/support/errors" + "github.com/stellar/go/support/log" + "github.com/tyler-smith/go-bip32" +) + +var ( + ten = big.NewInt(10) + eighteen = big.NewInt(18) + // weiInEth = 10^18 + weiInEth = new(big.Rat).SetInt(new(big.Int).Exp(ten, eighteen, nil)) +) + +// Listener listens for transactions using geth RPC. It calls TransactionHandler for each new +// transactions. It will reprocess the block if TransactionHandler returns error. It will +// start from the block number returned from Storage.GetEthereumBlockToProcess or the latest block +// if it returned 0. Transactions can be processed more than once, it's TransactionHandler +// responsibility to ignore duplicates. +// You can run multiple Listeners if Storage is implemented correctly. +// Listener ignores contract creation transactions. +// Listener requires geth 1.7.0. +type Listener struct { + Enabled bool + Client Client `inject:""` + Storage Storage `inject:""` + NetworkID string + TransactionHandler TransactionHandler + + log *log.Entry +} + +type Client interface { + NetworkID(ctx context.Context) (*big.Int, error) + BlockByNumber(ctx context.Context, number *big.Int) (*types.Block, error) +} + +// Storage is an interface that must be implemented by an object using +// persistent storage. +type Storage interface { + // GetEthereumBlockToProcess gets the number of Ethereum block to process. `0` means the + // processing should start from the current block. + GetEthereumBlockToProcess() (uint64, error) + // SaveLastProcessedEthereumBlock should update the number of the last processed Ethereum + // block. It should only update the block if block > current block in atomic transaction. + SaveLastProcessedEthereumBlock(block uint64) error +} + +type TransactionHandler func(transaction Transaction) error + +type Transaction struct { + Hash string + // Value in Wei + ValueWei *big.Int + To string +} + +type AddressGenerator struct { + masterPublicKey *bip32.Key +} + +func EthToWei(eth string) (*big.Int, error) { + valueRat := new(big.Rat) + _, ok := valueRat.SetString(eth) + if !ok { + return nil, errors.New("Could not convert to *big.Rat") + } + + // Calculate value in Wei + valueRat.Mul(valueRat, weiInEth) + + // Ensure denominator is equal `1` + if valueRat.Denom().Cmp(big.NewInt(1)) != 0 { + return nil, errors.New("Invalid precision, is value smaller than 1 Wei?") + } + + return valueRat.Num(), nil +} diff --git a/services/bifrost/ethereum/main_test.go b/services/bifrost/ethereum/main_test.go new file mode 100644 index 0000000000..e5fd59b8aa --- /dev/null +++ b/services/bifrost/ethereum/main_test.go @@ -0,0 +1,43 @@ +package ethereum + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestEthToWei(t *testing.T) { + hugeEth, ok := new(big.Int).SetString("100000000123456789012345678", 10) + assert.True(t, ok) + + tests := []struct { + amount string + expectedAmount *big.Int + expectedError string + }{ + {"", nil, "Could not convert to *big.Rat"}, + {"test", nil, "Could not convert to *big.Rat"}, + {"0.0000000000000000001", nil, "Invalid precision"}, + {"1.2345678912345678901", nil, "Invalid precision"}, + + {"0", big.NewInt(0), ""}, + {"0.00", big.NewInt(0), ""}, + {"0.000000000000000001", big.NewInt(1), ""}, + {"1", big.NewInt(1000000000000000000), ""}, + {"1.00", big.NewInt(1000000000000000000), ""}, + {"1.234567890123456789", big.NewInt(1234567890123456789), ""}, + {"100000000.123456789012345678", hugeEth, ""}, + } + + for _, test := range tests { + returnedAmount, err := EthToWei(test.amount) + if test.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), test.expectedError) + } else { + assert.NoError(t, err) + assert.Equal(t, 0, returnedAmount.Cmp(test.expectedAmount)) + } + } +} diff --git a/services/bifrost/ethereum/transaction.go b/services/bifrost/ethereum/transaction.go new file mode 100644 index 0000000000..94a838fbcd --- /dev/null +++ b/services/bifrost/ethereum/transaction.go @@ -0,0 +1,13 @@ +package ethereum + +import ( + "math/big" + + "github.com/stellar/go/services/bifrost/common" +) + +func (t Transaction) ValueToStellar() string { + valueEth := new(big.Rat) + valueEth.Quo(new(big.Rat).SetInt(t.ValueWei), weiInEth) + return valueEth.FloatString(common.StellarAmountPrecision) +} diff --git a/services/bifrost/ethereum/transaction_test.go b/services/bifrost/ethereum/transaction_test.go new file mode 100644 index 0000000000..e152a2fca1 --- /dev/null +++ b/services/bifrost/ethereum/transaction_test.go @@ -0,0 +1,28 @@ +package ethereum + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestTransactionAmount(t *testing.T) { + tests := []struct { + amount string + expectedStellarAmount string + }{ + {"1", "0.0000000"}, + {"1234567890123345678", "1.2345679"}, + {"1000000000000000000", "1.0000000"}, + {"150000000000000000000000000", "150000000.0000000"}, + } + + for _, test := range tests { + bigAmount, ok := new(big.Int).SetString(test.amount, 10) + assert.True(t, ok) + transaction := Transaction{ValueWei: bigAmount} + amount := transaction.ValueToStellar() + assert.Equal(t, test.expectedStellarAmount, amount) + } +} diff --git a/services/bifrost/images/architecture.png b/services/bifrost/images/architecture.png new file mode 100644 index 0000000000..6b43674557 Binary files /dev/null and b/services/bifrost/images/architecture.png differ diff --git a/services/bifrost/main.go b/services/bifrost/main.go new file mode 100644 index 0000000000..ecd44aa2c1 --- /dev/null +++ b/services/bifrost/main.go @@ -0,0 +1,399 @@ +// Skip this file in Go <1.8 because it's using http.Server.Shutdown +// +build go1.8 + +package main + +import ( + "fmt" + "net/http" + "os" + "time" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/rpcclient" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/facebookgo/inject" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/stellar/go/clients/horizon" + "github.com/stellar/go/services/bifrost/bitcoin" + "github.com/stellar/go/services/bifrost/config" + "github.com/stellar/go/services/bifrost/database" + "github.com/stellar/go/services/bifrost/ethereum" + "github.com/stellar/go/services/bifrost/server" + "github.com/stellar/go/services/bifrost/sse" + "github.com/stellar/go/services/bifrost/stellar" + "github.com/stellar/go/services/bifrost/stress" + supportConfig "github.com/stellar/go/support/config" + "github.com/stellar/go/support/errors" + "github.com/stellar/go/support/log" +) + +var rootCmd = &cobra.Command{ + Use: "bifrost", + Short: "Bridge server to allow participating in Stellar based ICOs using Bitcoin and Ethereum", +} + +var serverCmd = &cobra.Command{ + Use: "server", + Short: "Starts backend server", + Run: func(cmd *cobra.Command, args []string) { + var ( + cfgPath = rootCmd.PersistentFlags().Lookup("config").Value.String() + debugMode = rootCmd.PersistentFlags().Lookup("debug").Changed + ) + + if debugMode { + log.SetLevel(log.DebugLevel) + log.Debug("Debug mode ON") + } + + cfg := readConfig(cfgPath) + server := createServer(cfg, false) + err := server.Start() + if err != nil { + log.WithField("err", err).Error("Error starting the server") + os.Exit(-1) + } + }, +} + +var stressTestCmd = &cobra.Command{ + Use: "stress-test", + Short: "Starts stress test", + Long: `During stress test bitcoin and ethereum transaction will be generated randomly. +This command will create 3 server.Server's listening on ports 8000-8002.`, + Run: func(cmd *cobra.Command, args []string) { + cfgPath := rootCmd.PersistentFlags().Lookup("config").Value.String() + debugMode := rootCmd.PersistentFlags().Lookup("debug").Changed + usersPerSecond, _ := cmd.PersistentFlags().GetInt("users-per-second") + + if debugMode { + log.SetLevel(log.DebugLevel) + log.Debug("Debug mode ON") + } + + cfg := readConfig(cfgPath) + + bitcoinAccounts := make(chan string) + bitcoinClient := &stress.RandomBitcoinClient{} + bitcoinClient.Start(bitcoinAccounts) + + ethereumAccounts := make(chan string) + ethereumClient := &stress.RandomEthereumClient{} + ethereumClient.Start(ethereumAccounts) + + db, err := createDatabase(cfg.Database.DSN) + if err != nil { + log.WithField("err", err).Error("Error connecting to database") + os.Exit(-1) + } + + err = db.ResetBlockCounters() + if err != nil { + log.WithField("err", err).Error("Error reseting counters") + os.Exit(-1) + } + + // Start servers + const numServers = 3 + signers := []string{ + // GBQYGXC4AZDL7PPL2H274LYA6YV7OL4IRPWCYMBYCA5FAO45WMTNKGOD + "SBX76SCADD2SBIL6M2T62BR4GELMJPZV2MFHIQX24IBVOTIT6DGNAR3D", + // GAUDK66OCTKQB737ZNRD2ILB5ZGIZOKMWT3T5TDWVIN7ANVY3RD5DXF3 + "SCGJ6JRFMHWYTGVBPFXCBMMBENZM433M3JNZDRSI5PZ2DXGJLYDWR4CR", + // GDFR6QNVBUK32PGTAV3HATV3GDT7LF2SGVVLD2TOS4TCAD2ANSOH2MCW + "SAZUY2XGSILNMBLQSVMDGCCTSZNOB2EXHSFFRFJJ3GKRZIW3FTIMJYV7", + } + ports := []int{8000, 8001, 8002} + for i := 0; i < numServers; i++ { + go func(i int) { + cfg.Port = ports[i] + cfg.Stellar.SignerSecretKey = signers[i] + server := createServer(cfg, true) + // Replace clients in listeners with random transactions generators + server.BitcoinListener.Client = bitcoinClient + server.EthereumListener.Client = ethereumClient + err := server.Start() + if err != nil { + log.WithField("err", err).Error("Error starting the server") + os.Exit(-1) + } + }(i) + } + + // Wait for servers to start. We could wait in a more sophisticated way but this + // is just a test code. + time.Sleep(2 * time.Second) + + accounts := make(chan server.GenerateAddressResponse) + users := stress.Users{ + Horizon: &horizon.Client{ + URL: cfg.Stellar.Horizon, + HTTP: &http.Client{ + Timeout: 60 * time.Second, + }, + }, + NetworkPassphrase: cfg.Stellar.NetworkPassphrase, + UsersPerSecond: usersPerSecond, + BifrostPorts: ports, + IssuerPublicKey: cfg.Stellar.IssuerPublicKey, + } + go users.Start(accounts) + for { + account := <-accounts + switch account.Chain { + case string(database.ChainBitcoin): + bitcoinAccounts <- account.Address + case string(database.ChainEthereum): + ethereumAccounts <- account.Address + default: + panic("Unknown chain: " + account.Chain) + } + } + }, +} + +var checkKeysCmd = &cobra.Command{ + Use: "check-keys", + Short: "Displays a few public keys derived using master public keys", + Run: func(cmd *cobra.Command, args []string) { + cfgPath := rootCmd.PersistentFlags().Lookup("config").Value.String() + start, _ := cmd.PersistentFlags().GetUint32("start") + count, _ := cmd.PersistentFlags().GetUint32("count") + cfg := readConfig(cfgPath) + + fmt.Println("MAKE SURE YOU HAVE PRIVATE KEYS TO CORRESPONDING ADDRESSES:") + + fmt.Println("Bitcoin MainNet:") + if cfg.Bitcoin != nil && cfg.Bitcoin.MasterPublicKey != "" { + bitcoinAddressGenerator, err := bitcoin.NewAddressGenerator(cfg.Bitcoin.MasterPublicKey, &chaincfg.MainNetParams) + if err != nil { + log.Error(err) + os.Exit(-1) + } + + for i := uint32(start); i < start+count; i++ { + address, err := bitcoinAddressGenerator.Generate(i) + if err != nil { + fmt.Println("Error generating address", i) + continue + } + fmt.Printf("%d %s\n", i, address) + } + } else { + fmt.Println("No master key set...") + } + + fmt.Println("Ethereum:") + if cfg.Ethereum != nil && cfg.Ethereum.MasterPublicKey != "" { + ethereumAddressGenerator, err := ethereum.NewAddressGenerator(cfg.Ethereum.MasterPublicKey) + if err != nil { + log.Error(err) + os.Exit(-1) + } + + for i := uint32(start); i < start+count; i++ { + address, err := ethereumAddressGenerator.Generate(i) + if err != nil { + fmt.Println("Error generating address", i) + continue + } + fmt.Printf("%d %s\n", i, address) + } + } else { + fmt.Println("No master key set...") + } + }, +} + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version number", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("0.0.1") + }, +} + +func init() { + // TODO I think these should be default in stellar/go: + log.SetLevel(log.InfoLevel) + log.DefaultLogger.Logger.Formatter.(*logrus.TextFormatter).FullTimestamp = true + + rootCmd.PersistentFlags().Bool("debug", false, "debug mode") + rootCmd.PersistentFlags().StringP("config", "c", "bifrost.cfg", "config file path") + + rootCmd.AddCommand(checkKeysCmd) + rootCmd.AddCommand(serverCmd) + rootCmd.AddCommand(stressTestCmd) + rootCmd.AddCommand(versionCmd) + + stressTestCmd.PersistentFlags().IntP("users-per-second", "u", 2, "users per second") + + checkKeysCmd.PersistentFlags().Uint32P("start", "s", 0, "starting address index") + checkKeysCmd.PersistentFlags().Uint32P("count", "l", 10, "how many addresses generate") +} + +func main() { + rootCmd.Execute() +} + +func readConfig(cfgPath string) config.Config { + var cfg config.Config + + err := supportConfig.Read(cfgPath, &cfg) + if err != nil { + switch cause := errors.Cause(err).(type) { + case *supportConfig.InvalidConfigError: + log.Error("config file: ", cause) + default: + log.Error(err) + } + os.Exit(-1) + } + if cfg.AccessControlAllowOriginHeader == "" { + cfg.AccessControlAllowOriginHeader = "*" + } + + return cfg +} + +func createDatabase(dsn string) (*database.PostgresDatabase, error) { + db := &database.PostgresDatabase{} + err := db.Open(dsn) + if err != nil { + return nil, err + } + return db, err +} + +func createServer(cfg config.Config, stressTest bool) *server.Server { + var g inject.Graph + + db, err := createDatabase(cfg.Database.DSN) + if err != nil { + log.WithField("err", err).Error("Error connecting to database") + os.Exit(-1) + } + + server := &server.Server{} + + bitcoinClient := &rpcclient.Client{} + bitcoinListener := &bitcoin.Listener{} + bitcoinAddressGenerator := &bitcoin.AddressGenerator{} + + ethereumClient := ðclient.Client{} + ethereumListener := ðereum.Listener{} + ethereumAddressGenerator := ðereum.AddressGenerator{} + + if !stressTest { + // Configure real clients + if cfg.Bitcoin != nil { + connConfig := &rpcclient.ConnConfig{ + Host: cfg.Bitcoin.RpcServer, + User: cfg.Bitcoin.RpcUser, + Pass: cfg.Bitcoin.RpcPass, + HTTPPostMode: true, + DisableTLS: true, + } + bitcoinClient, err = rpcclient.New(connConfig, nil) + if err != nil { + log.WithField("err", err).Error("Error connecting to bitcoin-core") + os.Exit(-1) + } + + bitcoinListener.Enabled = true + bitcoinListener.Testnet = cfg.Bitcoin.Testnet + server.MinimumValueBtc = cfg.Bitcoin.MinimumValueBtc + + var chainParams *chaincfg.Params + if cfg.Bitcoin.Testnet { + chainParams = &chaincfg.TestNet3Params + } else { + chainParams = &chaincfg.MainNetParams + } + bitcoinAddressGenerator, err = bitcoin.NewAddressGenerator(cfg.Bitcoin.MasterPublicKey, chainParams) + if err != nil { + log.Error(err) + os.Exit(-1) + } + } + + if cfg.Ethereum != nil { + ethereumClient, err = ethclient.Dial("http://" + cfg.Ethereum.RpcServer) + if err != nil { + log.WithField("err", err).Error("Error connecting to geth") + os.Exit(-1) + } + + ethereumListener.Enabled = true + ethereumListener.NetworkID = cfg.Ethereum.NetworkID + server.MinimumValueEth = cfg.Ethereum.MinimumValueEth + + ethereumAddressGenerator, err = ethereum.NewAddressGenerator(cfg.Ethereum.MasterPublicKey) + if err != nil { + log.Error(err) + os.Exit(-1) + } + } + } else { + bitcoinListener.Enabled = true + bitcoinListener.Testnet = true + bitcoinAddressGenerator, err = bitcoin.NewAddressGenerator(cfg.Bitcoin.MasterPublicKey, &chaincfg.TestNet3Params) + if err != nil { + log.Error(err) + os.Exit(-1) + } + + ethereumListener.Enabled = true + ethereumListener.NetworkID = "3" + ethereumAddressGenerator, err = ethereum.NewAddressGenerator(cfg.Ethereum.MasterPublicKey) + if err != nil { + log.Error(err) + os.Exit(-1) + } + } + + stellarAccountConfigurator := &stellar.AccountConfigurator{ + NetworkPassphrase: cfg.Stellar.NetworkPassphrase, + IssuerPublicKey: cfg.Stellar.IssuerPublicKey, + SignerSecretKey: cfg.Stellar.SignerSecretKey, + NeedsAuthorize: cfg.Stellar.NeedsAuthorize, + TokenAssetCode: cfg.Stellar.TokenAssetCode, + } + + horizonClient := &horizon.Client{ + URL: cfg.Stellar.Horizon, + HTTP: &http.Client{ + Timeout: 20 * time.Second, + }, + } + + sseServer := &sse.Server{} + + err = g.Provide( + &inject.Object{Value: bitcoinAddressGenerator}, + &inject.Object{Value: bitcoinClient}, + &inject.Object{Value: bitcoinListener}, + &inject.Object{Value: &cfg}, + &inject.Object{Value: db}, + &inject.Object{Value: ethereumAddressGenerator}, + &inject.Object{Value: ethereumClient}, + &inject.Object{Value: ethereumListener}, + &inject.Object{Value: horizonClient}, + &inject.Object{Value: server}, + &inject.Object{Value: sseServer}, + &inject.Object{Value: stellarAccountConfigurator}, + ) + if err != nil { + log.WithField("err", err).Error("Error providing objects to injector") + os.Exit(-1) + } + + if err := g.Populate(); err != nil { + log.WithField("err", err).Error("Error injecting objects") + os.Exit(-1) + } + + return server +} diff --git a/services/bifrost/queue/main.go b/services/bifrost/queue/main.go new file mode 100644 index 0000000000..b5ab6d04ea --- /dev/null +++ b/services/bifrost/queue/main.go @@ -0,0 +1,37 @@ +package queue + +type AssetCode string + +const ( + AssetCodeBTC AssetCode = "BTC" + AssetCodeETH AssetCode = "ETH" +) + +type Transaction struct { + TransactionID string + AssetCode AssetCode + // CRITICAL REQUIREMENT: Amount in the base unit of currency. + // For 10 satoshi this should be equal 0.0000001 + // For 1 BTC this should be equal 1.0000000 + // For 1 Finney this should be equal 0.0010000 + // For 1 ETH this should be equal 1.0000000 + // Currently, the length of Amount string shouldn't be longer than 17 characters. + Amount string + StellarPublicKey string +} + +// Queue implements transactions queue. +// The queue must not allow duplicates (including history) or must implement deduplication +// interval so it should not allow duplicate entries for 5 minutes since the first +// entry with the same ID was added. +// This is a critical requirement! Otherwise ETH/BTC may be sent twice to Stellar account. +// If you don't know what to do, use default AWS SQS FIFO queue or DB queue. +type Queue interface { + // QueueAdd inserts the element to this queue. If element already exists in a queue, it should + // return nil. + QueueAdd(tx Transaction) error + // QueuePool receives and removes the head of this queue. Returns nil if no elements found. + QueuePool() (*Transaction, error) +} + +type SQSFiFo struct{} diff --git a/services/bifrost/queue/mock.go b/services/bifrost/queue/mock.go new file mode 100644 index 0000000000..c2345988cc --- /dev/null +++ b/services/bifrost/queue/mock.go @@ -0,0 +1,20 @@ +package queue + +import ( + "github.com/stretchr/testify/mock" +) + +// MockQueue is a mockable queue. +type MockQueue struct { + mock.Mock +} + +func (m *MockQueue) QueueAdd(tx Transaction) error { + a := m.Called(tx) + return a.Error(0) +} + +func (m *MockQueue) QueuePool() (*Transaction, error) { + a := m.Called() + return a.Get(0).(*Transaction), a.Error(1) +} diff --git a/services/bifrost/queue/sqs_fifo.go b/services/bifrost/queue/sqs_fifo.go new file mode 100644 index 0000000000..e068a41759 --- /dev/null +++ b/services/bifrost/queue/sqs_fifo.go @@ -0,0 +1,11 @@ +package queue + +func (s *SQSFiFo) Add(tx Transaction) error { + panic("Not implemented yet") + return nil +} + +func (s *SQSFiFo) Pool() (*Transaction, error) { + panic("Not implemented yet") + return nil, nil +} diff --git a/services/bifrost/server/bitcoin_rail.go b/services/bifrost/server/bitcoin_rail.go new file mode 100644 index 0000000000..36800a9ce4 --- /dev/null +++ b/services/bifrost/server/bitcoin_rail.go @@ -0,0 +1,75 @@ +package server + +import ( + "github.com/stellar/go/services/bifrost/bitcoin" + "github.com/stellar/go/services/bifrost/database" + "github.com/stellar/go/services/bifrost/queue" + "github.com/stellar/go/services/bifrost/sse" + "github.com/stellar/go/support/errors" + "github.com/stellar/go/support/log" +) + +// onNewBitcoinTransaction checks if transaction is valid and adds it to +// the transactions queue for StellarAccountConfigurator to consume. +// +// Transaction added to transactions queue should be in a format described in +// queue.Transaction (especialy amounts). Pooling service should not have to deal with any +// conversions. +// +// This is very unlikely but it's possible that a single transaction will have more than +// one output going to bifrost account. Then only first output will be processed. +// Because it's very unlikely that this will happen and it's not a security issue this +// will be fixed in a future release. +func (s *Server) onNewBitcoinTransaction(transaction bitcoin.Transaction) error { + localLog := s.log.WithFields(log.F{"transaction": transaction, "rail": "bitcoin"}) + localLog.Debug("Processing transaction") + + // Let's check if tx is valid first. + + // Check if value is above minimum required + if transaction.ValueSat < s.minimumValueSat { + localLog.Debug("Value is below minimum required amount, skipping") + return nil + } + + addressAssociation, err := s.Database.GetAssociationByChainAddress(database.ChainBitcoin, transaction.To) + if err != nil { + return errors.Wrap(err, "Error getting association") + } + + if addressAssociation == nil { + localLog.Debug("Associated address not found, skipping") + return nil + } + + // Add transaction as processing. + processed, err := s.Database.AddProcessedTransaction(database.ChainBitcoin, transaction.Hash, transaction.To) + if err != nil { + return err + } + + if processed { + localLog.Debug("Transaction already processed, skipping") + return nil + } + + // Add tx to the processing queue + queueTx := queue.Transaction{ + TransactionID: transaction.Hash, + AssetCode: queue.AssetCodeBTC, + // Amount in the base unit of currency. + Amount: transaction.ValueToStellar(), + StellarPublicKey: addressAssociation.StellarPublicKey, + } + + err = s.TransactionsQueue.QueueAdd(queueTx) + if err != nil { + return errors.Wrap(err, "Error adding transaction to the processing queue") + } + localLog.Info("Transaction added to transaction queue") + + // Broadcast event to address stream + s.SSEServer.BroadcastEvent(transaction.To, sse.TransactionReceivedAddressEvent, nil) + localLog.Info("Transaction processed successfully") + return nil +} diff --git a/services/bifrost/server/bitcoin_rail_test.go b/services/bifrost/server/bitcoin_rail_test.go new file mode 100644 index 0000000000..084a0c56fc --- /dev/null +++ b/services/bifrost/server/bitcoin_rail_test.go @@ -0,0 +1,139 @@ +// Skip this test file in Go <1.8 because it's using http.Server.Shutdown +// +build go1.8 + +package server + +import ( + "testing" + "time" + + "github.com/stellar/go/services/bifrost/bitcoin" + "github.com/stellar/go/services/bifrost/database" + "github.com/stellar/go/services/bifrost/queue" + "github.com/stellar/go/services/bifrost/sse" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +type BitcoinRailTestSuite struct { + suite.Suite + Server *Server + MockDatabase *database.MockDatabase + MockQueue *queue.MockQueue + MockSSEServer *sse.MockServer +} + +func (suite *BitcoinRailTestSuite) SetupTest() { + suite.MockDatabase = &database.MockDatabase{} + suite.MockQueue = &queue.MockQueue{} + suite.MockSSEServer = &sse.MockServer{} + + suite.Server = &Server{ + Database: suite.MockDatabase, + TransactionsQueue: suite.MockQueue, + SSEServer: suite.MockSSEServer, + minimumValueSat: 100000000, // 1 BTC + } + suite.Server.initLogger() +} + +func (suite *BitcoinRailTestSuite) TearDownTest() { + suite.MockDatabase.AssertExpectations(suite.T()) + suite.MockQueue.AssertExpectations(suite.T()) + suite.MockSSEServer.AssertExpectations(suite.T()) +} + +func (suite *BitcoinRailTestSuite) TestInvalidValue() { + transaction := bitcoin.Transaction{ + Hash: "109fa1c369680c2f27643fdd160620d010851a376d25b9b00ef71afe789ea6ed", + TxOutIndex: 0, + ValueSat: 50000000, // 0.5 BTC + To: "1Q74qRud8bXUn6FMtXWZwJa5pj56s3mdyf", + } + suite.MockDatabase.AssertNotCalled(suite.T(), "AddProcessedTransaction") + suite.MockQueue.AssertNotCalled(suite.T(), "QueueAdd") + err := suite.Server.onNewBitcoinTransaction(transaction) + suite.Require().NoError(err) +} + +func (suite *BitcoinRailTestSuite) TestAssociationNotExist() { + transaction := bitcoin.Transaction{ + Hash: "109fa1c369680c2f27643fdd160620d010851a376d25b9b00ef71afe789ea6ed", + TxOutIndex: 0, + ValueSat: 100000000, + To: "1Q74qRud8bXUn6FMtXWZwJa5pj56s3mdyf", + } + suite.MockDatabase. + On("GetAssociationByChainAddress", database.ChainBitcoin, "1Q74qRud8bXUn6FMtXWZwJa5pj56s3mdyf"). + Return(nil, nil) + suite.MockDatabase.AssertNotCalled(suite.T(), "AddProcessedTransaction") + suite.MockQueue.AssertNotCalled(suite.T(), "QueueAdd") + err := suite.Server.onNewBitcoinTransaction(transaction) + suite.Require().NoError(err) +} + +func (suite *BitcoinRailTestSuite) TestAssociationAlreadyProcessed() { + transaction := bitcoin.Transaction{ + Hash: "109fa1c369680c2f27643fdd160620d010851a376d25b9b00ef71afe789ea6ed", + TxOutIndex: 0, + ValueSat: 100000000, + To: "1Q74qRud8bXUn6FMtXWZwJa5pj56s3mdyf", + } + association := &database.AddressAssociation{ + Chain: database.ChainBitcoin, + AddressIndex: 1, + Address: "1Q74qRud8bXUn6FMtXWZwJa5pj56s3mdyf", + StellarPublicKey: "GDULKYRRVOMASFMXBYD4BYFRSHAKQDREEVVP2TMH2CER3DW2KATIOASB", + CreatedAt: time.Now(), + } + suite.MockDatabase. + On("GetAssociationByChainAddress", database.ChainBitcoin, transaction.To). + Return(association, nil) + suite.MockDatabase. + On("AddProcessedTransaction", database.ChainBitcoin, transaction.Hash, transaction.To). + Return(true, nil) + suite.MockQueue.AssertNotCalled(suite.T(), "QueueAdd") + err := suite.Server.onNewBitcoinTransaction(transaction) + suite.Require().NoError(err) +} + +func (suite *BitcoinRailTestSuite) TestAssociationSuccess() { + transaction := bitcoin.Transaction{ + Hash: "109fa1c369680c2f27643fdd160620d010851a376d25b9b00ef71afe789ea6ed", + TxOutIndex: 0, + ValueSat: 100000000, + To: "1Q74qRud8bXUn6FMtXWZwJa5pj56s3mdyf", + } + association := &database.AddressAssociation{ + Chain: database.ChainBitcoin, + AddressIndex: 1, + Address: "1Q74qRud8bXUn6FMtXWZwJa5pj56s3mdyf", + StellarPublicKey: "GDULKYRRVOMASFMXBYD4BYFRSHAKQDREEVVP2TMH2CER3DW2KATIOASB", + CreatedAt: time.Now(), + } + suite.MockDatabase. + On("GetAssociationByChainAddress", database.ChainBitcoin, transaction.To). + Return(association, nil) + suite.MockDatabase. + On("AddProcessedTransaction", database.ChainBitcoin, transaction.Hash, transaction.To). + Return(false, nil) + suite.MockQueue. + On("QueueAdd", mock.AnythingOfType("queue.Transaction")). + Return(nil). + Run(func(args mock.Arguments) { + queueTransaction := args.Get(0).(queue.Transaction) + suite.Assert().Equal(transaction.Hash, queueTransaction.TransactionID) + suite.Assert().Equal("BTC", string(queue.AssetCodeBTC)) + suite.Assert().Equal(queue.AssetCodeBTC, queueTransaction.AssetCode) + suite.Assert().Equal("1.0000000", queueTransaction.Amount) + suite.Assert().Equal(association.StellarPublicKey, queueTransaction.StellarPublicKey) + }) + suite.MockSSEServer. + On("BroadcastEvent", transaction.To, sse.TransactionReceivedAddressEvent, []byte(nil)) + err := suite.Server.onNewBitcoinTransaction(transaction) + suite.Require().NoError(err) +} + +func TestBitcoinRailTestSuite(t *testing.T) { + suite.Run(t, new(BitcoinRailTestSuite)) +} diff --git a/services/bifrost/server/ethereum_rail.go b/services/bifrost/server/ethereum_rail.go new file mode 100644 index 0000000000..309d2022f2 --- /dev/null +++ b/services/bifrost/server/ethereum_rail.go @@ -0,0 +1,70 @@ +package server + +import ( + "github.com/stellar/go/services/bifrost/database" + "github.com/stellar/go/services/bifrost/ethereum" + "github.com/stellar/go/services/bifrost/queue" + "github.com/stellar/go/services/bifrost/sse" + "github.com/stellar/go/support/errors" + "github.com/stellar/go/support/log" +) + +// onNewEthereumTransaction checks if transaction is valid and adds it to +// the transactions queue for StellarAccountConfigurator to consume. +// +// Transaction added to transactions queue should be in a format described in +// queue.Transaction (especialy amounts). Pooling service should not have to deal with any +// conversions. +func (s *Server) onNewEthereumTransaction(transaction ethereum.Transaction) error { + localLog := s.log.WithFields(log.F{"transaction": transaction, "rail": "ethereum"}) + localLog.Debug("Processing transaction") + + // Let's check if tx is valid first. + + // Check if value is above minimum required + if transaction.ValueWei.Cmp(s.minimumValueWei) < 0 { + localLog.Debug("Value is below minimum required amount, skipping") + return nil + } + + addressAssociation, err := s.Database.GetAssociationByChainAddress(database.ChainEthereum, transaction.To) + if err != nil { + return errors.Wrap(err, "Error getting association") + } + + if addressAssociation == nil { + localLog.Debug("Associated address not found, skipping") + return nil + } + + // Add transaction as processing. + processed, err := s.Database.AddProcessedTransaction(database.ChainEthereum, transaction.Hash, transaction.To) + if err != nil { + return err + } + + if processed { + localLog.Debug("Transaction already processed, skipping") + return nil + } + + // Add tx to the processing queue + queueTx := queue.Transaction{ + TransactionID: transaction.Hash, + AssetCode: queue.AssetCodeETH, + // Amount in the base unit of currency. + Amount: transaction.ValueToStellar(), + StellarPublicKey: addressAssociation.StellarPublicKey, + } + + err = s.TransactionsQueue.QueueAdd(queueTx) + if err != nil { + return errors.Wrap(err, "Error adding transaction to the processing queue") + } + localLog.Info("Transaction added to transaction queue") + + // Broadcast event to address stream + s.SSEServer.BroadcastEvent(transaction.To, sse.TransactionReceivedAddressEvent, nil) + localLog.Info("Transaction processed successfully") + return nil +} diff --git a/services/bifrost/server/ethereum_rail_test.go b/services/bifrost/server/ethereum_rail_test.go new file mode 100644 index 0000000000..ee67981124 --- /dev/null +++ b/services/bifrost/server/ethereum_rail_test.go @@ -0,0 +1,138 @@ +// Skip this test file in Go <1.8 because it's using http.Server.Shutdown +// +build go1.8 + +package server + +import ( + "math/big" + "testing" + "time" + + "github.com/stellar/go/services/bifrost/database" + "github.com/stellar/go/services/bifrost/ethereum" + "github.com/stellar/go/services/bifrost/queue" + "github.com/stellar/go/services/bifrost/sse" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +var weiInEth = new(big.Int).Exp(new(big.Int).SetInt64(10), new(big.Int).SetInt64(18), nil) + +type EthereumRailTestSuite struct { + suite.Suite + Server *Server + MockDatabase *database.MockDatabase + MockQueue *queue.MockQueue + MockSSEServer *sse.MockServer +} + +func (suite *EthereumRailTestSuite) SetupTest() { + suite.MockDatabase = &database.MockDatabase{} + suite.MockQueue = &queue.MockQueue{} + suite.MockSSEServer = &sse.MockServer{} + + suite.Server = &Server{ + Database: suite.MockDatabase, + TransactionsQueue: suite.MockQueue, + SSEServer: suite.MockSSEServer, + minimumValueWei: big.NewInt(1000000000000000000), // 1 ETH + } + suite.Server.initLogger() +} + +func (suite *EthereumRailTestSuite) TearDownTest() { + suite.MockDatabase.AssertExpectations(suite.T()) + suite.MockQueue.AssertExpectations(suite.T()) + suite.MockSSEServer.AssertExpectations(suite.T()) +} + +func (suite *EthereumRailTestSuite) TestInvalidValue() { + transaction := ethereum.Transaction{ + Hash: "0x0a190d17ba0405bce37fafd3a7a7bef51264ea4083ffae3b2de90ed61ee5264e", + ValueWei: big.NewInt(500000000000000000), // 0.5 ETH + To: "0x80D3ee1268DC1A2d1b9E73D49050083E75Ef7c2D", + } + suite.MockDatabase.AssertNotCalled(suite.T(), "AddProcessedTransaction") + suite.MockQueue.AssertNotCalled(suite.T(), "QueueAdd") + err := suite.Server.onNewEthereumTransaction(transaction) + suite.Require().NoError(err) +} + +func (suite *EthereumRailTestSuite) TestAssociationNotExist() { + transaction := ethereum.Transaction{ + Hash: "0x0a190d17ba0405bce37fafd3a7a7bef51264ea4083ffae3b2de90ed61ee5264e", + ValueWei: weiInEth, + To: "0x80D3ee1268DC1A2d1b9E73D49050083E75Ef7c2D", + } + suite.MockDatabase. + On("GetAssociationByChainAddress", database.ChainEthereum, "0x80D3ee1268DC1A2d1b9E73D49050083E75Ef7c2D"). + Return(nil, nil) + suite.MockDatabase.AssertNotCalled(suite.T(), "AddProcessedTransaction") + suite.MockQueue.AssertNotCalled(suite.T(), "QueueAdd") + err := suite.Server.onNewEthereumTransaction(transaction) + suite.Require().NoError(err) +} + +func (suite *EthereumRailTestSuite) TestAssociationAlreadyProcessed() { + transaction := ethereum.Transaction{ + Hash: "0x0a190d17ba0405bce37fafd3a7a7bef51264ea4083ffae3b2de90ed61ee5264e", + ValueWei: weiInEth, + To: "0x80D3ee1268DC1A2d1b9E73D49050083E75Ef7c2D", + } + association := &database.AddressAssociation{ + Chain: database.ChainEthereum, + AddressIndex: 1, + Address: "0x80D3ee1268DC1A2d1b9E73D49050083E75Ef7c2D", + StellarPublicKey: "GDULKYRRVOMASFMXBYD4BYFRSHAKQDREEVVP2TMH2CER3DW2KATIOASB", + CreatedAt: time.Now(), + } + suite.MockDatabase. + On("GetAssociationByChainAddress", database.ChainEthereum, transaction.To). + Return(association, nil) + suite.MockDatabase. + On("AddProcessedTransaction", database.ChainEthereum, transaction.Hash, transaction.To). + Return(true, nil) + suite.MockQueue.AssertNotCalled(suite.T(), "QueueAdd") + err := suite.Server.onNewEthereumTransaction(transaction) + suite.Require().NoError(err) +} + +func (suite *EthereumRailTestSuite) TestAssociationSuccess() { + transaction := ethereum.Transaction{ + Hash: "0x0a190d17ba0405bce37fafd3a7a7bef51264ea4083ffae3b2de90ed61ee5264e", + ValueWei: weiInEth, + To: "0x80D3ee1268DC1A2d1b9E73D49050083E75Ef7c2D", + } + association := &database.AddressAssociation{ + Chain: database.ChainEthereum, + AddressIndex: 1, + Address: "0x80D3ee1268DC1A2d1b9E73D49050083E75Ef7c2D", + StellarPublicKey: "GDULKYRRVOMASFMXBYD4BYFRSHAKQDREEVVP2TMH2CER3DW2KATIOASB", + CreatedAt: time.Now(), + } + suite.MockDatabase. + On("GetAssociationByChainAddress", database.ChainEthereum, transaction.To). + Return(association, nil) + suite.MockDatabase. + On("AddProcessedTransaction", database.ChainEthereum, transaction.Hash, transaction.To). + Return(false, nil) + suite.MockQueue. + On("QueueAdd", mock.AnythingOfType("queue.Transaction")). + Return(nil). + Run(func(args mock.Arguments) { + queueTransaction := args.Get(0).(queue.Transaction) + suite.Assert().Equal(transaction.Hash, queueTransaction.TransactionID) + suite.Assert().Equal("ETH", string(queue.AssetCodeETH)) + suite.Assert().Equal(queue.AssetCodeETH, queueTransaction.AssetCode) + suite.Assert().Equal("1.0000000", queueTransaction.Amount) + suite.Assert().Equal(association.StellarPublicKey, queueTransaction.StellarPublicKey) + }) + suite.MockSSEServer. + On("BroadcastEvent", transaction.To, sse.TransactionReceivedAddressEvent, []byte(nil)) + err := suite.Server.onNewEthereumTransaction(transaction) + suite.Require().NoError(err) +} + +func TestEthereumRailTestSuite(t *testing.T) { + suite.Run(t, new(EthereumRailTestSuite)) +} diff --git a/services/bifrost/server/main.go b/services/bifrost/server/main.go new file mode 100644 index 0000000000..22c4030c1f --- /dev/null +++ b/services/bifrost/server/main.go @@ -0,0 +1,45 @@ +package server + +import ( + "math/big" + "net/http" + + "github.com/stellar/go/services/bifrost/bitcoin" + "github.com/stellar/go/services/bifrost/config" + "github.com/stellar/go/services/bifrost/database" + "github.com/stellar/go/services/bifrost/ethereum" + "github.com/stellar/go/services/bifrost/queue" + "github.com/stellar/go/services/bifrost/sse" + "github.com/stellar/go/services/bifrost/stellar" + "github.com/stellar/go/support/log" +) + +// ProtocolVersion is the version of the protocol that Bifrost server and +// JS SDK use to communicate. +const ProtocolVersion int = 1 + +type Server struct { + BitcoinListener *bitcoin.Listener `inject:""` + BitcoinAddressGenerator *bitcoin.AddressGenerator `inject:""` + Config *config.Config `inject:""` + Database database.Database `inject:""` + EthereumListener *ethereum.Listener `inject:""` + EthereumAddressGenerator *ethereum.AddressGenerator `inject:""` + StellarAccountConfigurator *stellar.AccountConfigurator `inject:""` + TransactionsQueue queue.Queue `inject:""` + SSEServer sse.ServerInterface `inject:""` + + MinimumValueBtc string + MinimumValueEth string + + minimumValueSat int64 + minimumValueWei *big.Int + httpServer *http.Server + log *log.Entry +} + +type GenerateAddressResponse struct { + ProtocolVersion int `json:"protocol_version"` + Chain string `json:"chain"` + Address string `json:"address"` +} diff --git a/services/bifrost/server/queue.go b/services/bifrost/server/queue.go new file mode 100644 index 0000000000..f6034f86d2 --- /dev/null +++ b/services/bifrost/server/queue.go @@ -0,0 +1,32 @@ +package server + +import ( + "time" +) + +// poolTransactionsQueue pools transactions queue which contains only processed and +// validated transactions and sends it to StellarAccountConfigurator for account configuration. +func (s *Server) poolTransactionsQueue() { + s.log.Info("Started pooling transactions queue") + + for { + transaction, err := s.TransactionsQueue.QueuePool() + if err != nil { + s.log.WithField("err", err).Error("Error pooling transactions queue") + time.Sleep(time.Second) + continue + } + + if transaction == nil { + time.Sleep(time.Second) + continue + } + + s.log.WithField("transaction", transaction).Info("Received transaction from transactions queue") + go s.StellarAccountConfigurator.ConfigureAccount( + transaction.StellarPublicKey, + string(transaction.AssetCode), + transaction.Amount, + ) + } +} diff --git a/services/bifrost/server/server.go b/services/bifrost/server/server.go new file mode 100644 index 0000000000..eb69a86ae7 --- /dev/null +++ b/services/bifrost/server/server.go @@ -0,0 +1,314 @@ +// Skip this file in Go <1.8 because it's using http.Server.Shutdown +// +build go1.8 + +package server + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net/http" + "os" + "os/signal" + "time" + + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/stellar/go/keypair" + "github.com/stellar/go/services/bifrost/bitcoin" + "github.com/stellar/go/services/bifrost/common" + "github.com/stellar/go/services/bifrost/database" + "github.com/stellar/go/services/bifrost/ethereum" + "github.com/stellar/go/support/errors" + "github.com/stellar/go/support/log" + "github.com/stellar/go/xdr" +) + +func (s *Server) Start() error { + s.initLogger() + s.log.Info("Server starting") + + // Register callbacks + s.BitcoinListener.TransactionHandler = s.onNewBitcoinTransaction + s.EthereumListener.TransactionHandler = s.onNewEthereumTransaction + s.StellarAccountConfigurator.OnAccountCreated = s.onStellarAccountCreated + s.StellarAccountConfigurator.OnAccountCredited = s.onStellarAccountCredited + + if !s.BitcoinListener.Enabled && !s.EthereumListener.Enabled { + return errors.New("At least one listener (BitcoinListener or EthereumListener) must be enabled") + } + + if s.BitcoinListener.Enabled { + var err error + s.minimumValueSat, err = bitcoin.BtcToSat(s.MinimumValueBtc) + if err != nil { + return errors.Wrap(err, "Invalid minimum accepted Bitcoin transaction value: "+s.MinimumValueBtc) + } + + if s.minimumValueSat == 0 { + return errors.New("Minimum accepted Bitcoin transaction value must be larger than 0") + } + + err = s.BitcoinListener.Start() + if err != nil { + return errors.Wrap(err, "Error starting BitcoinListener") + } + } else { + s.log.Warn("BitcoinListener disabled") + } + + if s.EthereumListener.Enabled { + var err error + s.minimumValueWei, err = ethereum.EthToWei(s.MinimumValueEth) + if err != nil { + return errors.Wrap(err, "Invalid minimum accepted Ethereum transaction value") + } + + if s.minimumValueWei.Cmp(new(big.Int)) == 0 { + return errors.New("Minimum accepted Ethereum transaction value must be larger than 0") + } + + err = s.EthereumListener.Start(s.Config.Ethereum.RpcServer) + if err != nil { + return errors.Wrap(err, "Error starting EthereumListener") + } + } else { + s.log.Warn("EthereumListener disabled") + } + + err := s.StellarAccountConfigurator.Start() + if err != nil { + return errors.Wrap(err, "Error starting StellarAccountConfigurator") + } + + err = s.SSEServer.StartPublishing() + if err != nil { + return errors.Wrap(err, "Error starting SSE Server") + } + + signalInterrupt := make(chan os.Signal, 1) + signal.Notify(signalInterrupt, os.Interrupt) + + go s.poolTransactionsQueue() + go s.startHTTPServer() + + <-signalInterrupt + s.shutdown() + + return nil +} + +func (s *Server) initLogger() { + s.log = common.CreateLogger("Server") +} + +func (s *Server) shutdown() { + if s.httpServer != nil { + log.Info("Shutting down HTTP server...") + ctx, close := context.WithTimeout(context.Background(), 5*time.Second) + defer close() + s.httpServer.Shutdown(ctx) + } +} + +func (s *Server) startHTTPServer() { + r := chi.NewRouter() + if s.Config.UsingProxy { + r.Use(middleware.RealIP) + } + r.Use(middleware.RequestID) + r.Use(middleware.Recoverer) + r.Use(s.loggerMiddleware) + r.Get("/events", s.HandlerEvents) + r.Post("/generate-bitcoin-address", s.HandlerGenerateBitcoinAddress) + r.Post("/generate-ethereum-address", s.HandlerGenerateEthereumAddress) + r.Post("/recovery-transaction", s.HandlerRecoveryTransaction) + + log.WithField("port", s.Config.Port).Info("Starting HTTP server") + + s.httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", s.Config.Port), + Handler: r, + } + + err := s.httpServer.ListenAndServe() + if err != nil { + if err == http.ErrServerClosed { + log.Info("HTTP server closed") + } else { + log.WithField("err", err).Fatal("Cannot start HTTP server") + } + } +} + +func (s *Server) loggerMiddleware(next http.Handler) http.Handler { + fn := func(w http.ResponseWriter, r *http.Request) { + requestLog := s.log.WithFields(log.F{ + "request_id": r.Context().Value(middleware.RequestIDKey), + "method": r.Method, + "uri": r.RequestURI, + "ip": r.RemoteAddr, + }) + + requestLog.Info("HTTP request") + + ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor) + requestStartTime := time.Now() + + next.ServeHTTP(ww, r) + + duration := big.NewRat( + time.Since(requestStartTime).Nanoseconds(), + int64(time.Second), + ) + + requestLog.WithFields(log.F{ + "status": ww.Status(), + "response_bytes": ww.BytesWritten(), + "duration": duration.FloatString(8), + }).Info("HTTP response") + } + return http.HandlerFunc(fn) +} + +func (s *Server) HandlerEvents(w http.ResponseWriter, r *http.Request) { + // Create SSE stream if not exists but only if address exists. + // This is required to restart a stream after server restart or failure. + address := r.URL.Query().Get("stream") + if !s.SSEServer.StreamExists(address) { + var chain database.Chain + + if len(address) == 0 { + w.WriteHeader(http.StatusBadRequest) + return + } + + if address[0] == '0' { + chain = database.ChainEthereum + } else { + // 1 or m, n in testnet + chain = database.ChainBitcoin + } + + association, err := s.Database.GetAssociationByChainAddress(chain, address) + if err != nil { + log.WithField("err", err).Error("Error getting address association") + w.WriteHeader(http.StatusInternalServerError) + return + } + + if association != nil { + s.SSEServer.CreateStream(address) + } + } + + s.SSEServer.HTTPHandler(w, r) +} + +func (s *Server) HandlerGenerateBitcoinAddress(w http.ResponseWriter, r *http.Request) { + s.handlerGenerateAddress(w, r, database.ChainBitcoin) +} + +func (s *Server) HandlerGenerateEthereumAddress(w http.ResponseWriter, r *http.Request) { + s.handlerGenerateAddress(w, r, database.ChainEthereum) +} + +func (s *Server) handlerGenerateAddress(w http.ResponseWriter, r *http.Request, chain database.Chain) { + w.Header().Set("Access-Control-Allow-Origin", s.Config.AccessControlAllowOriginHeader) + + stellarPublicKey := r.PostFormValue("stellar_public_key") + _, err := keypair.Parse(stellarPublicKey) + if err != nil || (err == nil && stellarPublicKey[0] != 'G') { + log.WithField("stellarPublicKey", stellarPublicKey).Warn("Invalid stellarPublicKey") + w.WriteHeader(http.StatusBadRequest) + return + } + + index, err := s.Database.IncrementAddressIndex(chain) + if err != nil { + log.WithField("err", err).Error("Error incrementing address index") + w.WriteHeader(http.StatusInternalServerError) + return + } + + var address string + + switch chain { + case database.ChainBitcoin: + address, err = s.BitcoinAddressGenerator.Generate(index) + case database.ChainEthereum: + address, err = s.EthereumAddressGenerator.Generate(index) + default: + log.WithField("chain", chain).Error("Invalid chain") + w.WriteHeader(http.StatusInternalServerError) + return + } + + if err != nil { + log.WithFields(log.F{"err": err, "index": index}).Error("Error generating address") + w.WriteHeader(http.StatusInternalServerError) + return + } + + err = s.Database.CreateAddressAssociation(chain, stellarPublicKey, address, index) + if err != nil { + log.WithFields(log.F{ + "err": err, + "chain": chain, + "index": index, + "stellarPublicKey": stellarPublicKey, + "address": address, + }).Error("Error creating address association") + w.WriteHeader(http.StatusInternalServerError) + return + } + + // Create SSE stream + s.SSEServer.CreateStream(address) + + response := GenerateAddressResponse{ + ProtocolVersion: ProtocolVersion, + Chain: string(chain), + Address: address, + } + + responseBytes, err := json.Marshal(response) + if err != nil { + log.WithField("err", err).Error("Error encoding JSON") + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Write(responseBytes) +} + +func (s *Server) HandlerRecoveryTransaction(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", s.Config.AccessControlAllowOriginHeader) + var transactionEnvelope xdr.TransactionEnvelope + transactionXdr := r.PostFormValue("transaction_xdr") + localLog := log.WithField("transaction_xdr", transactionXdr) + + if transactionXdr == "" { + localLog.Warn("Invalid input. No Transaction XDR") + w.WriteHeader(http.StatusBadRequest) + return + } + + err := xdr.SafeUnmarshalBase64(transactionXdr, &transactionEnvelope) + if err != nil { + localLog.WithField("err", err).Warn("Invalid Transaction XDR") + w.WriteHeader(http.StatusBadRequest) + return + } + + err = s.Database.AddRecoveryTransaction(transactionEnvelope.Tx.SourceAccount.Address(), transactionXdr) + if err != nil { + localLog.WithField("err", err).Error("Error saving recovery transaction") + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + return +} diff --git a/services/bifrost/server/stellar_events.go b/services/bifrost/server/stellar_events.go new file mode 100644 index 0000000000..299e1d3341 --- /dev/null +++ b/services/bifrost/server/stellar_events.go @@ -0,0 +1,47 @@ +package server + +import ( + "encoding/json" + + "github.com/stellar/go/services/bifrost/sse" +) + +func (s *Server) onStellarAccountCreated(destination string) { + association, err := s.Database.GetAssociationByStellarPublicKey(destination) + if err != nil { + s.log.WithField("err", err).Error("Error getting association") + return + } + + if association == nil { + s.log.WithField("stellarPublicKey", destination).Error("Association not found") + return + } + + s.SSEServer.BroadcastEvent(association.Address, sse.AccountCreatedAddressEvent, nil) +} + +func (s *Server) onStellarAccountCredited(destination, assetCode, amount string) { + association, err := s.Database.GetAssociationByStellarPublicKey(destination) + if err != nil { + s.log.WithField("err", err).Error("Error getting association") + return + } + + if association == nil { + s.log.WithField("stellarPublicKey", destination).Error("Association not found") + return + } + + data := map[string]string{ + "assetCode": assetCode, + "amount": amount, + } + + j, err := json.Marshal(data) + if err != nil { + s.log.WithField("data", data).Error("Error marshalling json") + } + + s.SSEServer.BroadcastEvent(association.Address, sse.AccountCreditedAddressEvent, j) +} diff --git a/services/bifrost/sse/main.go b/services/bifrost/sse/main.go new file mode 100644 index 0000000000..b7cb29ece3 --- /dev/null +++ b/services/bifrost/sse/main.go @@ -0,0 +1,61 @@ +package sse + +import ( + "net/http" + "sync" + + "github.com/r3labs/sse" + "github.com/stellar/go/support/log" +) + +// AddressEvent is an event sent to address SSE stream. +type AddressEvent string + +const ( + TransactionReceivedAddressEvent AddressEvent = "transaction_received" + AccountCreatedAddressEvent AddressEvent = "account_created" + AccountCreditedAddressEvent AddressEvent = "account_credited" +) + +type Server struct { + Storage Storage `inject:""` + + lastID int64 + eventsServer *sse.Server + initOnce sync.Once + log *log.Entry +} + +type ServerInterface interface { + BroadcastEvent(address string, event AddressEvent, data []byte) + StartPublishing() error + CreateStream(address string) + StreamExists(address string) bool + HTTPHandler(w http.ResponseWriter, r *http.Request) +} + +type Event struct { + Address string `db:"address"` + Event AddressEvent `db:"event"` + Data string `db:"data"` +} + +// Storage contains history of sent events. Because each transaction and +// Stellar account is always processed by a single Bifrost server, we need +// to broadcast events in case client streams events from the other Bifrost +// server. +// +// It's used to broadcast events to all instances of Bifrost server and +// to handle clients' reconnections. +type Storage interface { + // AddEvent adds a new server-sent event to the storage. + AddEvent(event Event) error + // GetEventsSinceID returns all events since `id`. Used to load and publish + // all broadcasted events. + // It returns the last event ID, list of events or error. + // If `id` is equal `-1`: + // * it should return the last event ID and empty list if at least one + // event has been broadcasted. + // * it should return 0 if no events have been broadcasted. + GetEventsSinceID(id int64) (int64, []Event, error) +} diff --git a/services/bifrost/sse/mock.go b/services/bifrost/sse/mock.go new file mode 100644 index 0000000000..72fc8433cf --- /dev/null +++ b/services/bifrost/sse/mock.go @@ -0,0 +1,34 @@ +package sse + +import ( + "net/http" + + "github.com/stretchr/testify/mock" +) + +// MockServer is a mockable SSE server. +type MockServer struct { + mock.Mock +} + +func (m *MockServer) BroadcastEvent(address string, event AddressEvent, data []byte) { + m.Called(address, event, data) +} + +func (m *MockServer) StartPublishing() error { + a := m.Called() + return a.Error(0) +} + +func (m *MockServer) CreateStream(address string) { + m.Called(address) +} + +func (m *MockServer) StreamExists(address string) bool { + a := m.Called(address) + return a.Get(0).(bool) +} + +func (m *MockServer) HTTPHandler(w http.ResponseWriter, r *http.Request) { + m.Called(w, r) +} diff --git a/services/bifrost/sse/server.go b/services/bifrost/sse/server.go new file mode 100644 index 0000000000..acec76c412 --- /dev/null +++ b/services/bifrost/sse/server.go @@ -0,0 +1,103 @@ +package sse + +import ( + "net/http" + "time" + + "github.com/r3labs/sse" + "github.com/stellar/go/services/bifrost/common" + "github.com/stellar/go/support/log" +) + +func (s *Server) init() { + s.eventsServer = sse.New() + s.lastID = -1 + s.log = common.CreateLogger("SSEServer") +} + +func (s *Server) BroadcastEvent(address string, event AddressEvent, data []byte) { + s.initOnce.Do(s.init) + + eventRecord := Event{ + Address: address, + Event: event, + Data: string(data), + } + err := s.Storage.AddEvent(eventRecord) + if err != nil { + s.log.WithFields(log.F{"err": err, "event": eventRecord}).Error("Error broadcasting event") + } +} + +// StartPublishing starts publishing events from the shared storage. +func (s *Server) StartPublishing() error { + s.initOnce.Do(s.init) + + var err error + s.lastID, _, err = s.Storage.GetEventsSinceID(s.lastID) + if err != nil { + return err + } + + go func() { + // Start publishing + for { + lastID, events, err := s.Storage.GetEventsSinceID(s.lastID) + if err != nil { + s.log.WithField("err", err).Error("Error GetEventsSinceID") + time.Sleep(time.Second) + continue + } + + if len(events) == 0 { + time.Sleep(time.Second) + continue + } + + for _, event := range events { + s.publishEvent(event.Address, event.Event, []byte(event.Data)) + } + + s.lastID = lastID + } + }() + + return nil +} + +func (s *Server) publishEvent(address string, event AddressEvent, data []byte) { + s.initOnce.Do(s.init) + + // Create SSE stream if not exists + if !s.eventsServer.StreamExists(address) { + s.eventsServer.CreateStream(address) + } + + // github.com/r3labs/sse does not send new lines - TODO create PR + if data == nil { + data = []byte("{}\n") + } else { + data = append(data, byte('\n')) + } + + s.eventsServer.Publish(address, &sse.Event{ + ID: []byte(event), + Event: []byte(event), + Data: data, + }) +} + +func (s *Server) CreateStream(address string) { + s.initOnce.Do(s.init) + s.eventsServer.CreateStream(address) +} + +func (s *Server) StreamExists(address string) bool { + s.initOnce.Do(s.init) + return s.eventsServer.StreamExists(address) +} + +func (s *Server) HTTPHandler(w http.ResponseWriter, r *http.Request) { + s.initOnce.Do(s.init) + s.eventsServer.HTTPHandler(w, r) +} diff --git a/services/bifrost/stellar/account_configurator.go b/services/bifrost/stellar/account_configurator.go new file mode 100644 index 0000000000..7d96fae428 --- /dev/null +++ b/services/bifrost/stellar/account_configurator.go @@ -0,0 +1,177 @@ +package stellar + +import ( + "net/http" + "time" + + "github.com/stellar/go/clients/horizon" + "github.com/stellar/go/keypair" + "github.com/stellar/go/services/bifrost/common" + "github.com/stellar/go/support/errors" + "github.com/stellar/go/support/log" +) + +// NewAccountXLMBalance is amount of lumens sent to new accounts +const NewAccountXLMBalance = "41" + +func (ac *AccountConfigurator) Start() error { + ac.log = common.CreateLogger("StellarAccountConfigurator") + ac.log.Info("StellarAccountConfigurator starting") + + kp, err := keypair.Parse(ac.IssuerPublicKey) + if err != nil || (err == nil && ac.IssuerPublicKey[0] != 'G') { + err = errors.Wrap(err, "Invalid IssuerPublicKey") + ac.log.Error(err) + return err + } + + kp, err = keypair.Parse(ac.SignerSecretKey) + if err != nil || (err == nil && ac.SignerSecretKey[0] != 'S') { + err = errors.Wrap(err, "Invalid SignerSecretKey") + ac.log.Error(err) + return err + } + + ac.signerPublicKey = kp.Address() + + root, err := ac.Horizon.Root() + if err != nil { + err = errors.Wrap(err, "Error loading Horizon root") + ac.log.Error(err) + return err + } + + if root.NetworkPassphrase != ac.NetworkPassphrase { + return errors.Errorf("Invalid network passphrase (have=%s, want=%s)", root.NetworkPassphrase, ac.NetworkPassphrase) + } + + err = ac.updateSequence() + if err != nil { + err = errors.Wrap(err, "Error loading issuer sequence number") + ac.log.Error(err) + return err + } + + go ac.logStats() + return nil +} + +func (ac *AccountConfigurator) logStats() { + for { + ac.log.WithField("currently_processing", ac.processingCount).Info("Stats") + time.Sleep(15 * time.Second) + } +} + +// ConfigureAccount configures a new account that participated in ICO. +// * First it creates a new account. +// * Once a trusline exists, it credits it with received number of ETH or BTC. +func (ac *AccountConfigurator) ConfigureAccount(destination, assetCode, amount string) { + localLog := ac.log.WithFields(log.F{ + "destination": destination, + "assetCode": assetCode, + "amount": amount, + }) + localLog.Info("Configuring Stellar account") + + ac.processingCountMutex.Lock() + ac.processingCount++ + ac.processingCountMutex.Unlock() + + defer func() { + ac.processingCountMutex.Lock() + ac.processingCount-- + ac.processingCountMutex.Unlock() + }() + + // Check if account exists. If it is, skip creating it. + for { + _, exists, err := ac.getAccount(destination) + if err != nil { + localLog.WithField("err", err).Error("Error loading account from Horizon") + time.Sleep(2 * time.Second) + continue + } + + if exists { + break + } + + localLog.WithField("destination", destination).Info("Creating Stellar account") + err = ac.createAccount(destination) + if err != nil { + localLog.WithField("err", err).Error("Error creating Stellar account") + time.Sleep(2 * time.Second) + continue + } + + break + } + + if ac.OnAccountCreated != nil { + ac.OnAccountCreated(destination) + } + + // Wait for trust line to be created... + for { + account, err := ac.Horizon.LoadAccount(destination) + if err != nil { + localLog.WithField("err", err).Error("Error loading account to check trustline") + time.Sleep(2 * time.Second) + continue + } + + if ac.trustlineExists(account, assetCode) { + break + } + + time.Sleep(2 * time.Second) + } + + localLog.Info("Trust line found") + + // When trustline found check if needs to authorize, then send token + if ac.NeedsAuthorize { + localLog.Info("Authorizing trust line") + err := ac.allowTrust(destination, assetCode, ac.TokenAssetCode) + if err != nil { + localLog.WithField("err", err).Error("Error authorizing trust line") + } + } + + localLog.Info("Sending token") + err := ac.sendToken(destination, assetCode, amount) + if err != nil { + localLog.WithField("err", err).Error("Error sending asset to account") + return + } + + if ac.OnAccountCredited != nil { + ac.OnAccountCredited(destination, assetCode, amount) + } + + localLog.Info("Account successully configured") +} + +func (ac *AccountConfigurator) getAccount(account string) (horizon.Account, bool, error) { + var hAccount horizon.Account + hAccount, err := ac.Horizon.LoadAccount(account) + if err != nil { + if err, ok := err.(*horizon.Error); ok && err.Response.StatusCode == http.StatusNotFound { + return hAccount, false, nil + } + return hAccount, false, err + } + + return hAccount, true, nil +} + +func (ac *AccountConfigurator) trustlineExists(account horizon.Account, assetCode string) bool { + for _, balance := range account.Balances { + if balance.Asset.Issuer == ac.IssuerPublicKey && balance.Asset.Code == assetCode { + return true + } + } + + return false +} diff --git a/services/bifrost/stellar/main.go b/services/bifrost/stellar/main.go new file mode 100644 index 0000000000..fda94d3334 --- /dev/null +++ b/services/bifrost/stellar/main.go @@ -0,0 +1,28 @@ +package stellar + +import ( + "sync" + + "github.com/stellar/go/clients/horizon" + "github.com/stellar/go/support/log" +) + +// AccountConfigurator is responsible for configuring new Stellar accounts that +// participate in ICO. +type AccountConfigurator struct { + Horizon horizon.ClientInterface `inject:""` + NetworkPassphrase string + IssuerPublicKey string + SignerSecretKey string + NeedsAuthorize bool + TokenAssetCode string + OnAccountCreated func(destination string) + OnAccountCredited func(destination string, assetCode string, amount string) + + signerPublicKey string + sequence uint64 + sequenceMutex sync.Mutex + processingCount int + processingCountMutex sync.Mutex + log *log.Entry +} diff --git a/services/bifrost/stellar/transactions.go b/services/bifrost/stellar/transactions.go new file mode 100644 index 0000000000..00636a8771 --- /dev/null +++ b/services/bifrost/stellar/transactions.go @@ -0,0 +1,133 @@ +package stellar + +import ( + "strconv" + + "github.com/stellar/go/build" + "github.com/stellar/go/clients/horizon" + "github.com/stellar/go/support/errors" + "github.com/stellar/go/support/log" +) + +func (ac *AccountConfigurator) createAccount(destination string) error { + err := ac.submitTransaction( + build.CreateAccount( + build.SourceAccount{ac.IssuerPublicKey}, + build.Destination{destination}, + build.NativeAmount{NewAccountXLMBalance}, + ), + ) + if err != nil { + return errors.Wrap(err, "Error submitting transaction") + } + + return nil +} + +func (ac *AccountConfigurator) allowTrust(trustor, assetCode, tokenAssetCode string) error { + err := ac.submitTransaction( + // Chain token received (BTC/ETH) + build.AllowTrust( + build.SourceAccount{ac.IssuerPublicKey}, + build.Trustor{trustor}, + build.AllowTrustAsset{assetCode}, + build.Authorize{true}, + ), + // Destination token + build.AllowTrust( + build.SourceAccount{ac.IssuerPublicKey}, + build.Trustor{trustor}, + build.AllowTrustAsset{tokenAssetCode}, + build.Authorize{true}, + ), + ) + if err != nil { + return errors.Wrap(err, "Error submitting transaction") + } + + return nil +} + +func (ac *AccountConfigurator) sendToken(destination, assetCode, amount string) error { + err := ac.submitTransaction( + build.Payment( + build.SourceAccount{ac.IssuerPublicKey}, + build.Destination{destination}, + build.CreditAmount{ + Code: assetCode, + Issuer: ac.IssuerPublicKey, + Amount: amount, + }, + ), + ) + if err != nil { + return errors.Wrap(err, "Error submitting transaction") + } + + return nil +} + +func (ac *AccountConfigurator) submitTransaction(mutators ...build.TransactionMutator) error { + tx, err := ac.buildTransaction(mutators...) + if err != nil { + return errors.Wrap(err, "Error building transaction") + } + + localLog := log.WithField("tx", tx) + localLog.Info("Submitting transaction") + + _, err = ac.Horizon.SubmitTransaction(tx) + if err != nil { + fields := log.F{"err": err} + if err, ok := err.(*horizon.Error); ok { + fields["result"] = string(err.Problem.Extras["result_xdr"]) + ac.updateSequence() + } + localLog.WithFields(fields).Error("Error submitting transaction") + return errors.Wrap(err, "Error submitting transaction") + } + + localLog.Info("Transaction successfully submitted") + return nil +} + +func (ac *AccountConfigurator) buildTransaction(mutators ...build.TransactionMutator) (string, error) { + muts := []build.TransactionMutator{ + build.SourceAccount{ac.signerPublicKey}, + build.Sequence{ac.getSequence()}, + build.Network{ac.NetworkPassphrase}, + } + muts = append(muts, mutators...) + tx := build.Transaction(muts...) + txe := tx.Sign(ac.SignerSecretKey) + return txe.Base64() +} + +func (ac *AccountConfigurator) updateSequence() error { + ac.sequenceMutex.Lock() + defer ac.sequenceMutex.Unlock() + + account, err := ac.Horizon.LoadAccount(ac.signerPublicKey) + if err != nil { + err = errors.Wrap(err, "Error loading issuing account") + ac.log.Error(err) + return err + } + + ac.sequence, err = strconv.ParseUint(account.Sequence, 10, 64) + if err != nil { + err = errors.Wrap(err, "Invalid account.Sequence") + ac.log.Error(err) + return err + } + + return nil +} + +func (ac *AccountConfigurator) getSequence() uint64 { + ac.sequenceMutex.Lock() + defer ac.sequenceMutex.Unlock() + ac.sequence++ + sequence := ac.sequence + return sequence +} diff --git a/services/bifrost/stress/bitcoin.go b/services/bifrost/stress/bitcoin.go new file mode 100644 index 0000000000..a0a3f22eac --- /dev/null +++ b/services/bifrost/stress/bitcoin.go @@ -0,0 +1,153 @@ +package stress + +import ( + "encoding/hex" + "errors" + "math/rand" + "time" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/txscript" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcutil" + "github.com/haltingstate/secp256k1-go" + "github.com/stellar/go/services/bifrost/common" + "github.com/stellar/go/support/log" +) + +func (c *RandomBitcoinClient) Start(addresses <-chan string) { + c.log = common.CreateLogger("RandomBitcoinClient") + rand.Seed(time.Now().Unix()) + c.currentBlockNumber = 0 + c.heightHash = map[int64]*chainhash.Hash{} + c.hashBlock = map[*chainhash.Hash]*wire.MsgBlock{} + c.firstBlockGenerated = make(chan bool) + go c.generateBlocks() + go c.addUserAddresses(addresses) + go c.logStats() + <-c.firstBlockGenerated +} + +func (g *RandomBitcoinClient) logStats() { + for { + g.log.WithField("addresses", len(g.userAddresses)).Info("Stats") + time.Sleep(15 * time.Second) + } +} + +func (g *RandomBitcoinClient) addUserAddresses(addresses <-chan string) { + for { + address := <-addresses + g.userAddressesLock.Lock() + g.userAddresses = append(g.userAddresses, address) + g.userAddressesLock.Unlock() + } +} + +func (c *RandomBitcoinClient) GetBlockCount() (int64, error) { + return c.currentBlockNumber, nil +} + +// This should always return testnet genesis block hash for `0` so production +// bitcoin.Listener genesis block check will fail. +func (c *RandomBitcoinClient) GetBlockHash(blockHeight int64) (*chainhash.Hash, error) { + if blockHeight == 0 { + return chaincfg.TestNet3Params.GenesisHash, nil + } + + if blockHeight > c.currentBlockNumber { + return nil, errors.New("Block height out of range") + } + return c.heightHash[blockHeight], nil +} + +func (c *RandomBitcoinClient) GetBlock(blockHash *chainhash.Hash) (*wire.MsgBlock, error) { + block, ok := c.hashBlock[blockHash] + if !ok { + return nil, errors.New("Block cannot be found") + } + return block, nil +} + +func (c *RandomBitcoinClient) generateBlocks() { + for { + block := &wire.MsgBlock{ + Header: wire.BlockHeader{ + // We just want hashes to be different + Version: int32(c.currentBlockNumber), + Timestamp: time.Now(), + }, + } + + // Generate 50-200 txs + transactionsCount := 50 + rand.Int()%150 + for i := 0; i < transactionsCount; i++ { + pkscript, err := txscript.PayToAddrScript(c.randomAddress()) + if err != nil { + panic(err) + } + + tx := &wire.MsgTx{ + TxOut: []*wire.TxOut{ + { + Value: c.randomAmount(), + PkScript: pkscript, + }, + }, + } + block.AddTransaction(tx) + } + + nextBlockNumber := c.currentBlockNumber + 1 + + blockHash := block.BlockHash() + c.hashBlock[&blockHash] = block + c.heightHash[nextBlockNumber] = &blockHash + + c.currentBlockNumber = nextBlockNumber + + if c.currentBlockNumber == 1 { + c.firstBlockGenerated <- true + } + + c.log.WithFields(log.F{"blockNumber": nextBlockNumber, "txs": transactionsCount}).Info("Generated block") + // Stress tests, we want results faster than 1 block / 10 minutes. + time.Sleep(10 * time.Second) + } +} + +func (g *RandomBitcoinClient) randomAddress() btcutil.Address { + g.userAddressesLock.Lock() + defer g.userAddressesLock.Unlock() + + var err error + var address btcutil.Address + + if len(g.userAddresses) > 0 { + address, err = btcutil.DecodeAddress(g.userAddresses[0], &chaincfg.TestNet3Params) + if err != nil { + panic(err) + } + g.userAddresses = g.userAddresses[1:] + } else { + pubKey, _ := secp256k1.GenerateKeyPair() + address, err = btcutil.NewAddressPubKey(pubKey, &chaincfg.TestNet3Params) + if err != nil { + panic(err) + } + } + + return address +} + +// randomAmount generates random amount between [0, 100) BTC +func (g *RandomBitcoinClient) randomAmount() int64 { + return rand.Int63n(100 * satsInBtc) +} + +func (g *RandomBitcoinClient) randomHash() string { + var hash [32]byte + rand.Read(hash[:]) + return hex.EncodeToString(hash[:]) +} diff --git a/services/bifrost/stress/doc.go b/services/bifrost/stress/doc.go new file mode 100644 index 0000000000..695fab61e3 --- /dev/null +++ b/services/bifrost/stress/doc.go @@ -0,0 +1,2 @@ +// Structs and functions used in stress tests +package stress diff --git a/services/bifrost/stress/ethereum.go b/services/bifrost/stress/ethereum.go new file mode 100644 index 0000000000..ac7dd5d2f1 --- /dev/null +++ b/services/bifrost/stress/ethereum.go @@ -0,0 +1,119 @@ +package stress + +import ( + "context" + "errors" + "math/big" + "math/rand" + "time" + + ethereumCommon "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stellar/go/services/bifrost/common" + "github.com/stellar/go/support/log" +) + +func (c *RandomEthereumClient) Start(addresses <-chan string) { + c.log = common.CreateLogger("RandomEthereumClient") + rand.Seed(time.Now().Unix()) + c.currentBlockNumber = 0 + c.blocks = map[int64]*types.Block{} + c.firstBlockGenerated = make(chan bool) + go c.generateBlocks() + go c.addUserAddresses(addresses) + go c.logStats() + c.log.Info("Waiting for the first block...") + <-c.firstBlockGenerated +} + +func (g *RandomEthereumClient) logStats() { + for { + g.log.WithField("addresses", len(g.userAddresses)).Info("Stats") + time.Sleep(15 * time.Second) + } +} + +func (g *RandomEthereumClient) addUserAddresses(addresses <-chan string) { + for { + address := <-addresses + g.userAddressesLock.Lock() + g.userAddresses = append(g.userAddresses, address) + g.userAddressesLock.Unlock() + } +} + +// This should always return testnet ID so production ethereum.Listener +// genesis block check will fail. +func (c *RandomEthereumClient) NetworkID(ctx context.Context) (*big.Int, error) { + return big.NewInt(3), nil +} + +func (c *RandomEthereumClient) BlockByNumber(ctx context.Context, number *big.Int) (*types.Block, error) { + if number == nil { + number = big.NewInt(c.currentBlockNumber) + } + + block, ok := c.blocks[number.Int64()] + if !ok { + return nil, errors.New("not found") + } + return block, nil +} + +func (g *RandomEthereumClient) generateBlocks() { + for { + // Generate 50-200 txs + transactionsCount := 50 + rand.Int()%150 + transactions := make([]*types.Transaction, 0, transactionsCount) + for i := 0; i < transactionsCount; i++ { + tx := types.NewTransaction( + uint64(i), + g.randomAddress(), + g.randomAmount(), + big.NewInt(1), + big.NewInt(2), + []byte{0, 0, 0, 0}, + ) + transactions = append(transactions, tx) + } + + newBlockNumber := g.currentBlockNumber + 1 + + header := &types.Header{ + Number: big.NewInt(newBlockNumber), + Time: big.NewInt(time.Now().Unix()), + } + + g.blocks[newBlockNumber] = types.NewBlock(header, transactions, []*types.Header{}, []*types.Receipt{}) + g.currentBlockNumber = newBlockNumber + + if g.currentBlockNumber == 1 { + g.firstBlockGenerated <- true + } + + g.log.WithFields(log.F{"blockNumber": newBlockNumber, "txs": transactionsCount}).Info("Generated block") + time.Sleep(10 * time.Second) + } +} + +func (g *RandomEthereumClient) randomAddress() ethereumCommon.Address { + g.userAddressesLock.Lock() + defer g.userAddressesLock.Unlock() + + var address ethereumCommon.Address + if len(g.userAddresses) > 0 { + address = ethereumCommon.HexToAddress(g.userAddresses[0]) + g.userAddresses = g.userAddresses[1:] + } else { + rand.Read(address[:]) + } + return address +} + +// randomAmount generates random amount between [1, 50) ETH +func (g *RandomEthereumClient) randomAmount() *big.Int { + eth := big.NewInt(1 + rand.Int63n(49)) + amount := new(big.Int) + amount.Mul(eth, weiInEth) + return amount +} diff --git a/services/bifrost/stress/main.go b/services/bifrost/stress/main.go new file mode 100644 index 0000000000..d0b938f2a2 --- /dev/null +++ b/services/bifrost/stress/main.go @@ -0,0 +1,76 @@ +package stress + +import ( + "math/big" + "sync" + + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/ethereum/go-ethereum/core/types" + "github.com/stellar/go/clients/horizon" + "github.com/stellar/go/support/log" +) + +const satsInBtc = 100000000 + +var ( + ten = big.NewInt(10) + eighteen = big.NewInt(18) + // weiInEth = 10^18 + weiInEth = new(big.Int).Exp(ten, eighteen, nil) +) + +// RandomBitcoinClient implements bitcoin.Client and generates random bitcoin transactions. +type RandomBitcoinClient struct { + currentBlockNumber int64 + heightHash map[int64]*chainhash.Hash + hashBlock map[*chainhash.Hash]*wire.MsgBlock + userAddresses []string + userAddressesLock sync.Mutex + firstBlockGenerated chan bool + log *log.Entry +} + +// RandomEthereumClient implements ethereum.Client and generates random ethereum transactions. +type RandomEthereumClient struct { + currentBlockNumber int64 + blocks map[int64]*types.Block + userAddresses []string + userAddressesLock sync.Mutex + firstBlockGenerated chan bool + log *log.Entry +} + +type UserState int + +const ( + PendingUserState UserState = iota + GeneratedAddressUserState + AccountCreatedUserState + TrustLinesCreatedUserState + ReceivedPaymentUserState +) + +// Users is responsible for imitating user interactions: +// * Request a new bifrost address by calling /generate-bitcoin-address or /generate-ethereum-address. +// * Add a generate address to RandomBitcoinClient or RandomEthereumClient so it generates a new transaction +// and puts it in a future block. +// * Once account is funded, create a trustline. +// * Wait for BTC/ETH payment. +type Users struct { + Horizon horizon.ClientInterface + NetworkPassphrase string + UsersPerSecond int + BifrostPorts []int + IssuerPublicKey string + + users map[string]*User // public key => User + usersLock sync.Mutex + log *log.Entry +} + +type User struct { + State UserState + AccountCreated chan bool + PaymentReceived chan bool +} diff --git a/services/bifrost/stress/users.go b/services/bifrost/stress/users.go new file mode 100644 index 0000000000..b832894a03 --- /dev/null +++ b/services/bifrost/stress/users.go @@ -0,0 +1,278 @@ +package stress + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "math/big" + "math/rand" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/stellar/go/build" + "github.com/stellar/go/clients/horizon" + "github.com/stellar/go/keypair" + "github.com/stellar/go/services/bifrost/common" + "github.com/stellar/go/services/bifrost/server" + "golang.org/x/net/context" +) + +func (u *Users) Start(accounts chan<- server.GenerateAddressResponse) { + u.log = common.CreateLogger("Users") + rand.Seed(time.Now().Unix()) + u.users = map[string]*User{} + + go func() { + cursor := horizon.Cursor("now") + err := u.Horizon.StreamPayments(context.Background(), u.IssuerPublicKey, &cursor, u.onNewPayment) + if err != nil { + panic(err) + } + }() + + for { + for i := 0; i < u.UsersPerSecond; i++ { + go func() { + kp, err := keypair.Random() + if err != nil { + panic(err) + } + + u.usersLock.Lock() + u.users[kp.Address()] = &User{ + State: PendingUserState, + AccountCreated: make(chan bool), + PaymentReceived: make(chan bool), + } + u.usersLock.Unlock() + + accounts <- u.newUser(kp) + }() + } + u.printStatus() + time.Sleep(time.Second) + } +} + +func (u *Users) onNewPayment(payment horizon.Payment) { + var destination string + + switch payment.Type { + case "create_account": + destination = payment.Account + case "payment": + destination = payment.To + default: + return + } + + u.usersLock.Lock() + user := u.users[destination] + u.usersLock.Unlock() + if user == nil { + return + } + + switch payment.Type { + case "create_account": + user.AccountCreated <- true + case "payment": + user.PaymentReceived <- true + } +} + +// newUser generates a new user interaction. +func (u *Users) newUser(kp *keypair.Full) server.GenerateAddressResponse { + u.usersLock.Lock() + user := u.users[kp.Address()] + u.usersLock.Unlock() + + randomPort := u.BifrostPorts[rand.Int()%len(u.BifrostPorts)] + randomCoin := []string{"bitcoin", "ethereum"}[rand.Int()%2] + + params := url.Values{} + params.Add("stellar_public_key", kp.Address()) + req, err := http.PostForm( + fmt.Sprintf("http://localhost:%d/generate-%s-address", randomPort, randomCoin), + params, + ) + if err != nil { + panic(err) + } + + defer req.Body.Close() + body, err := ioutil.ReadAll(req.Body) + if err != nil { + panic(err) + } + + var response server.GenerateAddressResponse + err = json.Unmarshal(body, &response) + if err != nil { + panic(err) + } + + u.updateUserState(kp.Address(), GeneratedAddressUserState) + + // Return generated address and continue interactions asynchronously + go func() { + // Wait for account to be created. + <-user.AccountCreated + + account, exists, err := u.getAccount(kp.Address()) + if err != nil { + panic(err) + } + + if !exists { + panic("disappeared") + } + + u.updateUserState(kp.Address(), AccountCreatedUserState) + + sequence, err := strconv.ParseUint(account.Sequence, 10, 64) + if err != nil { + panic(err) + } + + // Create trust lines + sequence++ + tx := build.Transaction( + build.SourceAccount{kp.Address()}, + build.Sequence{sequence}, + build.Network{u.NetworkPassphrase}, + build.Trust("BTC", u.IssuerPublicKey), + build.Trust("ETH", u.IssuerPublicKey), + ) + txe := tx.Sign(kp.Seed()) + txeB64, err := txe.Base64() + if err != nil { + panic(err) + } + + _, err = u.Horizon.SubmitTransaction(txeB64) + if err != nil { + fmt.Println(txeB64) + panic(err) + } + + u.updateUserState(kp.Address(), TrustLinesCreatedUserState) + + // Wait for (BTC/ETH) payment + <-user.PaymentReceived + + var assetCode, assetBalance string + account, exists, err = u.getAccount(kp.Address()) + if err != nil { + panic(err) + } + + if !exists { + panic("disappeared") + } + + btcBalance := account.GetCreditBalance("BTC", u.IssuerPublicKey) + ethBalance := account.GetCreditBalance("ETH", u.IssuerPublicKey) + + btcBalanceRat, ok := new(big.Rat).SetString(btcBalance) + if !ok { + panic("Error BTC balance: " + btcBalance) + } + ethBalanceRat, ok := new(big.Rat).SetString(ethBalance) + if !ok { + panic("Error ETH balance: " + ethBalance) + } + + if btcBalanceRat.Sign() != 0 { + assetCode = "BTC" + assetBalance = btcBalance + } else if ethBalanceRat.Sign() != 0 { + assetCode = "ETH" + assetBalance = ethBalance + } + + u.updateUserState(kp.Address(), ReceivedPaymentUserState) + + // Merge account so we don't need to fund issuing account over and over again. + sequence++ + tx = build.Transaction( + build.SourceAccount{kp.Address()}, + build.Sequence{sequence}, + build.Network{u.NetworkPassphrase}, + build.Payment( + build.Destination{u.IssuerPublicKey}, + build.CreditAmount{ + Code: assetCode, + Issuer: u.IssuerPublicKey, + Amount: assetBalance, + }, + ), + build.RemoveTrust("BTC", u.IssuerPublicKey), + build.RemoveTrust("ETH", u.IssuerPublicKey), + build.AccountMerge( + build.Destination{u.IssuerPublicKey}, + ), + ) + txe = tx.Sign(kp.Seed()) + txeB64, err = txe.Base64() + if err != nil { + panic(err) + } + + _, err = u.Horizon.SubmitTransaction(txeB64) + if err != nil { + if herr, ok := err.(*horizon.Error); ok { + fmt.Println(herr.Problem) + } + panic(err) + } + }() + + return response +} + +func (u *Users) printStatus() { + u.usersLock.Lock() + defer u.usersLock.Unlock() + + counters := map[UserState]int{} + total := 0 + for _, user := range u.users { + counters[user.State]++ + total++ + } + + u.log.Info( + fmt.Sprintf( + "Stress test status: total: %d, pending: %d, generated_address: %d, account_created: %d, trust_lines_created: %d, received_payment: %d", + total, + counters[PendingUserState], + counters[GeneratedAddressUserState], + counters[AccountCreatedUserState], + counters[TrustLinesCreatedUserState], + counters[ReceivedPaymentUserState], + ), + ) +} + +func (u *Users) updateUserState(publicKey string, state UserState) { + u.usersLock.Lock() + defer u.usersLock.Unlock() + user := u.users[publicKey] + user.State = state +} + +func (u *Users) getAccount(account string) (horizon.Account, bool, error) { + var hAccount horizon.Account + hAccount, err := u.Horizon.LoadAccount(account) + if err != nil { + if err, ok := err.(*horizon.Error); ok && err.Response.StatusCode == http.StatusNotFound { + return hAccount, false, nil + } + return hAccount, false, err + } + + return hAccount, true, nil +}