From 0ecdf8c36213c1a0be21a7f313f59323e42b1f4a Mon Sep 17 00:00:00 2001 From: Bartek Nowotarski Date: Thu, 3 Aug 2017 17:43:53 +0200 Subject: [PATCH 01/24] Bifrost --- glide.lock | 71 +++++ glide.yaml | 3 + services/bifrost/README.md | 1 + services/bifrost/bifrost.cfg | 15 + services/bifrost/common/main.go | 15 + services/bifrost/config/main.go | 18 ++ services/bifrost/database/main.go | 61 ++++ .../bifrost/database/migrations/01_init.sql | 42 +++ services/bifrost/database/postgres.go | 296 ++++++++++++++++++ .../bifrost/ethereum/address_generator.go | 42 +++ .../ethereum/address_generator_test.go | 53 ++++ services/bifrost/ethereum/listener.go | 124 ++++++++ services/bifrost/ethereum/main.go | 40 +++ services/bifrost/main.go | 125 ++++++++ services/bifrost/queue/main.go | 59 ++++ services/bifrost/queue/sqs_fifo.go | 11 + services/bifrost/server/ethereum_rail.go | 119 +++++++ services/bifrost/server/main.go | 36 +++ services/bifrost/server/server.go | 115 +++++++ services/bifrost/server/stellar_events.go | 45 +++ .../bifrost/stellar/account-configurator.go | 156 +++++++++ services/bifrost/stellar/main.go | 23 ++ services/bifrost/stellar/transactions.go | 76 +++++ 23 files changed, 1546 insertions(+) create mode 100644 services/bifrost/README.md create mode 100644 services/bifrost/bifrost.cfg create mode 100644 services/bifrost/common/main.go create mode 100644 services/bifrost/config/main.go create mode 100644 services/bifrost/database/main.go create mode 100644 services/bifrost/database/migrations/01_init.sql create mode 100644 services/bifrost/database/postgres.go create mode 100644 services/bifrost/ethereum/address_generator.go create mode 100644 services/bifrost/ethereum/address_generator_test.go create mode 100644 services/bifrost/ethereum/listener.go create mode 100644 services/bifrost/ethereum/main.go create mode 100644 services/bifrost/main.go create mode 100644 services/bifrost/queue/main.go create mode 100644 services/bifrost/queue/sqs_fifo.go create mode 100644 services/bifrost/server/ethereum_rail.go create mode 100644 services/bifrost/server/main.go create mode 100644 services/bifrost/server/server.go create mode 100644 services/bifrost/server/stellar_events.go create mode 100644 services/bifrost/stellar/account-configurator.go create mode 100644 services/bifrost/stellar/main.go create mode 100644 services/bifrost/stellar/transactions.go diff --git a/glide.lock b/glide.lock index 08f1dc0d6f..10857f9a6b 100644 --- a/glide.lock +++ b/glide.lock @@ -43,6 +43,10 @@ imports: - private/waiter - service/s3 - service/sts +- name: github.com/btcsuite/btcd + version: 4803a8291c92a1d2d41041b942a9a9e37deab065 + subpackages: + - btcec - name: github.com/BurntSushi/toml version: 99064174e013895bbd9b025c31100bd1d9b590ca repo: https://github.com/BurntSushi/toml @@ -54,6 +58,30 @@ imports: repo: https://github.com/davecgh/go-spew subpackages: - spew +- name: github.com/ethereum/go-ethereum + version: f4c49bc0f03910f0caa16c4a067c0f201de293b7 + 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 +100,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 +118,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 +128,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 +262,8 @@ 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/rs/cors version: a62a804a8a009876ca59105f7899938a1349f4b3 repo: https://github.com/rs/cors @@ -290,6 +330,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 +386,7 @@ imports: version: 1f22c0103821b9390939b6776727195525381532 repo: https://go.googlesource.com/crypto subpackages: + - ripemd160 - ssh/terminal - name: golang.org/x/net version: 9bc2a3340c92c17a20edcd0080e93851ed58f5d5 @@ -358,10 +401,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,6 +417,31 @@ imports: - name: gopkg.in/tylerb/graceful.v1 version: 50a48b6e73fcc75b45e22c05b79629a67c79e938 repo: https://gopkg.in/tylerb/graceful.v1 +- 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 + subpackages: + - encoding + - encoding/charmap + - encoding/htmlindex + - encoding/internal + - encoding/internal/identifier + - encoding/japanese + - encoding/korean + - encoding/simplifiedchinese + - encoding/traditionalchinese + - encoding/unicode + - internal/tag + - internal/utf8internal + - 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..e61834b5d1 100644 --- a/glide.yaml +++ b/glide.yaml @@ -286,3 +286,6 @@ 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 diff --git a/services/bifrost/README.md b/services/bifrost/README.md new file mode 100644 index 0000000000..d40cc7ac9c --- /dev/null +++ b/services/bifrost/README.md @@ -0,0 +1 @@ +# bifrost diff --git a/services/bifrost/bifrost.cfg b/services/bifrost/bifrost.cfg new file mode 100644 index 0000000000..b6e11fcf6d --- /dev/null +++ b/services/bifrost/bifrost.cfg @@ -0,0 +1,15 @@ +port = 8000 + +[ethereum] +master_public_key = "xpub6DxSCdWu6jKqr4isjo7bsPeDD6s3J4YVQV1JSHZg12Eagdqnf7XX4fxqyW2sLhUoFWutL7tAELU2LiGZrEXtjVbvYptvTX5Eoa4Mamdjm9u" +rpc_server = "http://localhost:8545" + +[stellar] +# GDGVTKSEXWB4VFTBDWCBJVJZLIY6R3766EHBZFIGK2N7EQHVV5UTA63C +issuer_secret_key = "SAGC33ER53WGBISR5LQ4RJIBFG5UHXWNGTLG4KJRC737VYXNDGWLO54B" +horizon = "https://horizon-testnet.stellar.org" +network_passphrase = "Test SDF Network ; September 2015" + +[database] +type="postgres" +dsn="postgres://bartek@localhost/bifrost?sslmode=disable" diff --git a/services/bifrost/common/main.go b/services/bifrost/common/main.go new file mode 100644 index 0000000000..b8f6d8ab10 --- /dev/null +++ b/services/bifrost/common/main.go @@ -0,0 +1,15 @@ +package common + +import ( + "github.com/stellar/go/support/log" +) + +func CreateLogger(serviceName string) *log.Entry { + logger := log.DefaultLogger + + if serviceName != "" { + logger = logger.WithField("service", serviceName) + } + + return logger +} diff --git a/services/bifrost/config/main.go b/services/bifrost/config/main.go new file mode 100644 index 0000000000..156d29c4b8 --- /dev/null +++ b/services/bifrost/config/main.go @@ -0,0 +1,18 @@ +package config + +type Config struct { + Port int `valid:"required"` + Ethereum struct { + MasterPublicKey string `valid:"required" toml:"master_public_key"` + RpcServer string `valid:"required" toml:"rpc_server"` + } `valid:"required" toml:"ethereum"` + Stellar struct { + Horizon string `valid:"required" toml:"horizon"` + NetworkPassphrase string `valid:"required" toml:"network_passphrase"` + IssuerSecretKey string `valid:"required" toml:"issuer_secret_key"` + } `valid:"required" toml:"stellar"` + Database struct { + Type string `valid:"matches(^postgres$)"` + DSN string `valid:"required"` + } `valid:"required"` +} diff --git a/services/bifrost/database/main.go b/services/bifrost/database/main.go new file mode 100644 index 0000000000..3783a80e6d --- /dev/null +++ b/services/bifrost/database/main.go @@ -0,0 +1,61 @@ +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 ( + ChainEthereum Chain = "ethereum" +) + +type Database interface { + // CreateEthereumAddressAssociation creates Ethereum-Stellar association. `addressIndex` + // is the ethereum address derivation index (BIP-32). + CreateEthereumAddressAssociation(stellarAddress, ethereumAddress string, addressIndex uint32) error + // GetAssociationByEthereumAddress searches for previously saved Ethereum-Stellar association. + // Should return nil if not found. + GetAssociationByEthereumAddress(ethereumAddress string) (*AddressAssociation, error) + // GetAssociationByStellarPublicKey searches for previously saved 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 `nil` if transaction is already added. + AddProcessedTransaction(chain Chain, transactionID string) error + // IsTransactionProcessed returns `true` if transaction has been already processed. + IsTransactionProcessed(chain Chain, transactionID string) (bool, error) + + // IncrementEthereumAddressIndex returns the current value of index used for ethereum key + // derivation and then increments it. This operation must be atomic so this function + // should never return the same value more than once. + IncrementEthereumAddressIndex() (uint32, error) +} + +type PostgresDatabase struct { + session *db.Session +} + +type AddressAssociation struct { + // Chain is the name of the payment origin chain: currently `ethereum` only + 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..d28822b316 --- /dev/null +++ b/services/bifrost/database/migrations/01_init.sql @@ -0,0 +1,42 @@ +CREATE TYPE chain AS ENUM ('ethereum'); + +CREATE TABLE address_association ( + chain chain NOT NULL, + address_index bigint NOT NULL UNIQUE, + 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'); + +CREATE TABLE processed_transaction ( + chain chain NOT NULL, + /* Ethereum: "0x"+hash (so 64+2) */ + transaction_id varchar(66) NOT NULL, + PRIMARY KEY (chain, transaction_id) +); + +/* If using DB storage for the queue not AWS FIFO */ +CREATE TABLE transactions_queue ( + /* Ethereum: "0x"+hash (so 64+2) */ + transaction_id varchar(66) NOT NULL, + asset_code varchar(10) NOT NULL, + /* ethereum: 100000000 in year 2128 1 Wei = 0.000000000000000001 */ + /* bitcoin: 21000000 1 Satoshi = 0.00000001 */ + amount varchar(30) NOT NULL, + stellar_public_key varchar(56) NOT NULL, + pooled boolean NOT NULL DEFAULT false, + PRIMARY KEY (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) +); diff --git a/services/bifrost/database/postgres.go b/services/bifrost/database/postgres.go new file mode 100644 index 0000000000..85bd4399fa --- /dev/null +++ b/services/bifrost/database/postgres.go @@ -0,0 +1,296 @@ +package database + +import ( + "database/sql" + "strconv" + "time" + + "github.com/stellar/go/services/bifrost/queue" + "github.com/stellar/go/support/db" + "github.com/stellar/go/support/errors" +) + +const ( + ethereumAddressIndexKey = "ethereum_address_index" + ethereumLastBlockKey = "ethereum_last_block" + + addressAssociationTableName = "address_association" + keyValueStoreTableName = "key_value_store" + processedTransactionTableName = "processed_transaction" + transactionsQueueTableName = "transactions_queue" +) + +type keyValueStoreRow struct { + Key string `db:"key"` + Value string `db:"value"` +} + +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"` +} + +func fromQueueTransaction(tx queue.Transaction) *transactionsQueueRow { + return &transactionsQueueRow{ + TransactionID: tx.TransactionID, + AssetCode: tx.AssetCode, + Amount: tx.Amount, + StellarPublicKey: tx.StellarPublicKey, + } +} + +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) CreateEthereumAddressAssociation(stellarAddress, ethereumAddress string, addressIndex uint32) error { + addressAssociationTable := d.getTable(addressAssociationTableName, nil) + + association := &AddressAssociation{ + Chain: ChainEthereum, + AddressIndex: addressIndex, + Address: ethereumAddress, + StellarPublicKey: stellarAddress, + CreatedAt: time.Now(), + } + + _, err := addressAssociationTable.Insert(association).Exec() + return err +} + +func (d *PostgresDatabase) GetAssociationByEthereumAddress(ethereumAddress string) (*AddressAssociation, error) { + addressAssociationTable := d.getTable(addressAssociationTableName, nil) + row := &AddressAssociation{} + where := map[string]interface{}{"address": ethereumAddress, "chain": ChainEthereum} + 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 string) error { + processedTransactionTable := d.getTable(processedTransactionTableName, nil) + processedTransaction := processedTransactionRow{chain, transactionID} + _, err := processedTransactionTable.Insert(processedTransaction).Exec() + return err +} + +func (d *PostgresDatabase) IsTransactionProcessed(chain Chain, transactionID string) (bool, error) { + processedTransactionTable := d.getTable(processedTransactionTableName, nil) + + row := processedTransactionRow{} + where := map[string]interface{}{"chain": chain, "transaction_id": transactionID} + err := processedTransactionTable.Get(&row, where).Exec() + if err != nil { + switch errors.Cause(err) { + case sql.ErrNoRows: + return false, nil + default: + return false, errors.Wrap(err, "Error getting processedTransaction from DB") + } + } + + return true, nil +} + +func (d *PostgresDatabase) IncrementEthereumAddressIndex() (uint32, error) { + 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": ethereumAddressIndexKey}).Suffix("FOR UPDATE").Exec() + if err != nil { + return 0, errors.Wrap(err, "Error getting `ethereumAddressIndexKey` 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 `ethereumAddressIndexKey` 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": ethereumAddressIndexKey}).Set("value", index).Exec() + if err != nil { + return 0, errors.Wrap(err, "Error updating `ethereumAddressIndexKey`") + } + + err = session.Commit() + if err != nil { + return 0, errors.Wrap(err, "Error commiting a transaction") + } + + return uint32(index), nil +} + +func (d *PostgresDatabase) GetEthereumBlockToProcess() (uint64, error) { + keyValueStore := d.getTable(keyValueStoreTableName, nil) + row := keyValueStoreRow{} + + err := keyValueStore.Get(&row, map[string]interface{}{"key": ethereumLastBlockKey}).Exec() + if err != nil { + return 0, errors.Wrap(err, "Error getting `ethereumLastBlockKey` from DB") + } + + block, err := strconv.ParseUint(row.Value, 10, 64) + if err != nil { + return 0, errors.Wrap(err, "Error converting `ethereumLastBlockKey` 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) SaveLastProcessedEthereumBlock(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": ethereumLastBlockKey}).Suffix("FOR UPDATE").Exec() + if err != nil { + return errors.Wrap(err, "Error getting `ethereumLastBlockKey` from DB") + } + + lastBlock, err := strconv.ParseUint(row.Value, 10, 64) + if err != nil { + return errors.Wrap(err, "Error converting `ethereumLastBlockKey` 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": ethereumLastBlockKey}).Set("value", block).Exec() + if err != nil { + return errors.Wrap(err, "Error updating `ethereumLastBlockKey`") + } + } + + err = session.Commit() + if err != nil { + return errors.Wrap(err, "Error commiting a transaction") + } + + return nil +} + +// Add implements queue.Queue interface +func (d *PostgresDatabase) Add(tx queue.Transaction) error { + transactionsQueueTable := d.getTable(transactionsQueueTableName, nil) + transactionQueue := fromQueueTransaction(tx) + _, err := transactionsQueueTable.Insert(transactionQueue).Exec() + return err +} + +// Pool receives and removes the head of this queue. Returns nil if no elements found. +// Pool implements queue.Queue interface. +func (d *PostgresDatabase) Pool() (*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}).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 +} diff --git a/services/bifrost/ethereum/address_generator.go b/services/bifrost/ethereum/address_generator.go new file mode 100644 index 0000000000..45e72f81e5 --- /dev/null +++ b/services/bifrost/ethereum/address_generator.go @@ -0,0 +1,42 @@ +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" +) + +// TODO should we use account hardened key and then use it to generate change and index keys? +// That way we can create lot more accounts than 0x80000000-1. +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..6cd5c5cbe2 --- /dev/null +++ b/services/bifrost/ethereum/listener.go @@ -0,0 +1,124 @@ +package ethereum + +import ( + "context" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/rpc" + "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") + + rpcClient, err := rpc.Dial(rpcServer) + if err != nil { + err = errors.Wrap(err, "Error dialing geth") + l.log.Error(err) + return err + } + + l.client = ethclient.NewClient(rpcClient) + 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 + } + + 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) + } + + 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 { + time.Sleep(1 * time.Second) + continue + } + + 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 { + err := l.TransactionHandler(transaction) + 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..2b1449c3e1 --- /dev/null +++ b/services/bifrost/ethereum/main.go @@ -0,0 +1,40 @@ +package ethereum + +import ( + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stellar/go/support/log" + "github.com/tyler-smith/go-bip32" +) + +// Listener listens for transactions using geth RPC. It calls TransactionHandler for each new +// transactions. It will reproces 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 requires geth 1.7.0. +type Listener struct { + Storage Storage `inject:""` + TransactionHandler TransactionHandler + + client *ethclient.Client + log *log.Entry +} + +// 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 *types.Transaction) error + +type AddressGenerator struct { + masterPublicKey *bip32.Key +} diff --git a/services/bifrost/main.go b/services/bifrost/main.go new file mode 100644 index 0000000000..6a42571e6a --- /dev/null +++ b/services/bifrost/main.go @@ -0,0 +1,125 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "time" + + "github.com/facebookgo/inject" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/stellar/go/clients/horizon" + "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/stellar" + 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 Ethereum", +} + +var serverCmd = &cobra.Command{ + Use: "server", + Short: "Starts backend server", + Run: func(cmd *cobra.Command, args []string) { + var ( + cfg config.Config + cfgPath = cmd.PersistentFlags().Lookup("config").Value.String() + ) + + 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) + } + + db := &database.PostgresDatabase{} + err = db.Open(cfg.Database.DSN) + if err != nil { + log.WithField("err", err).Error("Error connecting to database") + os.Exit(-1) + } + + ethereumListener := ðereum.Listener{} + + stellarAccountConfigurator := &stellar.AccountConfigurator{ + NetworkPassphrase: cfg.Stellar.NetworkPassphrase, + IssuerSecretKey: cfg.Stellar.IssuerSecretKey, + } + + horizonClient := &horizon.Client{ + URL: cfg.Stellar.Horizon, + HTTP: &http.Client{ + Timeout: 10 * time.Second, + }, + } + + addressGenerator, err := ethereum.NewAddressGenerator(cfg.Ethereum.MasterPublicKey) + if err != nil { + log.Error(err) + os.Exit(-1) + } + + server := &server.Server{} + + var g inject.Graph + err = g.Provide( + &inject.Object{Value: addressGenerator}, + &inject.Object{Value: &cfg}, + &inject.Object{Value: db}, + &inject.Object{Value: ethereumListener}, + &inject.Object{Value: horizonClient}, + &inject.Object{Value: server}, + &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) + } + + err = server.Start() + if err != nil { + log.WithField("err", err).Error("Error starting the server") + os.Exit(-1) + } + }, +} + +var versionCmd = &cobra.Command{ + Use: "version", + Short: "Print the version number", + Run: func(cmd *cobra.Command, args []string) { + fmt.Println("version") + }, +} + +func init() { + log.SetLevel(log.InfoLevel) + log.DefaultLogger.Logger.Formatter.(*logrus.TextFormatter).FullTimestamp = true + + rootCmd.AddCommand(versionCmd) + rootCmd.AddCommand(serverCmd) + + serverCmd.PersistentFlags().StringP("config", "c", "bifrost.cfg", "config file path") +} + +func main() { + rootCmd.Execute() +} diff --git a/services/bifrost/queue/main.go b/services/bifrost/queue/main.go new file mode 100644 index 0000000000..0b9100358e --- /dev/null +++ b/services/bifrost/queue/main.go @@ -0,0 +1,59 @@ +package queue + +import ( + "math/big" + + "github.com/stellar/go/support/errors" +) + +type AssetCode string + +const ( + AssetCodeETH AssetCode = "ETH" +) + +var ( + ten = big.NewInt(10) + eighteen = big.NewInt(18) + // weiInEth = 10^18 + weiInEth = new(big.Rat).SetInt(new(big.Int).Exp(ten, eighteen, nil)) +) + +type Transaction struct { + TransactionID string + AssetCode AssetCode + // Amount in the smallest unit of currency. + // For 1 satoshi = 0.00000001 BTC this should be equal `1` + // For 1 Wei = 0.000000000000000001 ETH this should be equal `1` + Amount string + StellarPublicKey string +} + +func (t Transaction) AmountToEth(prec int) (string, error) { + if t.AssetCode != AssetCodeETH { + return "", errors.New("Asset code not ETH") + } + + valueWei := new(big.Int) + _, ok := valueWei.SetString(t.Amount, 10) + if !ok { + return "", errors.Errorf("%s is not a valid integer", t.Amount) + } + valueEth := new(big.Rat).Quo(new(big.Rat).SetInt(valueWei), weiInEth) + return valueEth.FloatString(prec), nil +} + +// Queue implements transactions queue. +// The queue must not allow duplicates (including history) or 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 { + // Add inserts the element to this queue. + Add(tx Transaction) error + // Pool receives and removes the head of this queue. Returns nil if no elements found. + Pool() (*Transaction, error) +} + +type SQSFiFo struct{} 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/ethereum_rail.go b/services/bifrost/server/ethereum_rail.go new file mode 100644 index 0000000000..0459e1a979 --- /dev/null +++ b/services/bifrost/server/ethereum_rail.go @@ -0,0 +1,119 @@ +package server + +import ( + "time" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/stellar/go/services/bifrost/database" + "github.com/stellar/go/services/bifrost/queue" + "github.com/stellar/go/support/errors" +) + +// onNewEthereumTransaction checks if transaction is valid and adds it to +// the transactions queue. +func (s *Server) onNewEthereumTransaction(transaction *types.Transaction) error { + transactionHash := transaction.Hash().Hex() + localLog := s.log.WithField("transaction", transactionHash) + localLog.Debug("Processing transaction") + + // Check if transaction has not been processed + processed, err := s.Database.IsTransactionProcessed(database.ChainEthereum, transactionHash) + if err != nil { + return err + } + + if processed { + localLog.Debug("Transaction already processed, skipping") + return nil + } + + // Check if transaction is sent to one of our addresses + to := transaction.To() + if to == nil { + // Contract creation + localLog.Debug("Transaction is a contract creation, skipping") + return nil + } + + // Check if value is above minimum required + // TODO, check actual minimum (so user doesn't get more in XLM than in ETH) + if transaction.Value().Sign() <= 0 { + localLog.Debug("Value is below minimum required amount, skipping") + return nil + } + + address := to.Hex() + + addressAssociation, err := s.Database.GetAssociationByEthereumAddress(address) + if err != nil { + return errors.Wrap(err, "Error getting association") + } + + if addressAssociation == nil { + localLog.Debug("Associated address not found, skipping") + return nil + } + + // Add tx to the processing queue + queueTx := queue.Transaction{ + TransactionID: transactionHash, + AssetCode: queue.AssetCodeETH, + // Amount in the smallest unit of currency. + // For 1 Wei = 0.000000000000000001 ETH this should be equal `1` + Amount: transaction.Value().String(), + StellarPublicKey: addressAssociation.StellarPublicKey, + } + + err = s.TransactionsQueue.Add(queueTx) + if err != nil { + return errors.Wrap(err, "Error adding transaction to the processing queue") + } + + localLog.Info("Transaction added to transaction queue") + + // Save transaction as processed + err = s.Database.AddProcessedTransaction(database.ChainEthereum, transactionHash) + if err != nil { + return errors.Wrap(err, "Error saving transaction as processed") + } + + localLog.Info("Transaction processed successfully") + + // Publish event to address stream + s.publishEvent(address, TransactionReceivedAddressEvent, nil) + + return nil +} + +func (s *Server) poolTransactionsQueue() { + s.log.Info("Started pooling transactions queue") + + for { + transaction, err := s.TransactionsQueue.Pool() + 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") + + // Use Stellar Precision + amount, err := transaction.AmountToEth(7) + if err != nil { + s.log.WithField("transaction", transaction).Error("Amount is invalid") + continue + } + + go s.StellarAccountConfigurator.ConfigureAccount( + transaction.StellarPublicKey, + string(transaction.AssetCode), + amount, + ) + } +} diff --git a/services/bifrost/server/main.go b/services/bifrost/server/main.go new file mode 100644 index 0000000000..7e45574c34 --- /dev/null +++ b/services/bifrost/server/main.go @@ -0,0 +1,36 @@ +package server + +import ( + "github.com/r3labs/sse" + "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/stellar" + "github.com/stellar/go/support/log" +) + +type Server struct { + Config *config.Config `inject:""` + Database database.Database `inject:""` + EthereumListener *ethereum.Listener `inject:""` + EthereumAddressGenerator *ethereum.AddressGenerator `inject:""` + StellarAccountConfigurator *stellar.AccountConfigurator `inject:""` + TransactionsQueue queue.Queue `inject:""` + + eventsServer *sse.Server + log *log.Entry +} + +type GenerateEthereumAddressResponse struct { + EthereumAddress string `json:"ethereum-address"` +} + +// 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" +) diff --git a/services/bifrost/server/server.go b/services/bifrost/server/server.go new file mode 100644 index 0000000000..4ecb1ee49f --- /dev/null +++ b/services/bifrost/server/server.go @@ -0,0 +1,115 @@ +package server + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "github.com/r3labs/sse" + "github.com/stellar/go/services/bifrost/common" + "github.com/stellar/go/support/errors" + "github.com/stellar/go/support/log" +) + +func (s *Server) Start() error { + s.log = common.CreateLogger("Server") + s.log.Info("Server starting") + + // Register callbacks + s.EthereumListener.TransactionHandler = s.onNewEthereumTransaction + s.StellarAccountConfigurator.OnAccountCreated = s.onStellarAccountCreated + s.StellarAccountConfigurator.OnAccountCredited = s.onStellarAccountCredited + + err := s.EthereumListener.Start(s.Config.Ethereum.RpcServer) + if err != nil { + return errors.Wrap(err, "Error starting EthereumListener") + } + + err = s.StellarAccountConfigurator.Start() + if err != nil { + return errors.Wrap(err, "Error starting StellarAccountConfigurator") + } + + go s.poolTransactionsQueue() + + s.startHTTPServer() + return nil +} + +func (s *Server) startHTTPServer() { + s.eventsServer = sse.New() + + r := chi.NewRouter() + r.Use(middleware.Recoverer) + r.Get("/events", s.eventsServer.HTTPHandler) + r.Post("/generate-ethereum-address", s.handlerGenerateEthereumAddress) + + log.Info("Starting HTTP server") + // TODO read from config + err := http.ListenAndServe(":3000", r) + if err != nil { + log.WithField("err", err).Fatal("Cannot start HTTP server") + } +} + +func (s *Server) handlerGenerateEthereumAddress(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + stellarPublicKey := r.PostFormValue("stellar_public_key") + // TODO validation and check if already exists + + index, err := s.Database.IncrementEthereumAddressIndex() + if err != nil { + log.WithField("err", err).Error("Error incrementing ethereum address index") + w.WriteHeader(http.StatusInternalServerError) + return + } + + address, err := s.EthereumAddressGenerator.Generate(index) + if err != nil { + log.WithFields(log.F{"err": err, "index": index}).Error("Error generating ethereum address") + w.WriteHeader(http.StatusInternalServerError) + return + } + + err = s.Database.CreateEthereumAddressAssociation(stellarPublicKey, address, index) + if err != nil { + log.WithFields(log.F{"err": err, "index": index}).Error("Error creating ethereum address association") + w.WriteHeader(http.StatusInternalServerError) + return + } + + // Create SSE stream + s.eventsServer.CreateStream(address) + + response := GenerateEthereumAddressResponse{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) publishEvent(address string, event AddressEvent, data []byte) { + // 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{ + Event: []byte(event), + Data: data, + }) +} diff --git a/services/bifrost/server/stellar_events.go b/services/bifrost/server/stellar_events.go new file mode 100644 index 0000000000..dd8f09b4a8 --- /dev/null +++ b/services/bifrost/server/stellar_events.go @@ -0,0 +1,45 @@ +package server + +import ( + "encoding/json" +) + +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).Warn("Association not found") + return + } + + s.publishEvent(association.Address, 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).Warn("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.publishEvent(association.Address, AccountCreditedAddressEvent, j) +} diff --git a/services/bifrost/stellar/account-configurator.go b/services/bifrost/stellar/account-configurator.go new file mode 100644 index 0000000000..bea3f5e59f --- /dev/null +++ b/services/bifrost/stellar/account-configurator.go @@ -0,0 +1,156 @@ +package stellar + +import ( + "net/http" + "strconv" + "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.IssuerSecretKey) + if err != nil { + err = errors.Wrap(err, "Invalid IssuerSecretKey") + ac.log.Error(err) + return err + } + + ac.issuerPublicKey = kp.Address() + + err = ac.updateSequence() + if err != nil { + err = errors.Wrap(err, "Error loading issuer sequence number") + ac.log.Error(err) + return err + } + + return nil +} + +// ConfigureAccount configures a new account that participated in ICO. +// * First it creates a new account. +// * Once a trusline exists, it credits it with sent 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") + + // Check if account exists. If it is, skip creating it. + _, exists, err := ac.getAccount(destination) + if err != nil { + localLog.WithField("err", err).Error("Error loading account from Horizon") + return + } + + if !exists { + localLog.WithField("destination", destination).Info("Creating Stellar account") + err := ac.createAccount(destination) + if err != nil { + localLog.WithField("err", err).Error("Error creating Stellar account") + // TODO repeat + return + } + } + + if ac.OnAccountCreated != nil { + ac.OnAccountCreated(destination) + } + + // TODO if exists but native balance is too small, send more XLM? + + // Wait for account and trustline 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 + } else { + time.Sleep(2 * time.Second) + } + } + + // When trustline found send token + localLog.Info("Trust line found, 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 +} + +func (ac *AccountConfigurator) updateSequence() error { + ac.sequenceMutex.Lock() + defer ac.sequenceMutex.Unlock() + + account, err := ac.Horizon.LoadAccount(ac.issuerPublicKey) + 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/stellar/main.go b/services/bifrost/stellar/main.go new file mode 100644 index 0000000000..78070e83f5 --- /dev/null +++ b/services/bifrost/stellar/main.go @@ -0,0 +1,23 @@ +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 + IssuerSecretKey string + OnAccountCreated func(string) + OnAccountCredited func(string, string, string) + + issuerPublicKey string + sequence uint64 + sequenceMutex 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..8fa53c2598 --- /dev/null +++ b/services/bifrost/stellar/transactions.go @@ -0,0 +1,76 @@ +package stellar + +import ( + "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.Destination{destination}, + build.NativeAmount{NewAccountXLMBalance}, + ), + ) + 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.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(mutator build.TransactionMutator) error { + tx, err := ac.buildTransaction(mutator) + 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(mutator build.TransactionMutator) (string, error) { + tx := build.Transaction( + build.SourceAccount{ac.IssuerSecretKey}, + build.Sequence{ac.getSequence()}, + build.Network{ac.NetworkPassphrase}, + mutator, + ) + + txe := tx.Sign(ac.IssuerSecretKey) + return txe.Base64() +} From 281edfa36fce382bb8c2bafd4b4a3891b12b79b4 Mon Sep 17 00:00:00 2001 From: Bartek Nowotarski Date: Wed, 4 Oct 2017 17:18:36 +0200 Subject: [PATCH 02/24] Bitcoin listener, updates, README --- glide.lock | 45 ++-- glide.yaml | 9 + services/bifrost/README.md | 38 ++++ services/bifrost/bifrost.cfg | 9 + services/bifrost/bitcoin/address_generator.go | 41 ++++ .../bifrost/bitcoin/address_generator_test.go | 53 +++++ services/bifrost/bitcoin/listener.go | 198 ++++++++++++++++++ services/bifrost/bitcoin/main.go | 49 +++++ services/bifrost/config/main.go | 11 +- services/bifrost/database/main.go | 19 +- .../bifrost/database/migrations/01_init.sql | 5 +- services/bifrost/database/postgres.go | 69 ++++-- services/bifrost/ethereum/listener.go | 27 +++ services/bifrost/ethereum/main.go | 5 +- services/bifrost/main.go | 33 ++- services/bifrost/queue/main.go | 22 +- services/bifrost/server/bitcoin_rail.go | 78 +++++++ services/bifrost/server/ethereum_rail.go | 40 +--- services/bifrost/server/main.go | 29 ++- services/bifrost/server/queue.go | 50 +++++ services/bifrost/server/server.go | 167 +++++++++++++-- .../bifrost/stellar/account-configurator.go | 2 +- 22 files changed, 872 insertions(+), 127 deletions(-) create mode 100644 services/bifrost/bitcoin/address_generator.go create mode 100644 services/bifrost/bitcoin/address_generator_test.go create mode 100644 services/bifrost/bitcoin/listener.go create mode 100644 services/bifrost/bitcoin/main.go create mode 100644 services/bifrost/server/bitcoin_rail.go create mode 100644 services/bifrost/server/queue.go diff --git a/glide.lock b/glide.lock index 10857f9a6b..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 @@ -47,6 +47,24 @@ imports: 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 @@ -59,7 +77,7 @@ imports: subpackages: - spew - name: github.com/ethereum/go-ethereum - version: f4c49bc0f03910f0caa16c4a067c0f201de293b7 + version: ad444752311b1a318a7933562749b4586d4469e9 subpackages: - common - common/hexutil @@ -264,6 +282,8 @@ imports: 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 @@ -445,22 +465,3 @@ testImports: - name: gopkg.in/yaml.v2 version: 7ad95dd0798a40da1ccdff6dff35fd177b5edf40 repo: https://gopkg.in/yaml.v2 -testImports: -- name: golang.org/x/text - version: 1cbadb444a806fd9430d14ad08967ed91da4fa0a - subpackages: - - encoding - - encoding/charmap - - encoding/htmlindex - - encoding/internal - - encoding/internal/identifier - - encoding/japanese - - encoding/korean - - encoding/simplifiedchinese - - encoding/traditionalchinese - - encoding/unicode - - internal/tag - - internal/utf8internal - - language - - runes - - transform diff --git a/glide.yaml b/glide.yaml index e61834b5d1..0576c3046c 100644 --- a/glide.yaml +++ b/glide.yaml @@ -289,3 +289,12 @@ import: - 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 index d40cc7ac9c..7732274f43 100644 --- a/services/bifrost/README.md +++ b/services/bifrost/README.md @@ -1 +1,39 @@ # bifrost + +## 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 +* `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) +* `stellar` + * `issuer_secret_key` - TODO this will be changed to a signer account of issuing account. + * `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)) + +## Going to production + +* Remember than 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. +* 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. +* Check public master key correct. Use CLI tool to generate a few addresses and ensure you have corresponding private keys! You should probably send test transactions to 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). +* Make sure that "Authorization required" [flag](https://www.stellar.org/developers/guides/concepts/accounts.html#flags) is not set on your issuing account. It's a good idea to set "Authorization revocable" flag during ICO stage to remove trustlines to accounts with lost keys. +* 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. +* Turn off horizon rate limiting. diff --git a/services/bifrost/bifrost.cfg b/services/bifrost/bifrost.cfg index b6e11fcf6d..ccaa72ca83 100644 --- a/services/bifrost/bifrost.cfg +++ b/services/bifrost/bifrost.cfg @@ -1,8 +1,17 @@ port = 8000 +using_proxy = false + +[bitcoin] +master_public_key = "xpub6DxSCdWu6jKqr4isjo7bsPeDD6s3J4YVQV1JSHZg12Eagdqnf7XX4fxqyW2sLhUoFWutL7tAELU2LiGZrEXtjVbvYptvTX5Eoa4Mamdjm9u" +rpc_server = "localhost:18332" +rpc_user = "user" +rpc_pass = "password" +testnet = true [ethereum] master_public_key = "xpub6DxSCdWu6jKqr4isjo7bsPeDD6s3J4YVQV1JSHZg12Eagdqnf7XX4fxqyW2sLhUoFWutL7tAELU2LiGZrEXtjVbvYptvTX5Eoa4Mamdjm9u" rpc_server = "http://localhost:8545" +network_id = "3" [stellar] # GDGVTKSEXWB4VFTBDWCBJVJZLIY6R3766EHBZFIGK2N7EQHVV5UTA63C diff --git a/services/bifrost/bitcoin/address_generator.go b/services/bifrost/bitcoin/address_generator.go new file mode 100644 index 0000000000..a7d7e74b2a --- /dev/null +++ b/services/bifrost/bitcoin/address_generator.go @@ -0,0 +1,41 @@ +package bitcoin + +import ( + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcutil" + "github.com/stellar/go/support/errors" + "github.com/tyler-smith/go-bip32" +) + +// TODO should we use account hardened key and then use it to generate change and index keys? +// That way we can create lot more accounts than 0x80000000-1. +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") + } + + address, err := btcutil.NewAddressPubKey(accountKey.Key, &chaincfg.MainNetParams) + 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..8463c37021 --- /dev/null +++ b/services/bifrost/bitcoin/address_generator_test.go @@ -0,0 +1,53 @@ +package bitcoin + +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'/0'/0'/0: + // xprvA1y8DJefYknMwXkdUrSk57z26Z3Fjr3rVpk8NzQKRQWjy3ogV43qr4eqTuF1rg5rrw28mqbDHfWsmoBbeDPcQ34teNgDyohSu6oyodoJ6Bu + // xpub6ExUcpBZP8LfA1q6asykSFvkeask9Jmhs3fjBNovyk3iqr8q2bN6PryKKCvLLkMs1u2667wJnoM5LRQc3JcsGbQAhjUqJavxhtdk363GbP2 + generator, err := NewAddressGenerator("xpub6ExUcpBZP8LfA1q6asykSFvkeask9Jmhs3fjBNovyk3iqr8q2bN6PryKKCvLLkMs1u2667wJnoM5LRQc3JcsGbQAhjUqJavxhtdk363GbP2") + 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..2788961aec --- /dev/null +++ b/services/bifrost/bitcoin/listener.go @@ -0,0 +1,198 @@ +package bitcoin + +import ( + "strings" + "time" + + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/rpcclient" + "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(rpcServer, rpcUser, rpcPass string) error { + l.log = common.CreateLogger("BitcoinListener") + l.log.Info("BitcoinListener starting") + + if l.Testnet { + l.chainParams = &chaincfg.TestNet3Params + } else { + l.chainParams = &chaincfg.MainNetParams + } + + var err error + connConfig := &rpcclient.ConnConfig{ + Host: rpcServer, + User: rpcUser, + Pass: rpcPass, + HTTPPostMode: true, + DisableTLS: true, + } + l.client, err = rpcclient.New(connConfig, nil) + if err != nil { + err = errors.Wrap(err, "Error connecting to bicoin-core") + l.log.Error(err) + return err + } + + 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) + } + + // TODO Check if connected to correct network + 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() + 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) > 20*time.Minute && !noBlockWarningLogged { + l.log.Warn("No new block in more than 20 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, "blockHash": block.Header.BlockHash().String()}).Error("Error processing block") + time.Sleep(1 * 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(1 * time.Second) + // We continue to the next block + } + + 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) + + if blockHeight == 0 { + blockCount, err := l.client.GetBlockCount() + if err != nil { + err = errors.Wrap(err, "Error getting block count from bitcoin-core") + l.log.Error(err) + return nil, err + } + + blockHeight = blockCount + } + + 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 P2PKH addresses + if class != txscript.PubKeyHashTy { + 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, + Value: 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..914ff465e0 --- /dev/null +++ b/services/bifrost/bitcoin/main.go @@ -0,0 +1,49 @@ +package bitcoin + +import ( + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/rpcclient" + "github.com/stellar/go/support/log" + "github.com/tyler-smith/go-bip32" +) + +// 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 { + Storage Storage `inject:""` + TransactionHandler TransactionHandler + Testnet bool + + client *rpcclient.Client + chainParams *chaincfg.Params + log *log.Entry +} + +// 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 int64 + To string +} + +type AddressGenerator struct { + masterPublicKey *bip32.Key +} diff --git a/services/bifrost/config/main.go b/services/bifrost/config/main.go index 156d29c4b8..55bdba2fc3 100644 --- a/services/bifrost/config/main.go +++ b/services/bifrost/config/main.go @@ -1,8 +1,17 @@ package config type Config struct { - Port int `valid:"required"` + Port int `valid:"required"` + UsingProxy bool `valid:"optional" toml:"using_proxy"` + Bitcoin struct { + MasterPublicKey string `valid:"required" toml:"master_public_key"` + 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"` + } `valid:"required" toml:"bitcoin"` Ethereum struct { + NetworkID string `valid:"required,int" toml:"network_id"` MasterPublicKey string `valid:"required" toml:"master_public_key"` RpcServer string `valid:"required" toml:"rpc_server"` } `valid:"required" toml:"ethereum"` diff --git a/services/bifrost/database/main.go b/services/bifrost/database/main.go index 3783a80e6d..1b1d33aea1 100644 --- a/services/bifrost/database/main.go +++ b/services/bifrost/database/main.go @@ -20,17 +20,18 @@ func (s *Chain) Scan(src interface{}) error { } const ( + ChainBitcoin Chain = "bitcoin" ChainEthereum Chain = "ethereum" ) type Database interface { - // CreateEthereumAddressAssociation creates Ethereum-Stellar association. `addressIndex` - // is the ethereum address derivation index (BIP-32). - CreateEthereumAddressAssociation(stellarAddress, ethereumAddress string, addressIndex uint32) error - // GetAssociationByEthereumAddress searches for previously saved Ethereum-Stellar association. + // CreateAddressAssociation creates Bitcoin/Ethereum-Stellar association. `addressIndex` + // is the chain (Bitcoin/Ethereum) address derivation index (BIP-32). + CreateAddressAssociation(chain Chain, stellarAddress, ethereumAddress string, addressIndex uint32) error + // GetAssociationByChainAddress searches for previously saved Bitcoin/Ethereum-Stellar association. // Should return nil if not found. - GetAssociationByEthereumAddress(ethereumAddress string) (*AddressAssociation, error) - // GetAssociationByStellarPublicKey searches for previously saved Ethereum-Stellar association. + 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) @@ -40,10 +41,10 @@ type Database interface { // IsTransactionProcessed returns `true` if transaction has been already processed. IsTransactionProcessed(chain Chain, transactionID string) (bool, error) - // IncrementEthereumAddressIndex returns the current value of index used for ethereum key + // 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. - IncrementEthereumAddressIndex() (uint32, error) + IncrementAddressIndex(chain Chain) (uint32, error) } type PostgresDatabase struct { @@ -51,7 +52,7 @@ type PostgresDatabase struct { } type AddressAssociation struct { - // Chain is the name of the payment origin chain: currently `ethereum` only + // Chain is the name of the payment origin chain Chain Chain `db:"chain"` // BIP-44 AddressIndex uint32 `db:"address_index"` diff --git a/services/bifrost/database/migrations/01_init.sql b/services/bifrost/database/migrations/01_init.sql index d28822b316..7263fa9480 100644 --- a/services/bifrost/database/migrations/01_init.sql +++ b/services/bifrost/database/migrations/01_init.sql @@ -1,4 +1,4 @@ -CREATE TYPE chain AS ENUM ('ethereum'); +CREATE TYPE chain AS ENUM ('bitcoin', 'ethereum'); CREATE TABLE address_association ( chain chain NOT NULL, @@ -19,6 +19,9 @@ CREATE TABLE key_value_store ( 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) */ diff --git a/services/bifrost/database/postgres.go b/services/bifrost/database/postgres.go index 85bd4399fa..2379c44e24 100644 --- a/services/bifrost/database/postgres.go +++ b/services/bifrost/database/postgres.go @@ -14,6 +14,9 @@ const ( ethereumAddressIndexKey = "ethereum_address_index" ethereumLastBlockKey = "ethereum_last_block" + bitcoinAddressIndexKey = "bitcoin_address_index" + bitcoinLastBlockKey = "bitcoin_last_block" + addressAssociationTableName = "address_association" keyValueStoreTableName = "key_value_store" processedTransactionTableName = "processed_transaction" @@ -76,13 +79,13 @@ func (d *PostgresDatabase) getTable(name string, session *db.Session) *db.Table } } -func (d *PostgresDatabase) CreateEthereumAddressAssociation(stellarAddress, ethereumAddress string, addressIndex uint32) error { +func (d *PostgresDatabase) CreateAddressAssociation(chain Chain, stellarAddress, address string, addressIndex uint32) error { addressAssociationTable := d.getTable(addressAssociationTableName, nil) association := &AddressAssociation{ - Chain: ChainEthereum, + Chain: chain, AddressIndex: addressIndex, - Address: ethereumAddress, + Address: address, StellarPublicKey: stellarAddress, CreatedAt: time.Now(), } @@ -91,10 +94,10 @@ func (d *PostgresDatabase) CreateEthereumAddressAssociation(stellarAddress, ethe return err } -func (d *PostgresDatabase) GetAssociationByEthereumAddress(ethereumAddress string) (*AddressAssociation, error) { +func (d *PostgresDatabase) GetAssociationByChainAddress(chain Chain, address string) (*AddressAssociation, error) { addressAssociationTable := d.getTable(addressAssociationTableName, nil) row := &AddressAssociation{} - where := map[string]interface{}{"address": ethereumAddress, "chain": ChainEthereum} + where := map[string]interface{}{"address": address, "chain": chain} err := addressAssociationTable.Get(row, where).Exec() if err != nil { switch errors.Cause(err) { @@ -150,7 +153,17 @@ func (d *PostgresDatabase) IsTransactionProcessed(chain Chain, transactionID str return true, nil } -func (d *PostgresDatabase) IncrementEthereumAddressIndex() (uint32, error) { +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() @@ -162,23 +175,23 @@ func (d *PostgresDatabase) IncrementEthereumAddressIndex() (uint32, error) { } defer session.Rollback() - err = keyValueStore.Get(&row, map[string]interface{}{"key": ethereumAddressIndexKey}).Suffix("FOR UPDATE").Exec() + err = keyValueStore.Get(&row, map[string]interface{}{"key": key}).Suffix("FOR UPDATE").Exec() if err != nil { - return 0, errors.Wrap(err, "Error getting `ethereumAddressIndexKey` from DB") + 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 `ethereumAddressIndexKey` value to uint32") + 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": ethereumAddressIndexKey}).Set("value", index).Exec() + _, err = keyValueStore.Update(nil, map[string]interface{}{"key": key}).Set("value", index).Exec() if err != nil { - return 0, errors.Wrap(err, "Error updating `ethereumAddressIndexKey`") + return 0, errors.Wrap(err, "Error updating `"+key+"`") } err = session.Commit() @@ -190,17 +203,33 @@ func (d *PostgresDatabase) IncrementEthereumAddressIndex() (uint32, error) { } 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": ethereumLastBlockKey}).Exec() + err := keyValueStore.Get(&row, map[string]interface{}{"key": key}).Exec() if err != nil { - return 0, errors.Wrap(err, "Error getting `ethereumLastBlockKey` from DB") + 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 `ethereumLastBlockKey` value to uint64") + 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. @@ -210,7 +239,7 @@ func (d *PostgresDatabase) GetEthereumBlockToProcess() (uint64, error) { return block, nil } -func (d *PostgresDatabase) SaveLastProcessedEthereumBlock(block uint64) error { +func (d *PostgresDatabase) saveLastProcessedBlock(key string, block uint64) error { row := keyValueStoreRow{} session := d.session.Clone() @@ -222,21 +251,21 @@ func (d *PostgresDatabase) SaveLastProcessedEthereumBlock(block uint64) error { } defer session.Rollback() - err = keyValueStore.Get(&row, map[string]interface{}{"key": ethereumLastBlockKey}).Suffix("FOR UPDATE").Exec() + err = keyValueStore.Get(&row, map[string]interface{}{"key": key}).Suffix("FOR UPDATE").Exec() if err != nil { - return errors.Wrap(err, "Error getting `ethereumLastBlockKey` from DB") + 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 `ethereumLastBlockKey` value to uint32") + 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": ethereumLastBlockKey}).Set("value", block).Exec() + _, err = keyValueStore.Update(nil, map[string]interface{}{"key": key}).Set("value", block).Exec() if err != nil { - return errors.Wrap(err, "Error updating `ethereumLastBlockKey`") + return errors.Wrap(err, "Error updating `"+key+"`") } } diff --git a/services/bifrost/ethereum/listener.go b/services/bifrost/ethereum/listener.go index 6cd5c5cbe2..5928cd5d37 100644 --- a/services/bifrost/ethereum/listener.go +++ b/services/bifrost/ethereum/listener.go @@ -32,6 +32,20 @@ func (l *Listener) Start(rpcServer string) error { 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 } @@ -43,6 +57,10 @@ 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() + noBlockWarningLogged := false + for { block, err := l.getBlock(blockNumber) if err != nil { @@ -53,10 +71,19 @@ func (l *Listener) processBlocks(blockNumber uint64) { // 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") diff --git a/services/bifrost/ethereum/main.go b/services/bifrost/ethereum/main.go index 2b1449c3e1..6e0773a7db 100644 --- a/services/bifrost/ethereum/main.go +++ b/services/bifrost/ethereum/main.go @@ -8,7 +8,7 @@ import ( ) // Listener listens for transactions using geth RPC. It calls TransactionHandler for each new -// transactions. It will reproces the block if TransactionHandler returns error. It will +// 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. @@ -16,6 +16,7 @@ import ( // Listener requires geth 1.7.0. type Listener struct { Storage Storage `inject:""` + NetworkID string TransactionHandler TransactionHandler client *ethclient.Client @@ -25,7 +26,7 @@ type Listener struct { // 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 + // 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 diff --git a/services/bifrost/main.go b/services/bifrost/main.go index 6a42571e6a..429ab9ef16 100644 --- a/services/bifrost/main.go +++ b/services/bifrost/main.go @@ -10,6 +10,7 @@ import ( "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" @@ -30,10 +31,16 @@ var serverCmd = &cobra.Command{ Short: "Starts backend server", Run: func(cmd *cobra.Command, args []string) { var ( - cfg config.Config - cfgPath = cmd.PersistentFlags().Lookup("config").Value.String() + cfg config.Config + cfgPath = cmd.PersistentFlags().Lookup("config").Value.String() + debugMode = cmd.PersistentFlags().Lookup("debug").Changed ) + if debugMode { + log.SetLevel(log.DebugLevel) + log.Debug("Debug mode ON") + } + err := supportConfig.Read(cfgPath, &cfg) if err != nil { switch cause := errors.Cause(err).(type) { @@ -52,7 +59,13 @@ var serverCmd = &cobra.Command{ os.Exit(-1) } - ethereumListener := ðereum.Listener{} + bitcoinListener := &bitcoin.Listener{ + Testnet: cfg.Bitcoin.Testnet, + } + + ethereumListener := ðereum.Listener{ + NetworkID: cfg.Ethereum.NetworkID, + } stellarAccountConfigurator := &stellar.AccountConfigurator{ NetworkPassphrase: cfg.Stellar.NetworkPassphrase, @@ -66,7 +79,13 @@ var serverCmd = &cobra.Command{ }, } - addressGenerator, err := ethereum.NewAddressGenerator(cfg.Ethereum.MasterPublicKey) + bitcoinAddressGenerator, err := bitcoin.NewAddressGenerator(cfg.Bitcoin.MasterPublicKey) + if err != nil { + log.Error(err) + os.Exit(-1) + } + + ethereumAddressGenerator, err := ethereum.NewAddressGenerator(cfg.Ethereum.MasterPublicKey) if err != nil { log.Error(err) os.Exit(-1) @@ -76,9 +95,11 @@ var serverCmd = &cobra.Command{ var g inject.Graph err = g.Provide( - &inject.Object{Value: addressGenerator}, + &inject.Object{Value: bitcoinAddressGenerator}, + &inject.Object{Value: bitcoinListener}, &inject.Object{Value: &cfg}, &inject.Object{Value: db}, + &inject.Object{Value: ethereumAddressGenerator}, &inject.Object{Value: ethereumListener}, &inject.Object{Value: horizonClient}, &inject.Object{Value: server}, @@ -111,6 +132,7 @@ var versionCmd = &cobra.Command{ } func init() { + // TODO I think these should be default in stellar/go: log.SetLevel(log.InfoLevel) log.DefaultLogger.Logger.Formatter.(*logrus.TextFormatter).FullTimestamp = true @@ -118,6 +140,7 @@ func init() { rootCmd.AddCommand(serverCmd) serverCmd.PersistentFlags().StringP("config", "c", "bifrost.cfg", "config file path") + serverCmd.PersistentFlags().Bool("debug", false, "debug mode") } func main() { diff --git a/services/bifrost/queue/main.go b/services/bifrost/queue/main.go index 0b9100358e..073056d736 100644 --- a/services/bifrost/queue/main.go +++ b/services/bifrost/queue/main.go @@ -9,14 +9,18 @@ import ( type AssetCode string const ( + AssetCodeBTC AssetCode = "BTC" AssetCodeETH AssetCode = "ETH" ) var ( + eight = big.NewInt(8) ten = big.NewInt(10) eighteen = big.NewInt(18) // weiInEth = 10^18 weiInEth = new(big.Rat).SetInt(new(big.Int).Exp(ten, eighteen, nil)) + // satInBtc = 10^8 + satInBtc = new(big.Rat).SetInt(new(big.Int).Exp(ten, eight, nil)) ) type Transaction struct { @@ -29,6 +33,22 @@ type Transaction struct { StellarPublicKey string } +// TODO tests! +func (t Transaction) AmountToBtc(prec int) (string, error) { + if t.AssetCode != AssetCodeBTC { + return "", errors.New("Asset code not ETH") + } + + valueSat := new(big.Int) + _, ok := valueSat.SetString(t.Amount, 10) + if !ok { + return "", errors.Errorf("%s is not a valid integer", t.Amount) + } + valueBtc := new(big.Rat).Quo(new(big.Rat).SetInt(valueSat), satInBtc) + return valueBtc.FloatString(prec), nil +} + +// TODO tests! func (t Transaction) AmountToEth(prec int) (string, error) { if t.AssetCode != AssetCodeETH { return "", errors.New("Asset code not ETH") @@ -44,7 +64,7 @@ func (t Transaction) AmountToEth(prec int) (string, error) { } // Queue implements transactions queue. -// The queue must not allow duplicates (including history) or implement deduplication +// 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. diff --git a/services/bifrost/server/bitcoin_rail.go b/services/bifrost/server/bitcoin_rail.go new file mode 100644 index 0000000000..b3423e5df0 --- /dev/null +++ b/services/bifrost/server/bitcoin_rail.go @@ -0,0 +1,78 @@ +package server + +import ( + "strconv" + + "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/support/errors" + "github.com/stellar/go/support/log" +) + +// onNewBitcoinTransaction checks if transaction is valid and adds it to +// the transactions queue. +func (s *Server) onNewBitcoinTransaction(transaction bitcoin.Transaction) error { + localLog := s.log.WithFields(log.F{"transaction": transaction, "rail": "bitcoin"}) + localLog.Debug("Processing transaction") + + // Check if transaction has not been processed + processed, err := s.Database.IsTransactionProcessed(database.ChainBitcoin, transaction.Hash) + if err != nil { + return err + } + + if processed { + localLog.Debug("Transaction already processed, skipping") + return nil + } + + // Check if value is above minimum required + // TODO, check actual minimum (so user doesn't get more in XLM than in ETH) + if transaction.Value <= 0 { + 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 + } + + value := strconv.FormatInt(transaction.Value, 10) + + // Add tx to the processing queue + queueTx := queue.Transaction{ + TransactionID: transaction.Hash, + AssetCode: queue.AssetCodeBTC, + // Amount in the smallest unit of currency. + // For 1 satoshi = 0.00000001 BTC this should be equal `1` + Amount: value, + StellarPublicKey: addressAssociation.StellarPublicKey, + } + + err = s.TransactionsQueue.Add(queueTx) + if err != nil { + return errors.Wrap(err, "Error adding transaction to the processing queue") + } + + localLog.Info("Transaction added to transaction queue") + + // Save transaction as processed + err = s.Database.AddProcessedTransaction(database.ChainBitcoin, transaction.Hash) + if err != nil { + return errors.Wrap(err, "Error saving transaction as processed") + } + + localLog.Info("Transaction processed successfully") + + // Publish event to address stream + s.publishEvent(transaction.To, TransactionReceivedAddressEvent, nil) + + return nil +} diff --git a/services/bifrost/server/ethereum_rail.go b/services/bifrost/server/ethereum_rail.go index 0459e1a979..d684728fba 100644 --- a/services/bifrost/server/ethereum_rail.go +++ b/services/bifrost/server/ethereum_rail.go @@ -1,19 +1,18 @@ package server import ( - "time" - "github.com/ethereum/go-ethereum/core/types" "github.com/stellar/go/services/bifrost/database" "github.com/stellar/go/services/bifrost/queue" "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. func (s *Server) onNewEthereumTransaction(transaction *types.Transaction) error { transactionHash := transaction.Hash().Hex() - localLog := s.log.WithField("transaction", transactionHash) + localLog := s.log.WithFields(log.F{"transaction": transactionHash, "rail": "ethereum"}) localLog.Debug("Processing transaction") // Check if transaction has not been processed @@ -44,7 +43,7 @@ func (s *Server) onNewEthereumTransaction(transaction *types.Transaction) error address := to.Hex() - addressAssociation, err := s.Database.GetAssociationByEthereumAddress(address) + addressAssociation, err := s.Database.GetAssociationByChainAddress(database.ChainEthereum, address) if err != nil { return errors.Wrap(err, "Error getting association") } @@ -84,36 +83,3 @@ func (s *Server) onNewEthereumTransaction(transaction *types.Transaction) error return nil } - -func (s *Server) poolTransactionsQueue() { - s.log.Info("Started pooling transactions queue") - - for { - transaction, err := s.TransactionsQueue.Pool() - 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") - - // Use Stellar Precision - amount, err := transaction.AmountToEth(7) - if err != nil { - s.log.WithField("transaction", transaction).Error("Amount is invalid") - continue - } - - go s.StellarAccountConfigurator.ConfigureAccount( - transaction.StellarPublicKey, - string(transaction.AssetCode), - amount, - ) - } -} diff --git a/services/bifrost/server/main.go b/services/bifrost/server/main.go index 7e45574c34..2334b2c980 100644 --- a/services/bifrost/server/main.go +++ b/services/bifrost/server/main.go @@ -1,7 +1,10 @@ package server import ( + "net/http" + "github.com/r3labs/sse" + "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" @@ -10,7 +13,18 @@ import ( "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 { + BitcoinListener *bitcoin.Listener `inject:""` + BitcoinAddressGenerator *bitcoin.AddressGenerator `inject:""` Config *config.Config `inject:""` Database database.Database `inject:""` EthereumListener *ethereum.Listener `inject:""` @@ -18,19 +32,12 @@ type Server struct { StellarAccountConfigurator *stellar.AccountConfigurator `inject:""` TransactionsQueue queue.Queue `inject:""` + httpServer *http.Server eventsServer *sse.Server log *log.Entry } -type GenerateEthereumAddressResponse struct { - EthereumAddress string `json:"ethereum-address"` +type GenerateAddressResponse struct { + Chain string `json:"chain"` + Address string `json:"address"` } - -// 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" -) diff --git a/services/bifrost/server/queue.go b/services/bifrost/server/queue.go new file mode 100644 index 0000000000..b07dec644c --- /dev/null +++ b/services/bifrost/server/queue.go @@ -0,0 +1,50 @@ +package server + +import ( + "time" + + "github.com/stellar/go/services/bifrost/queue" +) + +func (s *Server) poolTransactionsQueue() { + s.log.Info("Started pooling transactions queue") + + for { + transaction, err := s.TransactionsQueue.Pool() + 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") + + // Use Stellar Precision + var amount string + switch transaction.AssetCode { + case queue.AssetCodeBTC: + amount, err = transaction.AmountToBtc(7) + case queue.AssetCodeETH: + amount, err = transaction.AmountToEth(7) + default: + s.log.Error("Invalid asset code pooled from the queue") + continue + } + + if err != nil { + s.log.WithField("transaction", transaction).Error("Amount is invalid") + continue + } + + go s.StellarAccountConfigurator.ConfigureAccount( + transaction.StellarPublicKey, + string(transaction.AssetCode), + amount, + ) + } +} diff --git a/services/bifrost/server/server.go b/services/bifrost/server/server.go index 4ecb1ee49f..dc7714f4ec 100644 --- a/services/bifrost/server/server.go +++ b/services/bifrost/server/server.go @@ -1,13 +1,21 @@ 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/r3labs/sse" + "github.com/stellar/go/keypair" "github.com/stellar/go/services/bifrost/common" + "github.com/stellar/go/services/bifrost/database" "github.com/stellar/go/support/errors" "github.com/stellar/go/support/log" ) @@ -17,11 +25,21 @@ func (s *Server) Start() error { 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 - err := s.EthereumListener.Start(s.Config.Ethereum.RpcServer) + err := s.BitcoinListener.Start( + s.Config.Bitcoin.RpcServer, + s.Config.Bitcoin.RpcUser, + s.Config.Bitcoin.RpcPass, + ) + if err != nil { + return errors.Wrap(err, "Error starting BitcoinListener") + } + + err = s.EthereumListener.Start(s.Config.Ethereum.RpcServer) if err != nil { return errors.Wrap(err, "Error starting EthereumListener") } @@ -31,51 +49,163 @@ func (s *Server) Start() error { return errors.Wrap(err, "Error starting StellarAccountConfigurator") } + signalInterrupt := make(chan os.Signal, 1) + signal.Notify(signalInterrupt, os.Interrupt) + go s.poolTransactionsQueue() + go s.startHTTPServer() + + <-signalInterrupt + s.shutdown() - s.startHTTPServer() return nil } +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() { s.eventsServer = sse.New() r := chi.NewRouter() + if s.Config.UsingProxy { + r.Use(middleware.RealIP) + } + r.Use(middleware.RequestID) r.Use(middleware.Recoverer) - r.Get("/events", s.eventsServer.HTTPHandler) - r.Post("/generate-ethereum-address", s.handlerGenerateEthereumAddress) + r.Use(s.loggerMiddleware) + r.Get("/events", s.HandlerEvents) + r.Post("/generate-bitcoin-address", s.HandlerGenerateBitcoinAddress) + r.Post("/generate-ethereum-address", s.HandlerGenerateEthereumAddress) + + log.WithField("port", s.Config.Port).Info("Starting HTTP server") - log.Info("Starting HTTP server") - // TODO read from config - err := http.ListenAndServe(":3000", r) + s.httpServer = &http.Server{ + Addr: fmt.Sprintf(":%d", s.Config.Port), + Handler: r, + } + + err := s.httpServer.ListenAndServe() if err != nil { - log.WithField("err", err).Fatal("Cannot start HTTP server") + if err == http.ErrServerClosed { + log.Info("HTTP server closed") + } else { + log.WithField("err", err).Fatal("Cannot start HTTP server") + } } } -func (s *Server) handlerGenerateEthereumAddress(w http.ResponseWriter, r *http.Request) { +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.eventsServer.StreamExists(address) { + var chain database.Chain + if len(address) > 0 && address[0] == '1' { + chain = database.ChainBitcoin + } else { + chain = database.ChainEthereum + } + + 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.eventsServer.CreateStream(address) + } + } + + s.eventsServer.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", "*") stellarPublicKey := r.PostFormValue("stellar_public_key") - // TODO validation and check if already exists + _, 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.IncrementEthereumAddressIndex() + index, err := s.Database.IncrementAddressIndex(chain) if err != nil { - log.WithField("err", err).Error("Error incrementing ethereum address index") + 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 } - address, err := s.EthereumAddressGenerator.Generate(index) if err != nil { - log.WithFields(log.F{"err": err, "index": index}).Error("Error generating ethereum address") + log.WithFields(log.F{"err": err, "index": index}).Error("Error generating address") w.WriteHeader(http.StatusInternalServerError) return } - err = s.Database.CreateEthereumAddressAssociation(stellarPublicKey, address, index) + err = s.Database.CreateAddressAssociation(chain, stellarPublicKey, address, index) if err != nil { - log.WithFields(log.F{"err": err, "index": index}).Error("Error creating ethereum address association") + log.WithFields(log.F{"err": err, "index": index}).Error("Error creating address association") w.WriteHeader(http.StatusInternalServerError) return } @@ -83,7 +213,10 @@ func (s *Server) handlerGenerateEthereumAddress(w http.ResponseWriter, r *http.R // Create SSE stream s.eventsServer.CreateStream(address) - response := GenerateEthereumAddressResponse{address} + response := GenerateAddressResponse{ + Chain: string(chain), + Address: address, + } responseBytes, err := json.Marshal(response) if err != nil { diff --git a/services/bifrost/stellar/account-configurator.go b/services/bifrost/stellar/account-configurator.go index bea3f5e59f..fa85ffc0db 100644 --- a/services/bifrost/stellar/account-configurator.go +++ b/services/bifrost/stellar/account-configurator.go @@ -40,7 +40,7 @@ func (ac *AccountConfigurator) Start() error { // ConfigureAccount configures a new account that participated in ICO. // * First it creates a new account. -// * Once a trusline exists, it credits it with sent number of ETH or BTC. +// * 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, From 61527d3a5d0d185abac7814043013c7ddc0aa5f9 Mon Sep 17 00:00:00 2001 From: Bartek Nowotarski Date: Fri, 20 Oct 2017 15:35:05 +0200 Subject: [PATCH 03/24] Stress tests, bug fixes --- clients/horizon/responses.go | 14 + services/bifrost/README.md | 7 +- services/bifrost/bifrost.cfg | 6 +- services/bifrost/bitcoin/address_generator.go | 6 +- .../bifrost/bitcoin/address_generator_test.go | 3 +- services/bifrost/bitcoin/listener.go | 55 ++-- services/bifrost/bitcoin/main.go | 28 +- services/bifrost/bitcoin/transaction.go | 11 + services/bifrost/bitcoin/transaction_test.go | 29 ++ services/bifrost/config/main.go | 21 +- services/bifrost/database/main.go | 14 +- .../bifrost/database/migrations/01_init.sql | 14 +- services/bifrost/database/mock.go | 43 +++ services/bifrost/database/postgres.go | 69 +++-- services/bifrost/ethereum/listener.go | 27 +- services/bifrost/ethereum/main.go | 32 +- services/bifrost/ethereum/transaction.go | 11 + services/bifrost/ethereum/transaction_test.go | 28 ++ services/bifrost/main.go | 285 ++++++++++++++---- services/bifrost/queue/main.go | 64 +--- services/bifrost/queue/mock.go | 20 ++ services/bifrost/server/bitcoin_rail.go | 60 ++-- services/bifrost/server/bitcoin_rail_test.go | 135 +++++++++ services/bifrost/server/ethereum_rail.go | 74 ++--- services/bifrost/server/ethereum_rail_test.go | 134 ++++++++ services/bifrost/server/main.go | 17 +- services/bifrost/server/queue.go | 26 +- services/bifrost/server/server.go | 51 ++-- services/bifrost/server/stellar_events.go | 6 +- services/bifrost/sse/main.go | 69 +++++ services/bifrost/sse/mock.go | 29 ++ ...onfigurator.go => account_configurator.go} | 100 +++--- services/bifrost/stellar/main.go | 17 +- services/bifrost/stellar/transactions.go | 39 ++- services/bifrost/stress/bitcoin.go | 153 ++++++++++ services/bifrost/stress/doc.go | 2 + services/bifrost/stress/ethereum.go | 119 ++++++++ services/bifrost/stress/main.go | 76 +++++ services/bifrost/stress/users.go | 278 +++++++++++++++++ 39 files changed, 1740 insertions(+), 432 deletions(-) create mode 100644 services/bifrost/bitcoin/transaction.go create mode 100644 services/bifrost/bitcoin/transaction_test.go create mode 100644 services/bifrost/database/mock.go create mode 100644 services/bifrost/ethereum/transaction.go create mode 100644 services/bifrost/ethereum/transaction_test.go create mode 100644 services/bifrost/queue/mock.go create mode 100644 services/bifrost/server/bitcoin_rail_test.go create mode 100644 services/bifrost/server/ethereum_rail_test.go create mode 100644 services/bifrost/sse/main.go create mode 100644 services/bifrost/sse/mock.go rename services/bifrost/stellar/{account-configurator.go => account_configurator.go} (68%) create mode 100644 services/bifrost/stress/bitcoin.go create mode 100644 services/bifrost/stress/doc.go create mode 100644 services/bifrost/stress/ethereum.go create mode 100644 services/bifrost/stress/main.go create mode 100644 services/bifrost/stress/users.go diff --git a/clients/horizon/responses.go b/clients/horizon/responses.go index 7652543331..20d4d7f127 100644 --- a/clients/horizon/responses.go +++ b/clients/horizon/responses.go @@ -47,6 +47,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 +202,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/services/bifrost/README.md b/services/bifrost/README.md index 7732274f43..ba3e8b38d3 100644 --- a/services/bifrost/README.md +++ b/services/bifrost/README.md @@ -15,7 +15,8 @@ * `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) * `stellar` - * `issuer_secret_key` - TODO this will be changed to a signer account of issuing account. + * `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` @@ -37,3 +38,7 @@ * 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. * Turn off horizon rate limiting. + +## Stress-testing + +// diff --git a/services/bifrost/bifrost.cfg b/services/bifrost/bifrost.cfg index ccaa72ca83..76f0219917 100644 --- a/services/bifrost/bifrost.cfg +++ b/services/bifrost/bifrost.cfg @@ -10,12 +10,12 @@ testnet = true [ethereum] master_public_key = "xpub6DxSCdWu6jKqr4isjo7bsPeDD6s3J4YVQV1JSHZg12Eagdqnf7XX4fxqyW2sLhUoFWutL7tAELU2LiGZrEXtjVbvYptvTX5Eoa4Mamdjm9u" -rpc_server = "http://localhost:8545" +rpc_server = "localhost:8545" network_id = "3" [stellar] -# GDGVTKSEXWB4VFTBDWCBJVJZLIY6R3766EHBZFIGK2N7EQHVV5UTA63C -issuer_secret_key = "SAGC33ER53WGBISR5LQ4RJIBFG5UHXWNGTLG4KJRC737VYXNDGWLO54B" +issuer_public_key = "GDGVTKSEXWB4VFTBDWCBJVJZLIY6R3766EHBZFIGK2N7EQHVV5UTA63C" +signer_secret_key = "SAGC33ER53WGBISR5LQ4RJIBFG5UHXWNGTLG4KJRC737VYXNDGWLO54B" horizon = "https://horizon-testnet.stellar.org" network_passphrase = "Test SDF Network ; September 2015" diff --git a/services/bifrost/bitcoin/address_generator.go b/services/bifrost/bitcoin/address_generator.go index a7d7e74b2a..22edb1dca4 100644 --- a/services/bifrost/bitcoin/address_generator.go +++ b/services/bifrost/bitcoin/address_generator.go @@ -9,7 +9,7 @@ import ( // TODO should we use account hardened key and then use it to generate change and index keys? // That way we can create lot more accounts than 0x80000000-1. -func NewAddressGenerator(masterPublicKeyString string) (*AddressGenerator, error) { +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") @@ -19,7 +19,7 @@ func NewAddressGenerator(masterPublicKeyString string) (*AddressGenerator, error return nil, errors.New("Key is not a master public key") } - return &AddressGenerator{deserializedMasterPublicKey}, nil + return &AddressGenerator{deserializedMasterPublicKey, chainParams}, nil } func (g *AddressGenerator) Generate(index uint32) (string, error) { @@ -32,7 +32,7 @@ func (g *AddressGenerator) Generate(index uint32) (string, error) { return "", errors.Wrap(err, "Error creating new child key") } - address, err := btcutil.NewAddressPubKey(accountKey.Key, &chaincfg.MainNetParams) + address, err := btcutil.NewAddressPubKey(accountKey.Key, g.chainParams) if err != nil { return "", errors.Wrap(err, "Error creating address for new child key") } diff --git a/services/bifrost/bitcoin/address_generator_test.go b/services/bifrost/bitcoin/address_generator_test.go index 8463c37021..bedebd9762 100644 --- a/services/bifrost/bitcoin/address_generator_test.go +++ b/services/bifrost/bitcoin/address_generator_test.go @@ -3,6 +3,7 @@ package bitcoin import ( "testing" + "github.com/btcsuite/btcd/chaincfg" "github.com/stretchr/testify/assert" ) @@ -13,7 +14,7 @@ func TestAddressGenerator(t *testing.T) { // Derivation Path m/44'/0'/0'/0: // xprvA1y8DJefYknMwXkdUrSk57z26Z3Fjr3rVpk8NzQKRQWjy3ogV43qr4eqTuF1rg5rrw28mqbDHfWsmoBbeDPcQ34teNgDyohSu6oyodoJ6Bu // xpub6ExUcpBZP8LfA1q6asykSFvkeask9Jmhs3fjBNovyk3iqr8q2bN6PryKKCvLLkMs1u2667wJnoM5LRQc3JcsGbQAhjUqJavxhtdk363GbP2 - generator, err := NewAddressGenerator("xpub6ExUcpBZP8LfA1q6asykSFvkeask9Jmhs3fjBNovyk3iqr8q2bN6PryKKCvLLkMs1u2667wJnoM5LRQc3JcsGbQAhjUqJavxhtdk363GbP2") + generator, err := NewAddressGenerator("xpub6ExUcpBZP8LfA1q6asykSFvkeask9Jmhs3fjBNovyk3iqr8q2bN6PryKKCvLLkMs1u2667wJnoM5LRQc3JcsGbQAhjUqJavxhtdk363GbP2", &chaincfg.MainNetParams) assert.NoError(t, err) expectedChildren := []struct { diff --git a/services/bifrost/bitcoin/listener.go b/services/bifrost/bitcoin/listener.go index 2788961aec..c78d648522 100644 --- a/services/bifrost/bitcoin/listener.go +++ b/services/bifrost/bitcoin/listener.go @@ -5,7 +5,6 @@ import ( "time" "github.com/btcsuite/btcd/chaincfg" - "github.com/btcsuite/btcd/rpcclient" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/stellar/go/services/bifrost/common" @@ -13,29 +12,25 @@ import ( "github.com/stellar/go/support/log" ) -func (l *Listener) Start(rpcServer, rpcUser, rpcPass string) error { +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 + if !genesisBlockHash.IsEqual(chaincfg.TestNet3Params.GenesisHash) { + return errors.New("Invalid genesis hash") + } } else { l.chainParams = &chaincfg.MainNetParams - } - - var err error - connConfig := &rpcclient.ConnConfig{ - Host: rpcServer, - User: rpcUser, - Pass: rpcPass, - HTTPPostMode: true, - DisableTLS: true, - } - l.client, err = rpcclient.New(connConfig, nil) - if err != nil { - err = errors.Wrap(err, "Error connecting to bicoin-core") - l.log.Error(err) - return err + if !genesisBlockHash.IsEqual(chaincfg.MainNetParams.GenesisHash) { + return errors.New("Invalid genesis hash") + } } blockNumber, err := l.Storage.GetBitcoinBlockToProcess() @@ -46,7 +41,7 @@ func (l *Listener) Start(rpcServer, rpcUser, rpcPass string) error { } if blockNumber == 0 { - blockNumberTmp, err := l.client.GetBlockCount() + blockNumberTmp, err := l.Client.GetBlockCount() if err != nil { err = errors.Wrap(err, "Error getting the block count from bitcoin-core") l.log.Error(err) @@ -55,7 +50,6 @@ func (l *Listener) Start(rpcServer, rpcUser, rpcPass string) error { blockNumber = uint64(blockNumberTmp) } - // TODO Check if connected to correct network go l.processBlocks(blockNumber) return nil } @@ -112,19 +106,7 @@ func (l *Listener) processBlocks(blockNumber uint64) { // 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) - - if blockHeight == 0 { - blockCount, err := l.client.GetBlockCount() - if err != nil { - err = errors.Wrap(err, "Error getting block count from bitcoin-core") - l.log.Error(err) - return nil, err - } - - blockHeight = blockCount - } - - blockHash, err := l.client.GetBlockHash(blockHeight) + blockHash, err := l.Client.GetBlockHash(blockHeight) if err != nil { if strings.Contains(err.Error(), "Block height out of range") { // Block does not exist yet @@ -135,7 +117,7 @@ func (l *Listener) getBlock(blockNumber uint64) (*wire.MsgBlock, error) { return nil, err } - block, err := l.client.GetBlock(blockHash) + 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) @@ -167,8 +149,9 @@ func (l *Listener) processBlock(block *wire.MsgBlock) error { continue } - // We only support P2PKH addresses - if class != txscript.PubKeyHashTy { + // We only support P2PK and P2PKH addresses + if class != txscript.PubKeyTy && class != txscript.PubKeyHashTy { + transactionLog.WithField("class", class).Debug("Invalid addresses class") continue } @@ -181,7 +164,7 @@ func (l *Listener) processBlock(block *wire.MsgBlock) error { handlerTransaction := Transaction{ Hash: transaction.TxHash().String(), TxOutIndex: index, - Value: output.Value, + ValueSat: output.Value, To: addresses[0].EncodeAddress(), } diff --git a/services/bifrost/bitcoin/main.go b/services/bifrost/bitcoin/main.go index 914ff465e0..f2c95b87ff 100644 --- a/services/bifrost/bitcoin/main.go +++ b/services/bifrost/bitcoin/main.go @@ -1,12 +1,24 @@ package bitcoin import ( + "math/big" + "github.com/btcsuite/btcd/chaincfg" - "github.com/btcsuite/btcd/rpcclient" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" "github.com/stellar/go/support/log" "github.com/tyler-smith/go-bip32" ) +const stellarAmountPrecision = 7 + +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 @@ -15,15 +27,21 @@ import ( // Listener tracks only P2PKH payments. // You can run multiple Listeners if Storage is implemented correctly. type Listener struct { + Client Client `inject:""` Storage Storage `inject:""` TransactionHandler TransactionHandler Testnet bool - client *rpcclient.Client 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 { @@ -40,10 +58,12 @@ type TransactionHandler func(transaction Transaction) error type Transaction struct { Hash string TxOutIndex int - Value int64 - To string + // Value in sats + ValueSat int64 + To string } type AddressGenerator struct { masterPublicKey *bip32.Key + chainParams *chaincfg.Params } diff --git a/services/bifrost/bitcoin/transaction.go b/services/bifrost/bitcoin/transaction.go new file mode 100644 index 0000000000..ee0eae5ddc --- /dev/null +++ b/services/bifrost/bitcoin/transaction.go @@ -0,0 +1,11 @@ +package bitcoin + +import ( + "math/big" +) + +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(stellarAmountPrecision) +} diff --git a/services/bifrost/bitcoin/transaction_test.go b/services/bifrost/bitcoin/transaction_test.go new file mode 100644 index 0000000000..603d0ffacd --- /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"}, + {5, "0.0000001"}, + {10, "0.0000001"}, + {12345674, "0.1234567"}, + {12345678, "0.1234568"}, + {100000000, "1.0000000"}, + {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/config/main.go b/services/bifrost/config/main.go index 55bdba2fc3..2c35ab27de 100644 --- a/services/bifrost/config/main.go +++ b/services/bifrost/config/main.go @@ -5,20 +5,29 @@ type Config struct { UsingProxy bool `valid:"optional" toml:"using_proxy"` Bitcoin struct { MasterPublicKey string `valid:"required" toml:"master_public_key"` - 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"` + // 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"` } `valid:"required" toml:"bitcoin"` Ethereum struct { NetworkID string `valid:"required,int" toml:"network_id"` MasterPublicKey string `valid:"required" toml:"master_public_key"` - RpcServer string `valid:"required" toml:"rpc_server"` + // Host only + RpcServer string `valid:"required" toml:"rpc_server"` } `valid:"required" toml:"ethereum"` Stellar struct { Horizon string `valid:"required" toml:"horizon"` NetworkPassphrase string `valid:"required" toml:"network_passphrase"` - IssuerSecretKey string `valid:"required" toml:"issuer_secret_key"` + // 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$)"` diff --git a/services/bifrost/database/main.go b/services/bifrost/database/main.go index 1b1d33aea1..4da9a30578 100644 --- a/services/bifrost/database/main.go +++ b/services/bifrost/database/main.go @@ -27,24 +27,24 @@ const ( type Database interface { // CreateAddressAssociation creates Bitcoin/Ethereum-Stellar association. `addressIndex` // is the chain (Bitcoin/Ethereum) address derivation index (BIP-32). - CreateAddressAssociation(chain Chain, stellarAddress, ethereumAddress string, addressIndex uint32) error + 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 `nil` if transaction is already added. - AddProcessedTransaction(chain Chain, transactionID string) error - // IsTransactionProcessed returns `true` if transaction has been already processed. - IsTransactionProcessed(chain Chain, transactionID string) (bool, error) - + // should return `true` and no error if transaction processing has already started/finished. + AddProcessedTransaction(chain Chain, transactionID 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 } type PostgresDatabase struct { diff --git a/services/bifrost/database/migrations/01_init.sql b/services/bifrost/database/migrations/01_init.sql index 7263fa9480..505d5fb252 100644 --- a/services/bifrost/database/migrations/01_init.sql +++ b/services/bifrost/database/migrations/01_init.sql @@ -2,7 +2,7 @@ CREATE TYPE chain AS ENUM ('bitcoin', 'ethereum'); CREATE TABLE address_association ( chain chain NOT NULL, - address_index bigint NOT NULL UNIQUE, + address_index bigint NOT NULL, address varchar(42) NOT NULL UNIQUE, stellar_public_key varchar(56) NOT NULL UNIQUE, created_at timestamp NOT NULL, @@ -26,20 +26,24 @@ CREATE TABLE processed_transaction ( chain chain NOT NULL, /* Ethereum: "0x"+hash (so 64+2) */ transaction_id varchar(66) 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(10) NOT NULL, - /* ethereum: 100000000 in year 2128 1 Wei = 0.000000000000000001 */ - /* bitcoin: 21000000 1 Satoshi = 0.00000001 */ - amount varchar(30) 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 (transaction_id, asset_code), + 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) ); diff --git a/services/bifrost/database/mock.go b/services/bifrost/database/mock.go new file mode 100644 index 0000000000..74d5967500 --- /dev/null +++ b/services/bifrost/database/mock.go @@ -0,0 +1,43 @@ +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 string) (alreadyProcessing bool, err error) { + a := m.Called(chain, transactionID) + 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) +} diff --git a/services/bifrost/database/postgres.go b/services/bifrost/database/postgres.go index 2379c44e24..3b63c86a3e 100644 --- a/services/bifrost/database/postgres.go +++ b/services/bifrost/database/postgres.go @@ -3,6 +3,7 @@ package database import ( "database/sql" "strconv" + "strings" "time" "github.com/stellar/go/services/bifrost/queue" @@ -36,8 +37,9 @@ type transactionsQueueRow struct { } type processedTransactionRow struct { - Chain Chain `db:"chain"` - TransactionID string `db:"transaction_id"` + Chain Chain `db:"chain"` + TransactionID string `db:"transaction_id"` + CreatedAt time.Time `db:"created_at"` } func fromQueueTransaction(tx queue.Transaction) *transactionsQueueRow { @@ -49,6 +51,10 @@ func fromQueueTransaction(tx queue.Transaction) *transactionsQueueRow { } } +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, @@ -128,29 +134,14 @@ func (d *PostgresDatabase) GetAssociationByStellarPublicKey(stellarPublicKey str return row, nil } -func (d *PostgresDatabase) AddProcessedTransaction(chain Chain, transactionID string) error { +func (d *PostgresDatabase) AddProcessedTransaction(chain Chain, transactionID string) (bool, error) { processedTransactionTable := d.getTable(processedTransactionTableName, nil) - processedTransaction := processedTransactionRow{chain, transactionID} + processedTransaction := processedTransactionRow{chain, transactionID, time.Now()} _, err := processedTransactionTable.Insert(processedTransaction).Exec() - return err -} - -func (d *PostgresDatabase) IsTransactionProcessed(chain Chain, transactionID string) (bool, error) { - processedTransactionTable := d.getTable(processedTransactionTableName, nil) - - row := processedTransactionRow{} - where := map[string]interface{}{"chain": chain, "transaction_id": transactionID} - err := processedTransactionTable.Get(&row, where).Exec() - if err != nil { - switch errors.Cause(err) { - case sql.ErrNoRows: - return false, nil - default: - return false, errors.Wrap(err, "Error getting processedTransaction from DB") - } + if err != nil && isDuplicateError(err) { + return true, nil } - - return true, nil + return false, err } func (d *PostgresDatabase) IncrementAddressIndex(chain Chain) (uint32, error) { @@ -202,6 +193,22 @@ func (d *PostgresDatabase) IncrementAddressIndex(chain Chain) (uint32, error) { 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) } @@ -277,17 +284,23 @@ func (d *PostgresDatabase) saveLastProcessedBlock(key string, block uint64) erro return nil } -// Add implements queue.Queue interface -func (d *PostgresDatabase) Add(tx queue.Transaction) error { +// 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 } -// Pool receives and removes the head of this queue. Returns nil if no elements found. -// Pool implements queue.Queue interface. -func (d *PostgresDatabase) Pool() (*queue.Transaction, error) { +// 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() @@ -299,7 +312,7 @@ func (d *PostgresDatabase) Pool() (*queue.Transaction, error) { } defer session.Rollback() - err = transactionsQueueTable.Get(&row, map[string]interface{}{"pooled": false}).Suffix("FOR UPDATE").Exec() + 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: diff --git a/services/bifrost/ethereum/listener.go b/services/bifrost/ethereum/listener.go index 5928cd5d37..4f0921c34d 100644 --- a/services/bifrost/ethereum/listener.go +++ b/services/bifrost/ethereum/listener.go @@ -6,8 +6,6 @@ import ( "time" "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/ethclient" - "github.com/ethereum/go-ethereum/rpc" "github.com/stellar/go/services/bifrost/common" "github.com/stellar/go/support/errors" "github.com/stellar/go/support/log" @@ -17,14 +15,6 @@ func (l *Listener) Start(rpcServer string) error { l.log = common.CreateLogger("EthereumListener") l.log.Info("EthereumListener starting") - rpcClient, err := rpc.Dial(rpcServer) - if err != nil { - err = errors.Wrap(err, "Error dialing geth") - l.log.Error(err) - return err - } - - l.client = ethclient.NewClient(rpcClient) blockNumber, err := l.Storage.GetEthereumBlockToProcess() if err != nil { err = errors.Wrap(err, "Error getting ethereum block to process from DB") @@ -35,7 +25,7 @@ func (l *Listener) Start(rpcServer string) error { // 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) + id, err := l.Client.NetworkID(ctx) if err != nil { err = errors.Wrap(err, "Error getting ethereum network ID") l.log.Error(err) @@ -114,7 +104,7 @@ func (l *Listener) getBlock(blockNumber uint64) (*types.Block, error) { ctx, cancel := context.WithDeadline(context.Background(), d) defer cancel() - block, err := l.client.BlockByNumber(ctx, blockNumberInt) + block, err := l.Client.BlockByNumber(ctx, blockNumberInt) if err != nil { if err.Error() == "not found" { return nil, nil @@ -139,7 +129,18 @@ func (l *Listener) processBlock(block *types.Block) error { localLog.Info("Processing block") for _, transaction := range transactions { - err := l.TransactionHandler(transaction) + 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") } diff --git a/services/bifrost/ethereum/main.go b/services/bifrost/ethereum/main.go index 6e0773a7db..ec73eb24a8 100644 --- a/services/bifrost/ethereum/main.go +++ b/services/bifrost/ethereum/main.go @@ -1,26 +1,43 @@ package ethereum import ( + "context" + "math/big" + "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/ethclient" "github.com/stellar/go/support/log" "github.com/tyler-smith/go-bip32" ) +const stellarAmountPrecision = 7 + +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 { + Client Client `inject:""` Storage Storage `inject:""` NetworkID string TransactionHandler TransactionHandler - client *ethclient.Client - log *log.Entry + 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 @@ -34,7 +51,14 @@ type Storage interface { SaveLastProcessedEthereumBlock(block uint64) error } -type TransactionHandler func(transaction *types.Transaction) 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 diff --git a/services/bifrost/ethereum/transaction.go b/services/bifrost/ethereum/transaction.go new file mode 100644 index 0000000000..1276ae10e0 --- /dev/null +++ b/services/bifrost/ethereum/transaction.go @@ -0,0 +1,11 @@ +package ethereum + +import ( + "math/big" +) + +func (t Transaction) ValueToStellar() string { + valueEth := new(big.Rat) + valueEth.Quo(new(big.Rat).SetInt(t.ValueWei), weiInEth) + return valueEth.FloatString(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/main.go b/services/bifrost/main.go index 429ab9ef16..5d5895414d 100644 --- a/services/bifrost/main.go +++ b/services/bifrost/main.go @@ -6,6 +6,9 @@ import ( "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" @@ -16,6 +19,7 @@ import ( "github.com/stellar/go/services/bifrost/ethereum" "github.com/stellar/go/services/bifrost/server" "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" @@ -23,7 +27,7 @@ import ( var rootCmd = &cobra.Command{ Use: "bifrost", - Short: "Bridge server to allow participating in Stellar based ICOs using Ethereum", + Short: "Bridge server to allow participating in Stellar based ICOs using Bitcoin and Ethereum", } var serverCmd = &cobra.Command{ @@ -31,9 +35,8 @@ var serverCmd = &cobra.Command{ Short: "Starts backend server", Run: func(cmd *cobra.Command, args []string) { var ( - cfg config.Config - cfgPath = cmd.PersistentFlags().Lookup("config").Value.String() - debugMode = cmd.PersistentFlags().Lookup("debug").Changed + cfgPath = rootCmd.PersistentFlags().Lookup("config").Value.String() + debugMode = rootCmd.PersistentFlags().Lookup("debug").Changed ) if debugMode { @@ -41,84 +44,108 @@ var serverCmd = &cobra.Command{ log.Debug("Debug mode ON") } - err := supportConfig.Read(cfgPath, &cfg) + cfg := readConfig(cfgPath) + server := createServer(cfg, false) + err := server.Start() if err != nil { - switch cause := errors.Cause(err).(type) { - case *supportConfig.InvalidConfigError: - log.Error("config file: ", cause) - default: - log.Error(err) - } + log.WithField("err", err).Error("Error starting the server") os.Exit(-1) } + }, +} - db := &database.PostgresDatabase{} - err = db.Open(cfg.Database.DSN) - if err != nil { - log.WithField("err", err).Error("Error connecting to database") - 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") - bitcoinListener := &bitcoin.Listener{ - Testnet: cfg.Bitcoin.Testnet, + if debugMode { + log.SetLevel(log.DebugLevel) + log.Debug("Debug mode ON") } - ethereumListener := ðereum.Listener{ - NetworkID: cfg.Ethereum.NetworkID, - } + cfg := readConfig(cfgPath) - stellarAccountConfigurator := &stellar.AccountConfigurator{ - NetworkPassphrase: cfg.Stellar.NetworkPassphrase, - IssuerSecretKey: cfg.Stellar.IssuerSecretKey, - } + bitcoinAccounts := make(chan string) + bitcoinClient := &stress.RandomBitcoinClient{} + bitcoinClient.Start(bitcoinAccounts) - horizonClient := &horizon.Client{ - URL: cfg.Stellar.Horizon, - HTTP: &http.Client{ - Timeout: 10 * time.Second, - }, - } + ethereumAccounts := make(chan string) + ethereumClient := &stress.RandomEthereumClient{} + ethereumClient.Start(ethereumAccounts) - bitcoinAddressGenerator, err := bitcoin.NewAddressGenerator(cfg.Bitcoin.MasterPublicKey) + db, err := createDatabase(cfg.Database.DSN) if err != nil { - log.Error(err) + log.WithField("err", err).Error("Error connecting to database") os.Exit(-1) } - ethereumAddressGenerator, err := ethereum.NewAddressGenerator(cfg.Ethereum.MasterPublicKey) + err = db.ResetBlockCounters() if err != nil { - log.Error(err) + log.WithField("err", err).Error("Error reseting counters") os.Exit(-1) } - server := &server.Server{} - - var g inject.Graph - err = g.Provide( - &inject.Object{Value: bitcoinAddressGenerator}, - &inject.Object{Value: bitcoinListener}, - &inject.Object{Value: &cfg}, - &inject.Object{Value: db}, - &inject.Object{Value: ethereumAddressGenerator}, - &inject.Object{Value: ethereumListener}, - &inject.Object{Value: horizonClient}, - &inject.Object{Value: server}, - &inject.Object{Value: stellarAccountConfigurator}, - ) - if err != nil { - log.WithField("err", err).Error("Error providing objects to injector") - os.Exit(-1) + // Start servers + const numServers = 3 + signers := []string{ + // GBQYGXC4AZDL7PPL2H274LYA6YV7OL4IRPWCYMBYCA5FAO45WMTNKGOD + "SBX76SCADD2SBIL6M2T62BR4GELMJPZV2MFHIQX24IBVOTIT6DGNAR3D", + // GAUDK66OCTKQB737ZNRD2ILB5ZGIZOKMWT3T5TDWVIN7ANVY3RD5DXF3 + "SCGJ6JRFMHWYTGVBPFXCBMMBENZM433M3JNZDRSI5PZ2DXGJLYDWR4CR", + // GDFR6QNVBUK32PGTAV3HATV3GDT7LF2SGVVLD2TOS4TCAD2ANSOH2MCW + "SAZUY2XGSILNMBLQSVMDGCCTSZNOB2EXHSFFRFJJ3GKRZIW3FTIMJYV7", } - - if err := g.Populate(); err != nil { - log.WithField("err", err).Error("Error injecting objects") - os.Exit(-1) + 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) } - err = server.Start() - if err != nil { - log.WithField("err", err).Error("Error starting the server") - os.Exit(-1) + // 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) + } } }, } @@ -136,13 +163,143 @@ func init() { log.SetLevel(log.InfoLevel) log.DefaultLogger.Logger.Formatter.(*logrus.TextFormatter).FullTimestamp = true - rootCmd.AddCommand(versionCmd) + rootCmd.PersistentFlags().Bool("debug", false, "debug mode") + rootCmd.PersistentFlags().StringP("config", "c", "bifrost.cfg", "config file path") + rootCmd.AddCommand(serverCmd) + rootCmd.AddCommand(stressTestCmd) + rootCmd.AddCommand(versionCmd) - serverCmd.PersistentFlags().StringP("config", "c", "bifrost.cfg", "config file path") - serverCmd.PersistentFlags().Bool("debug", false, "debug mode") + stressTestCmd.PersistentFlags().IntP("users-per-second", "u", 2, "users per second") } 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) + } + + 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) + } + + bitcoinClient := &rpcclient.Client{} + ethereumClient := ðclient.Client{} + + if !stressTest { + // Configure real clients + 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) + } + + ethereumClient, err = ethclient.Dial("http://" + cfg.Ethereum.RpcServer) + if err != nil { + log.WithField("err", err).Error("Error connecting to geth") + os.Exit(-1) + } + } + + bitcoinListener := &bitcoin.Listener{ + Testnet: cfg.Bitcoin.Testnet, + } + + ethereumListener := ðereum.Listener{ + NetworkID: cfg.Ethereum.NetworkID, + } + + stellarAccountConfigurator := &stellar.AccountConfigurator{ + NetworkPassphrase: cfg.Stellar.NetworkPassphrase, + IssuerPublicKey: cfg.Stellar.IssuerPublicKey, + SignerSecretKey: cfg.Stellar.SignerSecretKey, + } + + horizonClient := &horizon.Client{ + URL: cfg.Stellar.Horizon, + HTTP: &http.Client{ + Timeout: 20 * time.Second, + }, + } + + 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) + } + + ethereumAddressGenerator, err := ethereum.NewAddressGenerator(cfg.Ethereum.MasterPublicKey) + if err != nil { + log.Error(err) + os.Exit(-1) + } + + server := &server.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: 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 index 073056d736..b5ab6d04ea 100644 --- a/services/bifrost/queue/main.go +++ b/services/bifrost/queue/main.go @@ -1,11 +1,5 @@ package queue -import ( - "math/big" - - "github.com/stellar/go/support/errors" -) - type AssetCode string const ( @@ -13,56 +7,19 @@ const ( AssetCodeETH AssetCode = "ETH" ) -var ( - eight = big.NewInt(8) - ten = big.NewInt(10) - eighteen = big.NewInt(18) - // weiInEth = 10^18 - weiInEth = new(big.Rat).SetInt(new(big.Int).Exp(ten, eighteen, nil)) - // satInBtc = 10^8 - satInBtc = new(big.Rat).SetInt(new(big.Int).Exp(ten, eight, nil)) -) - type Transaction struct { TransactionID string AssetCode AssetCode - // Amount in the smallest unit of currency. - // For 1 satoshi = 0.00000001 BTC this should be equal `1` - // For 1 Wei = 0.000000000000000001 ETH this should be equal `1` + // 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 } -// TODO tests! -func (t Transaction) AmountToBtc(prec int) (string, error) { - if t.AssetCode != AssetCodeBTC { - return "", errors.New("Asset code not ETH") - } - - valueSat := new(big.Int) - _, ok := valueSat.SetString(t.Amount, 10) - if !ok { - return "", errors.Errorf("%s is not a valid integer", t.Amount) - } - valueBtc := new(big.Rat).Quo(new(big.Rat).SetInt(valueSat), satInBtc) - return valueBtc.FloatString(prec), nil -} - -// TODO tests! -func (t Transaction) AmountToEth(prec int) (string, error) { - if t.AssetCode != AssetCodeETH { - return "", errors.New("Asset code not ETH") - } - - valueWei := new(big.Int) - _, ok := valueWei.SetString(t.Amount, 10) - if !ok { - return "", errors.Errorf("%s is not a valid integer", t.Amount) - } - valueEth := new(big.Rat).Quo(new(big.Rat).SetInt(valueWei), weiInEth) - return valueEth.FloatString(prec), nil -} - // 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 @@ -70,10 +27,11 @@ func (t Transaction) AmountToEth(prec int) (string, error) { // 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 { - // Add inserts the element to this queue. - Add(tx Transaction) error - // Pool receives and removes the head of this queue. Returns nil if no elements found. - Pool() (*Transaction, error) + // 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/server/bitcoin_rail.go b/services/bifrost/server/bitcoin_rail.go index b3423e5df0..bab6d5cc30 100644 --- a/services/bifrost/server/bitcoin_rail.go +++ b/services/bifrost/server/bitcoin_rail.go @@ -1,35 +1,34 @@ package server import ( - "strconv" - "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. +// the transactions queue for StellarAccountConfigurator to consume. +// +// Transaction added to transactions queue should be in a format described in +// queue.Queue (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") - // Check if transaction has not been processed - processed, err := s.Database.IsTransactionProcessed(database.ChainBitcoin, transaction.Hash) - if err != nil { - return err - } - - if processed { - localLog.Debug("Transaction already processed, skipping") - return nil - } + // Let's check if tx is valid first. // Check if value is above minimum required - // TODO, check actual minimum (so user doesn't get more in XLM than in ETH) - if transaction.Value <= 0 { + // TODO, make this configurable + if transaction.ValueSat <= 0 { localLog.Debug("Value is below minimum required amount, skipping") return nil } @@ -44,35 +43,34 @@ func (s *Server) onNewBitcoinTransaction(transaction bitcoin.Transaction) error return nil } - value := strconv.FormatInt(transaction.Value, 10) + // Add transaction as processing. + processed, err := s.Database.AddProcessedTransaction(database.ChainBitcoin, transaction.Hash) + 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 smallest unit of currency. - // For 1 satoshi = 0.00000001 BTC this should be equal `1` - Amount: value, + // Amount in the base unit of currency. + Amount: transaction.ValueToStellar(), StellarPublicKey: addressAssociation.StellarPublicKey, } - err = s.TransactionsQueue.Add(queueTx) + 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") - // Save transaction as processed - err = s.Database.AddProcessedTransaction(database.ChainBitcoin, transaction.Hash) - if err != nil { - return errors.Wrap(err, "Error saving transaction as processed") - } - - localLog.Info("Transaction processed successfully") - // Publish event to address stream - s.publishEvent(transaction.To, TransactionReceivedAddressEvent, nil) - + s.sseServer.PublishEvent(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..e7fb00a71b --- /dev/null +++ b/services/bifrost/server/bitcoin_rail_test.go @@ -0,0 +1,135 @@ +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, + } + 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: 0, + 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). + 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). + 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("PublishEvent", 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 index d684728fba..f3ceff92ad 100644 --- a/services/bifrost/server/ethereum_rail.go +++ b/services/bifrost/server/ethereum_rail.go @@ -1,49 +1,34 @@ package server import ( - "github.com/ethereum/go-ethereum/core/types" "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. -func (s *Server) onNewEthereumTransaction(transaction *types.Transaction) error { - transactionHash := transaction.Hash().Hex() - localLog := s.log.WithFields(log.F{"transaction": transactionHash, "rail": "ethereum"}) +// the transactions queue for StellarAccountConfigurator to consume. +// +// Transaction added to transactions queue should be in a format described in +// queue.Queue (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") - // Check if transaction has not been processed - processed, err := s.Database.IsTransactionProcessed(database.ChainEthereum, transactionHash) - if err != nil { - return err - } - - if processed { - localLog.Debug("Transaction already processed, skipping") - return nil - } - - // Check if transaction is sent to one of our addresses - to := transaction.To() - if to == nil { - // Contract creation - localLog.Debug("Transaction is a contract creation, skipping") - return nil - } + // Let's check if tx is valid first. // Check if value is above minimum required - // TODO, check actual minimum (so user doesn't get more in XLM than in ETH) - if transaction.Value().Sign() <= 0 { + // TODO, make this configurable + if transaction.ValueWei.Sign() <= 0 { localLog.Debug("Value is below minimum required amount, skipping") return nil } - address := to.Hex() - - addressAssociation, err := s.Database.GetAssociationByChainAddress(database.ChainEthereum, address) + addressAssociation, err := s.Database.GetAssociationByChainAddress(database.ChainEthereum, transaction.To) if err != nil { return errors.Wrap(err, "Error getting association") } @@ -53,33 +38,34 @@ func (s *Server) onNewEthereumTransaction(transaction *types.Transaction) error return nil } + // Add transaction as processing. + processed, err := s.Database.AddProcessedTransaction(database.ChainEthereum, transaction.Hash) + 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: transactionHash, + TransactionID: transaction.Hash, AssetCode: queue.AssetCodeETH, - // Amount in the smallest unit of currency. - // For 1 Wei = 0.000000000000000001 ETH this should be equal `1` - Amount: transaction.Value().String(), + // Amount in the base unit of currency. + Amount: transaction.ValueToStellar(), StellarPublicKey: addressAssociation.StellarPublicKey, } - err = s.TransactionsQueue.Add(queueTx) + 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") - // Save transaction as processed - err = s.Database.AddProcessedTransaction(database.ChainEthereum, transactionHash) - if err != nil { - return errors.Wrap(err, "Error saving transaction as processed") - } - - localLog.Info("Transaction processed successfully") - // Publish event to address stream - s.publishEvent(address, TransactionReceivedAddressEvent, nil) - + s.sseServer.PublishEvent(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..6d0573b06d --- /dev/null +++ b/services/bifrost/server/ethereum_rail_test.go @@ -0,0 +1,134 @@ +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, + } + 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: new(big.Int), + 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). + 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). + 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("PublishEvent", 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 index 2334b2c980..ddfdee6c0b 100644 --- a/services/bifrost/server/main.go +++ b/services/bifrost/server/main.go @@ -3,25 +3,16 @@ package server import ( "net/http" - "github.com/r3labs/sse" "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" ) -// 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 { BitcoinListener *bitcoin.Listener `inject:""` BitcoinAddressGenerator *bitcoin.AddressGenerator `inject:""` @@ -32,9 +23,9 @@ type Server struct { StellarAccountConfigurator *stellar.AccountConfigurator `inject:""` TransactionsQueue queue.Queue `inject:""` - httpServer *http.Server - eventsServer *sse.Server - log *log.Entry + sseServer sse.ServerInterface + httpServer *http.Server + log *log.Entry } type GenerateAddressResponse struct { diff --git a/services/bifrost/server/queue.go b/services/bifrost/server/queue.go index b07dec644c..f6034f86d2 100644 --- a/services/bifrost/server/queue.go +++ b/services/bifrost/server/queue.go @@ -2,15 +2,15 @@ package server import ( "time" - - "github.com/stellar/go/services/bifrost/queue" ) +// 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.Pool() + transaction, err := s.TransactionsQueue.QueuePool() if err != nil { s.log.WithField("err", err).Error("Error pooling transactions queue") time.Sleep(time.Second) @@ -23,28 +23,10 @@ func (s *Server) poolTransactionsQueue() { } s.log.WithField("transaction", transaction).Info("Received transaction from transactions queue") - - // Use Stellar Precision - var amount string - switch transaction.AssetCode { - case queue.AssetCodeBTC: - amount, err = transaction.AmountToBtc(7) - case queue.AssetCodeETH: - amount, err = transaction.AmountToEth(7) - default: - s.log.Error("Invalid asset code pooled from the queue") - continue - } - - if err != nil { - s.log.WithField("transaction", transaction).Error("Amount is invalid") - continue - } - go s.StellarAccountConfigurator.ConfigureAccount( transaction.StellarPublicKey, string(transaction.AssetCode), - amount, + transaction.Amount, ) } } diff --git a/services/bifrost/server/server.go b/services/bifrost/server/server.go index dc7714f4ec..44e704c67a 100644 --- a/services/bifrost/server/server.go +++ b/services/bifrost/server/server.go @@ -12,16 +12,16 @@ import ( "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" - "github.com/r3labs/sse" "github.com/stellar/go/keypair" "github.com/stellar/go/services/bifrost/common" "github.com/stellar/go/services/bifrost/database" + "github.com/stellar/go/services/bifrost/sse" "github.com/stellar/go/support/errors" "github.com/stellar/go/support/log" ) func (s *Server) Start() error { - s.log = common.CreateLogger("Server") + s.initLogger() s.log.Info("Server starting") // Register callbacks @@ -30,11 +30,7 @@ func (s *Server) Start() error { s.StellarAccountConfigurator.OnAccountCreated = s.onStellarAccountCreated s.StellarAccountConfigurator.OnAccountCredited = s.onStellarAccountCredited - err := s.BitcoinListener.Start( - s.Config.Bitcoin.RpcServer, - s.Config.Bitcoin.RpcUser, - s.Config.Bitcoin.RpcPass, - ) + err := s.BitcoinListener.Start() if err != nil { return errors.Wrap(err, "Error starting BitcoinListener") } @@ -61,6 +57,10 @@ func (s *Server) Start() error { 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...") @@ -71,7 +71,7 @@ func (s *Server) shutdown() { } func (s *Server) startHTTPServer() { - s.eventsServer = sse.New() + s.sseServer = &sse.Server{} r := chi.NewRouter() if s.Config.UsingProxy { @@ -135,7 +135,7 @@ 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.eventsServer.StreamExists(address) { + if !s.sseServer.StreamExists(address) { var chain database.Chain if len(address) > 0 && address[0] == '1' { chain = database.ChainBitcoin @@ -151,11 +151,11 @@ func (s *Server) HandlerEvents(w http.ResponseWriter, r *http.Request) { } if association != nil { - s.eventsServer.CreateStream(address) + s.sseServer.CreateStream(address) } } - s.eventsServer.HTTPHandler(w, r) + s.sseServer.HTTPHandler(w, r) } func (s *Server) HandlerGenerateBitcoinAddress(w http.ResponseWriter, r *http.Request) { @@ -205,13 +205,19 @@ func (s *Server) handlerGenerateAddress(w http.ResponseWriter, r *http.Request, err = s.Database.CreateAddressAssociation(chain, stellarPublicKey, address, index) if err != nil { - log.WithFields(log.F{"err": err, "index": index}).Error("Error creating address association") + 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.eventsServer.CreateStream(address) + s.sseServer.CreateStream(address) response := GenerateAddressResponse{ Chain: string(chain), @@ -227,22 +233,3 @@ func (s *Server) handlerGenerateAddress(w http.ResponseWriter, r *http.Request, w.Write(responseBytes) } - -func (s *Server) publishEvent(address string, event AddressEvent, data []byte) { - // 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{ - Event: []byte(event), - Data: data, - }) -} diff --git a/services/bifrost/server/stellar_events.go b/services/bifrost/server/stellar_events.go index dd8f09b4a8..0013c7210e 100644 --- a/services/bifrost/server/stellar_events.go +++ b/services/bifrost/server/stellar_events.go @@ -2,6 +2,8 @@ package server import ( "encoding/json" + + "github.com/stellar/go/services/bifrost/sse" ) func (s *Server) onStellarAccountCreated(destination string) { @@ -16,7 +18,7 @@ func (s *Server) onStellarAccountCreated(destination string) { return } - s.publishEvent(association.Address, AccountCreatedAddressEvent, nil) + s.sseServer.PublishEvent(association.Address, sse.AccountCreatedAddressEvent, nil) } func (s *Server) onStellarAccountCredited(destination, assetCode, amount string) { @@ -41,5 +43,5 @@ func (s *Server) onStellarAccountCredited(destination, assetCode, amount string) s.log.WithField("data", data).Error("Error marshalling json") } - s.publishEvent(association.Address, AccountCreditedAddressEvent, j) + s.sseServer.PublishEvent(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..26b0b6882f --- /dev/null +++ b/services/bifrost/sse/main.go @@ -0,0 +1,69 @@ +package sse + +import ( + "net/http" + "sync" + + "github.com/r3labs/sse" +) + +// 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 { + eventsServer *sse.Server + initOnce sync.Once +} + +type ServerInterface interface { + PublishEvent(address string, event AddressEvent, data []byte) + CreateStream(address string) + StreamExists(address string) bool + HTTPHandler(w http.ResponseWriter, r *http.Request) +} + +func (s *Server) init() { + s.eventsServer = sse.New() +} + +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{ + 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/sse/mock.go b/services/bifrost/sse/mock.go new file mode 100644 index 0000000000..7bbb054e8e --- /dev/null +++ b/services/bifrost/sse/mock.go @@ -0,0 +1,29 @@ +package sse + +import ( + "net/http" + + "github.com/stretchr/testify/mock" +) + +// MockServer is a mockable SSE server. +type MockServer struct { + mock.Mock +} + +func (m *MockServer) PublishEvent(address string, event AddressEvent, data []byte) { + m.Called(address, event, data) +} + +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/stellar/account-configurator.go b/services/bifrost/stellar/account_configurator.go similarity index 68% rename from services/bifrost/stellar/account-configurator.go rename to services/bifrost/stellar/account_configurator.go index fa85ffc0db..6629a3a688 100644 --- a/services/bifrost/stellar/account-configurator.go +++ b/services/bifrost/stellar/account_configurator.go @@ -2,7 +2,6 @@ package stellar import ( "net/http" - "strconv" "time" "github.com/stellar/go/clients/horizon" @@ -19,14 +18,21 @@ func (ac *AccountConfigurator) Start() error { ac.log = common.CreateLogger("StellarAccountConfigurator") ac.log.Info("StellarAccountConfigurator starting") - kp, err := keypair.Parse(ac.IssuerSecretKey) - if err != nil { - err = errors.Wrap(err, "Invalid IssuerSecretKey") + 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.issuerPublicKey = kp.Address() + ac.signerPublicKey = kp.Address() err = ac.updateSequence() if err != nil { @@ -35,9 +41,17 @@ func (ac *AccountConfigurator) Start() error { 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. @@ -49,30 +63,45 @@ func (ac *AccountConfigurator) ConfigureAccount(destination, assetCode, amount s }) 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. - _, exists, err := ac.getAccount(destination) - if err != nil { - localLog.WithField("err", err).Error("Error loading account from Horizon") - return - } + 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 + } - if !exists { localLog.WithField("destination", destination).Info("Creating Stellar account") - err := ac.createAccount(destination) + err = ac.createAccount(destination) if err != nil { localLog.WithField("err", err).Error("Error creating Stellar account") - // TODO repeat - return + time.Sleep(2 * time.Second) + continue } + + break } if ac.OnAccountCreated != nil { ac.OnAccountCreated(destination) } - // TODO if exists but native balance is too small, send more XLM? - - // Wait for account and trustline to be created... + // Wait for trust line to be created... for { account, err := ac.Horizon.LoadAccount(destination) if err != nil { @@ -83,14 +112,14 @@ func (ac *AccountConfigurator) ConfigureAccount(destination, assetCode, amount s if ac.trustlineExists(account, assetCode) { break - } else { - time.Sleep(2 * time.Second) } + + time.Sleep(2 * time.Second) } // When trustline found send token localLog.Info("Trust line found, sending token") - err = ac.sendToken(destination, assetCode, amount) + err := ac.sendToken(destination, assetCode, amount) if err != nil { localLog.WithField("err", err).Error("Error sending asset to account") return @@ -118,39 +147,10 @@ func (ac *AccountConfigurator) getAccount(account string) (horizon.Account, bool 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 { + if balance.Asset.Issuer == ac.IssuerPublicKey && balance.Asset.Code == assetCode { return true } } return false } - -func (ac *AccountConfigurator) updateSequence() error { - ac.sequenceMutex.Lock() - defer ac.sequenceMutex.Unlock() - - account, err := ac.Horizon.LoadAccount(ac.issuerPublicKey) - 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/stellar/main.go b/services/bifrost/stellar/main.go index 78070e83f5..de2e51d128 100644 --- a/services/bifrost/stellar/main.go +++ b/services/bifrost/stellar/main.go @@ -12,12 +12,15 @@ import ( type AccountConfigurator struct { Horizon horizon.ClientInterface `inject:""` NetworkPassphrase string - IssuerSecretKey string - OnAccountCreated func(string) - OnAccountCredited func(string, string, string) + IssuerPublicKey string + SignerSecretKey string + OnAccountCreated func(destination string) + OnAccountCredited func(destination string, assetCode string, amount string) - issuerPublicKey string - sequence uint64 - sequenceMutex sync.Mutex - log *log.Entry + 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 index 8fa53c2598..1f78282b9f 100644 --- a/services/bifrost/stellar/transactions.go +++ b/services/bifrost/stellar/transactions.go @@ -1,6 +1,8 @@ package stellar import ( + "strconv" + "github.com/stellar/go/build" "github.com/stellar/go/clients/horizon" "github.com/stellar/go/support/errors" @@ -10,6 +12,7 @@ import ( func (ac *AccountConfigurator) createAccount(destination string) error { err := ac.submitTransaction( build.CreateAccount( + build.SourceAccount{ac.IssuerPublicKey}, build.Destination{destination}, build.NativeAmount{NewAccountXLMBalance}, ), @@ -24,10 +27,11 @@ func (ac *AccountConfigurator) createAccount(destination string) error { 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, + Issuer: ac.IssuerPublicKey, Amount: amount, }, ), @@ -65,12 +69,41 @@ func (ac *AccountConfigurator) submitTransaction(mutator build.TransactionMutato func (ac *AccountConfigurator) buildTransaction(mutator build.TransactionMutator) (string, error) { tx := build.Transaction( - build.SourceAccount{ac.IssuerSecretKey}, + build.SourceAccount{ac.signerPublicKey}, build.Sequence{ac.getSequence()}, build.Network{ac.NetworkPassphrase}, mutator, ) - txe := tx.Sign(ac.IssuerSecretKey) + 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 +} From a1d2b3f40c7511e5ff83cbada46838729a6bf2ab Mon Sep 17 00:00:00 2001 From: Bartek Nowotarski Date: Wed, 25 Oct 2017 20:32:20 +0200 Subject: [PATCH 04/24] Fix trailing slash bug in horizon.Client --- clients/horizon/client.go | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/clients/horizon/client.go b/clients/horizon/client.go index 813514497f..de2e123a68 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,16 @@ 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, "/") +} + // 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.fixURL() resp, err := c.HTTP.Get(c.URL + "/accounts/" + accountID) if err != nil { return @@ -39,7 +47,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.fixURL() endpoint := "" query := url.Values{} @@ -115,6 +123,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.fixURL() query := url.Values{} query.Add("selling_asset_type", selling.Type) @@ -237,6 +246,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.fixURL() url := fmt.Sprintf("%s/ledgers", c.URL) return c.stream(ctx, url, cursor, func(data []byte) error { var ledger Ledger @@ -252,6 +262,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.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 +278,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.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 +292,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.fixURL() v := url.Values{} v.Set("tx", transactionEnvelopeXdr) @@ -297,5 +308,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 } From 4a5422533580e6a8964777cb8ab40175d31f5ec2 Mon Sep 17 00:00:00 2001 From: Bartek Nowotarski Date: Thu, 26 Oct 2017 16:06:58 +0200 Subject: [PATCH 05/24] Fix SSE streaming in multiserver environment --- .../bifrost/database/migrations/01_init.sql | 15 +++ services/bifrost/database/postgres.go | 87 +++++++++++++++ services/bifrost/main.go | 4 + services/bifrost/server/bitcoin_rail.go | 4 +- services/bifrost/server/bitcoin_rail_test.go | 2 +- services/bifrost/server/ethereum_rail.go | 4 +- services/bifrost/server/ethereum_rail_test.go | 2 +- services/bifrost/server/main.go | 2 +- services/bifrost/server/server.go | 16 +-- services/bifrost/server/stellar_events.go | 8 +- services/bifrost/sse/main.go | 66 +++++------ services/bifrost/sse/mock.go | 7 +- services/bifrost/sse/server.go | 103 ++++++++++++++++++ 13 files changed, 264 insertions(+), 56 deletions(-) create mode 100644 services/bifrost/sse/server.go diff --git a/services/bifrost/database/migrations/01_init.sql b/services/bifrost/database/migrations/01_init.sql index 505d5fb252..6b14c3152f 100644 --- a/services/bifrost/database/migrations/01_init.sql +++ b/services/bifrost/database/migrations/01_init.sql @@ -3,6 +3,8 @@ 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, @@ -47,3 +49,16 @@ CREATE TABLE transactions_queue ( 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) +); diff --git a/services/bifrost/database/postgres.go b/services/bifrost/database/postgres.go index 3b63c86a3e..f1c43760ba 100644 --- a/services/bifrost/database/postgres.go +++ b/services/bifrost/database/postgres.go @@ -7,6 +7,7 @@ import ( "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" ) @@ -19,6 +20,7 @@ const ( bitcoinLastBlockKey = "bitcoin_last_block" addressAssociationTableName = "address_association" + broadcastedEventTableName = "broadcasted_event" keyValueStoreTableName = "key_value_store" processedTransactionTableName = "processed_transaction" transactionsQueueTableName = "transactions_queue" @@ -29,6 +31,21 @@ type keyValueStoreRow struct { 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"` @@ -336,3 +353,73 @@ func (d *PostgresDatabase) QueuePool() (*queue.Transaction, error) { 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 +} diff --git a/services/bifrost/main.go b/services/bifrost/main.go index 5d5895414d..bb9edd0ea7 100644 --- a/services/bifrost/main.go +++ b/services/bifrost/main.go @@ -18,6 +18,7 @@ import ( "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" @@ -276,6 +277,8 @@ func createServer(cfg config.Config, stressTest bool) *server.Server { os.Exit(-1) } + sseServer := &sse.Server{} + server := &server.Server{} err = g.Provide( @@ -289,6 +292,7 @@ func createServer(cfg config.Config, stressTest bool) *server.Server { &inject.Object{Value: ethereumListener}, &inject.Object{Value: horizonClient}, &inject.Object{Value: server}, + &inject.Object{Value: sseServer}, &inject.Object{Value: stellarAccountConfigurator}, ) if err != nil { diff --git a/services/bifrost/server/bitcoin_rail.go b/services/bifrost/server/bitcoin_rail.go index bab6d5cc30..099613fb4b 100644 --- a/services/bifrost/server/bitcoin_rail.go +++ b/services/bifrost/server/bitcoin_rail.go @@ -69,8 +69,8 @@ func (s *Server) onNewBitcoinTransaction(transaction bitcoin.Transaction) error } localLog.Info("Transaction added to transaction queue") - // Publish event to address stream - s.sseServer.PublishEvent(transaction.To, sse.TransactionReceivedAddressEvent, nil) + // 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 index e7fb00a71b..f0481a05b7 100644 --- a/services/bifrost/server/bitcoin_rail_test.go +++ b/services/bifrost/server/bitcoin_rail_test.go @@ -125,7 +125,7 @@ func (suite *BitcoinRailTestSuite) TestAssociationSuccess() { suite.Assert().Equal(association.StellarPublicKey, queueTransaction.StellarPublicKey) }) suite.MockSSEServer. - On("PublishEvent", transaction.To, sse.TransactionReceivedAddressEvent, []byte(nil)) + On("BroadcastEvent", transaction.To, sse.TransactionReceivedAddressEvent, []byte(nil)) err := suite.Server.onNewBitcoinTransaction(transaction) suite.Require().NoError(err) } diff --git a/services/bifrost/server/ethereum_rail.go b/services/bifrost/server/ethereum_rail.go index f3ceff92ad..5b3a54e9e4 100644 --- a/services/bifrost/server/ethereum_rail.go +++ b/services/bifrost/server/ethereum_rail.go @@ -64,8 +64,8 @@ func (s *Server) onNewEthereumTransaction(transaction ethereum.Transaction) erro } localLog.Info("Transaction added to transaction queue") - // Publish event to address stream - s.sseServer.PublishEvent(transaction.To, sse.TransactionReceivedAddressEvent, nil) + // 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 index 6d0573b06d..f7342182df 100644 --- a/services/bifrost/server/ethereum_rail_test.go +++ b/services/bifrost/server/ethereum_rail_test.go @@ -124,7 +124,7 @@ func (suite *EthereumRailTestSuite) TestAssociationSuccess() { suite.Assert().Equal(association.StellarPublicKey, queueTransaction.StellarPublicKey) }) suite.MockSSEServer. - On("PublishEvent", transaction.To, sse.TransactionReceivedAddressEvent, []byte(nil)) + On("BroadcastEvent", transaction.To, sse.TransactionReceivedAddressEvent, []byte(nil)) err := suite.Server.onNewEthereumTransaction(transaction) suite.Require().NoError(err) } diff --git a/services/bifrost/server/main.go b/services/bifrost/server/main.go index ddfdee6c0b..b4fa594433 100644 --- a/services/bifrost/server/main.go +++ b/services/bifrost/server/main.go @@ -22,8 +22,8 @@ type Server struct { EthereumAddressGenerator *ethereum.AddressGenerator `inject:""` StellarAccountConfigurator *stellar.AccountConfigurator `inject:""` TransactionsQueue queue.Queue `inject:""` + SSEServer sse.ServerInterface `inject:""` - sseServer sse.ServerInterface httpServer *http.Server log *log.Entry } diff --git a/services/bifrost/server/server.go b/services/bifrost/server/server.go index 44e704c67a..70a018ca50 100644 --- a/services/bifrost/server/server.go +++ b/services/bifrost/server/server.go @@ -15,7 +15,6 @@ import ( "github.com/stellar/go/keypair" "github.com/stellar/go/services/bifrost/common" "github.com/stellar/go/services/bifrost/database" - "github.com/stellar/go/services/bifrost/sse" "github.com/stellar/go/support/errors" "github.com/stellar/go/support/log" ) @@ -45,6 +44,11 @@ func (s *Server) Start() error { 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) @@ -71,8 +75,6 @@ func (s *Server) shutdown() { } func (s *Server) startHTTPServer() { - s.sseServer = &sse.Server{} - r := chi.NewRouter() if s.Config.UsingProxy { r.Use(middleware.RealIP) @@ -135,7 +137,7 @@ 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) { + if !s.SSEServer.StreamExists(address) { var chain database.Chain if len(address) > 0 && address[0] == '1' { chain = database.ChainBitcoin @@ -151,11 +153,11 @@ func (s *Server) HandlerEvents(w http.ResponseWriter, r *http.Request) { } if association != nil { - s.sseServer.CreateStream(address) + s.SSEServer.CreateStream(address) } } - s.sseServer.HTTPHandler(w, r) + s.SSEServer.HTTPHandler(w, r) } func (s *Server) HandlerGenerateBitcoinAddress(w http.ResponseWriter, r *http.Request) { @@ -217,7 +219,7 @@ func (s *Server) handlerGenerateAddress(w http.ResponseWriter, r *http.Request, } // Create SSE stream - s.sseServer.CreateStream(address) + s.SSEServer.CreateStream(address) response := GenerateAddressResponse{ Chain: string(chain), diff --git a/services/bifrost/server/stellar_events.go b/services/bifrost/server/stellar_events.go index 0013c7210e..299e1d3341 100644 --- a/services/bifrost/server/stellar_events.go +++ b/services/bifrost/server/stellar_events.go @@ -14,11 +14,11 @@ func (s *Server) onStellarAccountCreated(destination string) { } if association == nil { - s.log.WithField("stellarPublicKey", destination).Warn("Association not found") + s.log.WithField("stellarPublicKey", destination).Error("Association not found") return } - s.sseServer.PublishEvent(association.Address, sse.AccountCreatedAddressEvent, nil) + s.SSEServer.BroadcastEvent(association.Address, sse.AccountCreatedAddressEvent, nil) } func (s *Server) onStellarAccountCredited(destination, assetCode, amount string) { @@ -29,7 +29,7 @@ func (s *Server) onStellarAccountCredited(destination, assetCode, amount string) } if association == nil { - s.log.WithField("stellarPublicKey", destination).Warn("Association not found") + s.log.WithField("stellarPublicKey", destination).Error("Association not found") return } @@ -43,5 +43,5 @@ func (s *Server) onStellarAccountCredited(destination, assetCode, amount string) s.log.WithField("data", data).Error("Error marshalling json") } - s.sseServer.PublishEvent(association.Address, sse.AccountCreditedAddressEvent, j) + s.SSEServer.BroadcastEvent(association.Address, sse.AccountCreditedAddressEvent, j) } diff --git a/services/bifrost/sse/main.go b/services/bifrost/sse/main.go index 26b0b6882f..b7cb29ece3 100644 --- a/services/bifrost/sse/main.go +++ b/services/bifrost/sse/main.go @@ -5,6 +5,7 @@ import ( "sync" "github.com/r3labs/sse" + "github.com/stellar/go/support/log" ) // AddressEvent is an event sent to address SSE stream. @@ -17,53 +18,44 @@ const ( ) type Server struct { + Storage Storage `inject:""` + + lastID int64 eventsServer *sse.Server initOnce sync.Once + log *log.Entry } type ServerInterface interface { - PublishEvent(address string, event AddressEvent, data []byte) + BroadcastEvent(address string, event AddressEvent, data []byte) + StartPublishing() error CreateStream(address string) StreamExists(address string) bool HTTPHandler(w http.ResponseWriter, r *http.Request) } -func (s *Server) init() { - s.eventsServer = sse.New() -} - -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{ - 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) +type Event struct { + Address string `db:"address"` + Event AddressEvent `db:"event"` + Data string `db:"data"` } -func (s *Server) HTTPHandler(w http.ResponseWriter, r *http.Request) { - s.initOnce.Do(s.init) - s.eventsServer.HTTPHandler(w, r) +// 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 index 7bbb054e8e..72fc8433cf 100644 --- a/services/bifrost/sse/mock.go +++ b/services/bifrost/sse/mock.go @@ -11,10 +11,15 @@ type MockServer struct { mock.Mock } -func (m *MockServer) PublishEvent(address string, event AddressEvent, data []byte) { +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) } 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) +} From 89812bdd69104af2ada7f4af3ec73f9da669a79c Mon Sep 17 00:00:00 2001 From: Bartek Nowotarski Date: Fri, 27 Oct 2017 17:22:28 +0200 Subject: [PATCH 06/24] Allow transaction from only one chain, minimum BTC and ETH value in config file Close #129 Close #128 --- clients/horizon/client.go | 14 +-- clients/horizon/main.go | 3 + services/bifrost/README.md | 6 +- services/bifrost/bifrost.cfg | 2 + services/bifrost/bitcoin/main.go | 22 +++- services/bifrost/bitcoin/main_test.go | 40 +++++++ services/bifrost/bitcoin/transaction.go | 4 +- services/bifrost/common/main.go | 14 +++ services/bifrost/config/main.go | 44 +++++--- services/bifrost/ethereum/main.go | 22 +++- services/bifrost/ethereum/main_test.go | 43 ++++++++ services/bifrost/ethereum/transaction.go | 4 +- services/bifrost/main.go | 101 +++++++++++------- services/bifrost/server/bitcoin_rail.go | 3 +- services/bifrost/server/bitcoin_rail_test.go | 5 +- services/bifrost/server/ethereum_rail.go | 3 +- services/bifrost/server/ethereum_rail_test.go | 5 +- services/bifrost/server/main.go | 10 +- services/bifrost/server/server.go | 48 +++++++-- 19 files changed, 307 insertions(+), 86 deletions(-) create mode 100644 services/bifrost/bitcoin/main_test.go create mode 100644 services/bifrost/ethereum/main_test.go diff --git a/clients/horizon/client.go b/clients/horizon/client.go index de2e123a68..cdf03643ca 100644 --- a/clients/horizon/client.go +++ b/clients/horizon/client.go @@ -34,7 +34,7 @@ func (c *Client) fixURL() { // 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.fixURL() + c.fixURLOnce.Do(c.fixURL) resp, err := c.HTTP.Get(c.URL + "/accounts/" + accountID) if err != nil { return @@ -47,7 +47,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.fixURL() + c.fixURLOnce.Do(c.fixURL) endpoint := "" query := url.Values{} @@ -123,7 +123,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.fixURL() + c.fixURLOnce.Do(c.fixURL) query := url.Values{} query.Add("selling_asset_type", selling.Type) @@ -246,7 +246,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.fixURL() + 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 @@ -262,7 +262,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.fixURL() + 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 @@ -278,7 +278,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.fixURL() + 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 @@ -293,7 +293,7 @@ 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) { - c.fixURL() + c.fixURLOnce.Do(c.fixURL) v := url.Values{} v.Set("tx", transactionEnvelopeXdr) diff --git a/clients/horizon/main.go b/clients/horizon/main.go index 996dfb0fe0..0e014af049 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,6 +65,8 @@ type Client struct { // HTTP client to make requests with HTTP HTTP + + fixURLOnce sync.Once } type ClientInterface interface { diff --git a/services/bifrost/README.md b/services/bifrost/README.md index ba3e8b38d3..c255450e80 100644 --- a/services/bifrost/README.md +++ b/services/bifrost/README.md @@ -10,10 +10,12 @@ * `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. @@ -27,7 +29,7 @@ * Remember than 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. -* 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. +* 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 public master key correct. Use CLI tool to generate a few addresses and ensure you have corresponding private keys! You should probably send test transactions to 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. @@ -38,6 +40,8 @@ * 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. * Turn off horizon rate limiting. +* Make sure you configured minimum accepted value for Bitcoin and Ethereum transactions. +* Make sure you start from a fresh DB in production. If Bifrost was running, you stopped it and then started again all the block mined during that that will be processed what can take a lot of time. ## Stress-testing diff --git a/services/bifrost/bifrost.cfg b/services/bifrost/bifrost.cfg index 76f0219917..b5070e9cf6 100644 --- a/services/bifrost/bifrost.cfg +++ b/services/bifrost/bifrost.cfg @@ -7,11 +7,13 @@ 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" diff --git a/services/bifrost/bitcoin/main.go b/services/bifrost/bitcoin/main.go index f2c95b87ff..aa27c7151e 100644 --- a/services/bifrost/bitcoin/main.go +++ b/services/bifrost/bitcoin/main.go @@ -6,12 +6,11 @@ import ( "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" ) -const stellarAmountPrecision = 7 - var ( eight = big.NewInt(8) ten = big.NewInt(10) @@ -27,6 +26,7 @@ var ( // 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 @@ -67,3 +67,21 @@ 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 index ee0eae5ddc..8a4ed98c1b 100644 --- a/services/bifrost/bitcoin/transaction.go +++ b/services/bifrost/bitcoin/transaction.go @@ -2,10 +2,12 @@ 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(stellarAmountPrecision) + return valueBtc.FloatString(common.StellarAmountPrecision) } diff --git a/services/bifrost/common/main.go b/services/bifrost/common/main.go index b8f6d8ab10..b78f6cece1 100644 --- a/services/bifrost/common/main.go +++ b/services/bifrost/common/main.go @@ -1,9 +1,23 @@ package common import ( + "math/big" + "github.com/stellar/go/support/log" ) +const StellarAmountPrecision = 7 + +var ( + eight = big.NewInt(8) + ten = big.NewInt(10) + eighteen = big.NewInt(18) + // weiInEth = 10^18 + weiInEth = new(big.Rat).SetInt(new(big.Int).Exp(ten, eighteen, nil)) + // satInBtc = 10^8 + satInBtc = new(big.Rat).SetInt(new(big.Int).Exp(ten, eight, nil)) +) + func CreateLogger(serviceName string) *log.Entry { logger := log.DefaultLogger diff --git a/services/bifrost/config/main.go b/services/bifrost/config/main.go index 2c35ab27de..0e1947aaeb 100644 --- a/services/bifrost/config/main.go +++ b/services/bifrost/config/main.go @@ -1,23 +1,11 @@ package config type Config struct { - Port int `valid:"required"` - UsingProxy bool `valid:"optional" toml:"using_proxy"` - Bitcoin struct { - MasterPublicKey string `valid:"required" toml:"master_public_key"` - // 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"` - } `valid:"required" toml:"bitcoin"` - Ethereum struct { - NetworkID string `valid:"required,int" toml:"network_id"` - MasterPublicKey string `valid:"required" toml:"master_public_key"` - // Host only - RpcServer string `valid:"required" toml:"rpc_server"` - } `valid:"required" toml:"ethereum"` - Stellar struct { + Port int `valid:"required"` + UsingProxy bool `valid:"optional" toml:"using_proxy"` + Bitcoin *bitcoinConfig `valid:"optional" toml:"bitcoin"` + Ethereum *ethereumConfig `valid:"optional" toml:"ethereum"` + Stellar struct { Horizon string `valid:"required" toml:"horizon"` NetworkPassphrase string `valid:"required" toml:"network_passphrase"` // IssuerPublicKey is public key of the assets issuer or hot wallet. @@ -34,3 +22,25 @@ type Config struct { 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/ethereum/main.go b/services/bifrost/ethereum/main.go index ec73eb24a8..c34bd37a89 100644 --- a/services/bifrost/ethereum/main.go +++ b/services/bifrost/ethereum/main.go @@ -5,12 +5,11 @@ import ( "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" ) -const stellarAmountPrecision = 7 - var ( ten = big.NewInt(10) eighteen = big.NewInt(18) @@ -27,6 +26,7 @@ var ( // Listener ignores contract creation transactions. // Listener requires geth 1.7.0. type Listener struct { + Enabled bool Client Client `inject:""` Storage Storage `inject:""` NetworkID string @@ -63,3 +63,21 @@ type Transaction struct { 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 index 1276ae10e0..94a838fbcd 100644 --- a/services/bifrost/ethereum/transaction.go +++ b/services/bifrost/ethereum/transaction.go @@ -2,10 +2,12 @@ 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(stellarAmountPrecision) + return valueEth.FloatString(common.StellarAmountPrecision) } diff --git a/services/bifrost/main.go b/services/bifrost/main.go index bb9edd0ea7..4deef5811f 100644 --- a/services/bifrost/main.go +++ b/services/bifrost/main.go @@ -213,39 +213,84 @@ func createServer(cfg config.Config, stressTest bool) *server.Server { 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 - connConfig := &rpcclient.ConnConfig{ - Host: cfg.Bitcoin.RpcServer, - User: cfg.Bitcoin.RpcUser, - Pass: cfg.Bitcoin.RpcPass, - HTTPPostMode: true, - DisableTLS: true, + 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) + } } - bitcoinClient, err = rpcclient.New(connConfig, nil) + + 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.WithField("err", err).Error("Error connecting to bitcoin-core") + log.Error(err) os.Exit(-1) } - ethereumClient, err = ethclient.Dial("http://" + cfg.Ethereum.RpcServer) + ethereumListener.Enabled = true + ethereumListener.NetworkID = "3" + ethereumAddressGenerator, err = ethereum.NewAddressGenerator(cfg.Ethereum.MasterPublicKey) if err != nil { - log.WithField("err", err).Error("Error connecting to geth") + log.Error(err) os.Exit(-1) } } - bitcoinListener := &bitcoin.Listener{ - Testnet: cfg.Bitcoin.Testnet, - } - - ethereumListener := ðereum.Listener{ - NetworkID: cfg.Ethereum.NetworkID, - } - stellarAccountConfigurator := &stellar.AccountConfigurator{ NetworkPassphrase: cfg.Stellar.NetworkPassphrase, IssuerPublicKey: cfg.Stellar.IssuerPublicKey, @@ -259,28 +304,8 @@ func createServer(cfg config.Config, stressTest bool) *server.Server { }, } - 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) - } - - ethereumAddressGenerator, err := ethereum.NewAddressGenerator(cfg.Ethereum.MasterPublicKey) - if err != nil { - log.Error(err) - os.Exit(-1) - } - sseServer := &sse.Server{} - server := &server.Server{} - err = g.Provide( &inject.Object{Value: bitcoinAddressGenerator}, &inject.Object{Value: bitcoinClient}, diff --git a/services/bifrost/server/bitcoin_rail.go b/services/bifrost/server/bitcoin_rail.go index 099613fb4b..9169d89430 100644 --- a/services/bifrost/server/bitcoin_rail.go +++ b/services/bifrost/server/bitcoin_rail.go @@ -27,8 +27,7 @@ func (s *Server) onNewBitcoinTransaction(transaction bitcoin.Transaction) error // Let's check if tx is valid first. // Check if value is above minimum required - // TODO, make this configurable - if transaction.ValueSat <= 0 { + if transaction.ValueSat < s.minimumValueSat { localLog.Debug("Value is below minimum required amount, skipping") return nil } diff --git a/services/bifrost/server/bitcoin_rail_test.go b/services/bifrost/server/bitcoin_rail_test.go index f0481a05b7..136996ef51 100644 --- a/services/bifrost/server/bitcoin_rail_test.go +++ b/services/bifrost/server/bitcoin_rail_test.go @@ -28,7 +28,8 @@ func (suite *BitcoinRailTestSuite) SetupTest() { suite.Server = &Server{ Database: suite.MockDatabase, TransactionsQueue: suite.MockQueue, - sseServer: suite.MockSSEServer, + SSEServer: suite.MockSSEServer, + minimumValueSat: 100000000, // 1 BTC } suite.Server.initLogger() } @@ -43,7 +44,7 @@ func (suite *BitcoinRailTestSuite) TestInvalidValue() { transaction := bitcoin.Transaction{ Hash: "109fa1c369680c2f27643fdd160620d010851a376d25b9b00ef71afe789ea6ed", TxOutIndex: 0, - ValueSat: 0, + ValueSat: 50000000, // 0.5 BTC To: "1Q74qRud8bXUn6FMtXWZwJa5pj56s3mdyf", } suite.MockDatabase.AssertNotCalled(suite.T(), "AddProcessedTransaction") diff --git a/services/bifrost/server/ethereum_rail.go b/services/bifrost/server/ethereum_rail.go index 5b3a54e9e4..eccf30191d 100644 --- a/services/bifrost/server/ethereum_rail.go +++ b/services/bifrost/server/ethereum_rail.go @@ -22,8 +22,7 @@ func (s *Server) onNewEthereumTransaction(transaction ethereum.Transaction) erro // Let's check if tx is valid first. // Check if value is above minimum required - // TODO, make this configurable - if transaction.ValueWei.Sign() <= 0 { + if transaction.ValueWei.Cmp(s.minimumValueWei) < 0 { localLog.Debug("Value is below minimum required amount, skipping") return nil } diff --git a/services/bifrost/server/ethereum_rail_test.go b/services/bifrost/server/ethereum_rail_test.go index f7342182df..7eda7c782b 100644 --- a/services/bifrost/server/ethereum_rail_test.go +++ b/services/bifrost/server/ethereum_rail_test.go @@ -31,7 +31,8 @@ func (suite *EthereumRailTestSuite) SetupTest() { suite.Server = &Server{ Database: suite.MockDatabase, TransactionsQueue: suite.MockQueue, - sseServer: suite.MockSSEServer, + SSEServer: suite.MockSSEServer, + minimumValueWei: big.NewInt(1000000000000000000), // 1 ETH } suite.Server.initLogger() } @@ -45,7 +46,7 @@ func (suite *EthereumRailTestSuite) TearDownTest() { func (suite *EthereumRailTestSuite) TestInvalidValue() { transaction := ethereum.Transaction{ Hash: "0x0a190d17ba0405bce37fafd3a7a7bef51264ea4083ffae3b2de90ed61ee5264e", - ValueWei: new(big.Int), + ValueWei: big.NewInt(500000000000000000), // 0.5 ETH To: "0x80D3ee1268DC1A2d1b9E73D49050083E75Ef7c2D", } suite.MockDatabase.AssertNotCalled(suite.T(), "AddProcessedTransaction") diff --git a/services/bifrost/server/main.go b/services/bifrost/server/main.go index b4fa594433..84d25cc6a8 100644 --- a/services/bifrost/server/main.go +++ b/services/bifrost/server/main.go @@ -1,6 +1,7 @@ package server import ( + "math/big" "net/http" "github.com/stellar/go/services/bifrost/bitcoin" @@ -24,8 +25,13 @@ type Server struct { TransactionsQueue queue.Queue `inject:""` SSEServer sse.ServerInterface `inject:""` - httpServer *http.Server - log *log.Entry + MinimumValueBtc string + MinimumValueEth string + + minimumValueSat int64 + minimumValueWei *big.Int + httpServer *http.Server + log *log.Entry } type GenerateAddressResponse struct { diff --git a/services/bifrost/server/server.go b/services/bifrost/server/server.go index 70a018ca50..81329a8a48 100644 --- a/services/bifrost/server/server.go +++ b/services/bifrost/server/server.go @@ -13,8 +13,10 @@ import ( "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" ) @@ -29,17 +31,49 @@ func (s *Server) Start() error { s.StellarAccountConfigurator.OnAccountCreated = s.onStellarAccountCreated s.StellarAccountConfigurator.OnAccountCredited = s.onStellarAccountCredited - err := s.BitcoinListener.Start() - if err != nil { - return errors.Wrap(err, "Error starting BitcoinListener") + if !s.BitcoinListener.Enabled && !s.EthereumListener.Enabled { + return errors.New("At least one listener (BitcoinListener or EthereumListener) must be enabled") } - err = s.EthereumListener.Start(s.Config.Ethereum.RpcServer) - if err != nil { - return errors.Wrap(err, "Error starting EthereumListener") + 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") + } + + 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() + err := s.StellarAccountConfigurator.Start() if err != nil { return errors.Wrap(err, "Error starting StellarAccountConfigurator") } From c359b96c0a2f9593c10fbf7b775e3ed60ad7815b Mon Sep 17 00:00:00 2001 From: Bartek Nowotarski Date: Tue, 31 Oct 2017 15:51:30 +0100 Subject: [PATCH 07/24] Updates Close #117 Close #118 Close #122 --- clients/horizon/client.go | 12 ++++ clients/horizon/main.go | 1 + clients/horizon/mocks.go | 6 ++ clients/horizon/responses.go | 22 +++++++ services/bifrost/common/main.go | 12 ---- services/bifrost/database/main.go | 2 +- .../bifrost/database/migrations/01_init.sql | 3 + services/bifrost/database/mock.go | 4 +- services/bifrost/database/postgres.go | 11 ++-- services/bifrost/main.go | 57 +++++++++++++++++++ services/bifrost/server/bitcoin_rail.go | 2 +- services/bifrost/server/bitcoin_rail_test.go | 4 +- services/bifrost/server/ethereum_rail.go | 2 +- services/bifrost/server/ethereum_rail_test.go | 4 +- .../bifrost/stellar/account_configurator.go | 11 ++++ 15 files changed, 127 insertions(+), 26 deletions(-) diff --git a/clients/horizon/client.go b/clients/horizon/client.go index cdf03643ca..726ab842e9 100644 --- a/clients/horizon/client.go +++ b/clients/horizon/client.go @@ -31,6 +31,18 @@ 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) { diff --git a/clients/horizon/main.go b/clients/horizon/main.go index 0e014af049..620433193e 100644 --- a/clients/horizon/main.go +++ b/clients/horizon/main.go @@ -70,6 +70,7 @@ type Client struct { } 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 20d4d7f127..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"` diff --git a/services/bifrost/common/main.go b/services/bifrost/common/main.go index b78f6cece1..f362f194a3 100644 --- a/services/bifrost/common/main.go +++ b/services/bifrost/common/main.go @@ -1,23 +1,11 @@ package common import ( - "math/big" - "github.com/stellar/go/support/log" ) const StellarAmountPrecision = 7 -var ( - eight = big.NewInt(8) - ten = big.NewInt(10) - eighteen = big.NewInt(18) - // weiInEth = 10^18 - weiInEth = new(big.Rat).SetInt(new(big.Int).Exp(ten, eighteen, nil)) - // satInBtc = 10^8 - satInBtc = new(big.Rat).SetInt(new(big.Int).Exp(ten, eight, nil)) -) - func CreateLogger(serviceName string) *log.Entry { logger := log.DefaultLogger diff --git a/services/bifrost/database/main.go b/services/bifrost/database/main.go index 4da9a30578..ffa66a5f1d 100644 --- a/services/bifrost/database/main.go +++ b/services/bifrost/database/main.go @@ -36,7 +36,7 @@ type Database interface { 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 string) (alreadyProcessing bool, err error) + 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. diff --git a/services/bifrost/database/migrations/01_init.sql b/services/bifrost/database/migrations/01_init.sql index 6b14c3152f..52cb9b0d45 100644 --- a/services/bifrost/database/migrations/01_init.sql +++ b/services/bifrost/database/migrations/01_init.sql @@ -28,6 +28,9 @@ 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) ); diff --git a/services/bifrost/database/mock.go b/services/bifrost/database/mock.go index 74d5967500..6fb4262248 100644 --- a/services/bifrost/database/mock.go +++ b/services/bifrost/database/mock.go @@ -27,8 +27,8 @@ func (m *MockDatabase) GetAssociationByStellarPublicKey(stellarPublicKey string) return a.Get(0).(*AddressAssociation), a.Error(1) } -func (m *MockDatabase) AddProcessedTransaction(chain Chain, transactionID string) (alreadyProcessing bool, err error) { - a := m.Called(chain, transactionID) +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) } diff --git a/services/bifrost/database/postgres.go b/services/bifrost/database/postgres.go index f1c43760ba..acada7a7ef 100644 --- a/services/bifrost/database/postgres.go +++ b/services/bifrost/database/postgres.go @@ -54,9 +54,10 @@ type transactionsQueueRow struct { } type processedTransactionRow struct { - Chain Chain `db:"chain"` - TransactionID string `db:"transaction_id"` - CreatedAt time.Time `db:"created_at"` + Chain Chain `db:"chain"` + TransactionID string `db:"transaction_id"` + ReceivingAddress string `db:"receiving_address"` + CreatedAt time.Time `db:"created_at"` } func fromQueueTransaction(tx queue.Transaction) *transactionsQueueRow { @@ -151,9 +152,9 @@ func (d *PostgresDatabase) GetAssociationByStellarPublicKey(stellarPublicKey str return row, nil } -func (d *PostgresDatabase) AddProcessedTransaction(chain Chain, transactionID string) (bool, error) { +func (d *PostgresDatabase) AddProcessedTransaction(chain Chain, transactionID, receivingAddress string) (bool, error) { processedTransactionTable := d.getTable(processedTransactionTableName, nil) - processedTransaction := processedTransactionRow{chain, transactionID, time.Now()} + processedTransaction := processedTransactionRow{chain, transactionID, receivingAddress, time.Now()} _, err := processedTransactionTable.Insert(processedTransaction).Exec() if err != nil && isDuplicateError(err) { return true, nil diff --git a/services/bifrost/main.go b/services/bifrost/main.go index 4deef5811f..872b37483b 100644 --- a/services/bifrost/main.go +++ b/services/bifrost/main.go @@ -151,6 +151,59 @@ This command will create 3 server.Server's listening on ports 8000-8002.`, }, } +var checkKeysCmd = &cobra.Command{ + Use: "check-keys", + Short: "Displays a few public keys derivied 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...") + } + + if cfg.Ethereum != nil && cfg.Ethereum.MasterPublicKey != "" { + ethereumAddressGenerator, err := ethereum.NewAddressGenerator(cfg.Ethereum.MasterPublicKey) + if err != nil { + log.Error(err) + os.Exit(-1) + } + + fmt.Println("Ethereum:") + 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", @@ -167,11 +220,15 @@ func init() { 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() { diff --git a/services/bifrost/server/bitcoin_rail.go b/services/bifrost/server/bitcoin_rail.go index 9169d89430..ee0a49c848 100644 --- a/services/bifrost/server/bitcoin_rail.go +++ b/services/bifrost/server/bitcoin_rail.go @@ -43,7 +43,7 @@ func (s *Server) onNewBitcoinTransaction(transaction bitcoin.Transaction) error } // Add transaction as processing. - processed, err := s.Database.AddProcessedTransaction(database.ChainBitcoin, transaction.Hash) + processed, err := s.Database.AddProcessedTransaction(database.ChainBitcoin, transaction.Hash, transaction.To) if err != nil { return err } diff --git a/services/bifrost/server/bitcoin_rail_test.go b/services/bifrost/server/bitcoin_rail_test.go index 136996ef51..ca01c3a1f0 100644 --- a/services/bifrost/server/bitcoin_rail_test.go +++ b/services/bifrost/server/bitcoin_rail_test.go @@ -87,7 +87,7 @@ func (suite *BitcoinRailTestSuite) TestAssociationAlreadyProcessed() { On("GetAssociationByChainAddress", database.ChainBitcoin, transaction.To). Return(association, nil) suite.MockDatabase. - On("AddProcessedTransaction", database.ChainBitcoin, transaction.Hash). + On("AddProcessedTransaction", database.ChainBitcoin, transaction.Hash, transaction.To). Return(true, nil) suite.MockQueue.AssertNotCalled(suite.T(), "QueueAdd") err := suite.Server.onNewBitcoinTransaction(transaction) @@ -112,7 +112,7 @@ func (suite *BitcoinRailTestSuite) TestAssociationSuccess() { On("GetAssociationByChainAddress", database.ChainBitcoin, transaction.To). Return(association, nil) suite.MockDatabase. - On("AddProcessedTransaction", database.ChainBitcoin, transaction.Hash). + On("AddProcessedTransaction", database.ChainBitcoin, transaction.Hash, transaction.To). Return(false, nil) suite.MockQueue. On("QueueAdd", mock.AnythingOfType("queue.Transaction")). diff --git a/services/bifrost/server/ethereum_rail.go b/services/bifrost/server/ethereum_rail.go index eccf30191d..f528a9aea5 100644 --- a/services/bifrost/server/ethereum_rail.go +++ b/services/bifrost/server/ethereum_rail.go @@ -38,7 +38,7 @@ func (s *Server) onNewEthereumTransaction(transaction ethereum.Transaction) erro } // Add transaction as processing. - processed, err := s.Database.AddProcessedTransaction(database.ChainEthereum, transaction.Hash) + processed, err := s.Database.AddProcessedTransaction(database.ChainEthereum, transaction.Hash, transaction.To) if err != nil { return err } diff --git a/services/bifrost/server/ethereum_rail_test.go b/services/bifrost/server/ethereum_rail_test.go index 7eda7c782b..c73124540b 100644 --- a/services/bifrost/server/ethereum_rail_test.go +++ b/services/bifrost/server/ethereum_rail_test.go @@ -87,7 +87,7 @@ func (suite *EthereumRailTestSuite) TestAssociationAlreadyProcessed() { On("GetAssociationByChainAddress", database.ChainEthereum, transaction.To). Return(association, nil) suite.MockDatabase. - On("AddProcessedTransaction", database.ChainEthereum, transaction.Hash). + On("AddProcessedTransaction", database.ChainEthereum, transaction.Hash, transaction.To). Return(true, nil) suite.MockQueue.AssertNotCalled(suite.T(), "QueueAdd") err := suite.Server.onNewEthereumTransaction(transaction) @@ -111,7 +111,7 @@ func (suite *EthereumRailTestSuite) TestAssociationSuccess() { On("GetAssociationByChainAddress", database.ChainEthereum, transaction.To). Return(association, nil) suite.MockDatabase. - On("AddProcessedTransaction", database.ChainEthereum, transaction.Hash). + On("AddProcessedTransaction", database.ChainEthereum, transaction.Hash, transaction.To). Return(false, nil) suite.MockQueue. On("QueueAdd", mock.AnythingOfType("queue.Transaction")). diff --git a/services/bifrost/stellar/account_configurator.go b/services/bifrost/stellar/account_configurator.go index 6629a3a688..7579e8e92c 100644 --- a/services/bifrost/stellar/account_configurator.go +++ b/services/bifrost/stellar/account_configurator.go @@ -34,6 +34,17 @@ func (ac *AccountConfigurator) Start() error { 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") From 88b25a68a66c525f356141e09d792ff7a9e31cff Mon Sep 17 00:00:00 2001 From: Peter Oliha Date: Fri, 27 Oct 2017 14:55:39 +0100 Subject: [PATCH 08/24] wip: recovery transaction --- .../migrations/02_recovery_transaction.sql | 8 +++++ services/bifrost/database/postgres.go | 14 +++++++++ services/bifrost/server/server.go | 30 +++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 services/bifrost/database/migrations/02_recovery_transaction.sql diff --git a/services/bifrost/database/migrations/02_recovery_transaction.sql b/services/bifrost/database/migrations/02_recovery_transaction.sql new file mode 100644 index 0000000000..3a297f68db --- /dev/null +++ b/services/bifrost/database/migrations/02_recovery_transaction.sql @@ -0,0 +1,8 @@ +-- +migrate Up +CREATE TABLE `RecoveryTransactions` ( + `source` varchar(56) NOT NULL, + `envelope_xdr` text NOT NULL, + ); + +-- +migrate Down +DROP TABLE `RecoveryTransactions`; diff --git a/services/bifrost/database/postgres.go b/services/bifrost/database/postgres.go index acada7a7ef..6e9cedb57b 100644 --- a/services/bifrost/database/postgres.go +++ b/services/bifrost/database/postgres.go @@ -60,6 +60,12 @@ type processedTransactionRow struct { 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, @@ -424,3 +430,11 @@ func (d *PostgresDatabase) getEventsLastID() (int64, error) { return row.ID, nil } + +func (d *PostgresDatabase) AddRecoveryTransaction(sourceAccount string txEnvelope string) error { + recoveryTransactionTable := d.getTable("RecoveryTransactions", nil) + recoveryTransaction := recoveryTransactionRow{Source: sourceAccount, EnvelopeXDR: txEnvelope,} + + _, err := recoveryTransactionTable.Insert(recoveryTransaction).Exec() + return err +} diff --git a/services/bifrost/server/server.go b/services/bifrost/server/server.go index 81329a8a48..6f13247c67 100644 --- a/services/bifrost/server/server.go +++ b/services/bifrost/server/server.go @@ -19,6 +19,7 @@ import ( "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 { @@ -119,6 +120,7 @@ func (s *Server) startHTTPServer() { 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") @@ -269,3 +271,31 @@ func (s *Server) handlerGenerateAddress(w http.ResponseWriter, r *http.Request, w.Write(responseBytes) } + +func (s *Server) HandlerRecoveryTransaction(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + var transactionEnvelope xdr.TransactionEnvelope + transactionXdr := r.PostFormValue("transaction_xdr") + if transactionXdr == "" { + log.WithField("transaction_xdr", transactionXdr).Warn("Invalid Transaction Xdr") + w.WriteHeader(http.StatusBadRequest) + return + } + + err := xdr.SafeUnmarshalBase64(transactionXdr, &transactionEnvelope) + if err != nil { + log.WithField("transaction", transactionXdr).Warn("Invalid Transaction Xdr") + w.WriteHeader(http.StatusBadRequest) + return + } + + err := s.Database.AddRecoveryTransaction(transactionEnvelope.Tx.SourceAccount, transactionEnvelope) + if err != nil { + log.WithField("err", err).Error("Error savinf recovery transaction") + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) + return +} From b0412382a92912a1480b6d3186bcc025915b2cb3 Mon Sep 17 00:00:00 2001 From: Peter Oliha Date: Mon, 30 Oct 2017 00:11:21 +0100 Subject: [PATCH 09/24] update bifrost migrations --- services/bifrost/database/migrations/01_init.sql | 7 +++++++ .../database/migrations/02_recovery_transaction.sql | 8 -------- services/bifrost/database/postgres.go | 12 ++++++------ 3 files changed, 13 insertions(+), 14 deletions(-) delete mode 100644 services/bifrost/database/migrations/02_recovery_transaction.sql diff --git a/services/bifrost/database/migrations/01_init.sql b/services/bifrost/database/migrations/01_init.sql index 52cb9b0d45..3b29955309 100644 --- a/services/bifrost/database/migrations/01_init.sql +++ b/services/bifrost/database/migrations/01_init.sql @@ -65,3 +65,10 @@ CREATE TABLE broadcasted_event ( 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/migrations/02_recovery_transaction.sql b/services/bifrost/database/migrations/02_recovery_transaction.sql deleted file mode 100644 index 3a297f68db..0000000000 --- a/services/bifrost/database/migrations/02_recovery_transaction.sql +++ /dev/null @@ -1,8 +0,0 @@ --- +migrate Up -CREATE TABLE `RecoveryTransactions` ( - `source` varchar(56) NOT NULL, - `envelope_xdr` text NOT NULL, - ); - --- +migrate Down -DROP TABLE `RecoveryTransactions`; diff --git a/services/bifrost/database/postgres.go b/services/bifrost/database/postgres.go index 6e9cedb57b..042d88375a 100644 --- a/services/bifrost/database/postgres.go +++ b/services/bifrost/database/postgres.go @@ -24,6 +24,7 @@ const ( keyValueStoreTableName = "key_value_store" processedTransactionTableName = "processed_transaction" transactionsQueueTableName = "transactions_queue" + recoveryTransactionTableName = "recovery_transaction" ) type keyValueStoreRow struct { @@ -60,9 +61,8 @@ type processedTransactionRow struct { CreatedAt time.Time `db:"created_at"` } - type recoveryTransactionRow struct { - Source string `db:source` + Source string `db:source` EnvelopeXDR string `db:envelope_xdr` } @@ -431,10 +431,10 @@ func (d *PostgresDatabase) getEventsLastID() (int64, error) { return row.ID, nil } -func (d *PostgresDatabase) AddRecoveryTransaction(sourceAccount string txEnvelope string) error { - recoveryTransactionTable := d.getTable("RecoveryTransactions", nil) - recoveryTransaction := recoveryTransactionRow{Source: sourceAccount, EnvelopeXDR: txEnvelope,} +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() + _, err := recoveryTransactionTable.Insert(recoveryTransaction).Exec() return err } From 9a2c546a5e28865cb5b66803faed78674232803b Mon Sep 17 00:00:00 2001 From: Peter Oliha Date: Mon, 30 Oct 2017 00:12:25 +0100 Subject: [PATCH 10/24] update config --- services/bifrost/bifrost.cfg | 2 ++ services/bifrost/config/main.go | 1 + 2 files changed, 3 insertions(+) diff --git a/services/bifrost/bifrost.cfg b/services/bifrost/bifrost.cfg index b5070e9cf6..58e0774b68 100644 --- a/services/bifrost/bifrost.cfg +++ b/services/bifrost/bifrost.cfg @@ -24,3 +24,5 @@ network_passphrase = "Test SDF Network ; September 2015" [database] type="postgres" dsn="postgres://bartek@localhost/bifrost?sslmode=disable" + +allowed_url="*" diff --git a/services/bifrost/config/main.go b/services/bifrost/config/main.go index 0e1947aaeb..dcd328f506 100644 --- a/services/bifrost/config/main.go +++ b/services/bifrost/config/main.go @@ -21,6 +21,7 @@ type Config struct { Type string `valid:"matches(^postgres$)"` DSN string `valid:"required"` } `valid:"required"` + AllowedURL string `valid:"required" toml:"allowed_url"` } type bitcoinConfig struct { From 3c029be811527f41ba0de471dda94924f83665e1 Mon Sep 17 00:00:00 2001 From: Peter Oliha Date: Mon, 30 Oct 2017 00:14:41 +0100 Subject: [PATCH 11/24] use allowed URL config --- services/bifrost/server/server.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/services/bifrost/server/server.go b/services/bifrost/server/server.go index 6f13247c67..ce7b03734f 100644 --- a/services/bifrost/server/server.go +++ b/services/bifrost/server/server.go @@ -205,7 +205,7 @@ func (s *Server) HandlerGenerateEthereumAddress(w http.ResponseWriter, r *http.R } func (s *Server) handlerGenerateAddress(w http.ResponseWriter, r *http.Request, chain database.Chain) { - w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Origin", s.Config.AllowedURL) stellarPublicKey := r.PostFormValue("stellar_public_key") _, err := keypair.Parse(stellarPublicKey) @@ -273,25 +273,27 @@ func (s *Server) handlerGenerateAddress(w http.ResponseWriter, r *http.Request, } func (s *Server) HandlerRecoveryTransaction(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Origin", s.Config.AllowedURL) var transactionEnvelope xdr.TransactionEnvelope transactionXdr := r.PostFormValue("transaction_xdr") + localLog := log.WithField("transaction_xdr", transactionXdr) + if transactionXdr == "" { - log.WithField("transaction_xdr", transactionXdr).Warn("Invalid Transaction Xdr") + localLog.Warn("Invalid input. No Transaction XDR") w.WriteHeader(http.StatusBadRequest) return } err := xdr.SafeUnmarshalBase64(transactionXdr, &transactionEnvelope) if err != nil { - log.WithField("transaction", transactionXdr).Warn("Invalid Transaction Xdr") + localLog.Warn("Invalid Transaction XDR") w.WriteHeader(http.StatusBadRequest) return } err := s.Database.AddRecoveryTransaction(transactionEnvelope.Tx.SourceAccount, transactionEnvelope) if err != nil { - log.WithField("err", err).Error("Error savinf recovery transaction") + log.WithField("err", err).Error("Error saving recovery transaction") w.WriteHeader(http.StatusInternalServerError) return } From d68257ae4398fd4f46ac513c63ae2fb6def93734 Mon Sep 17 00:00:00 2001 From: Peter Oliha Date: Tue, 31 Oct 2017 16:15:50 +0100 Subject: [PATCH 12/24] change config variable. --- services/bifrost/bifrost.cfg | 3 +-- services/bifrost/config/main.go | 22 +++++++++++++++++++++- services/bifrost/database/main.go | 3 +++ services/bifrost/server/server.go | 8 ++++---- 4 files changed, 29 insertions(+), 7 deletions(-) diff --git a/services/bifrost/bifrost.cfg b/services/bifrost/bifrost.cfg index 58e0774b68..ba2c9d7ce4 100644 --- a/services/bifrost/bifrost.cfg +++ b/services/bifrost/bifrost.cfg @@ -1,5 +1,6 @@ port = 8000 using_proxy = false +access-control-allow-origin-header = "*" [bitcoin] master_public_key = "xpub6DxSCdWu6jKqr4isjo7bsPeDD6s3J4YVQV1JSHZg12Eagdqnf7XX4fxqyW2sLhUoFWutL7tAELU2LiGZrEXtjVbvYptvTX5Eoa4Mamdjm9u" @@ -24,5 +25,3 @@ network_passphrase = "Test SDF Network ; September 2015" [database] type="postgres" dsn="postgres://bartek@localhost/bifrost?sslmode=disable" - -allowed_url="*" diff --git a/services/bifrost/config/main.go b/services/bifrost/config/main.go index dcd328f506..37773598bd 100644 --- a/services/bifrost/config/main.go +++ b/services/bifrost/config/main.go @@ -1,11 +1,32 @@ package config type Config struct { +<<<<<<< HEAD Port int `valid:"required"` UsingProxy bool `valid:"optional" toml:"using_proxy"` Bitcoin *bitcoinConfig `valid:"optional" toml:"bitcoin"` Ethereum *ethereumConfig `valid:"optional" toml:"ethereum"` Stellar struct { +======= + Port int `valid:"required"` + UsingProxy bool `valid:"optional" toml:"using_proxy"` + AccessControlAllowOriginHeader string `valid:"required" toml:"access-control-allow-origin-header"` + Bitcoin struct { + MasterPublicKey string `valid:"required" toml:"master_public_key"` + // 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"` + } `valid:"required" toml:"bitcoin"` + Ethereum struct { + NetworkID string `valid:"required,int" toml:"network_id"` + MasterPublicKey string `valid:"required" toml:"master_public_key"` + // Host only + RpcServer string `valid:"required" toml:"rpc_server"` + } `valid:"required" toml:"ethereum"` + Stellar struct { +>>>>>>> change config variable. Horizon string `valid:"required" toml:"horizon"` NetworkPassphrase string `valid:"required" toml:"network_passphrase"` // IssuerPublicKey is public key of the assets issuer or hot wallet. @@ -21,7 +42,6 @@ type Config struct { Type string `valid:"matches(^postgres$)"` DSN string `valid:"required"` } `valid:"required"` - AllowedURL string `valid:"required" toml:"allowed_url"` } type bitcoinConfig struct { diff --git a/services/bifrost/database/main.go b/services/bifrost/database/main.go index ffa66a5f1d..4cd2b37e80 100644 --- a/services/bifrost/database/main.go +++ b/services/bifrost/database/main.go @@ -45,6 +45,9 @@ type Database interface { // 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 { diff --git a/services/bifrost/server/server.go b/services/bifrost/server/server.go index ce7b03734f..fdc7532b98 100644 --- a/services/bifrost/server/server.go +++ b/services/bifrost/server/server.go @@ -205,7 +205,7 @@ func (s *Server) HandlerGenerateEthereumAddress(w http.ResponseWriter, r *http.R } func (s *Server) handlerGenerateAddress(w http.ResponseWriter, r *http.Request, chain database.Chain) { - w.Header().Set("Access-Control-Allow-Origin", s.Config.AllowedURL) + w.Header().Set("Access-Control-Allow-Origin", s.Config.AccessControlAllowOriginHeader) stellarPublicKey := r.PostFormValue("stellar_public_key") _, err := keypair.Parse(stellarPublicKey) @@ -273,7 +273,7 @@ func (s *Server) handlerGenerateAddress(w http.ResponseWriter, r *http.Request, } func (s *Server) HandlerRecoveryTransaction(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", s.Config.AllowedURL) + 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) @@ -286,14 +286,14 @@ func (s *Server) HandlerRecoveryTransaction(w http.ResponseWriter, r *http.Reque err := xdr.SafeUnmarshalBase64(transactionXdr, &transactionEnvelope) if err != nil { - localLog.Warn("Invalid Transaction XDR") + localLog.WithField("err", err).Warn("Invalid Transaction XDR") w.WriteHeader(http.StatusBadRequest) return } err := s.Database.AddRecoveryTransaction(transactionEnvelope.Tx.SourceAccount, transactionEnvelope) if err != nil { - log.WithField("err", err).Error("Error saving recovery transaction") + localLog.WithField("err", err).Error("Error saving recovery transaction") w.WriteHeader(http.StatusInternalServerError) return } From 114e89447a5c9cbe38bd8cf3f9172fab77fab7db Mon Sep 17 00:00:00 2001 From: Peter Oliha Date: Tue, 31 Oct 2017 17:06:44 +0100 Subject: [PATCH 13/24] AccessControlAllowOriginHeader default --- services/bifrost/config/main.go | 31 ++++++------------------------- services/bifrost/main.go | 3 +++ 2 files changed, 9 insertions(+), 25 deletions(-) diff --git a/services/bifrost/config/main.go b/services/bifrost/config/main.go index 37773598bd..f716b323c8 100644 --- a/services/bifrost/config/main.go +++ b/services/bifrost/config/main.go @@ -1,32 +1,13 @@ package config type Config struct { -<<<<<<< HEAD - Port int `valid:"required"` - UsingProxy bool `valid:"optional" toml:"using_proxy"` - Bitcoin *bitcoinConfig `valid:"optional" toml:"bitcoin"` - Ethereum *ethereumConfig `valid:"optional" toml:"ethereum"` - Stellar struct { -======= - Port int `valid:"required"` - UsingProxy bool `valid:"optional" toml:"using_proxy"` - AccessControlAllowOriginHeader string `valid:"required" toml:"access-control-allow-origin-header"` - Bitcoin struct { - MasterPublicKey string `valid:"required" toml:"master_public_key"` - // 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"` - } `valid:"required" toml:"bitcoin"` - Ethereum struct { - NetworkID string `valid:"required,int" toml:"network_id"` - MasterPublicKey string `valid:"required" toml:"master_public_key"` - // Host only - RpcServer string `valid:"required" toml:"rpc_server"` - } `valid:"required" toml:"ethereum"` + 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:"required" toml:"access-control-allow-origin-header"` + Stellar struct { ->>>>>>> change config variable. Horizon string `valid:"required" toml:"horizon"` NetworkPassphrase string `valid:"required" toml:"network_passphrase"` // IssuerPublicKey is public key of the assets issuer or hot wallet. diff --git a/services/bifrost/main.go b/services/bifrost/main.go index 872b37483b..b767de2edb 100644 --- a/services/bifrost/main.go +++ b/services/bifrost/main.go @@ -248,6 +248,9 @@ func readConfig(cfgPath string) config.Config { } os.Exit(-1) } + if cfg.AccessControlAllowOriginHeader == "" { + cfg.AccessControlAllowOriginHeader = "*" + } return cfg } From a656f80f7e7d6ee766d6e0e6adf8674a21b5e196 Mon Sep 17 00:00:00 2001 From: Peter Oliha Date: Tue, 31 Oct 2017 20:17:38 +0100 Subject: [PATCH 14/24] fix errors --- services/bifrost/config/main.go | 2 +- services/bifrost/database/mock.go | 5 +++++ services/bifrost/server/server.go | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/services/bifrost/config/main.go b/services/bifrost/config/main.go index f716b323c8..3d9b4c5b5a 100644 --- a/services/bifrost/config/main.go +++ b/services/bifrost/config/main.go @@ -5,7 +5,7 @@ type Config struct { UsingProxy bool `valid:"optional" toml:"using_proxy"` Bitcoin *bitcoinConfig `valid:"optional" toml:"bitcoin"` Ethereum *ethereumConfig `valid:"optional" toml:"ethereum"` - AccessControlAllowOriginHeader string `valid:"required" toml:"access-control-allow-origin-header"` + AccessControlAllowOriginHeader string `valid:"optional" toml:"access-control-allow-origin-header"` Stellar struct { Horizon string `valid:"required" toml:"horizon"` diff --git a/services/bifrost/database/mock.go b/services/bifrost/database/mock.go index 6fb4262248..f6c73391df 100644 --- a/services/bifrost/database/mock.go +++ b/services/bifrost/database/mock.go @@ -41,3 +41,8 @@ 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/server/server.go b/services/bifrost/server/server.go index fdc7532b98..5ce6eba82e 100644 --- a/services/bifrost/server/server.go +++ b/services/bifrost/server/server.go @@ -291,7 +291,7 @@ func (s *Server) HandlerRecoveryTransaction(w http.ResponseWriter, r *http.Reque return } - err := s.Database.AddRecoveryTransaction(transactionEnvelope.Tx.SourceAccount, transactionEnvelope) + 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) From 5195e3e36806f1d24479b9997bd3e6860f7a8cd8 Mon Sep 17 00:00:00 2001 From: Bartek Nowotarski Date: Thu, 2 Nov 2017 00:42:59 +0100 Subject: [PATCH 15/24] Trust line authorization --- services/bifrost/bifrost.cfg | 2 + services/bifrost/config/main.go | 4 ++ services/bifrost/main.go | 4 +- .../bifrost/stellar/account_configurator.go | 14 ++++++- services/bifrost/stellar/main.go | 2 + services/bifrost/stellar/transactions.go | 38 +++++++++++++++---- 6 files changed, 54 insertions(+), 10 deletions(-) diff --git a/services/bifrost/bifrost.cfg b/services/bifrost/bifrost.cfg index ba2c9d7ce4..a016c26f7c 100644 --- a/services/bifrost/bifrost.cfg +++ b/services/bifrost/bifrost.cfg @@ -19,6 +19,8 @@ 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" diff --git a/services/bifrost/config/main.go b/services/bifrost/config/main.go index 3d9b4c5b5a..6a36053086 100644 --- a/services/bifrost/config/main.go +++ b/services/bifrost/config/main.go @@ -10,6 +10,10 @@ type Config struct { 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: diff --git a/services/bifrost/main.go b/services/bifrost/main.go index b767de2edb..b1c56d5fda 100644 --- a/services/bifrost/main.go +++ b/services/bifrost/main.go @@ -182,6 +182,7 @@ var checkKeysCmd = &cobra.Command{ 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 { @@ -189,7 +190,6 @@ var checkKeysCmd = &cobra.Command{ os.Exit(-1) } - fmt.Println("Ethereum:") for i := uint32(start); i < start+count; i++ { address, err := ethereumAddressGenerator.Generate(i) if err != nil { @@ -355,6 +355,8 @@ func createServer(cfg config.Config, stressTest bool) *server.Server { NetworkPassphrase: cfg.Stellar.NetworkPassphrase, IssuerPublicKey: cfg.Stellar.IssuerPublicKey, SignerSecretKey: cfg.Stellar.SignerSecretKey, + NeedsAuthorize: cfg.Stellar.NeedsAuthorize, + TokenAssetCode: cfg.Stellar.TokenAssetCode, } horizonClient := &horizon.Client{ diff --git a/services/bifrost/stellar/account_configurator.go b/services/bifrost/stellar/account_configurator.go index 7579e8e92c..7d96fae428 100644 --- a/services/bifrost/stellar/account_configurator.go +++ b/services/bifrost/stellar/account_configurator.go @@ -128,8 +128,18 @@ func (ac *AccountConfigurator) ConfigureAccount(destination, assetCode, amount s time.Sleep(2 * time.Second) } - // When trustline found send token - localLog.Info("Trust line found, sending token") + 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") diff --git a/services/bifrost/stellar/main.go b/services/bifrost/stellar/main.go index de2e51d128..fda94d3334 100644 --- a/services/bifrost/stellar/main.go +++ b/services/bifrost/stellar/main.go @@ -14,6 +14,8 @@ type AccountConfigurator struct { NetworkPassphrase string IssuerPublicKey string SignerSecretKey string + NeedsAuthorize bool + TokenAssetCode string OnAccountCreated func(destination string) OnAccountCredited func(destination string, assetCode string, amount string) diff --git a/services/bifrost/stellar/transactions.go b/services/bifrost/stellar/transactions.go index 1f78282b9f..00636a8771 100644 --- a/services/bifrost/stellar/transactions.go +++ b/services/bifrost/stellar/transactions.go @@ -24,6 +24,30 @@ func (ac *AccountConfigurator) createAccount(destination string) error { 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( @@ -43,8 +67,8 @@ func (ac *AccountConfigurator) sendToken(destination, assetCode, amount string) return nil } -func (ac *AccountConfigurator) submitTransaction(mutator build.TransactionMutator) error { - tx, err := ac.buildTransaction(mutator) +func (ac *AccountConfigurator) submitTransaction(mutators ...build.TransactionMutator) error { + tx, err := ac.buildTransaction(mutators...) if err != nil { return errors.Wrap(err, "Error building transaction") } @@ -67,14 +91,14 @@ func (ac *AccountConfigurator) submitTransaction(mutator build.TransactionMutato return nil } -func (ac *AccountConfigurator) buildTransaction(mutator build.TransactionMutator) (string, error) { - tx := build.Transaction( +func (ac *AccountConfigurator) buildTransaction(mutators ...build.TransactionMutator) (string, error) { + muts := []build.TransactionMutator{ build.SourceAccount{ac.signerPublicKey}, build.Sequence{ac.getSequence()}, build.Network{ac.NetworkPassphrase}, - mutator, - ) - + } + muts = append(muts, mutators...) + tx := build.Transaction(muts...) txe := tx.Sign(ac.SignerSecretKey) return txe.Base64() } From db5513f2f5ed53d4a83b5173866706895ce87874 Mon Sep 17 00:00:00 2001 From: Bartek Nowotarski Date: Fri, 3 Nov 2017 14:45:25 +0100 Subject: [PATCH 16/24] Fix recovery --- services/bifrost/database/postgres.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/bifrost/database/postgres.go b/services/bifrost/database/postgres.go index 042d88375a..8f3b4558d0 100644 --- a/services/bifrost/database/postgres.go +++ b/services/bifrost/database/postgres.go @@ -62,8 +62,8 @@ type processedTransactionRow struct { } type recoveryTransactionRow struct { - Source string `db:source` - EnvelopeXDR string `db:envelope_xdr` + Source string `db:"source"` + EnvelopeXDR string `db:"envelope_xdr"` } func fromQueueTransaction(tx queue.Transaction) *transactionsQueueRow { From 6caf13fd5afeb0b30dfe9c539ad5e536de9a5732 Mon Sep 17 00:00:00 2001 From: Bartek Nowotarski Date: Tue, 14 Nov 2017 16:37:32 +0100 Subject: [PATCH 17/24] Changes for @poliha comments --- services/bifrost/main.go | 2 +- services/bifrost/server/bitcoin_rail.go | 2 +- services/bifrost/server/ethereum_rail.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/bifrost/main.go b/services/bifrost/main.go index b1c56d5fda..63c92a74fa 100644 --- a/services/bifrost/main.go +++ b/services/bifrost/main.go @@ -153,7 +153,7 @@ This command will create 3 server.Server's listening on ports 8000-8002.`, var checkKeysCmd = &cobra.Command{ Use: "check-keys", - Short: "Displays a few public keys derivied using master public 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") diff --git a/services/bifrost/server/bitcoin_rail.go b/services/bifrost/server/bitcoin_rail.go index ee0a49c848..36800a9ce4 100644 --- a/services/bifrost/server/bitcoin_rail.go +++ b/services/bifrost/server/bitcoin_rail.go @@ -13,7 +13,7 @@ import ( // the transactions queue for StellarAccountConfigurator to consume. // // Transaction added to transactions queue should be in a format described in -// queue.Queue (especialy amounts). Pooling service should not have to deal with any +// 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 diff --git a/services/bifrost/server/ethereum_rail.go b/services/bifrost/server/ethereum_rail.go index f528a9aea5..309d2022f2 100644 --- a/services/bifrost/server/ethereum_rail.go +++ b/services/bifrost/server/ethereum_rail.go @@ -13,7 +13,7 @@ import ( // the transactions queue for StellarAccountConfigurator to consume. // // Transaction added to transactions queue should be in a format described in -// queue.Queue (especialy amounts). Pooling service should not have to deal with any +// 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"}) From 0e0fa806e5106ed7dc9ae9a9a68050b86a379fbd Mon Sep 17 00:00:00 2001 From: Bartek Nowotarski Date: Thu, 16 Nov 2017 21:01:59 +0100 Subject: [PATCH 18/24] Fix testnet bitcoin /events --- services/bifrost/server/server.go | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/services/bifrost/server/server.go b/services/bifrost/server/server.go index 5ce6eba82e..0c5d4014f9 100644 --- a/services/bifrost/server/server.go +++ b/services/bifrost/server/server.go @@ -175,10 +175,17 @@ func (s *Server) HandlerEvents(w http.ResponseWriter, r *http.Request) { address := r.URL.Query().Get("stream") if !s.SSEServer.StreamExists(address) { var chain database.Chain - if len(address) > 0 && address[0] == '1' { - chain = database.ChainBitcoin - } else { + + if len(address) == 0 { + w.WriteHeader(http.StatusBadGateway) + 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) From f6fb767be18fe5114268712258e56e87c1be9365 Mon Sep 17 00:00:00 2001 From: Bartek Nowotarski Date: Thu, 16 Nov 2017 21:03:59 +0100 Subject: [PATCH 19/24] Update readme --- services/bifrost/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/bifrost/README.md b/services/bifrost/README.md index c255450e80..1d6ac28655 100644 --- a/services/bifrost/README.md +++ b/services/bifrost/README.md @@ -30,17 +30,17 @@ * Remember than 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. * 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 public master key correct. Use CLI tool to generate a few addresses and ensure you have corresponding private keys! You should probably send test transactions to these addresses and check if you can withdraw funds. +* 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). -* Make sure that "Authorization required" [flag](https://www.stellar.org/developers/guides/concepts/accounts.html#flags) is not set on your issuing account. It's a good idea to set "Authorization revocable" flag during ICO stage to remove trustlines to accounts with lost keys. +* 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. * 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. * Turn off horizon rate limiting. -* Make sure you configured minimum accepted value for Bitcoin and Ethereum transactions. +* 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 DB in production. If Bifrost was running, you stopped it and then started again all the block mined during that that will be processed what can take a lot of time. ## Stress-testing From ccc52d0f4af002e2eab2d527c120472c84891837 Mon Sep 17 00:00:00 2001 From: Bartek Nowotarski Date: Thu, 16 Nov 2017 21:05:18 +0100 Subject: [PATCH 20/24] StatusBadGateway -> StatusBadRequest --- services/bifrost/server/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/bifrost/server/server.go b/services/bifrost/server/server.go index 0c5d4014f9..9910b464ed 100644 --- a/services/bifrost/server/server.go +++ b/services/bifrost/server/server.go @@ -177,7 +177,7 @@ func (s *Server) HandlerEvents(w http.ResponseWriter, r *http.Request) { var chain database.Chain if len(address) == 0 { - w.WriteHeader(http.StatusBadGateway) + w.WriteHeader(http.StatusBadRequest) return } From 35bd82e25a8214854e39c56062ac6b98daf23650 Mon Sep 17 00:00:00 2001 From: Bartek Nowotarski Date: Thu, 30 Nov 2017 16:47:50 +0100 Subject: [PATCH 21/24] Updates --- services/bifrost/README.md | 6 ++-- services/bifrost/bifrost.cfg | 2 +- services/bifrost/bitcoin/address_generator.go | 2 -- services/bifrost/bitcoin/listener.go | 32 +++++++++---------- services/bifrost/bitcoin/transaction_test.go | 2 +- services/bifrost/common/main.go | 8 +---- .../bifrost/database/migrations/01_init.sql | 4 +-- .../bifrost/ethereum/address_generator.go | 2 -- services/bifrost/server/server.go | 2 +- 9 files changed, 25 insertions(+), 35 deletions(-) diff --git a/services/bifrost/README.md b/services/bifrost/README.md index 1d6ac28655..db6fedeaaf 100644 --- a/services/bifrost/README.md +++ b/services/bifrost/README.md @@ -27,7 +27,7 @@ ## Going to production -* Remember than 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. +* 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. * 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. @@ -39,9 +39,9 @@ * 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. * 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. -* Turn off horizon rate limiting. +* 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 DB in production. If Bifrost was running, you stopped it and then started again all the block mined during that that will be processed what can take a lot of time. +* 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. ## Stress-testing diff --git a/services/bifrost/bifrost.cfg b/services/bifrost/bifrost.cfg index a016c26f7c..326c8072bd 100644 --- a/services/bifrost/bifrost.cfg +++ b/services/bifrost/bifrost.cfg @@ -26,4 +26,4 @@ network_passphrase = "Test SDF Network ; September 2015" [database] type="postgres" -dsn="postgres://bartek@localhost/bifrost?sslmode=disable" +dsn="postgres://root@localhost/bifrost?sslmode=[sslmode]" diff --git a/services/bifrost/bitcoin/address_generator.go b/services/bifrost/bitcoin/address_generator.go index 22edb1dca4..4c14ce9986 100644 --- a/services/bifrost/bitcoin/address_generator.go +++ b/services/bifrost/bitcoin/address_generator.go @@ -7,8 +7,6 @@ import ( "github.com/tyler-smith/go-bip32" ) -// TODO should we use account hardened key and then use it to generate change and index keys? -// That way we can create lot more accounts than 0x80000000-1. func NewAddressGenerator(masterPublicKeyString string, chainParams *chaincfg.Params) (*AddressGenerator, error) { deserializedMasterPublicKey, err := bip32.B58Deserialize(masterPublicKeyString) if err != nil { diff --git a/services/bifrost/bitcoin/listener.go b/services/bifrost/bitcoin/listener.go index c78d648522..e1c044a798 100644 --- a/services/bifrost/bitcoin/listener.go +++ b/services/bifrost/bitcoin/listener.go @@ -23,14 +23,12 @@ func (l *Listener) Start() error { if l.Testnet { l.chainParams = &chaincfg.TestNet3Params - if !genesisBlockHash.IsEqual(chaincfg.TestNet3Params.GenesisHash) { - return errors.New("Invalid genesis hash") - } } else { l.chainParams = &chaincfg.MainNetParams - if !genesisBlockHash.IsEqual(chaincfg.MainNetParams.GenesisHash) { - return errors.New("Invalid genesis hash") - } + } + + if !genesisBlockHash.IsEqual(l.chainParams.GenesisHash) { + return errors.New("Invalid genesis hash") } blockNumber, err := l.Storage.GetBitcoinBlockToProcess() @@ -59,35 +57,35 @@ func (l *Listener) processBlocks(blockNumber uint64) { // Time when last new block has been seen lastBlockSeen := time.Now() - noBlockWarningLogged := false + 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(1 * time.Second) + time.Sleep(time.Second) continue } // Block doesn't exist yet if block == nil { - if time.Since(lastBlockSeen) > 20*time.Minute && !noBlockWarningLogged { + if time.Since(lastBlockSeen) > 20*time.Minute && !missingBlockWarningLogged { l.log.Warn("No new block in more than 20 minutes") - noBlockWarningLogged = true + missingBlockWarningLogged = true } - time.Sleep(1 * time.Second) + time.Sleep(time.Second) continue } // Reset counter when new block appears lastBlockSeen = time.Now() - noBlockWarningLogged = false + 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(1 * time.Second) + time.Sleep(time.Second) continue } @@ -95,8 +93,10 @@ func (l *Listener) processBlocks(blockNumber uint64) { err = l.Storage.SaveLastProcessedBitcoinBlock(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 + 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++ @@ -151,7 +151,7 @@ func (l *Listener) processBlock(block *wire.MsgBlock) error { // We only support P2PK and P2PKH addresses if class != txscript.PubKeyTy && class != txscript.PubKeyHashTy { - transactionLog.WithField("class", class).Debug("Invalid addresses class") + transactionLog.WithField("class", class).Debug("Unsupported addresses class") continue } diff --git a/services/bifrost/bitcoin/transaction_test.go b/services/bifrost/bitcoin/transaction_test.go index 603d0ffacd..33d88a97bd 100644 --- a/services/bifrost/bitcoin/transaction_test.go +++ b/services/bifrost/bitcoin/transaction_test.go @@ -12,12 +12,12 @@ func TestTransactionAmount(t *testing.T) { expectedStellarAmount string }{ {1, "0.0000000"}, + {4, "0.0000000"}, {5, "0.0000001"}, {10, "0.0000001"}, {12345674, "0.1234567"}, {12345678, "0.1234568"}, {100000000, "1.0000000"}, - {100000000, "1.0000000"}, {2100000000000000, "21000000.0000000"}, } diff --git a/services/bifrost/common/main.go b/services/bifrost/common/main.go index f362f194a3..588d4561ef 100644 --- a/services/bifrost/common/main.go +++ b/services/bifrost/common/main.go @@ -7,11 +7,5 @@ import ( const StellarAmountPrecision = 7 func CreateLogger(serviceName string) *log.Entry { - logger := log.DefaultLogger - - if serviceName != "" { - logger = logger.WithField("service", serviceName) - } - - return logger + return log.DefaultLogger.WithField("service", serviceName) } diff --git a/services/bifrost/database/migrations/01_init.sql b/services/bifrost/database/migrations/01_init.sql index 3b29955309..8dbe761a92 100644 --- a/services/bifrost/database/migrations/01_init.sql +++ b/services/bifrost/database/migrations/01_init.sql @@ -40,7 +40,7 @@ CREATE TABLE transactions_queue ( id bigserial, /* Ethereum: "0x"+hash (so 64+2) */ transaction_id varchar(66) NOT NULL, - asset_code varchar(10) 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 */ @@ -49,7 +49,7 @@ CREATE TABLE transactions_queue ( 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_asset_code CHECK (char_length(asset_code) = 3), CONSTRAINT valid_stellar_public_key CHECK (char_length(stellar_public_key) = 56) ); diff --git a/services/bifrost/ethereum/address_generator.go b/services/bifrost/ethereum/address_generator.go index 45e72f81e5..e0d3a7b426 100644 --- a/services/bifrost/ethereum/address_generator.go +++ b/services/bifrost/ethereum/address_generator.go @@ -8,8 +8,6 @@ import ( "github.com/tyler-smith/go-bip32" ) -// TODO should we use account hardened key and then use it to generate change and index keys? -// That way we can create lot more accounts than 0x80000000-1. func NewAddressGenerator(masterPublicKeyString string) (*AddressGenerator, error) { deserializedMasterPublicKey, err := bip32.B58Deserialize(masterPublicKeyString) if err != nil { diff --git a/services/bifrost/server/server.go b/services/bifrost/server/server.go index 9910b464ed..d8ab257f76 100644 --- a/services/bifrost/server/server.go +++ b/services/bifrost/server/server.go @@ -40,7 +40,7 @@ func (s *Server) Start() error { var err error s.minimumValueSat, err = bitcoin.BtcToSat(s.MinimumValueBtc) if err != nil { - return errors.Wrap(err, "Invalid minimum accepted Bitcoin transaction value") + return errors.Wrap(err, "Invalid minimum accepted Bitcoin transaction value: "+s.MinimumValueBtc) } if s.minimumValueSat == 0 { From ded793df08f7ed61286abdad3a33e3a6241f7e88 Mon Sep 17 00:00:00 2001 From: Bartek Nowotarski Date: Mon, 4 Dec 2017 18:26:20 +0100 Subject: [PATCH 22/24] Updates --- services/bifrost/README.md | 67 +++++++++++++++++++++-- services/bifrost/images/architecture.png | Bin 0 -> 57992 bytes services/bifrost/main.go | 2 +- services/bifrost/server/main.go | 9 ++- services/bifrost/server/server.go | 5 +- 5 files changed, 72 insertions(+), 11 deletions(-) create mode 100644 services/bifrost/images/architecture.png diff --git a/services/bifrost/README.md b/services/bifrost/README.md index db6fedeaaf..08733752ec 100644 --- a/services/bifrost/README.md +++ b/services/bifrost/README.md @@ -1,5 +1,50 @@ # 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 @@ -25,10 +70,24 @@ * `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. +* 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. @@ -36,13 +95,9 @@ * 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. +* 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. - -## Stress-testing - -// diff --git a/services/bifrost/images/architecture.png b/services/bifrost/images/architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..6b43674557ae39c10ea463a4dd9b35102e529a79 GIT binary patch literal 57992 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 S4gC^6?qhSo-m=czGwJ_|5<5Kr literal 0 HcmV?d00001 diff --git a/services/bifrost/main.go b/services/bifrost/main.go index 63c92a74fa..7e47ed1dd0 100644 --- a/services/bifrost/main.go +++ b/services/bifrost/main.go @@ -208,7 +208,7 @@ var versionCmd = &cobra.Command{ Use: "version", Short: "Print the version number", Run: func(cmd *cobra.Command, args []string) { - fmt.Println("version") + fmt.Println("0.0.1") }, } diff --git a/services/bifrost/server/main.go b/services/bifrost/server/main.go index 84d25cc6a8..22c4030c1f 100644 --- a/services/bifrost/server/main.go +++ b/services/bifrost/server/main.go @@ -14,6 +14,10 @@ import ( "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:""` @@ -35,6 +39,7 @@ type Server struct { } type GenerateAddressResponse struct { - Chain string `json:"chain"` - Address string `json:"address"` + ProtocolVersion int `json:"protocol_version"` + Chain string `json:"chain"` + Address string `json:"address"` } diff --git a/services/bifrost/server/server.go b/services/bifrost/server/server.go index d8ab257f76..2aa6136be5 100644 --- a/services/bifrost/server/server.go +++ b/services/bifrost/server/server.go @@ -265,8 +265,9 @@ func (s *Server) handlerGenerateAddress(w http.ResponseWriter, r *http.Request, s.SSEServer.CreateStream(address) response := GenerateAddressResponse{ - Chain: string(chain), - Address: address, + ProtocolVersion: ProtocolVersion, + Chain: string(chain), + Address: address, } responseBytes, err := json.Marshal(response) From c6be61c4c95dbd2c82b7802d191da05a4a744b1e Mon Sep 17 00:00:00 2001 From: Bartek Nowotarski Date: Tue, 5 Dec 2017 18:21:46 +0100 Subject: [PATCH 23/24] Skip server tests in Go <1.8 --- services/bifrost/main.go | 3 +++ services/bifrost/server/bitcoin_rail_test.go | 3 +++ services/bifrost/server/ethereum_rail_test.go | 3 +++ services/bifrost/server/server.go | 3 +++ 4 files changed, 12 insertions(+) diff --git a/services/bifrost/main.go b/services/bifrost/main.go index 7e47ed1dd0..ecd44aa2c1 100644 --- a/services/bifrost/main.go +++ b/services/bifrost/main.go @@ -1,3 +1,6 @@ +// Skip this file in Go <1.8 because it's using http.Server.Shutdown +// +build go1.8 + package main import ( diff --git a/services/bifrost/server/bitcoin_rail_test.go b/services/bifrost/server/bitcoin_rail_test.go index ca01c3a1f0..084a0c56fc 100644 --- a/services/bifrost/server/bitcoin_rail_test.go +++ b/services/bifrost/server/bitcoin_rail_test.go @@ -1,3 +1,6 @@ +// Skip this test file in Go <1.8 because it's using http.Server.Shutdown +// +build go1.8 + package server import ( diff --git a/services/bifrost/server/ethereum_rail_test.go b/services/bifrost/server/ethereum_rail_test.go index c73124540b..ee67981124 100644 --- a/services/bifrost/server/ethereum_rail_test.go +++ b/services/bifrost/server/ethereum_rail_test.go @@ -1,3 +1,6 @@ +// Skip this test file in Go <1.8 because it's using http.Server.Shutdown +// +build go1.8 + package server import ( diff --git a/services/bifrost/server/server.go b/services/bifrost/server/server.go index 2aa6136be5..eb69a86ae7 100644 --- a/services/bifrost/server/server.go +++ b/services/bifrost/server/server.go @@ -1,3 +1,6 @@ +// Skip this file in Go <1.8 because it's using http.Server.Shutdown +// +build go1.8 + package server import ( From 14553a43d4feeea9b7ac1a81cfb565e0480c574c Mon Sep 17 00:00:00 2001 From: Bartek Nowotarski Date: Tue, 5 Dec 2017 20:19:35 +0100 Subject: [PATCH 24/24] Fix README --- services/bifrost/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/bifrost/README.md b/services/bifrost/README.md index 08733752ec..a3e99ba9e8 100644 --- a/services/bifrost/README.md +++ b/services/bifrost/README.md @@ -82,7 +82,7 @@ We recommend the latter. Here's the proposed architecture diagram of high-availability deployment: -![Architecture][./images/architecture.png] +![Architecture](./images/architecture.png) ## Going to production