├── .circleci └── config.yml ├── .dockerignore ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── common ├── const.go ├── kv_store.go ├── message.go ├── orderbook.go ├── orderbook_test.go ├── queue.go ├── queue_test.go └── test_helper.go ├── engine ├── engine.go ├── engine_test.go └── market_handler.go ├── go.mod ├── go.sum ├── launcher ├── gas_price.go ├── launch_log.go ├── launcher.go └── signer.go ├── sdk ├── crypto │ ├── crypto.go │ └── crypto_test.go ├── ethereum │ ├── erc20.go │ ├── erc20_test.go │ ├── ethereum.go │ ├── ethereum_hydro.go │ ├── ethereum_hydro_protocol.go │ ├── ethereum_hydro_protocol_test.go │ └── ethereum_test.go ├── interface.go ├── rlp │ ├── rlp.go │ └── rlp_test.go ├── signer │ ├── signer.go │ └── signer_test.go ├── test_helper.go └── types │ ├── hash.go │ ├── transaction.go │ └── types_util.go ├── utils ├── hex.go ├── hex_test.go ├── http_client.go ├── http_client_test.go ├── json.go ├── logger.go ├── metrics.go └── number.go ├── watcher ├── watcher.go └── watcher_test.go └── websocket ├── channel.go ├── channel_test.go ├── client.go ├── consumer.go ├── interface.go ├── market_channel.go ├── message.go ├── orderbook.go ├── server.go ├── snapshot.go ├── snapshot_test.go └── websocket.go /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | branches: 5 | only: 6 | - master 7 | docker: 8 | - image: circleci/golang:1.11 9 | - image: hydroprotocolio/ethereum-test-node:latest 10 | steps: 11 | - checkout 12 | - run: 13 | name: run test 14 | command: | 15 | go mod download 16 | go test ./... -cover 17 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | .git 3 | *.db 4 | .cover 5 | .idea -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin 2 | .cover 3 | .idea 4 | debug.test 5 | debug 6 | *.db 7 | main 8 | vendor 9 | .env 10 | .vscode 11 | 12 | .DS_Store 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | go test ./... --count=1 --cover 3 | 4 | clean: 5 | go clean 6 | 7 | .PHONY: test api ws watcher engine launcher 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hydro SDK Backend 2 | 3 | [![CircleCI](https://circleci.com/gh/HydroProtocol/hydro-sdk-backend.svg?style=svg)](https://circleci.com/gh/HydroProtocol/hydro-sdk-backend) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/hydroprotocol/hydro-sdk-backend)](https://goreportcard.com/report/github.com/hydroprotocol/hydro-sdk-backend) 5 | 6 | The Hydro SDK is a collection of golang language packages. 7 | You can use it to build a Dapp application backend based on the Hydro contract quickly. 8 | It can help to communicate with Ethereum node, match orders, monitor Ethereum results and so on. 9 | Some general data structures are also provided. 10 | 11 | This project cannot be used alone. 12 | You need to add your own application logic. 13 | The following projects are built on top of this SDK. 14 | 15 | - [hydro-scaffold-dex](https://github.com/hydroprotocol/hydro-scaffold-dex) 16 | - [hydro-augur-scaffold](https://github.com/hydroprotocol/hydro-augur-scaffold) (working in progress) 17 | 18 | ## Break down to each package 19 | 20 | ### sdk 21 | 22 | The main function of this package is to define the interface to communicate with a blockchain. 23 | We have implemented Ethereum communication codes based on this interface spec. 24 | So as long as the interface is implemented for a blockchain, 25 | hydro SDK backend can be used on top it.This makes it possible to support multi-chain environments easily. 26 | 27 | ### common 28 | 29 | We put some common data structures and interface definitions into this package for sharing with other projects. 30 | 31 | ### engine 32 | 33 | The engine maintains a series of market orderbooks. 34 | It is responsible for handling all placing orders and cancel requests. 35 | Requests in each market are processed serially, 36 | and multiple markets are concurrent. 37 | 38 | The engine in this package only maintains the orderbook based on the received message 39 | and returns the result of the operation. 40 | It is not responsible for persisting these changes, 41 | nor for pushing messages to users. 42 | Persistent data and push messages are business logic and should be done by the upper application. 43 | 44 | 45 | ### watcher 46 | 47 | Blockchain Watcher is responsible for monitoring blockchain changes. 48 | Whenever a new block is generated, 49 | it gets all the transactions in that block. 50 | And pass each transaction to a specific method to deal with. 51 | This method requires you to register with the `RegisterHandler` function. 52 | You can process the transactions you are interested in as needed and skip unrelated transactions. 53 | 54 | ### websocket 55 | 56 | The Websocket package allows you to easily launch a websocket server. 57 | The server is channel based. 58 | Users can join multiple channels and can leave at any time. 59 | 60 | The websocket server should have a message source. 61 | Every message read from the source will be broadcast to that channel. 62 | All users in the channel will receive this message. 63 | 64 | If you want to make some special logic and not just broadcast the message. 65 | This can be done by creating your own channel. 66 | 67 | Any structure that implements the IChannel interface can be registered to the websocket server. 68 | 69 | There are already a customized channel called `MarketChannel` in this package. 70 | It keep maintaining the newest order book in memory. 71 | If a new user joins this channel, 72 | it sends a snapshot of current market order book to the user. 73 | After receive a new event from source, 74 | it will update the order book in memory, 75 | then push the change event to all subscribers. 76 | 77 | ```golang 78 | import ( 79 | github.com/hydroprotocol/hydor-sdk-backend/common 80 | github.com/hydroprotocol/hydor-sdk-backend/websocket 81 | ) 82 | 83 | // new a source queue 84 | queue, _ := common.InitQueue(&common.RedisQueueConfig{ 85 | Name: common.HYDRO_WEBSOCKET_MESSAGES_QUEUE_KEY, 86 | Ctx: ctx, 87 | Client: redisClient, 88 | }) 89 | 90 | // new a websockert server 91 | wsServer := websocket.NewWSServer("localhost:3002", queue) 92 | 93 | websocket.RegisterChannelCreator( 94 | common.MarketChannelPrefix, 95 | websocket.NewMarketChannelCreator(&websocket.DefaultHttpSnapshotFetcher{ 96 | ApiUrl: os.Getenv("HSK_API_URL"), 97 | }), 98 | ) 99 | 100 | // Start the server 101 | // It will block the current process to listen on the `addr` your provided. 102 | wsServer.Start() 103 | ``` 104 | 105 | ## License 106 | 107 | This project is licensed under the Apache 2.0 License - see the [LICENSE](LICENSE) file for details 108 | -------------------------------------------------------------------------------- /common/const.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import "fmt" 4 | 5 | const STATUS_SUCCESSFUL = "successful" 6 | const STATUS_PENDING = "pending" 7 | const STATUS_FAILED = "failed" 8 | 9 | func GetMarketOrderbookSnapshotV2Key(marketID string) string { 10 | return fmt.Sprintf("HYDRO_MARKET_ORDERBOOK_SNAPSHOT_V2:%s", marketID) 11 | } 12 | 13 | // queue key 14 | const HYDRO_WEBSOCKET_MESSAGES_QUEUE_KEY = "HYDRO_WEBSOCKET_MESSAGES_QUEUE_KEY" 15 | const HYDRO_ENGINE_EVENTS_QUEUE_KEY = "HYDRO_ENGINE_EVENTS_QUEUE_KEY" 16 | 17 | // cache key 18 | const HYDRO_WATCHER_BLOCK_NUMBER_CACHE_KEY = "HYDRO_WATCHER_BLOCK_NUMBER_CACHE_KEY" 19 | 20 | // order status 21 | const ORDER_CANCELED = "canceled" 22 | const ORDER_PENDING = "pending" 23 | const ORDER_PARTIAL_FILLED = "partial_filled" 24 | const ORDER_FULL_FILLED = "full_filled" 25 | -------------------------------------------------------------------------------- /common/kv_store.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/go-redis/redis" 8 | "time" 9 | ) 10 | 11 | type IKVStore interface { 12 | Set(key string, value string, expire time.Duration) error 13 | Get(key string) (string, error) 14 | } 15 | 16 | var KVStoreEmpty = errors.New("KVStoreEmpty") 17 | 18 | func InitKVStore(config interface{}) (store IKVStore, err error) { 19 | switch c := config.(type) { 20 | case nil: 21 | return nil, fmt.Errorf("need Config to init KVStore") 22 | case *RedisKVStoreConfig: 23 | KVStore := &RedisKVStore{} 24 | err = KVStore.Init(c) 25 | 26 | if err != nil { 27 | return 28 | } 29 | 30 | return KVStore, nil 31 | default: 32 | return nil, fmt.Errorf("KVStore config is not support %v", config) 33 | } 34 | } 35 | 36 | type ( 37 | RedisKVStore struct { 38 | ctx context.Context 39 | client *redis.Client 40 | } 41 | 42 | RedisKVStoreConfig struct { 43 | Ctx context.Context 44 | Client *redis.Client 45 | } 46 | ) 47 | 48 | func (queue RedisKVStore) Set(key, value string, expire time.Duration) error { 49 | ret := queue.client.Set(key, value, expire) 50 | return ret.Err() 51 | } 52 | 53 | func (queue RedisKVStore) Get(key string) (string, error) { 54 | ret := queue.client.Get(key) 55 | res, err := ret.Result() 56 | 57 | if err == redis.Nil { 58 | return "", KVStoreEmpty 59 | } 60 | 61 | return res, err 62 | } 63 | 64 | func (queue *RedisKVStore) Init(config *RedisKVStoreConfig) error { 65 | if config.Client == nil { 66 | return fmt.Errorf("no redis Connection") 67 | } 68 | 69 | queue.client = config.Client 70 | queue.ctx = config.Ctx 71 | 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /common/message.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "github.com/shopspring/decimal" 6 | ) 7 | 8 | // WebsocketMessage is message unit between engine and websocket 9 | // Engine is producer 10 | // Websocket is consumer 11 | 12 | const WsTypeOrderChange = "orderChange" 13 | const WsTypeTradeChange = "tradeChange" 14 | const WsTypeLockedBalanceChange = "lockedBalanceChange" 15 | 16 | const WsTypeNewMarketTrade = "newMarketTrade" 17 | 18 | //const MessageTypeAccount = "account" 19 | //const MessageTypeMarket = "market" 20 | 21 | type WebSocketMessage struct { 22 | //MessageType string `json:"message_type"` 23 | ChannelID string `json:"channel_id"` 24 | Payload interface{} `json:"payload"` 25 | } 26 | 27 | type WebsocketMarketOrderChangePayload struct { 28 | Side string `json:"side"` 29 | Sequence uint64 `json:"sequence"` 30 | Price string `json:"price"` 31 | Amount string `json:"amount"` 32 | } 33 | 34 | type WebsocketLockedBalanceChangePayload struct { 35 | Type string `json:"type"` 36 | Symbol string `json:"symbol"` 37 | Balance decimal.Decimal `json:"balance"` 38 | } 39 | 40 | type WebsocketOrderChangePayload struct { 41 | Type string `json:"type"` 42 | Order interface{} `json:"order"` 43 | } 44 | 45 | type WebsocketTradeChangePayload struct { 46 | Type string `json:"type"` 47 | Trade interface{} `json:"trade"` 48 | } 49 | 50 | type WebsocketMarketNewMarketTradePayload struct { 51 | Type string `json:"type"` 52 | Trade interface{} `json:"trade"` 53 | } 54 | 55 | // engine event 56 | const ( 57 | EventNewOrder = "EVENT/NEW_ORDER" 58 | EventCancelOrder = "EVENT/EVENT_CANCEL_ORDER" 59 | EventRestartEngine = "EVENT/EVENT_RESTART" 60 | EventConfirmTransaction = "EVENT/EVENT_CONFIRM_TRANSACTION" 61 | EventOpenMarket = "EVENT/EVENT_OPEN_MARKET" 62 | EventCloseMarket = "EVENT/EVENT_CLOSE_MARKET" 63 | ) 64 | 65 | type Event struct { 66 | Type string `json:"eventType"` 67 | MarketID string `json:"marketID"` 68 | } 69 | 70 | type NewOrderEvent struct { 71 | Event 72 | Order string `json:"order"` 73 | } 74 | 75 | type CancelOrderEvent struct { 76 | Event 77 | ID string `json:"id"` 78 | Price string `json:"price"` 79 | Side string `json:"side"` 80 | } 81 | 82 | type ConfirmTransactionEvent struct { 83 | Event 84 | Hash string `json:"hash"` 85 | Status string `json:"status"` 86 | Timestamp uint64 `json:"timestamp"` 87 | } 88 | 89 | // channel 90 | 91 | const MarketChannelPrefix = "Market" 92 | const AccountChannelPrefix = "TraderAddress" 93 | 94 | func GetAccountChannelID(address string) string { 95 | return fmt.Sprintf("%s#%s", AccountChannelPrefix, address) 96 | } 97 | 98 | func GetMarketChannelID(marketID string) string { 99 | return fmt.Sprintf("%s#%s", MarketChannelPrefix, marketID) 100 | } 101 | 102 | func OrderBookChangeMessage(marketID string, sequence uint64, side string, price, amount decimal.Decimal) WebSocketMessage { 103 | payload := &WebsocketMarketOrderChangePayload{ 104 | Sequence: sequence, 105 | Side: side, 106 | Price: price.String(), 107 | Amount: amount.String(), 108 | } 109 | 110 | return marketChannelMessage(marketID, payload) 111 | } 112 | 113 | func marketChannelMessage(marketID string, payload interface{}) WebSocketMessage { 114 | return WebSocketMessage{ 115 | //MessageType: MessageTypeMarket, 116 | ChannelID: GetMarketChannelID(marketID), 117 | Payload: payload, 118 | } 119 | } 120 | 121 | func MessagesForUpdateOrder(order *MemoryOrder) []WebSocketMessage { 122 | updateMsg := orderUpdateMessage(order) 123 | 124 | var balanceChangeMsg WebSocketMessage 125 | if order.Side == "buy" { 126 | balanceChangeMsg = lockedBalanceChangeMessage(order.Trader, order.QuoteTokenSymbol()) 127 | } else { 128 | balanceChangeMsg = lockedBalanceChangeMessage(order.Trader, order.BaseTokenSymbol()) 129 | } 130 | 131 | return []WebSocketMessage{updateMsg, balanceChangeMsg} 132 | } 133 | 134 | func orderUpdateMessage(order *MemoryOrder) WebSocketMessage { 135 | return accountMessage(order.Trader, &WebsocketOrderChangePayload{ 136 | Type: WsTypeOrderChange, 137 | Order: order, 138 | }) 139 | } 140 | 141 | func lockedBalanceChangeMessage(address, symbol string) WebSocketMessage { 142 | return accountMessage(address, &WebsocketLockedBalanceChangePayload{ 143 | Type: WsTypeLockedBalanceChange, 144 | Symbol: symbol, 145 | }) 146 | } 147 | 148 | func accountMessage(address string, payload interface{}) WebSocketMessage { 149 | return WebSocketMessage{ 150 | //MessageType: MessageTypeAccount, 151 | ChannelID: GetAccountChannelID(address), 152 | Payload: payload, 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /common/orderbook.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 6 | "github.com/cevaris/ordered_map" 7 | "github.com/labstack/gommon/log" 8 | "github.com/petar/GoLLRB/llrb" 9 | "github.com/shopspring/decimal" 10 | "strings" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | type OrderbookEvent struct { 16 | Side string 17 | OrderID string 18 | Price decimal.Decimal 19 | Amount decimal.Decimal 20 | } 21 | 22 | type OrderbookPlugin func(event *OrderbookEvent) 23 | 24 | type IOrderBook interface { 25 | InsertOrder(*MemoryOrder) 26 | RemoveOrder(*MemoryOrder) 27 | ChangeOrder(*MemoryOrder, decimal.Decimal) 28 | 29 | UsePlugin(plugin OrderbookPlugin) 30 | 31 | SnapshotV2() *SnapshotV2 32 | CanMatch(*MemoryOrder) bool 33 | MatchOrder(*MemoryOrder, int) *MatchResult 34 | ExecuteMatch(*MemoryOrder, int) *MatchResult 35 | } 36 | 37 | type ( 38 | MatchResult struct { 39 | TakerOrder *MemoryOrder 40 | TakerOrderIsDone bool 41 | MatchItems []*MatchItem 42 | TakerOrderLeftAmount decimal.Decimal 43 | OrderBookActivities []WebSocketMessage 44 | } 45 | 46 | MatchItem struct { 47 | MakerOrder *MemoryOrder 48 | MakerOrderIsDone bool 49 | MatchedAmount decimal.Decimal 50 | MatchShouldBeCanceled bool 51 | } 52 | 53 | MemoryOrder struct { 54 | ID string `json:"id"` 55 | MarketID string `json:"marketID"` 56 | Price decimal.Decimal `json:"price"` 57 | Amount decimal.Decimal `json:"amount"` 58 | Side string `json:"side"` 59 | Type string `json:"type"` 60 | Trader string `json:"trader"` 61 | GasFeeAmount decimal.Decimal `json:"gasFeeAmount"` 62 | MakerFeeRate decimal.Decimal `json:"makerFeeRate"` 63 | TakerFeeRate decimal.Decimal `json:"takerFeeRate"` 64 | } 65 | 66 | SnapshotV2 struct { 67 | Sequence uint64 `json:"sequence"` 68 | Bids [][2]string `json:"bids"` 69 | Asks [][2]string `json:"asks"` 70 | } 71 | ) 72 | 73 | func (order *MemoryOrder) QuoteTokenSymbol() string { 74 | parts := strings.Split(order.MarketID, "-") 75 | if len(parts) == 2 { 76 | return parts[1] 77 | } else { 78 | return "unknown" 79 | } 80 | } 81 | 82 | func (order *MemoryOrder) BaseTokenSymbol() string { 83 | parts := strings.Split(order.MarketID, "-") 84 | if len(parts) == 2 { 85 | return parts[0] 86 | } else { 87 | return "unknown" 88 | } 89 | } 90 | 91 | func (matchResult *MatchResult) QuoteTokenTotalMatchedAmt() decimal.Decimal { 92 | quoteTokenAmt := decimal.Zero 93 | for _, item := range matchResult.MatchItems { 94 | quoteTokenAmt = quoteTokenAmt.Add(item.MatchedAmount.Mul(item.MakerOrder.Price)) 95 | } 96 | 97 | return quoteTokenAmt 98 | } 99 | 100 | func (matchResult *MatchResult) TakerTradeFeeInQuoteToken() decimal.Decimal { 101 | return matchResult.QuoteTokenTotalMatchedAmt().Mul(matchResult.TakerOrder.TakerFeeRate) 102 | } 103 | 104 | func (matchResult *MatchResult) MakerTradeFeeInQuoteToken() (sum decimal.Decimal) { 105 | for _, item := range matchResult.MatchItems { 106 | sum = sum.Add(item.MatchedAmount.Mul(item.MakerOrder.Price).Mul(item.MakerOrder.MakerFeeRate)) 107 | } 108 | 109 | return 110 | } 111 | 112 | func (matchResult *MatchResult) BaseTokenTotalMatchedAmtWithoutCanceledMatch() decimal.Decimal { 113 | baseTokenAmt := decimal.Zero 114 | for _, item := range matchResult.MatchItems { 115 | if !item.MatchShouldBeCanceled { 116 | baseTokenAmt = baseTokenAmt.Add(item.MatchedAmount) 117 | } 118 | } 119 | 120 | return baseTokenAmt 121 | } 122 | 123 | func (matchResult *MatchResult) SumOfGasOfMakerOrders() decimal.Decimal { 124 | sum := decimal.Zero 125 | for _, item := range matchResult.MatchItems { 126 | sum = sum.Add(item.MakerOrder.GasFeeAmount) 127 | } 128 | 129 | return sum 130 | } 131 | 132 | func (matchResult MatchResult) ExistMatchToBeExecuted() bool { 133 | for _, match := range matchResult.MatchItems { 134 | if !match.MatchShouldBeCanceled { 135 | return true 136 | } 137 | } 138 | 139 | return false 140 | } 141 | 142 | type priceLevel struct { 143 | price decimal.Decimal 144 | totalAmount decimal.Decimal 145 | orderMap *ordered_map.OrderedMap 146 | } 147 | 148 | func newPriceLevel(price decimal.Decimal) *priceLevel { 149 | return &priceLevel{ 150 | price: price, 151 | totalAmount: decimal.Zero, 152 | orderMap: ordered_map.NewOrderedMap(), 153 | } 154 | } 155 | 156 | func (p *priceLevel) Len() int { 157 | return p.orderMap.Len() 158 | } 159 | 160 | func (p *priceLevel) InsertOrder(order *MemoryOrder) { 161 | log.Debug("InsertOrder:", order.ID) 162 | 163 | if _, ok := p.orderMap.Get(order.ID); ok { 164 | panic(fmt.Errorf("can't add order which is already in this priceLevel. priceLevel: %s, orderID: %s", p.price.String(), order.ID)) 165 | } 166 | 167 | p.orderMap.Set(order.ID, order) 168 | p.totalAmount = p.totalAmount.Add(order.Amount) 169 | } 170 | 171 | func (p *priceLevel) RemoveOrder(o *MemoryOrder) { 172 | orderItem, ok := p.orderMap.Get(o.ID) 173 | 174 | if !ok { 175 | panic(fmt.Errorf("can't remove order which is not in this priceLevel. priceLevel: %s", p.price.String())) 176 | } 177 | 178 | order := orderItem.(*MemoryOrder) 179 | p.orderMap.Delete(order.ID) 180 | p.totalAmount = p.totalAmount.Sub(order.Amount) 181 | } 182 | 183 | func (p *priceLevel) GetOrder(id string) (order *MemoryOrder, exist bool) { 184 | orderItem, exist := p.orderMap.Get(id) 185 | if !exist { 186 | return nil, exist 187 | } 188 | 189 | return orderItem.(*MemoryOrder), exist 190 | } 191 | 192 | func (p *priceLevel) ChangeOrder(o *MemoryOrder, changeAmount decimal.Decimal) { 193 | _, ok := p.orderMap.Get(o.ID) 194 | 195 | if !ok { 196 | panic(fmt.Errorf("can't remove order which is not in this priceLevel. priceLevel: %s", p.price.String())) 197 | } 198 | 199 | p.totalAmount = p.totalAmount.Add(changeAmount) 200 | } 201 | 202 | func (p *priceLevel) Less(item llrb.Item) bool { 203 | another := item.(*priceLevel) 204 | return p.price.LessThan(another.price) 205 | } 206 | 207 | // Orderbook ... 208 | type Orderbook struct { 209 | market string 210 | 211 | plugins []OrderbookPlugin 212 | 213 | bidsTree *llrb.LLRB 214 | asksTree *llrb.LLRB 215 | 216 | lock sync.RWMutex 217 | 218 | Sequence uint64 219 | } 220 | 221 | // NewOrderbook return a new book 222 | func NewOrderbook(market string) *Orderbook { 223 | book := &Orderbook{ 224 | plugins: make([]OrderbookPlugin, 0, 3), 225 | market: market, 226 | bidsTree: llrb.New(), 227 | asksTree: llrb.New(), 228 | } 229 | 230 | return book 231 | } 232 | 233 | func (book *Orderbook) SnapshotV2() *SnapshotV2 { 234 | //startTime := time.Now() 235 | 236 | book.lock.RLock() 237 | defer book.lock.RUnlock() 238 | 239 | //utils.Debug("== cost in lock, Snapshot : %f", float64(time.Since(startTime))/1000000) 240 | 241 | bids := make([][2]string, 0, 0) 242 | asks := make([][2]string, 0, 0) 243 | 244 | asyncWaitGroup := sync.WaitGroup{} 245 | 246 | asyncWaitGroup.Add(1) 247 | go func() { 248 | book.asksTree.AscendGreaterOrEqual(newPriceLevel(decimal.Zero), func(i llrb.Item) bool { 249 | pl := i.(*priceLevel) 250 | asks = append(asks, [2]string{pl.price.String(), pl.totalAmount.String()}) 251 | return true 252 | }) 253 | asyncWaitGroup.Done() 254 | }() 255 | 256 | asyncWaitGroup.Add(1) 257 | go func() { 258 | book.bidsTree.DescendLessOrEqual(newPriceLevel(decimal.New(1, 99)), func(i llrb.Item) bool { 259 | pl := i.(*priceLevel) 260 | bids = append(bids, [2]string{pl.price.String(), pl.totalAmount.String()}) 261 | return true 262 | }) 263 | asyncWaitGroup.Done() 264 | }() 265 | 266 | asyncWaitGroup.Wait() 267 | 268 | res := &SnapshotV2{ 269 | Bids: bids, 270 | Asks: asks, 271 | } 272 | 273 | //utils.Debugf("== cost in lock read, Snapshot : %f", float64(time.Since(startTime))/1000000) 274 | 275 | return res 276 | } 277 | 278 | func (book *Orderbook) InsertOrder(order *MemoryOrder) *OrderbookEvent { 279 | startTime := time.Now().UTC() 280 | book.lock.Lock() 281 | defer book.lock.Unlock() 282 | 283 | log.Debug("cost in lock, InsertOrder :", order.ID, float64(time.Since(startTime))/1000000) 284 | 285 | var tree *llrb.LLRB 286 | if order.Side == "sell" { 287 | tree = book.asksTree 288 | } else { 289 | tree = book.bidsTree 290 | } 291 | 292 | price := tree.Get(newPriceLevel(order.Price)) 293 | 294 | if price == nil { 295 | price = newPriceLevel(order.Price) 296 | tree.InsertNoReplace(price) 297 | } 298 | 299 | price.(*priceLevel).InsertOrder(order) 300 | 301 | orderBookEvent := &OrderbookEvent{ 302 | OrderID: order.ID, 303 | Side: order.Side, 304 | Amount: order.Amount, 305 | Price: order.Price, 306 | } 307 | 308 | book.RunPlugins(orderBookEvent) 309 | 310 | return orderBookEvent 311 | } 312 | 313 | func (book *Orderbook) RemoveOrder(order *MemoryOrder) *OrderbookEvent { 314 | book.lock.Lock() 315 | defer book.lock.Unlock() 316 | 317 | var tree *llrb.LLRB 318 | if order.Side == "sell" { 319 | tree = book.asksTree 320 | } else { 321 | tree = book.bidsTree 322 | } 323 | 324 | // log 325 | plItem := tree.Get(newPriceLevel(order.Price)) 326 | if plItem == nil { 327 | log.Infof("plItem is nil when RemoveOrder") 328 | return nil 329 | } 330 | 331 | price := plItem.(*priceLevel) 332 | 333 | if price == nil { 334 | panic(fmt.Sprintf("pl is nil when RemoveOrder, book: %s, order: %+v", book.market, order)) 335 | } 336 | 337 | price.RemoveOrder(order) 338 | if price.Len() <= 0 { 339 | tree.Delete(price) 340 | } 341 | 342 | event := &OrderbookEvent{ 343 | OrderID: order.ID, 344 | Side: order.Side, 345 | Amount: order.Amount.Mul(decimal.New(-1, 0)), 346 | Price: order.Price, 347 | } 348 | 349 | book.RunPlugins(event) 350 | 351 | return event 352 | } 353 | 354 | func (book *Orderbook) ChangeOrder(order *MemoryOrder, changeAmount decimal.Decimal) *OrderbookEvent { 355 | book.lock.Lock() 356 | defer book.lock.Unlock() 357 | 358 | var tree *llrb.LLRB 359 | if order.Side == "sell" { 360 | tree = book.asksTree 361 | } else { 362 | tree = book.bidsTree 363 | } 364 | 365 | price := tree.Get(newPriceLevel(order.Price)) 366 | 367 | if price == nil { 368 | fmt.Println("book snapshot:", book.SnapshotV2()) 369 | panic(fmt.Sprintf("can't change order which is not in this orderbook. book: %s, order: %+v", book.market, order)) 370 | } 371 | 372 | price.(*priceLevel).ChangeOrder(order, changeAmount) 373 | 374 | event := &OrderbookEvent{ 375 | OrderID: order.ID, 376 | Side: order.Side, 377 | Amount: changeAmount, 378 | Price: order.Price, 379 | } 380 | book.RunPlugins(event) 381 | 382 | return event 383 | } 384 | 385 | func (book *Orderbook) UsePlugin(plugin OrderbookPlugin) { 386 | book.plugins = append(book.plugins, plugin) 387 | } 388 | 389 | func (book *Orderbook) RunPlugins(event *OrderbookEvent) { 390 | for _, plugin := range book.plugins { 391 | plugin(event) 392 | } 393 | } 394 | 395 | func (book *Orderbook) GetOrder(id string, side string, price decimal.Decimal) (*MemoryOrder, bool) { 396 | book.lock.Lock() 397 | defer book.lock.Unlock() 398 | 399 | var tree *llrb.LLRB 400 | if side == "sell" { 401 | tree = book.asksTree 402 | } else { 403 | tree = book.bidsTree 404 | } 405 | 406 | pl := tree.Get(newPriceLevel(price)) 407 | 408 | if pl == nil { 409 | return nil, false 410 | } 411 | 412 | return pl.(*priceLevel).GetOrder(id) 413 | } 414 | 415 | // MaxBid ... 416 | func (book *Orderbook) MaxBid() *decimal.Decimal { 417 | book.lock.Lock() 418 | defer book.lock.Unlock() 419 | 420 | maxItem := book.bidsTree.Max() 421 | if maxItem != nil { 422 | return &maxItem.(*priceLevel).price 423 | } 424 | return nil 425 | } 426 | 427 | // MinAsk ... 428 | func (book *Orderbook) MinAsk() *decimal.Decimal { 429 | book.lock.Lock() 430 | defer book.lock.Unlock() 431 | 432 | minItem := book.asksTree.Min() 433 | 434 | if minItem != nil { 435 | return &minItem.(*priceLevel).price 436 | } 437 | 438 | return nil 439 | } 440 | 441 | func (book *Orderbook) CanMatch(order *MemoryOrder) bool { 442 | if strings.EqualFold("buy", order.Side) { 443 | minItem := book.asksTree.Min() 444 | if minItem == nil { 445 | return false 446 | } 447 | 448 | if order.Price.GreaterThanOrEqual(minItem.(*priceLevel).price) { 449 | return true 450 | } 451 | 452 | return false 453 | } else { 454 | maxItem := book.bidsTree.Max() 455 | if maxItem == nil { 456 | return false 457 | } 458 | 459 | if order.Price.LessThanOrEqual(maxItem.(*priceLevel).price) { 460 | return true 461 | } 462 | 463 | return false 464 | } 465 | } 466 | 467 | // return matching orders in book 468 | // will NOT modify the order book 469 | // 470 | // amt is quoteCurrency when order is MarketID Buy Order 471 | // all other amount is baseCurrencyAmt 472 | func (book *Orderbook) MatchOrder(takerOrder *MemoryOrder, marketAmountDecimals int) *MatchResult { 473 | book.lock.Lock() 474 | defer book.lock.Unlock() 475 | 476 | matchedResult := make([]*MatchItem, 0) 477 | 478 | totalMatchedAmount := decimal.NewFromFloat(0) 479 | leftAmount := takerOrder.Amount 480 | 481 | // This function will be called multi times 482 | // Return false to break the loop 483 | limitOrderIterator := func(i llrb.Item) bool { 484 | pl := i.(*priceLevel) 485 | 486 | if takerOrder.Side == "buy" && pl.price.GreaterThan(takerOrder.Price) { 487 | return false 488 | } else if takerOrder.Side == "sell" && pl.price.LessThan(takerOrder.Price) { 489 | return false 490 | } 491 | 492 | iter := pl.orderMap.IterFunc() 493 | for kv, ok := iter(); ok; kv, ok = iter() { 494 | if leftAmount.LessThanOrEqual(decimal.Zero) { 495 | break 496 | } 497 | 498 | bookOrder := kv.Value.(*MemoryOrder) 499 | 500 | if leftAmount.GreaterThanOrEqual(bookOrder.Amount) { 501 | matchedAmount := bookOrder.Amount 502 | 503 | matchedItem := &MatchItem{ 504 | MatchedAmount: matchedAmount, 505 | MakerOrder: bookOrder, 506 | } 507 | 508 | matchedResult = append(matchedResult, matchedItem) 509 | totalMatchedAmount = totalMatchedAmount.Add(matchedAmount) 510 | leftAmount = leftAmount.Sub(matchedAmount) 511 | } else { 512 | eatAmount := leftAmount 513 | matchedItem := &MatchItem{ 514 | MatchedAmount: eatAmount, 515 | MakerOrder: bookOrder, 516 | } 517 | 518 | matchedResult = append(matchedResult, matchedItem) 519 | totalMatchedAmount = totalMatchedAmount.Add(eatAmount) 520 | leftAmount = decimal.Zero 521 | } 522 | } 523 | 524 | return leftAmount.GreaterThan(decimal.Zero) 525 | } 526 | 527 | marketOrderIterator := func(i llrb.Item) bool { 528 | pl := i.(*priceLevel) 529 | 530 | iter := pl.orderMap.IterFunc() 531 | for kv, ok := iter(); ok; kv, ok = iter() { 532 | // break when no leftAmount 533 | if leftAmount.LessThanOrEqual(decimal.Zero) { 534 | return false 535 | } 536 | 537 | // for marketOrder with price limit 538 | if takerOrder.Price.GreaterThan(decimal.Zero) { 539 | if takerOrder.Side == "buy" && pl.price.GreaterThan(takerOrder.Price) { 540 | utils.Infof("market buy exit early for price bound: %s", takerOrder.Price) 541 | 542 | return false 543 | } else if takerOrder.Side == "sell" && pl.price.LessThan(takerOrder.Price) { 544 | utils.Infof("market sell exit early for price bound: %s", takerOrder.Price) 545 | 546 | return false 547 | } 548 | } 549 | 550 | memoryOrder := kv.Value.(*MemoryOrder) 551 | 552 | matchedItem := &MatchItem{ 553 | MakerOrder: memoryOrder, 554 | } 555 | 556 | // for market order buy, leftAmount is quoteCurrencyAmount 557 | if takerOrder.Side == "buy" { 558 | //price = wethAmt / hotAmt 559 | makerQuoteCurrencyAmt := memoryOrder.Amount.Mul(memoryOrder.Price) 560 | 561 | if leftAmount.GreaterThanOrEqual(makerQuoteCurrencyAmt) { 562 | //can take this whole maker order 563 | matchedItem.MatchedAmount = memoryOrder.Amount 564 | leftAmount = leftAmount.Sub(makerQuoteCurrencyAmt) 565 | } else { 566 | // can take part of this order, round down with marketAmountDecimals 567 | eatBaseCurrencyAmt := leftAmount.DivRound(memoryOrder.Price, int32(marketAmountDecimals)+1).Truncate(int32(marketAmountDecimals)) 568 | 569 | matchedItem.MatchedAmount = eatBaseCurrencyAmt 570 | leftAmount = decimal.Zero 571 | } 572 | } else { 573 | // for sell, leftAmount is baseCurrencyAmount 574 | if leftAmount.GreaterThanOrEqual(memoryOrder.Amount) { 575 | matchedItem.MatchedAmount = memoryOrder.Amount 576 | leftAmount = leftAmount.Sub(memoryOrder.Amount) 577 | } else { 578 | matchedItem.MatchedAmount = leftAmount 579 | leftAmount = decimal.Zero 580 | } 581 | } 582 | 583 | matchedResult = append(matchedResult, matchedItem) 584 | 585 | utils.Infof("matchedItem.MatchedAmount: %s", matchedItem.MatchedAmount) 586 | totalMatchedAmount = totalMatchedAmount.Add(matchedItem.MatchedAmount) 587 | } 588 | 589 | return leftAmount.GreaterThan(decimal.Zero) 590 | } 591 | 592 | // decide iterator 593 | var iterator llrb.ItemIterator 594 | if takerOrder.Type == "market" { 595 | iterator = marketOrderIterator 596 | } else { 597 | iterator = limitOrderIterator 598 | } 599 | 600 | if takerOrder.Side == "sell" { 601 | book.bidsTree.DescendLessOrEqual(newPriceLevel(decimal.New(1, 99)), iterator) 602 | } else { 603 | book.asksTree.AscendGreaterOrEqual(newPriceLevel(decimal.Zero), iterator) 604 | } 605 | 606 | return &MatchResult{ 607 | MatchItems: matchedResult, 608 | TakerOrder: takerOrder, 609 | } 610 | } 611 | 612 | func (book *Orderbook) ExecuteMatch(takerOrder *MemoryOrder, marketAmountDecimals int) *MatchResult { 613 | result := book.MatchOrder(takerOrder, marketAmountDecimals) 614 | 615 | cancelSmallMatchesIfExist(result) 616 | 617 | for _, item := range result.MatchItems { 618 | var e *OrderbookEvent 619 | 620 | // after match, gasFee is paid 621 | if !item.MatchShouldBeCanceled && item.MatchedAmount.IsPositive() { 622 | item.MakerOrder.GasFeeAmount = decimal.Zero 623 | } 624 | 625 | if makerOrderShouldBeRemovedAfterMatch(takerOrder.GasFeeAmount, takerOrder.TakerFeeRate, item) { 626 | e = book.RemoveOrder(item.MakerOrder) 627 | item.MakerOrder.Amount = decimal.Zero 628 | 629 | item.MakerOrderIsDone = true 630 | } else { 631 | changeAmt := item.MatchedAmount 632 | 633 | e = book.ChangeOrder(item.MakerOrder, changeAmt.Mul(decimal.New(-1, 0))) 634 | item.MakerOrder.Amount = item.MakerOrder.Amount.Sub(changeAmt) 635 | } 636 | 637 | msg := OrderBookChangeMessage(book.market, book.Sequence, e.Side, e.Price, e.Amount) 638 | result.OrderBookActivities = append(result.OrderBookActivities, msg) 639 | } 640 | 641 | return result 642 | } 643 | 644 | // when makerOrder is sell 645 | // one cases when maker order should be removed 646 | // 1. all matched - no remaining amount left 647 | // 648 | // when makerOrder is buy 649 | // two cases when maker order should be removed 650 | // 1. all matched - no remaining amount left 651 | // 2. remaining amount too small 652 | func makerOrderShouldBeRemovedAfterMatch(assumedTakerOrderGasFee, assumedTakerOrderFeeRate decimal.Decimal, item *MatchItem) bool { 653 | remainingAmtInQuote := item.MakerOrder.Amount.Sub(item.MatchedAmount).Mul(item.MakerOrder.Price) 654 | 655 | return orderShouldBeRemoved(assumedTakerOrderGasFee, assumedTakerOrderFeeRate, remainingAmtInQuote, item.MakerOrder.Side) 656 | } 657 | 658 | func TakerOrderShouldBeRemoved(taker *MemoryOrder) bool { 659 | remainingAmtInQuote := taker.Amount.Mul(taker.Price) 660 | 661 | return orderShouldBeRemoved(taker.GasFeeAmount, taker.TakerFeeRate, remainingAmtInQuote, taker.Side) 662 | } 663 | 664 | // whether order should be removed depends on it's taker order, 665 | // so we need assumedTakerGasFee & assumedTakerFeeRate 666 | func orderShouldBeRemoved(assumedTakerGasFee, assumedTakerFeeRate, remainingAmtInQuote decimal.Decimal, orderSide string) bool { 667 | if orderSide == "sell" { 668 | return remainingAmtInQuote.LessThanOrEqual(decimal.Zero) 669 | } else { 670 | // take away taker's gas & tradeFee 671 | subtractAmtFromTaker := assumedTakerGasFee.Add(remainingAmtInQuote.Mul(assumedTakerFeeRate)) 672 | 673 | return remainingAmtInQuote.LessThanOrEqual(decimal.Zero) || remainingAmtInQuote.Sub(subtractAmtFromTaker).IsNegative() 674 | } 675 | } 676 | 677 | // small matches should be canceled to avoid transaction revert 678 | func cancelSmallMatchesIfExist(matchResult *MatchResult) (canceledAmtSum decimal.Decimal) { 679 | 680 | if matchResult.TakerOrder.Side == "buy" { 681 | // taker buy, every match > gas + tradeFee 682 | for _, matchItem := range matchResult.MatchItems { 683 | tradeAmt := matchItem.MatchedAmount.Mul(matchItem.MakerOrder.Price) 684 | // subtract taker's gas & tradeFee 685 | subtractAmt := matchItem.MakerOrder.GasFeeAmount.Add(matchItem.MakerOrder.MakerFeeRate.Mul(tradeAmt)) 686 | 687 | if tradeAmt.LessThan(subtractAmt) { 688 | amt := cancelMatch(matchItem) 689 | canceledAmtSum = canceledAmtSum.Add(amt) 690 | } 691 | } 692 | } else { 693 | tradeAmtInQuoteToken := matchResult.QuoteTokenTotalMatchedAmt() 694 | // subtract taker's gas & tradeFee 695 | subtractAmt := matchResult.TakerOrder.GasFeeAmount.Add(matchResult.TakerTradeFeeInQuoteToken()) 696 | 697 | if tradeAmtInQuoteToken.LessThan(subtractAmt) { 698 | canceledAmtSum = cancelAllMatches(matchResult) 699 | } 700 | } 701 | 702 | return 703 | } 704 | 705 | func cancelMatch(match *MatchItem) (cancelAmt decimal.Decimal) { 706 | match.MatchShouldBeCanceled = true 707 | 708 | return match.MatchedAmount 709 | } 710 | 711 | func cancelAllMatches(match *MatchResult) (sum decimal.Decimal) { 712 | for _, item := range match.MatchItems { 713 | amt := cancelMatch(item) 714 | 715 | sum = sum.Add(amt) 716 | } 717 | 718 | return sum 719 | } 720 | -------------------------------------------------------------------------------- /common/orderbook_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "fmt" 5 | "github.com/shopspring/decimal" 6 | "github.com/stretchr/testify/suite" 7 | "testing" 8 | ) 9 | 10 | type orderbookTestSuite struct { 11 | suite.Suite 12 | book *Orderbook 13 | } 14 | 15 | func (s *orderbookTestSuite) SetupSuite() { 16 | } 17 | 18 | func (s *orderbookTestSuite) SetupTest() { 19 | s.book = NewOrderbook("test") 20 | } 21 | 22 | func (s *orderbookTestSuite) TearDownTest() { 23 | } 24 | 25 | func (s *orderbookTestSuite) TearDownSuite() { 26 | } 27 | 28 | func (s *orderbookTestSuite) TestSnapshot() { 29 | s.book.InsertOrder(NewLimitOrder("o1", "buy", "1.2", "3.4")) 30 | s.book.InsertOrder(NewLimitOrder("o2", "buy", "1.3", "3.4")) 31 | s.book.InsertOrder(NewLimitOrder("o3", "sell", "1.4", "3.4")) 32 | s.book.InsertOrder(NewLimitOrder("o4", "sell", "1.5", "3.4")) 33 | 34 | s.Equal(&SnapshotV2{ 35 | Bids: [][2]string{{"1.3", "3.4"}, {"1.2", "3.4"}}, 36 | Asks: [][2]string{{"1.4", "3.4"}, {"1.5", "3.4"}}, 37 | }, s.book.SnapshotV2()) 38 | } 39 | 40 | func (s *orderbookTestSuite) TestNewOrderbok() { 41 | s.Equal(0, s.book.bidsTree.Len()) 42 | s.Equal(0, s.book.asksTree.Len()) 43 | s.Nil(s.book.MaxBid()) 44 | s.Nil(s.book.MinAsk()) 45 | } 46 | 47 | func (s *orderbookTestSuite) TestInsertAndRemoveOrder() { 48 | s.book.InsertOrder(NewLimitOrder("o1", "buy", "1.2", "1")) 49 | s.book.InsertOrder(NewLimitOrder("o2", "buy", "1.2", "2")) 50 | s.book.InsertOrder(NewLimitOrder("o3", "sell", "1.8", "4")) 51 | 52 | s.Equal(1, s.book.bidsTree.Len()) 53 | s.Equal(1, s.book.asksTree.Len()) 54 | 55 | maxBidPriceLevel := s.book.bidsTree.Max().(*priceLevel) 56 | s.Equal(2, maxBidPriceLevel.Len()) 57 | s.Equal("3", maxBidPriceLevel.totalAmount.String()) 58 | 59 | s.book.RemoveOrder(NewLimitOrder("o1", "buy", "1.2", "1")) 60 | s.Equal(1, maxBidPriceLevel.Len()) 61 | s.Equal("2", maxBidPriceLevel.totalAmount.String()) 62 | } 63 | 64 | func (s *orderbookTestSuite) TestInsertAndChangeOrder() { 65 | s.book.InsertOrder(NewLimitOrder("o1", "buy", "1.2", "1")) 66 | s.book.InsertOrder(NewLimitOrder("o2", "buy", "1.2", "2")) 67 | s.book.InsertOrder(NewLimitOrder("o3", "sell", "1.8", "4")) 68 | 69 | s.Equal(1, s.book.bidsTree.Len()) 70 | s.Equal(1, s.book.asksTree.Len()) 71 | 72 | maxBidPriceLevel := s.book.bidsTree.Max().(*priceLevel) 73 | s.Equal(2, maxBidPriceLevel.Len()) 74 | s.Equal("3", maxBidPriceLevel.totalAmount.String()) 75 | 76 | s.book.ChangeOrder(NewLimitOrder("o1", "buy", "1.2", "1"), decimal.NewFromFloat(0.9)) 77 | s.Equal(2, maxBidPriceLevel.Len()) 78 | s.Equal("3.9", maxBidPriceLevel.totalAmount.String()) 79 | } 80 | 81 | var amtDecimals = 3 82 | 83 | func (s *orderbookTestSuite) TestMatch() { 84 | s.book.InsertOrder(NewLimitOrder("o1", "buy", "1.2", "1")) 85 | s.book.InsertOrder(NewLimitOrder("o2", "buy", "1.2", "2")) 86 | s.book.InsertOrder(NewLimitOrder("o3", "buy", "1.3", "2")) 87 | s.book.InsertOrder(NewLimitOrder("o4", "buy", "1.5", "2")) 88 | 89 | // no match 90 | result := s.book.MatchOrder(NewLimitOrder("o5", "sell", "2", "2"), amtDecimals) 91 | //s.True(result.NoMatch) 92 | //s.False(result.FullMatch) 93 | s.Equal("0", result.QuoteTokenTotalMatchedAmt().String()) 94 | s.Equal(0, len(result.MatchItems)) 95 | } 96 | 97 | func (s *orderbookTestSuite) TestMatch2() { 98 | s.book.InsertOrder(NewLimitOrder("o1", "buy", "1.2", "1")) 99 | s.book.InsertOrder(NewLimitOrder("o2", "buy", "1.2", "2")) 100 | s.book.InsertOrder(NewLimitOrder("o3", "buy", "1.3", "2")) 101 | s.book.InsertOrder(NewLimitOrder("o4", "buy", "1.5", "2")) 102 | 103 | // full match 104 | result := s.book.MatchOrder(NewLimitOrder("o5", "sell", "1.5", "2"), amtDecimals) 105 | //s.False(result.NoMatch) 106 | //s.True(result.FullMatch) 107 | s.Equal("2", result.BaseTokenTotalMatchedAmtWithoutCanceledMatch().String()) 108 | s.Equal(1, len(result.MatchItems)) 109 | s.Equal("2", result.MatchItems[0].MatchedAmount.String()) 110 | s.Equal("o4", result.MatchItems[0].MakerOrder.ID) 111 | } 112 | 113 | func (s *orderbookTestSuite) TestMatch3() { 114 | s.book.InsertOrder(NewLimitOrder("o1", "buy", "1.2", "1")) 115 | s.book.InsertOrder(NewLimitOrder("o2", "buy", "1.2", "2")) 116 | s.book.InsertOrder(NewLimitOrder("o3", "buy", "1.3", "2")) 117 | s.book.InsertOrder(NewLimitOrder("o4", "buy", "1.5", "2")) 118 | 119 | // partial match 120 | result := s.book.MatchOrder(NewLimitOrder("o5", "sell", "1.5", "3"), amtDecimals) 121 | //s.False(result.NoMatch) 122 | //s.False(result.FullMatch) 123 | s.Equal("2", result.BaseTokenTotalMatchedAmtWithoutCanceledMatch().String()) 124 | s.Equal(1, len(result.MatchItems)) 125 | s.Equal("2", result.MatchItems[0].MatchedAmount.String()) 126 | s.Equal("o4", result.MatchItems[0].MakerOrder.ID) 127 | } 128 | 129 | func (s *orderbookTestSuite) TestMatch4() { 130 | s.book.InsertOrder(NewLimitOrder("o1", "buy", "1.2", "1")) 131 | s.book.InsertOrder(NewLimitOrder("o2", "buy", "1.2", "2")) 132 | s.book.InsertOrder(NewLimitOrder("o3", "buy", "1.3", "2")) 133 | s.book.InsertOrder(NewLimitOrder("o4", "buy", "1.5", "2")) 134 | 135 | // multi price full match 136 | result := s.book.MatchOrder(NewLimitOrder("o5", "sell", "1.2", "5"), amtDecimals) 137 | //s.False(result.NoMatch) 138 | //s.True(result.FullMatch) 139 | s.Equal("5", result.BaseTokenTotalMatchedAmtWithoutCanceledMatch().String()) 140 | s.Equal(3, len(result.MatchItems)) 141 | s.Equal("2", result.MatchItems[0].MatchedAmount.String()) 142 | s.Equal("o4", result.MatchItems[0].MakerOrder.ID) 143 | s.Equal("2", result.MatchItems[1].MatchedAmount.String()) 144 | s.Equal("o3", result.MatchItems[1].MakerOrder.ID) 145 | s.Equal("1", result.MatchItems[2].MatchedAmount.String()) 146 | s.Equal("o1", result.MatchItems[2].MakerOrder.ID) 147 | 148 | } 149 | 150 | func (s *orderbookTestSuite) TestMatch5() { 151 | s.book.InsertOrder(NewLimitOrder("o1", "buy", "1.2", "1")) 152 | s.book.InsertOrder(NewLimitOrder("o2", "buy", "1.2", "2")) 153 | s.book.InsertOrder(NewLimitOrder("o3", "buy", "1.3", "2")) 154 | s.book.InsertOrder(NewLimitOrder("o4", "buy", "1.5", "2")) 155 | 156 | // multi price partial match 157 | result := s.book.MatchOrder(NewLimitOrder("o5", "sell", "1.2", "8"), amtDecimals) 158 | //s.False(result.NoMatch) 159 | //s.False(result.FullMatch) 160 | s.Equal("7", result.BaseTokenTotalMatchedAmtWithoutCanceledMatch().String()) 161 | s.Equal(4, len(result.MatchItems)) 162 | s.Equal("2", result.MatchItems[0].MatchedAmount.String()) 163 | s.Equal("o4", result.MatchItems[0].MakerOrder.ID) 164 | s.Equal("2", result.MatchItems[1].MatchedAmount.String()) 165 | s.Equal("o3", result.MatchItems[1].MakerOrder.ID) 166 | s.Equal("1", result.MatchItems[2].MatchedAmount.String()) 167 | s.Equal("o1", result.MatchItems[2].MakerOrder.ID) 168 | s.Equal("2", result.MatchItems[3].MatchedAmount.String()) 169 | s.Equal("o2", result.MatchItems[3].MakerOrder.ID) 170 | } 171 | 172 | func (s *orderbookTestSuite) TestCanBeMatched() { 173 | s.book.InsertOrder(NewLimitOrder("o1", "buy", "1.2", "3.4")) 174 | s.book.InsertOrder(NewLimitOrder("o2", "buy", "1.3", "3.4")) 175 | s.book.InsertOrder(NewLimitOrder("o3", "sell", "1.4", "3.4")) 176 | s.book.InsertOrder(NewLimitOrder("o4", "sell", "1.5", "3.4")) 177 | 178 | canBeMatched0 := s.book.CanMatch(NewLimitOrder("1", "buy", "1.1", "1")) 179 | canBeMatched1 := s.book.CanMatch(NewLimitOrder("1", "sell", "1.3", "1")) 180 | canBeMatched2 := s.book.CanMatch(NewLimitOrder("1", "sell", "1.4", "1")) 181 | canBeMatched3 := s.book.CanMatch(NewLimitOrder("1", "buy", "1.6", "1")) 182 | canBeMatched4 := s.book.CanMatch(NewLimitOrder("1", "buy", "1.1", "1")) 183 | 184 | s.Equal(canBeMatched0, false) 185 | s.Equal(canBeMatched1, true) 186 | s.Equal(canBeMatched2, false) 187 | s.Equal(canBeMatched3, true) 188 | s.Equal(canBeMatched4, false) 189 | } 190 | 191 | func TestOrderbookTestSuite(t *testing.T) { 192 | suite.Run(t, new(orderbookTestSuite)) 193 | } 194 | 195 | type orderTestSuite struct { 196 | suite.Suite 197 | } 198 | 199 | func (s *orderTestSuite) SetupSuite() { 200 | } 201 | 202 | func (s *orderTestSuite) TearDownSuite() { 203 | } 204 | 205 | func (s *orderTestSuite) TearDownTest() { 206 | } 207 | 208 | func (s *orderTestSuite) TestNewOrder() { 209 | s.Panics(func() { NewLimitOrder("", "buy", "1", "2") }) // no id 210 | s.Panics(func() { NewLimitOrder("123", "hehe", "1", "2") }) // wrong type 211 | s.Panics(func() { NewLimitOrder("123", "buy", "a", "2") }) // wrong price 212 | s.Panics(func() { NewLimitOrder("123", "buy", "1", "b") }) // wrong Amount 213 | s.NotPanics(func() { NewLimitOrder("123", "buy", "1", "2") }) 214 | s.NotPanics(func() { NewLimitOrder("123", "sell", "1", "2") }) 215 | s.NotPanics(func() { NewLimitOrder("123", "sell", "1.121423", "0.1241242") }) 216 | } 217 | 218 | func TestOrderTestSuite(t *testing.T) { 219 | suite.Run(t, new(orderTestSuite)) 220 | } 221 | 222 | // NewLimitOrder ... 223 | func NewLimitOrder(id string, side string, price string, amount string) *MemoryOrder { 224 | return NewOrder(id, side, price, amount, "limit") 225 | } 226 | 227 | func NewOrder(id, side, price, amount, _type string) *MemoryOrder { 228 | if len(id) <= 0 { 229 | panic(fmt.Errorf("ID can't be blank")) 230 | } 231 | 232 | amountDecimal, err := decimal.NewFromString(amount) 233 | 234 | if side != "buy" && side != "sell" { 235 | panic(fmt.Errorf("side should be buy/sell. passed: %s", side)) 236 | } 237 | 238 | if err != nil { 239 | panic(fmt.Errorf("amount decimal error, Amount: %s, error: %+v", amount, err)) 240 | } 241 | 242 | priceDecimal, err := decimal.NewFromString(price) 243 | if err != nil { 244 | panic(fmt.Errorf("price decimal error, Price: %s, error: %+v", price, err)) 245 | } 246 | 247 | return &MemoryOrder{ 248 | ID: id, 249 | Side: side, 250 | Price: priceDecimal, 251 | 252 | Amount: amountDecimal, 253 | Type: _type, 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /common/queue.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/go-redis/redis" 8 | "time" 9 | ) 10 | 11 | // Iqueue is an interface of common queue service 12 | // You can use your favourite backend to handle messages. 13 | type IQueue interface { 14 | Push([]byte) error 15 | 16 | // Pop should not block the current thread all the time. 17 | Pop() ([]byte, error) 18 | } 19 | 20 | func InitQueue(config interface{}) (queue IQueue, err error) { 21 | switch c := config.(type) { 22 | case nil: 23 | return nil, fmt.Errorf("Need Config to init queue") 24 | case *RedisQueueConfig: 25 | client := &RedisQueue{} 26 | err = client.Init(c) 27 | 28 | if err != nil { 29 | return 30 | } 31 | return client, nil 32 | default: 33 | return nil, fmt.Errorf("Config is not support %v", config) 34 | } 35 | } 36 | 37 | // Redis Queue Implement 38 | 39 | var EXIT = errors.New("EXIT") 40 | 41 | type ( 42 | RedisQueue struct { 43 | name string 44 | ctx context.Context 45 | client *redis.Client 46 | } 47 | 48 | RedisQueueConfig struct { 49 | Name string 50 | Ctx context.Context 51 | Client *redis.Client 52 | } 53 | ) 54 | 55 | func (queue *RedisQueue) Push(data []byte) error { 56 | ret := queue.client.LPush(queue.name, data) 57 | return ret.Err() 58 | } 59 | 60 | func (queue *RedisQueue) Pop() ([]byte, error) { 61 | for { 62 | select { 63 | case <-queue.ctx.Done(): 64 | return nil, EXIT 65 | default: 66 | res, err := queue.client.BRPop(time.Second, queue.name).Result() 67 | 68 | if err == redis.Nil { 69 | continue 70 | } else if err != nil { 71 | return nil, err 72 | } 73 | 74 | return []byte(res[1]), err 75 | } 76 | } 77 | } 78 | 79 | func (queue *RedisQueue) Init(config *RedisQueueConfig) error { 80 | if config.Client == nil { 81 | return fmt.Errorf("No redis Connection") 82 | } 83 | 84 | queue.client = config.Client 85 | queue.ctx = config.Ctx 86 | queue.name = config.Name 87 | 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /common/queue_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "github.com/stretchr/testify/suite" 5 | "testing" 6 | ) 7 | 8 | type redisQueueTest struct { 9 | suite.Suite 10 | } 11 | 12 | func (s *redisQueueTest) SetupSuite() { 13 | } 14 | 15 | func (s *redisQueueTest) SetupTest() { 16 | } 17 | 18 | func (s *redisQueueTest) TearDownTest() { 19 | } 20 | 21 | func (s *redisQueueTest) TearDownSuite() { 22 | } 23 | 24 | func TestRedisQueue(t *testing.T) { 25 | suite.Run(t, new(redisQueueTest)) 26 | } 27 | -------------------------------------------------------------------------------- /common/test_helper.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "github.com/stretchr/testify/mock" 5 | "time" 6 | ) 7 | 8 | type MockKVStore struct { 9 | mock.Mock 10 | } 11 | 12 | func (m *MockKVStore) Set(key string, value string, expire time.Duration) error { 13 | args := m.Called(key, value, expire) 14 | return args.Error(0) 15 | } 16 | 17 | func (m *MockKVStore) Get(key string) (string, error) { 18 | args := m.Called(key) 19 | return args.String(0), args.Error(1) 20 | } 21 | 22 | type MockQueue struct { 23 | mock.Mock 24 | Buffers [][]byte 25 | } 26 | 27 | func (m *MockQueue) Push(bts []byte) error { 28 | args := m.Called(bts) 29 | 30 | if m.Buffers == nil { 31 | m.ResetBuffer() 32 | } 33 | 34 | m.Buffers = append(m.Buffers, bts) 35 | return args.Error(0) 36 | } 37 | 38 | func (m *MockQueue) Pop() ([]byte, error) { 39 | args := m.Called() 40 | return args.Get(0).([]byte), args.Error(1) 41 | } 42 | 43 | func (m *MockQueue) ResetBuffer() { 44 | m.Buffers = make([][]byte, 0, 0) 45 | } 46 | -------------------------------------------------------------------------------- /engine/engine.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "context" 5 | "github.com/HydroProtocol/hydro-sdk-backend/common" 6 | "sync" 7 | ) 8 | 9 | type Engine struct { 10 | marketHandlerMap map[string]*MarketHandler 11 | 12 | // Wait for all queue handler exit gracefully 13 | Wg sync.WaitGroup 14 | 15 | // global ctx, if this ctx is canceled, queue handlers should exit in a short time. 16 | ctx context.Context 17 | 18 | dbHandler *DBHandler 19 | orderBookSnapshotHandler *OrderBookSnapshotHandler 20 | orderBookActivitiesHandler *OrderBookActivitiesHandler 21 | 22 | lock sync.Mutex 23 | } 24 | 25 | func NewEngine(ctx context.Context) *Engine { 26 | engine := &Engine{ 27 | ctx: ctx, 28 | marketHandlerMap: make(map[string]*MarketHandler), 29 | Wg: sync.WaitGroup{}, 30 | } 31 | 32 | return engine 33 | } 34 | 35 | func (e *Engine) RegisterDBHandler(handler DBHandler) { 36 | e.dbHandler = &handler 37 | } 38 | func (e *Engine) RegisterOrderBookSnapshotHandler(handler OrderBookSnapshotHandler) { 39 | e.orderBookSnapshotHandler = &handler 40 | } 41 | func (e *Engine) RegisterOrderBookActivitiesHandler(handler OrderBookActivitiesHandler) { 42 | e.orderBookActivitiesHandler = &handler 43 | } 44 | 45 | type DBHandler interface { 46 | Update(matchResult common.MatchResult) sync.WaitGroup 47 | } 48 | type OrderBookSnapshotHandler interface { 49 | Update(key string, snapshot *common.SnapshotV2) sync.WaitGroup 50 | } 51 | type OrderBookActivitiesHandler interface { 52 | Update(webSocketMessages []common.WebSocketMessage) sync.WaitGroup 53 | } 54 | 55 | func (e *Engine) HandleNewOrder(order *common.MemoryOrder) (matchResult common.MatchResult, hasMatch bool) { 56 | e.lock.Lock() 57 | defer e.lock.Unlock() 58 | 59 | // find or create marketHandler if not exist yet 60 | if _, exist := e.marketHandlerMap[order.MarketID]; !exist { 61 | marketHandler, err := NewMarketHandler(e.ctx, order.MarketID) 62 | if err != nil { 63 | panic(err) 64 | } 65 | 66 | e.marketHandlerMap[order.MarketID] = marketHandler 67 | } 68 | 69 | // feed the handler with this new order 70 | handler, _ := e.marketHandlerMap[order.MarketID] 71 | matchResult, hasMatch = handler.handleNewOrder(order) 72 | 73 | e.triggerDBHandlerIfNotNil(matchResult) 74 | e.triggerOrderBookSnapshotHandlerIfNotNil(handler) 75 | e.triggerOrderBookActivityHandlerIfNotNil(matchResult.OrderBookActivities) 76 | 77 | return 78 | } 79 | 80 | func (e *Engine) ReInsertOrder(order *common.MemoryOrder) (msg *common.WebSocketMessage) { 81 | e.lock.Lock() 82 | defer e.lock.Unlock() 83 | 84 | if _, exist := e.marketHandlerMap[order.MarketID]; !exist { 85 | marketHandler, err := NewMarketHandler(e.ctx, order.MarketID) 86 | if err != nil { 87 | panic(err) 88 | } 89 | 90 | e.marketHandlerMap[order.MarketID] = marketHandler 91 | } 92 | 93 | handler, _ := e.marketHandlerMap[order.MarketID] 94 | event := handler.orderbook.InsertOrder(order) 95 | 96 | e.triggerOrderBookSnapshotHandlerIfNotNil(handler) 97 | 98 | if event == nil { 99 | return 100 | } else { 101 | msg := common.OrderBookChangeMessage(handler.market, handler.orderbook.Sequence, event.Side, event.Price, event.Amount) 102 | return &msg 103 | } 104 | } 105 | 106 | func (e *Engine) HandleCancelOrder(order *common.MemoryOrder) (msg *common.WebSocketMessage, success bool) { 107 | e.lock.Lock() 108 | defer e.lock.Unlock() 109 | 110 | handler, _ := e.marketHandlerMap[order.MarketID] 111 | 112 | event := handler.handleCancelOrder(order) 113 | if event == nil { 114 | return 115 | } else { 116 | e.triggerOrderBookSnapshotHandlerIfNotNil(handler) 117 | 118 | msg := common.OrderBookChangeMessage(handler.market, handler.orderbook.Sequence, event.Side, event.Price, event.Amount) 119 | return &msg, true 120 | } 121 | } 122 | 123 | func (e *Engine) triggerDBHandlerIfNotNil(matchResult common.MatchResult) { 124 | if e.dbHandler != nil { 125 | (*e.dbHandler).Update(matchResult) 126 | } 127 | } 128 | 129 | func (e *Engine) triggerOrderBookSnapshotHandlerIfNotNil(handler *MarketHandler) { 130 | if e.orderBookSnapshotHandler != nil { 131 | snapshot := handler.orderbook.SnapshotV2() 132 | snapshot.Sequence = handler.orderbook.Sequence 133 | 134 | snapshotKey := common.GetMarketOrderbookSnapshotV2Key(handler.market) 135 | 136 | (*e.orderBookSnapshotHandler).Update(snapshotKey, snapshot) 137 | } 138 | } 139 | 140 | func (e *Engine) triggerOrderBookActivityHandlerIfNotNil(msgs []common.WebSocketMessage) { 141 | if e.orderBookActivitiesHandler != nil { 142 | (*e.orderBookActivitiesHandler).Update(msgs) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /engine/engine_test.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "context" 5 | "github.com/HydroProtocol/hydro-sdk-backend/common" 6 | "github.com/labstack/gommon/log" 7 | "github.com/shopspring/decimal" 8 | "github.com/stretchr/testify/suite" 9 | "sync" 10 | "testing" 11 | ) 12 | 13 | type engineTestSuite struct { 14 | suite.Suite 15 | } 16 | 17 | func TestEngineTestSuite(t *testing.T) { 18 | suite.Run(t, new(engineTestSuite)) 19 | } 20 | 21 | func (s *engineTestSuite) TestNewEngine() { 22 | e := NewEngine(context.Background()) 23 | 24 | order := common.MemoryOrder{ 25 | ID: "fake-id", 26 | MarketID: "HOT-WETH", 27 | Price: decimal.NewFromFloat(1.0), 28 | Amount: decimal.NewFromFloat(100.0), 29 | Side: "sell", 30 | Type: "limit", 31 | } 32 | 33 | matchRst, hasMatch := e.HandleNewOrder(&order) 34 | 35 | s.False(hasMatch, "should have no match") 36 | s.True(len(matchRst.MatchItems) == 0, "should have no match") 37 | } 38 | 39 | func (s *engineTestSuite) TestNewEngineHandleOrders() { 40 | e := NewEngine(context.Background()) 41 | 42 | orderSell := common.MemoryOrder{ 43 | ID: "fake-id1", 44 | MarketID: "HOT-WETH", 45 | Price: decimal.NewFromFloat(1.0), 46 | Amount: decimal.NewFromFloat(100.0), 47 | Side: "sell", 48 | Type: "limit", 49 | } 50 | orderBuy := common.MemoryOrder{ 51 | ID: "fake-id2", 52 | MarketID: "HOT-WETH", 53 | Price: decimal.NewFromFloat(1.0), 54 | Amount: decimal.NewFromFloat(100.0), 55 | Side: "buy", 56 | Type: "limit", 57 | } 58 | 59 | matchRst, hasMatch := e.HandleNewOrder(&orderSell) 60 | matchRst2, hasMatch2 := e.HandleNewOrder(&orderBuy) 61 | 62 | s.False(hasMatch, "should have no match") 63 | s.Equal(0, len(matchRst.MatchItems), "should have no match") 64 | 65 | s.True(hasMatch2, "should have match") 66 | s.True(len(matchRst2.MatchItems) > 0, "should have match") 67 | 68 | s.True(matchRst2.TakerOrderLeftAmount.IsZero()) 69 | s.Equal(1, len(matchRst2.MatchItems)) 70 | 71 | matchItem := matchRst2.MatchItems[0] 72 | s.Equal("fake-id1", matchItem.MakerOrder.ID) 73 | s.True(matchItem.MatchedAmount.Equal(decimal.NewFromFloat(100))) 74 | } 75 | 76 | func (s *engineTestSuite) TestNewEngineHandleOrders2() { 77 | e := NewEngine(context.Background()) 78 | 79 | orderSell := common.MemoryOrder{ 80 | ID: "fake-id1", 81 | MarketID: "HOT-WETH", 82 | Price: decimal.NewFromFloat(1.0), 83 | Amount: decimal.NewFromFloat(101.0), 84 | Side: "sell", 85 | Type: "limit", 86 | } 87 | orderBuy := common.MemoryOrder{ 88 | ID: "fake-id2", 89 | MarketID: "HOT-WETH", 90 | Price: decimal.NewFromFloat(1.0), 91 | Amount: decimal.NewFromFloat(100.0), 92 | Side: "buy", 93 | Type: "limit", 94 | } 95 | 96 | matchRst, hasMatch := e.HandleNewOrder(&orderSell) 97 | matchRst2, hasMatch2 := e.HandleNewOrder(&orderBuy) 98 | 99 | s.False(hasMatch, "should have no match") 100 | s.Equal(0, len(matchRst.MatchItems), "should have no match") 101 | 102 | s.True(hasMatch2, "should have match") 103 | s.True(len(matchRst2.MatchItems) > 0, "should have match") 104 | 105 | s.True(matchRst2.TakerOrderLeftAmount.IsZero()) 106 | s.Equal(1, len(matchRst2.MatchItems)) 107 | 108 | matchItem := matchRst2.MatchItems[0] 109 | s.Equal("fake-id1", matchItem.MakerOrder.ID) 110 | s.True(matchItem.MatchedAmount.Equal(decimal.NewFromFloat(100))) 111 | 112 | handler, _ := e.marketHandlerMap["HOT-WETH"] 113 | 114 | sellOrder, _ := handler.orderbook.GetOrder("fake-id1", "sell", decimal.NewFromFloat(1)) 115 | s.True(sellOrder.Amount.Equal(decimal.NewFromFloat(1))) 116 | s.True(sellOrder.GasFeeAmount.Equal(decimal.Zero)) 117 | 118 | s.Nil(handler.orderbook.MaxBid()) 119 | } 120 | 121 | func (s *engineTestSuite) TestHandleOrdersAfterMatchSellOrderCanBeSmall() { 122 | e := NewEngine(context.Background()) 123 | 124 | orderSell := common.MemoryOrder{ 125 | ID: "fake-id1", 126 | MarketID: "HOT-WETH", 127 | Price: decimal.NewFromFloat(1.0), 128 | Amount: decimal.NewFromFloat(100.01), 129 | Side: "sell", 130 | Type: "limit", 131 | GasFeeAmount: decimal.NewFromFloat(0.1), 132 | } 133 | orderBuy := common.MemoryOrder{ 134 | ID: "fake-id2", 135 | MarketID: "HOT-WETH", 136 | Price: decimal.NewFromFloat(1.0), 137 | Amount: decimal.NewFromFloat(100.0), 138 | Side: "buy", 139 | Type: "limit", 140 | GasFeeAmount: decimal.NewFromFloat(0.1), 141 | } 142 | 143 | e.HandleNewOrder(&orderSell) 144 | e.HandleNewOrder(&orderBuy) 145 | 146 | handler, _ := e.marketHandlerMap["HOT-WETH"] 147 | s.NotNil(handler.orderbook.MinAsk()) 148 | 149 | sellOrder, _ := handler.orderbook.GetOrder("fake-id1", "sell", decimal.NewFromFloat(1)) 150 | s.True(sellOrder.Amount.Equal(decimal.NewFromFloat(0.01))) 151 | s.True(sellOrder.GasFeeAmount.Equal(decimal.Zero)) 152 | } 153 | 154 | // a small buy(quoteAmt < taker gas + takerTradeFee) will not be accept 155 | func (s *engineTestSuite) TestSmallBuyBeIgnored() { 156 | e := NewEngine(context.Background()) 157 | 158 | // quoteAmt = 0.1 159 | // gas + tradeFee = 0.1 + 0.1*0.003 (assume taker's gas & fee same as this order) 160 | smallBuy := common.MemoryOrder{ 161 | ID: "fake-id", 162 | MarketID: "HOT-WETH", 163 | Price: decimal.NewFromFloat(0.1), 164 | Amount: decimal.NewFromFloat(1.0), 165 | Side: "buy", 166 | Type: "limit", 167 | GasFeeAmount: decimal.NewFromFloat(0.1), 168 | MakerFeeRate: decimal.NewFromFloat(0.001), 169 | TakerFeeRate: decimal.NewFromFloat(0.003), 170 | } 171 | 172 | e.HandleNewOrder(&smallBuy) 173 | 174 | handler, _ := e.marketHandlerMap["HOT-WETH"] 175 | s.Nil(handler.orderbook.MaxBid()) 176 | } 177 | func (s *engineTestSuite) TestBigBuyWillNotBeIgnored() { 178 | e := NewEngine(context.Background()) 179 | 180 | // quoteAmt = 0.1 181 | // gas + tradeFee = 0.0997 + 0.1*0.003 (assume taker's gas & fee same as this order) 182 | smallBuy := common.MemoryOrder{ 183 | ID: "fake-id", 184 | MarketID: "HOT-WETH", 185 | Price: decimal.NewFromFloat(0.1), 186 | Amount: decimal.NewFromFloat(1.0), 187 | Side: "buy", 188 | Type: "limit", 189 | GasFeeAmount: decimal.NewFromFloat(0.0997), 190 | MakerFeeRate: decimal.NewFromFloat(0.001), 191 | TakerFeeRate: decimal.NewFromFloat(0.003), 192 | } 193 | 194 | e.HandleNewOrder(&smallBuy) 195 | 196 | handler, _ := e.marketHandlerMap["HOT-WETH"] 197 | s.NotNil(handler.orderbook.MaxBid()) 198 | } 199 | 200 | // a small sell (quoteAmt < its gas + its tradeFee) will not be accept 201 | func (s *engineTestSuite) TestSmallSellBeIgnored() { 202 | e := NewEngine(context.Background()) 203 | 204 | // quoteAmt = 0.1 205 | // gas + tradeFee = 0.1 + 0.1*0.003 (assume taker's gas & fee same as this order) 206 | smallSell := common.MemoryOrder{ 207 | ID: "fake-id", 208 | MarketID: "HOT-WETH", 209 | Price: decimal.NewFromFloat(0.1), 210 | Amount: decimal.NewFromFloat(1.0), 211 | Side: "sell", 212 | Type: "limit", 213 | GasFeeAmount: decimal.NewFromFloat(0.1), 214 | MakerFeeRate: decimal.NewFromFloat(0.001), 215 | TakerFeeRate: decimal.NewFromFloat(0.003), 216 | } 217 | 218 | _, hasMatch := e.HandleNewOrder(&smallSell) 219 | s.False(hasMatch) 220 | 221 | handler, _ := e.marketHandlerMap["HOT-WETH"] 222 | s.Nil(handler.orderbook.MaxBid()) 223 | } 224 | 225 | func (s *engineTestSuite) TestBigSellWillNotBeIgnored() { 226 | e := NewEngine(context.Background()) 227 | 228 | // quoteAmt = 0.1 229 | // gas + tradeFee = 0.0997 + 0.1*0.003 (assume taker's gas & fee same as this order) 230 | bigSell := common.MemoryOrder{ 231 | ID: "fake-id", 232 | MarketID: "HOT-WETH", 233 | Price: decimal.NewFromFloat(0.1), 234 | Amount: decimal.NewFromFloat(1.0), 235 | Side: "sell", 236 | Type: "limit", 237 | GasFeeAmount: decimal.NewFromFloat(0.0997), 238 | MakerFeeRate: decimal.NewFromFloat(0.001), 239 | TakerFeeRate: decimal.NewFromFloat(0.003), 240 | } 241 | 242 | _, hasMatch := e.HandleNewOrder(&bigSell) 243 | s.False(hasMatch) 244 | 245 | handler, _ := e.marketHandlerMap["HOT-WETH"] 246 | s.NotNil(handler.orderbook.MinAsk()) 247 | } 248 | 249 | // after match, maker Sell will stay on orderbook however small it is 250 | func (s *engineTestSuite) TestSmallSellCanStayOnBookAfterMatch() { 251 | e := NewEngine(context.Background()) 252 | 253 | orderSell := common.MemoryOrder{ 254 | ID: "fake-id1", 255 | MarketID: "HOT-WETH", 256 | Price: decimal.NewFromFloat(1.0), 257 | Amount: decimal.NewFromFloat(100.01), 258 | Side: "sell", 259 | Type: "limit", 260 | GasFeeAmount: decimal.NewFromFloat(0.1), 261 | MakerFeeRate: decimal.NewFromFloat(0.001), 262 | TakerFeeRate: decimal.NewFromFloat(0.003), 263 | } 264 | orderBuy := common.MemoryOrder{ 265 | ID: "fake-id2", 266 | MarketID: "HOT-WETH", 267 | Price: decimal.NewFromFloat(1.0), 268 | Amount: decimal.NewFromFloat(100.0), 269 | Side: "buy", 270 | Type: "limit", 271 | GasFeeAmount: decimal.NewFromFloat(0.1), 272 | MakerFeeRate: decimal.NewFromFloat(0.001), 273 | TakerFeeRate: decimal.NewFromFloat(0.003), 274 | } 275 | 276 | e.HandleNewOrder(&orderSell) 277 | e.HandleNewOrder(&orderBuy) 278 | 279 | handler, _ := e.marketHandlerMap["HOT-WETH"] 280 | s.NotNil(handler.orderbook.MinAsk()) 281 | 282 | sellOrder, _ := handler.orderbook.GetOrder("fake-id1", "sell", decimal.NewFromFloat(1)) 283 | s.True(sellOrder.Amount.Equal(decimal.NewFromFloat(0.01))) 284 | s.True(sellOrder.GasFeeAmount.Equal(decimal.Zero)) 285 | } 286 | 287 | // after match, maker Buy won't stay on orderbook if its small (quoteAmt < takerGas + takerTradeFee) 288 | func (s *engineTestSuite) TestSmallBuyCanNotStayOnBookAfterMatch() { 289 | e := NewEngine(context.Background()) 290 | 291 | orderBuy := common.MemoryOrder{ 292 | ID: "fake-id2", 293 | MarketID: "HOT-WETH", 294 | Price: decimal.NewFromFloat(1.0), 295 | Amount: decimal.NewFromFloat(100.1), 296 | Side: "buy", 297 | Type: "limit", 298 | GasFeeAmount: decimal.NewFromFloat(0.1), 299 | MakerFeeRate: decimal.NewFromFloat(0.001), 300 | TakerFeeRate: decimal.NewFromFloat(0.003), 301 | } 302 | orderSell := common.MemoryOrder{ 303 | ID: "fake-id1", 304 | MarketID: "HOT-WETH", 305 | Price: decimal.NewFromFloat(1.0), 306 | Amount: decimal.NewFromFloat(100), 307 | Side: "sell", 308 | Type: "limit", 309 | GasFeeAmount: decimal.NewFromFloat(0.1), 310 | MakerFeeRate: decimal.NewFromFloat(0.001), 311 | TakerFeeRate: decimal.NewFromFloat(0.003), 312 | } 313 | 314 | e.HandleNewOrder(&orderBuy) 315 | e.HandleNewOrder(&orderSell) 316 | 317 | handler, _ := e.marketHandlerMap["HOT-WETH"] 318 | s.Nil(handler.orderbook.MaxBid()) 319 | s.Nil(handler.orderbook.MinAsk()) 320 | } 321 | func (s *engineTestSuite) TestBigBuyCanStayOnBookAfterMatch() { 322 | e := NewEngine(context.Background()) 323 | 324 | orderBuy := common.MemoryOrder{ 325 | ID: "fake-id2", 326 | MarketID: "HOT-WETH", 327 | Price: decimal.NewFromFloat(1.0), 328 | Amount: decimal.NewFromFloat(100.1), 329 | Side: "buy", 330 | Type: "limit", 331 | GasFeeAmount: decimal.NewFromFloat(0.1), 332 | MakerFeeRate: decimal.NewFromFloat(0.001), 333 | TakerFeeRate: decimal.NewFromFloat(0.003), 334 | } 335 | orderSell := common.MemoryOrder{ 336 | ID: "fake-id1", 337 | MarketID: "HOT-WETH", 338 | Price: decimal.NewFromFloat(1.0), 339 | Amount: decimal.NewFromFloat(100), 340 | Side: "sell", 341 | Type: "limit", 342 | GasFeeAmount: decimal.NewFromFloat(0.0997), 343 | MakerFeeRate: decimal.NewFromFloat(0.001), 344 | TakerFeeRate: decimal.NewFromFloat(0.003), 345 | } 346 | 347 | // after match 348 | // quote: 0.1*1 349 | // taker gas + tradeFee = 0.0997 + 0.1*0.003 350 | 351 | e.HandleNewOrder(&orderBuy) 352 | e.HandleNewOrder(&orderSell) 353 | 354 | handler, _ := e.marketHandlerMap["HOT-WETH"] 355 | s.NotNil(handler.orderbook.MaxBid()) 356 | s.Nil(handler.orderbook.MinAsk()) 357 | 358 | buyOrder, _ := handler.orderbook.GetOrder("fake-id2", "buy", decimal.NewFromFloat(1)) 359 | s.True(buyOrder.Amount.Equal(decimal.NewFromFloat(0.1))) 360 | s.True(buyOrder.GasFeeAmount.Equal(decimal.Zero)) 361 | } 362 | 363 | func (s *engineTestSuite) TestHandleOrdersKeepBigRemainingOrder() { 364 | e := NewEngine(context.Background()) 365 | 366 | orderSell := common.MemoryOrder{ 367 | ID: "fake-id1", 368 | MarketID: "HOT-WETH", 369 | Price: decimal.NewFromFloat(1.0), 370 | Amount: decimal.NewFromFloat(100.1), 371 | Side: "sell", 372 | Type: "limit", 373 | GasFeeAmount: decimal.NewFromFloat(0.1), 374 | } 375 | orderBuy := common.MemoryOrder{ 376 | ID: "fake-id2", 377 | MarketID: "HOT-WETH", 378 | Price: decimal.NewFromFloat(1.0), 379 | Amount: decimal.NewFromFloat(100.0), 380 | Side: "buy", 381 | Type: "limit", 382 | GasFeeAmount: decimal.NewFromFloat(0.1), 383 | } 384 | 385 | e.HandleNewOrder(&orderSell) 386 | e.HandleNewOrder(&orderBuy) 387 | 388 | handler, _ := e.marketHandlerMap["HOT-WETH"] 389 | s.NotNil(handler.orderbook.MinAsk()) 390 | } 391 | 392 | type FakeDBHandler struct { 393 | } 394 | 395 | func (handler FakeDBHandler) Update(matchRst common.MatchResult) sync.WaitGroup { 396 | log.Info("Update called of fake db handler") 397 | return sync.WaitGroup{} 398 | } 399 | 400 | func (s *engineTestSuite) TestNewEngineWithDBHandler() { 401 | h := FakeDBHandler{} 402 | 403 | e := NewEngine(context.Background()) 404 | e.RegisterDBHandler(h) 405 | 406 | order := common.MemoryOrder{ 407 | ID: "fake-id", 408 | MarketID: "HOT-WETH", 409 | Price: decimal.NewFromFloat(1.0), 410 | Amount: decimal.NewFromFloat(100.0), 411 | Side: "sell", 412 | Type: "limit", 413 | } 414 | 415 | matchRst, hasMatch := e.HandleNewOrder(&order) 416 | 417 | s.False(hasMatch, "should have no match") 418 | s.Equal(0, len(matchRst.MatchItems), "should have no match") 419 | } 420 | -------------------------------------------------------------------------------- /engine/market_handler.go: -------------------------------------------------------------------------------- 1 | package engine 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/HydroProtocol/hydro-sdk-backend/common" 7 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 8 | "github.com/labstack/gommon/log" 9 | "github.com/shopspring/decimal" 10 | ) 11 | 12 | type MarketHandler struct { 13 | ctx context.Context 14 | market string 15 | marketAmountDecimals int 16 | orderbook *common.Orderbook 17 | } 18 | 19 | func (m MarketHandler) handleNewOrder(newOrder *common.MemoryOrder) (matchResult common.MatchResult, hasMatchOrder bool) { 20 | 21 | if m.orderbook.CanMatch(newOrder) { 22 | matchResult = *m.orderbook.ExecuteMatch(newOrder, m.marketAmountDecimals) 23 | 24 | if len(matchResult.MatchItems) == 0 { 25 | log.Errorf("No Match Items, %+v %+v", matchResult, newOrder) 26 | panic(fmt.Errorf("no match items")) 27 | } 28 | 29 | for i := range matchResult.MatchItems { 30 | item := matchResult.MatchItems[i] 31 | 32 | msgs := common.MessagesForUpdateOrder(item.MakerOrder) 33 | matchResult.OrderBookActivities = append(matchResult.OrderBookActivities, msgs...) 34 | 35 | newOrder.Amount = newOrder.Amount.Sub(item.MatchedAmount) 36 | utils.Debugf(" [Take Liquidity] price: %s amount: %s (%s) ", item.MakerOrder.Price.StringFixed(5), item.MatchedAmount.StringFixed(5), item.MakerOrder.ID) 37 | } 38 | 39 | hasMatchOrder = true 40 | } 41 | 42 | msgs := common.MessagesForUpdateOrder(newOrder) 43 | matchResult.OrderBookActivities = append(matchResult.OrderBookActivities, msgs...) 44 | 45 | // check if newOrder can be added to orderbook 46 | if common.TakerOrderShouldBeRemoved(newOrder) { 47 | matchResult.TakerOrderIsDone = true 48 | } else { 49 | // if matched, gasFee is paid 50 | if matchResult.BaseTokenTotalMatchedAmtWithoutCanceledMatch().IsPositive() { 51 | newOrder.GasFeeAmount = decimal.Zero 52 | } 53 | 54 | e := m.orderbook.InsertOrder(newOrder) 55 | msg := common.OrderBookChangeMessage(m.market, m.orderbook.Sequence, e.Side, e.Price, e.Amount) 56 | matchResult.OrderBookActivities = append(matchResult.OrderBookActivities, msg) 57 | 58 | utils.Debugf(" [Make Liquidity] price: %s amount: %s (%s)", newOrder.Price.StringFixed(5), newOrder.Amount.StringFixed(5), newOrder.ID) 59 | } 60 | 61 | return 62 | } 63 | 64 | func (m *MarketHandler) handleCancelOrder(bookOrder *common.MemoryOrder) *common.OrderbookEvent { 65 | return m.orderbook.RemoveOrder(bookOrder) 66 | } 67 | 68 | func NewMarketHandler(ctx context.Context, market string) (*MarketHandler, error) { 69 | marketOrderbook := common.NewOrderbook(market) 70 | 71 | marketOrderbook.UsePlugin(func(e *common.OrderbookEvent) { 72 | marketOrderbook.Sequence = marketOrderbook.Sequence + 1 73 | }) 74 | 75 | marketHandler := MarketHandler{ 76 | market: market, 77 | ctx: ctx, 78 | orderbook: marketOrderbook, 79 | } 80 | 81 | return &marketHandler, nil 82 | } 83 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/HydroProtocol/hydro-sdk-backend 2 | 3 | require ( 4 | github.com/btcsuite/btcd v0.0.0-20190115013929-ed77733ec07d 5 | github.com/cevaris/ordered_map v0.0.0-20180310183325-0efaee1733e3 6 | github.com/go-redis/redis v6.15.1+incompatible 7 | github.com/gorilla/websocket v1.4.0 8 | github.com/jarcoal/httpmock v1.0.3 // indirect 9 | github.com/labstack/gommon v0.2.8 10 | github.com/mattn/go-colorable v0.1.1 // indirect 11 | github.com/mattn/go-isatty v0.0.7 // indirect 12 | github.com/onrik/ethrpc v0.0.0-20190213081453-aa076c1849e6 13 | github.com/petar/GoLLRB v0.0.0-20130427215148-53be0d36a84c 14 | github.com/satori/go.uuid v1.2.0 15 | github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 16 | github.com/sirupsen/logrus v1.0.6 17 | github.com/stretchr/objx v0.2.0 // indirect 18 | github.com/stretchr/testify v1.3.0 19 | github.com/tidwall/gjson v1.2.1 20 | github.com/tidwall/match v1.0.1 // indirect 21 | github.com/tidwall/pretty v0.0.0-20190325153808-1166b9ac2b65 // indirect 22 | github.com/valyala/fasttemplate v1.0.1 // indirect 23 | golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a 24 | gopkg.in/airbrake/gobrake.v2 v2.0.9 // indirect 25 | gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= 2 | github.com/btcsuite/btcd v0.0.0-20190115013929-ed77733ec07d h1:xG8Pj6Y6J760xwETNmMzmlt38QSwz0BLp1cZ09g27uw= 3 | github.com/btcsuite/btcd v0.0.0-20190115013929-ed77733ec07d/go.mod h1:d3C0AkH6BRcvO8T0UEPu53cnw4IbV63x1bEjildYhO0= 4 | github.com/btcsuite/btclog v0.0.0-20170628155309-84c8d2346e9f/go.mod h1:TdznJufoqS23FtqVCzL0ZqgP5MqXbb4fg/WgDys70nA= 5 | github.com/btcsuite/btcutil v0.0.0-20180706230648-ab6388e0c60a/go.mod h1:+5NJ2+qvTyV9exUAL/rxXi3DcLg2Ts+ymUAY5y4NvMg= 6 | github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd/go.mod h1:HHNXQzUsZCxOoE+CPiyCTO6x34Zs86zZUiwtpXoGdtg= 7 | github.com/btcsuite/goleveldb v0.0.0-20160330041536-7834afc9e8cd/go.mod h1:F+uVaaLLH7j4eDXPRvw78tMflu7Ie2bzYOH4Y8rRKBY= 8 | github.com/btcsuite/snappy-go v0.0.0-20151229074030-0bdef8d06723/go.mod h1:8woku9dyThutzjeg+3xrA5iCpBRH8XEEg3lh6TiUghc= 9 | github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= 10 | github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= 11 | github.com/cevaris/ordered_map v0.0.0-20180310183325-0efaee1733e3 h1:z8dxVlK3evexcUcIgacZgqQgiAy6IqVLg0E4dDnGC6Q= 12 | github.com/cevaris/ordered_map v0.0.0-20180310183325-0efaee1733e3/go.mod h1:507vXsotcZop7NZfBWdhPmVeOse4ko2R7AagJYrpoEg= 13 | github.com/davecgh/go-spew v0.0.0-20171005155431-ecdeabc65495/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 16 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 17 | github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= 18 | github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 19 | github.com/go-redis/redis v6.15.1+incompatible h1:BZ9s4/vHrIqwOb0OPtTQ5uABxETJ3NRuUNoSUurnkew= 20 | github.com/go-redis/redis v6.15.1+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= 21 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 22 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 23 | github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= 24 | github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 25 | github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI= 26 | github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 27 | github.com/jarcoal/httpmock v1.0.3 h1:Qgv39cyHvgEguAofjb5GomnBCm10Dq71K+k1Aq0h7/o= 28 | github.com/jarcoal/httpmock v1.0.3/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= 29 | github.com/jessevdk/go-flags v0.0.0-20141203071132-1679536dcc89/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 30 | github.com/jrick/logrotate v1.0.0/go.mod h1:LNinyqDIJnpAur+b8yyulnQw/wDuN1+BYKlTRt3OuAQ= 31 | github.com/kkdai/bstream v0.0.0-20161212061736-f391b8402d23/go.mod h1:J+Gs4SYgM6CZQHDETBtE9HaSEkGmuNXF86RwHhHUvq4= 32 | github.com/labstack/gommon v0.2.8 h1:JvRqmeZcfrHC5u6uVleB4NxxNbzx6gpbJiQknDbKQu0= 33 | github.com/labstack/gommon v0.2.8/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4= 34 | github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg= 35 | github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= 36 | github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 37 | github.com/mattn/go-isatty v0.0.7 h1:UvyT9uN+3r7yLEYSlJsbQGdsaB/a0DlgWP3pql6iwOc= 38 | github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 39 | github.com/onrik/ethrpc v0.0.0-20190213081453-aa076c1849e6 h1:7bPXPcqnUpG0voONTZhyrDlShg0ANhdI/ObCPzSkwGs= 40 | github.com/onrik/ethrpc v0.0.0-20190213081453-aa076c1849e6/go.mod h1:RoqOlDiBBs1qYamkcYhxMgkPijxu5R8t55mgUiy4le8= 41 | github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 42 | github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs= 43 | github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 44 | github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU= 45 | github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= 46 | github.com/petar/GoLLRB v0.0.0-20130427215148-53be0d36a84c h1:AwcgVYzW1T+QuJ2fc55ceOSCiVaOpdYUNpFj9t7+n9U= 47 | github.com/petar/GoLLRB v0.0.0-20130427215148-53be0d36a84c/go.mod h1:HUpKUBZnpzkdx0kD/+Yfuft+uD3zHGtXF/XJB14TUr4= 48 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 49 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 50 | github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= 51 | github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= 52 | github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24 h1:pntxY8Ary0t43dCZ5dqY4YTJCObLY1kIXl0uzMv+7DE= 53 | github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= 54 | github.com/sirupsen/logrus v1.0.6 h1:hcP1GmhGigz/O7h1WVUM5KklBp1JoNS9FggWKdj/j3s= 55 | github.com/sirupsen/logrus v1.0.6/go.mod h1:pMByvHTf9Beacp5x1UXfOR9xyW/9antXMhjMPG0dEzc= 56 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 57 | github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48= 58 | github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 59 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 60 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 61 | github.com/tidwall/gjson v1.2.1 h1:j0efZLrZUvNerEf6xqoi0NjWMK5YlLrR7Guo/dxY174= 62 | github.com/tidwall/gjson v1.2.1/go.mod h1:c/nTNbUr0E0OrXEhq1pwa8iEgc2DOt4ZZqAt1HtCkPA= 63 | github.com/tidwall/match v1.0.1 h1:PnKP62LPNxHKTwvHHZZzdOAOCtsJTjo6dZLCwpKm5xc= 64 | github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E= 65 | github.com/tidwall/pretty v0.0.0-20190325153808-1166b9ac2b65 h1:rQ229MBgvW68s1/g6f1/63TgYwYxfF4E+bi/KC19P8g= 66 | github.com/tidwall/pretty v0.0.0-20190325153808-1166b9ac2b65/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= 67 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 68 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 69 | github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8WdUSz8= 70 | github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= 71 | golang.org/x/crypto v0.0.0-20170930174604-9419663f5a44/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 72 | golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a h1:Igim7XhdOpBnWPuYJ70XcNpq8q3BCACtVgNfoJxOV7g= 73 | golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= 74 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd h1:nTDtHvHSdCn1m6ITfMRqtOd/9+7a3s8RBNOZ3eYZzJA= 75 | golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 76 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f h1:wMNYb4v58l5UBM7MYRLPG6ZhfOqbKu7X5eyFl8ZhKvA= 77 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 78 | golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 79 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 80 | golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e h1:nFYrTHrdrAOpShe27kaFHjsqYSEQ0KWqdWLu3xuZJts= 81 | golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 82 | golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= 83 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 84 | gopkg.in/airbrake/gobrake.v2 v2.0.9 h1:7z2uVWwn7oVeeugY1DtlPAy5H+KYgB1KeKTnqjNatLo= 85 | gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U= 86 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 87 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 88 | gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= 89 | gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 90 | gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2 h1:OAj3g0cR6Dx/R07QgQe8wkA9RNjB2u4i700xBkIT4e0= 91 | gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo= 92 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 93 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 94 | gopkg.in/yaml.v2 v2.2.1 h1:mUhvW9EsL+naU5Q3cakzfE91YhliOondGd6ZrsDBHQE= 95 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 96 | -------------------------------------------------------------------------------- /launcher/gas_price.go: -------------------------------------------------------------------------------- 1 | package launcher 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/shopspring/decimal" 6 | "io/ioutil" 7 | "net/http" 8 | ) 9 | 10 | type GasPriceDecider interface { 11 | GasPriceInWei() decimal.Decimal 12 | } 13 | 14 | type StaticGasPriceDecider struct { 15 | PriceInWei decimal.Decimal 16 | } 17 | 18 | func (s StaticGasPriceDecider) GasPriceInWei() decimal.Decimal { 19 | return s.PriceInWei 20 | } 21 | 22 | type GasStationPriceDeciderWithFallback struct { 23 | FallbackGasPriceInWei decimal.Decimal 24 | } 25 | 26 | func (s GasStationPriceDeciderWithFallback) GasPriceInWei() decimal.Decimal { 27 | url := "https://ethgasstation.info/json/ethgasAPI.json" 28 | resp, err := http.Get(url) 29 | if err != nil { 30 | return s.FallbackGasPriceInWei 31 | } 32 | 33 | body, err := ioutil.ReadAll(resp.Body) 34 | if err != nil { 35 | return s.FallbackGasPriceInWei 36 | } 37 | 38 | gasStationResp := GasStationRespBody{} 39 | err = json.Unmarshal(body, &gasStationResp) 40 | if err != nil || gasStationResp.Fast.IsZero() { 41 | return s.FallbackGasPriceInWei 42 | } 43 | 44 | // returned value from gasStation api is in 0.1Gwei 45 | gwei := decimal.New(1, 9) 46 | return gasStationResp.Fast.Div(decimal.NewFromFloat(10)).Mul(gwei) 47 | } 48 | 49 | type GasStationRespBody struct { 50 | Fast decimal.Decimal `json:"fast"` 51 | Average decimal.Decimal `json:"average"` 52 | } 53 | 54 | func NewStaticGasPriceDecider(gasPrice decimal.Decimal) GasPriceDecider { 55 | return StaticGasPriceDecider{ 56 | PriceInWei: gasPrice, 57 | } 58 | } 59 | 60 | func NewGasStationGasPriceDecider(fallbackGasPrice decimal.Decimal) GasPriceDecider { 61 | return GasStationPriceDeciderWithFallback{ 62 | FallbackGasPriceInWei: fallbackGasPrice, 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /launcher/launch_log.go: -------------------------------------------------------------------------------- 1 | package launcher 2 | 3 | import ( 4 | "database/sql" 5 | "github.com/shopspring/decimal" 6 | "time" 7 | ) 8 | 9 | type LaunchLog struct { 10 | ID int64 `db:"id" auto:"true" primaryKey:"true" autoIncrement:"true"` 11 | ItemType string `db:"item_type"` 12 | ItemID int64 `db:"item_id"` 13 | Status string `db:"status"` 14 | Hash sql.NullString `db:"transaction_hash"` 15 | 16 | BlockNumber sql.NullInt64 `db:"block_number"` 17 | 18 | From string `db:"t_from"` 19 | To string `db:"t_to"` 20 | Value decimal.Decimal `db:"value"` 21 | GasLimit int64 `db:"gas_limit"` 22 | GasUsed sql.NullInt64 `db:"gas_used"` 23 | GasPrice decimal.NullDecimal `db:"gas_price"` 24 | Nonce sql.NullInt64 `db:"nonce"` 25 | Data string `db:"data"` 26 | 27 | ExecutedAt time.Time `db:"executed_at"` 28 | CreatedAt time.Time `db:"created_at"` 29 | UpdatedAt time.Time `db:"updated_at"` 30 | } 31 | -------------------------------------------------------------------------------- /launcher/launcher.go: -------------------------------------------------------------------------------- 1 | package launcher 2 | 3 | import ( 4 | "context" 5 | "github.com/HydroProtocol/hydro-sdk-backend/common" 6 | "github.com/HydroProtocol/hydro-sdk-backend/sdk" 7 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 8 | "github.com/shopspring/decimal" 9 | ) 10 | 11 | type Launcher struct { 12 | Ctx context.Context `json:"ctx"` 13 | GasPriceDecider GasPriceDecider 14 | SignService ISignService 15 | BlockChain sdk.BlockChain 16 | } 17 | 18 | func NewLauncher(ctx context.Context, sign ISignService, hydro sdk.Hydro, gasPriceDecider GasPriceDecider) *Launcher { 19 | return &Launcher{ 20 | Ctx: ctx, 21 | SignService: sign, 22 | BlockChain: hydro, 23 | GasPriceDecider: gasPriceDecider, 24 | } 25 | } 26 | 27 | func (l *Launcher) add(launchLog *LaunchLog) { 28 | launchLog.GasPrice = decimal.NullDecimal{ 29 | Decimal: l.GasPriceDecider.GasPriceInWei(), 30 | Valid: true, 31 | } 32 | 33 | signedRawTransaction := l.SignService.Sign(launchLog) 34 | transactionHash, err := l.BlockChain.SendRawTransaction(signedRawTransaction) 35 | 36 | if err != nil { 37 | utils.Debugf("%+v", launchLog) 38 | utils.Infof("Send Tx failed, launchLog ID: %d, err: %+v", launchLog.ID, err) 39 | panic(err) 40 | } 41 | 42 | utils.Infof("Send Tx, launchLog ID: %d, hash: %s", launchLog.ID, transactionHash) 43 | 44 | launchLog.Status = common.STATUS_PENDING 45 | 46 | if err != nil { 47 | utils.Infof("Update Launch Log Failed, ID: %d, err: %s", launchLog.ID, err) 48 | panic(err) 49 | } 50 | 51 | l.SignService.AfterSign() 52 | } 53 | -------------------------------------------------------------------------------- /launcher/signer.go: -------------------------------------------------------------------------------- 1 | package launcher 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "database/sql" 6 | "github.com/HydroProtocol/hydro-sdk-backend/sdk/crypto" 7 | "github.com/HydroProtocol/hydro-sdk-backend/sdk/signer" 8 | "github.com/HydroProtocol/hydro-sdk-backend/sdk/types" 9 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 10 | "sync" 11 | ) 12 | 13 | type ISignService interface { 14 | Sign(launchLog *LaunchLog) string 15 | AfterSign() //what you want to do when signature has been used 16 | } 17 | 18 | type localSignService struct { 19 | privateKey *ecdsa.PrivateKey 20 | nonce int64 21 | mutex sync.Mutex 22 | } 23 | 24 | func (s *localSignService) AfterSign() { 25 | s.mutex.Lock() 26 | defer s.mutex.Unlock() 27 | s.nonce = s.nonce + 1 28 | } 29 | 30 | func (s *localSignService) Sign(launchLog *LaunchLog) string { 31 | transaction := types.NewTransaction( 32 | uint64(s.nonce), 33 | launchLog.To, 34 | utils.DecimalToBigInt(launchLog.Value), 35 | uint64(launchLog.GasLimit), 36 | utils.DecimalToBigInt(launchLog.GasPrice.Decimal), 37 | utils.Hex2Bytes(launchLog.Data[2:]), 38 | ) 39 | 40 | signedTransaction, err := signer.SignTx(transaction, s.privateKey) 41 | 42 | if err != nil { 43 | utils.Errorf("sign transaction error: %v", err) 44 | panic(err) 45 | } 46 | 47 | launchLog.Nonce = sql.NullInt64{ 48 | Int64: s.nonce, 49 | Valid: true, 50 | } 51 | 52 | launchLog.Hash = sql.NullString{ 53 | String: utils.Bytes2HexP(signer.Hash(signedTransaction)), 54 | Valid: true, 55 | } 56 | 57 | return utils.Bytes2HexP(signer.EncodeRlp(signedTransaction)) 58 | } 59 | 60 | func NewDefaultSignService(privateKeyStr string, getNonce func(string) (int, error)) ISignService { 61 | utils.Infof(privateKeyStr) 62 | privateKey, err := crypto.NewPrivateKeyByHex(privateKeyStr) 63 | if err != nil { 64 | panic(err) 65 | } 66 | 67 | //nonce := LaunchLogDao.FindPendingLogWithMaxNonce() + 1 68 | chainNonce, err := getNonce(crypto.PubKey2Address(privateKey.PublicKey)) 69 | 70 | if err != nil { 71 | panic(err) 72 | } 73 | 74 | //if int64(chainNonce) > nonce { 75 | // nonce = int64(chainNonce) 76 | //} 77 | 78 | return &localSignService{ 79 | privateKey: privateKey, 80 | mutex: sync.Mutex{}, 81 | nonce: int64(chainNonce), 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /sdk/crypto/crypto.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "crypto/elliptic" 6 | "errors" 7 | "fmt" 8 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 9 | "github.com/btcsuite/btcd/btcec" 10 | "golang.org/x/crypto/sha3" 11 | "math/big" 12 | ) 13 | 14 | var bitCurve = btcec.S256() 15 | 16 | func Keccak256(data ...[]byte) []byte { 17 | d := sha3.NewLegacyKeccak256() 18 | for _, b := range data { 19 | d.Write(b) 20 | } 21 | return d.Sum(nil) 22 | } 23 | 24 | func NewPrivateKey(privateKeyBytes []byte) (*ecdsa.PrivateKey, error) { 25 | priv := new(ecdsa.PrivateKey) 26 | priv.PublicKey.Curve = bitCurve 27 | if 8*len(privateKeyBytes) != priv.Params().BitSize { 28 | return nil, fmt.Errorf("invalid length, need %d bits", priv.Params().BitSize) 29 | } 30 | priv.D = new(big.Int).SetBytes(privateKeyBytes) 31 | 32 | // The priv.D must < N 33 | if priv.D.Cmp(bitCurve.N) >= 0 { 34 | return nil, fmt.Errorf("invalid private key, >=N") 35 | } 36 | // The priv.D must not be zero or negative. 37 | if priv.D.Sign() <= 0 { 38 | return nil, fmt.Errorf("invalid private key, zero or negative") 39 | } 40 | 41 | priv.PublicKey.X, priv.PublicKey.Y = priv.PublicKey.Curve.ScalarBaseMult(privateKeyBytes) 42 | if priv.PublicKey.X == nil { 43 | return nil, errors.New("invalid private key") 44 | } 45 | return priv, nil 46 | } 47 | 48 | func NewPrivateKeyByHex(privateKeyHex string) (*ecdsa.PrivateKey, error) { 49 | privateKeyBytes := utils.Hex2Bytes(privateKeyHex) 50 | return NewPrivateKey(privateKeyBytes) 51 | } 52 | 53 | func Sign(hash []byte, prv *ecdsa.PrivateKey) ([]byte, error) { 54 | if len(hash) != 32 { 55 | return nil, fmt.Errorf("hash is required to be exactly 32 bytes (%d)", len(hash)) 56 | } 57 | if prv.Curve != btcec.S256() { 58 | return nil, fmt.Errorf("private key curve is not secp256k1") 59 | } 60 | sig, err := btcec.SignCompact(btcec.S256(), (*btcec.PrivateKey)(prv), hash, false) 61 | if err != nil { 62 | return nil, err 63 | } 64 | // Convert to Ethereum signature format with 'recovery id' v at the end. 65 | v := sig[0] - 27 66 | copy(sig, sig[1:]) 67 | sig[64] = v 68 | return sig, nil 69 | } 70 | 71 | func PersonalSign(message []byte, privateKey string) ([]byte, error) { 72 | personalHash := hashPersonalMessage(message) 73 | 74 | pk, err := NewPrivateKeyByHex(privateKey) 75 | 76 | if err != nil { 77 | return nil, err 78 | } 79 | 80 | singBytes, err := Sign(personalHash, pk) 81 | 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | return singBytes, nil 87 | } 88 | 89 | func PersonalSignByPrivateKey(message []byte, privateKey *ecdsa.PrivateKey) ([]byte, error) { 90 | personalHash := hashPersonalMessage(message) 91 | singBytes, err := Sign(personalHash, privateKey) 92 | 93 | if err != nil { 94 | return nil, err 95 | } 96 | 97 | return singBytes, nil 98 | } 99 | 100 | func EcRecover(hash, sig []byte) ([]byte, error) { 101 | pub, err := SigToPub(hash, sig) 102 | if err != nil { 103 | return nil, err 104 | } 105 | bytes := (*btcec.PublicKey)(pub).SerializeUncompressed() 106 | return bytes, err 107 | } 108 | 109 | func PersonalEcRecover(data []byte, sig []byte) (string, error) { 110 | if len(sig) != 65 { 111 | return "", fmt.Errorf("signature must be 65 bytes long") 112 | } 113 | if sig[64] >= 27 { 114 | sig[64] -= 27 115 | } 116 | 117 | rpk, err := SigToPub(hashPersonalMessage(data), sig) 118 | 119 | if err != nil { 120 | return "", err 121 | } 122 | 123 | if rpk == nil || rpk.X == nil || rpk.Y == nil { 124 | return "", errors.New("") 125 | } 126 | pubBytes := elliptic.Marshal(bitCurve, rpk.X, rpk.Y) 127 | return utils.Bytes2Hex(Keccak256(pubBytes[1:])[12:]), nil 128 | } 129 | 130 | func hashPersonalMessage(data []byte) []byte { 131 | msg := fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(data), data) 132 | return Keccak256([]byte(msg)) 133 | } 134 | 135 | func PubKey2Bytes(pub *ecdsa.PublicKey) []byte { 136 | if pub == nil || pub.X == nil || pub.Y == nil { 137 | return nil 138 | } 139 | return elliptic.Marshal(bitCurve, pub.X, pub.Y) 140 | } 141 | 142 | func PubKey2Address(p ecdsa.PublicKey) string { 143 | pubBytes := PubKey2Bytes(&p) 144 | return utils.Bytes2HexP(Keccak256(pubBytes[1:])[12:]) 145 | } 146 | 147 | func SigToPub(hash, sig []byte) (*ecdsa.PublicKey, error) { 148 | // Convert to btcec input format with 'recovery id' v at the beginning. 149 | btcSig := make([]byte, 65) 150 | btcSig[0] = sig[64] + 27 151 | copy(btcSig[1:], sig) 152 | 153 | pub, _, err := btcec.RecoverCompact(btcec.S256(), btcSig, hash) 154 | return (*ecdsa.PublicKey)(pub), err 155 | } 156 | -------------------------------------------------------------------------------- /sdk/crypto/crypto_test.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func TestNewPrivateKey(t *testing.T) { 10 | address := "0x93388b4efe13b9b18ed480783c05462409851547" 11 | prvKeyHex := "95b0a982c0dfc5ab70bf915dcf9f4b790544d25bc5e6cff0f38a59d0bba58651" 12 | except := address 13 | 14 | act, _ := NewPrivateKeyByHex(prvKeyHex) 15 | assert.EqualValues(t, except, PubKey2Address(act.PublicKey)) 16 | 17 | prvKeyHex2 := "95b0a982c0dfc5ab70bf915dcf9f4b790544d25bc5e6cff0f38a59d0bba586" 18 | act2, err := NewPrivateKeyByHex(prvKeyHex2) 19 | assert.Nil(t, act2) 20 | assert.EqualValues(t, err.Error(), "invalid length, need 256 bits") 21 | 22 | prvKeyHex3 := "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" 23 | act3, err := NewPrivateKeyByHex(prvKeyHex3) 24 | assert.Nil(t, act3) 25 | assert.EqualValues(t, err.Error(), "invalid private key, >=N") 26 | 27 | prvKeyHex4 := "0000000000000000000000000000000000000000000000000000000000000000" 28 | act4, err := NewPrivateKeyByHex(prvKeyHex4) 29 | assert.Nil(t, act4) 30 | assert.EqualValues(t, err.Error(), "invalid private key, zero or negative") 31 | 32 | } 33 | 34 | func TestNewPrivateKeyByHex(t *testing.T) { 35 | address := "0x93388b4efe13b9b18ed480783c05462409851547" 36 | prvKeyHex := "95b0a982c0dfc5ab70bf915dcf9f4b790544d25bc5e6cff0f38a59d0bba58651" 37 | prvKeyBytes := utils.Hex2Bytes(prvKeyHex) 38 | except := address 39 | 40 | act, _ := NewPrivateKey(prvKeyBytes) 41 | 42 | assert.EqualValues(t, except, PubKey2Address(act.PublicKey)) 43 | } 44 | 45 | func TestSign(t *testing.T) { 46 | prvKeyHex := "95b0a982c0dfc5ab70bf915dcf9f4b790544d25bc5e6cff0f38a59d0bba58651" 47 | message := utils.Hex2Bytes("9df8dba3720d00bd48ad744722021ef91b035e273bccfb78660ca8df9574b086") 48 | except := utils.Hex2Bytes("2736b2ca3e2d4778e53a33e0d9bb2d9bad91ec858ab71ad49e31f540f15728a83dbea28bd686bb66d06e4ad9f48912ef437b92a272ea47563c2df80ed59b508e00") 49 | actKey, _ := NewPrivateKeyByHex(prvKeyHex) 50 | act, _ := Sign([]byte(message), actKey) 51 | assert.EqualValues(t, except, act) 52 | } 53 | 54 | func TestPersonalSignAndPersonalSignByPrivateKey(t *testing.T) { 55 | prvKeyHex := "95b0a982c0dfc5ab70bf915dcf9f4b790544d25bc5e6cff0f38a59d0bba58651" 56 | message := utils.Hex2Bytes("9df8dba3720d00bd48ad744722021ef91b035e273bccfb78660ca8df9574b086") 57 | except := utils.Hex2Bytes("aa7cd9f5a7eb485771215d45cc2a4c535e270c75c3595ae6b1c158aef72e67066ad5df037ad5945c65da90edfaa4fe418e5b6bd2225ec9d4b704433a779e4bff00") 58 | 59 | actKey, _ := NewPrivateKeyByHex(prvKeyHex) 60 | act, _ := PersonalSignByPrivateKey(message, actKey) 61 | act2, _ := PersonalSign(message, prvKeyHex) 62 | assert.EqualValues(t, except, act) 63 | assert.EqualValues(t, except, act2) 64 | } 65 | 66 | func TestEcRecover(t *testing.T) { 67 | sign := utils.Hex2Bytes("2736b2ca3e2d4778e53a33e0d9bb2d9bad91ec858ab71ad49e31f540f15728a83dbea28bd686bb66d06e4ad9f48912ef437b92a272ea47563c2df80ed59b508e00") 68 | message := Keccak256([]byte("some message")) 69 | except := utils.Hex2Bytes("0450d7aa97f7496fd412f393e54df0cbe3f6cbeacf15d1ddb12133e408522feb8896dd1652ee84b18788bc7753663302a6489f779352bbfec010ab25c9e3806843") 70 | 71 | act, _ := EcRecover(message, sign) 72 | assert.EqualValues(t, except, act) 73 | } 74 | 75 | func TestPersonalEcRecover(t *testing.T) { 76 | address := "0x93388b4efe13b9b18ed480783c05462409851547" 77 | sign := utils.Hex2Bytes("aa7cd9f5a7eb485771215d45cc2a4c535e270c75c3595ae6b1c158aef72e67066ad5df037ad5945c65da90edfaa4fe418e5b6bd2225ec9d4b704433a779e4bff00") 78 | message := Keccak256([]byte("some message")) 79 | 80 | act, _ := PersonalEcRecover(message, sign) 81 | assert.EqualValues(t, address[2:], act) 82 | } 83 | 84 | func TestSigToPub(t *testing.T) { 85 | address := "0x93388b4efe13b9b18ed480783c05462409851547" 86 | message := Keccak256([]byte("some message")) 87 | sign := utils.Hex2Bytes("aa7cd9f5a7eb485771215d45cc2a4c535e270c75c3595ae6b1c158aef72e67066ad5df037ad5945c65da90edfaa4fe418e5b6bd2225ec9d4b704433a779e4bff00") 88 | 89 | act, _ := SigToPub(hashPersonalMessage(message), sign) 90 | assert.EqualValues(t, address, PubKey2Address(*act)) 91 | } 92 | -------------------------------------------------------------------------------- /sdk/ethereum/erc20.go: -------------------------------------------------------------------------------- 1 | package ethereum 2 | 3 | import ( 4 | "bytes" 5 | "encoding/hex" 6 | "errors" 7 | "fmt" 8 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 9 | "github.com/onrik/ethrpc" 10 | "math" 11 | "math/big" 12 | "os" 13 | "strconv" 14 | "unicode/utf8" 15 | ) 16 | 17 | type IErc20 interface { 18 | Symbol(address string) (error, string) 19 | Decimals(address string) (error, int) 20 | Name(address string) (error, string) 21 | TotalSupply(address string) (error, *big.Int) 22 | BalanceOf(tokenAddress, address string) (error, *big.Int) 23 | AllowanceOf(tokenAddress, proxyAddress, address string) (error, *big.Int) 24 | } 25 | 26 | type Erc20Service struct { 27 | client *ethrpc.EthRPC 28 | } 29 | 30 | func NewErc20Service(client *ethrpc.EthRPC) IErc20 { 31 | if client == nil { 32 | blockChainNodeUrl := os.Getenv("HSK_BLOCKCHAIN_RPC_URL") 33 | if len(blockChainNodeUrl) == 0 { 34 | panic(errors.New("empty env HSK_BLOCKCHAIN_RPC_URL")) 35 | } 36 | client = ethrpc.New(blockChainNodeUrl) 37 | } 38 | 39 | return &Erc20Service{ 40 | client: client, 41 | } 42 | } 43 | 44 | func (e *Erc20Service) BalanceOf(tokenAddress, address string) (error, *big.Int) { 45 | res, err := e.client.EthCall(ethrpc.T{ 46 | To: tokenAddress, 47 | From: address, 48 | Data: fmt.Sprintf("0x70a08231000000000000000000000000%s", without0xPrefix(address)), 49 | }, "latest") 50 | 51 | if err != nil { 52 | return err, nil 53 | } 54 | 55 | balance := utils.String2BigInt(res) 56 | return nil, &balance 57 | } 58 | 59 | func (e *Erc20Service) AllowanceOf(tokenAddress, proxyAddress, address string) (error, *big.Int) { 60 | res, err := e.client.EthCall(ethrpc.T{ 61 | To: tokenAddress, 62 | From: address, 63 | Data: fmt.Sprintf("0xdd62ed3e000000000000000000000000%s000000000000000000000000%s", without0xPrefix(address), without0xPrefix(proxyAddress)), 64 | }, "latest") 65 | 66 | if err != nil { 67 | return err, nil 68 | } 69 | 70 | allowance := utils.String2BigInt(res) 71 | return nil, &allowance 72 | } 73 | 74 | func (e *Erc20Service) TotalSupply(address string) (error, *big.Int) { 75 | result := callContract(e, address, ERC20TotalSupply) 76 | value := parseBigIntResult(result) 77 | 78 | if value.Cmp(big.NewInt(0)) < 0 { 79 | return fmt.Errorf("cannot find TotalSupply by address %s on chain", address), big.NewInt(-1) 80 | } 81 | 82 | return nil, value 83 | } 84 | 85 | func (e *Erc20Service) Symbol(address string) (error, string) { 86 | result := callContract(e, address, ERC20Symbol) 87 | retStr := parseStringResult(result) 88 | 89 | if retStr == "" { 90 | return fmt.Errorf("cannot find Symbol by address %s on chain", address), retStr 91 | } 92 | return nil, tuncate(retStr, 90) 93 | } 94 | 95 | func (e *Erc20Service) Decimals(address string) (error, int) { 96 | result := callContract(e, address, ERC20Decimals) 97 | value := parseIntResult(result) 98 | 99 | if value > math.MaxInt8 || value < 0 { 100 | return fmt.Errorf("cannot find Decimals by address %s on chain", address), -1 101 | } 102 | 103 | return nil, value 104 | } 105 | 106 | func (e *Erc20Service) Name(address string) (error, string) { 107 | result := callContract(e, address, ERC20Name) 108 | retStr := parseStringResult(result) 109 | 110 | if retStr == "" { 111 | return fmt.Errorf("cannot find Name by address %s on chain", address), retStr 112 | } 113 | return nil, tuncate(retStr, 250) 114 | } 115 | 116 | // sha3 result of erc20 method 117 | const ( 118 | ERC20TotalSupply = "0x18160ddd" // totalSupply() 119 | ERC20Symbol = "0x95d89b41" // symbol() 120 | ERC20Name = "0x06fdde03" // name() 121 | ERC20Decimals = "0x313ce567" // decimals() 122 | //ERC20BalanceOf = "0x70a08231" // balanceOf(address) 123 | //ERC20Allowance = "0xdd62ed3e" // allowance(address,address) 124 | //ERC20Transfer = "0xa9059cbb" // transfer(address,uint256) 125 | //ERC20Approve = "0x095ea7b3" // approve(address,uint256) 126 | //ERC20TransferFrom = "0x23b872dd" // transferFrom(address,address,uint256) 127 | //ERC20TransferEvent = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef" // Transfer(address,address,uint256) 128 | //ERC20ApprovalEvent = "0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925" // Approval(address,address,uint256) 129 | //WETHWithdrawal = "0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65" // Withdrawal(index_topic_1 address src, uint256 wad) 130 | //WETHDeposit = "0xe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c" // Deposit(index_topic_1 address dst, uint256 wad) 131 | ) 132 | 133 | // erc20 name, symbol 134 | // about how to parse return value 135 | // please read more: http://solidity.readthedocs.io/en/latest/abi-spec.html#examples 136 | func parseStringResult(result string) (res string) { 137 | defer func() { 138 | if err := recover(); err != nil { 139 | res = "" 140 | } 141 | }() 142 | 143 | result = removeLeading0x(result) 144 | 145 | if len(result) == 64 { 146 | return parseString(result) 147 | } else { 148 | startPosition, err := strconv.ParseInt(result[:64], 16, 64) 149 | if err != nil { 150 | panic(err) 151 | } 152 | startPosition = startPosition * 2 // byte length to hex length, 32 bytes is 64 hex 153 | 154 | length, err := strconv.ParseInt(result[startPosition:startPosition+64], 16, 64) 155 | if err != nil { 156 | panic(err) 157 | } 158 | 159 | return parseString(result[startPosition+64 : startPosition+64+length*2]) 160 | } 161 | } 162 | 163 | func parseString(hexStr string) string { 164 | b, err := hex.DecodeString(hexStr) 165 | 166 | if err != nil { 167 | panic(err) 168 | } 169 | 170 | b = bytes.Replace(b, []byte{0}, nil, -1) 171 | 172 | str := string(b) 173 | 174 | if !utf8.ValidString(str) { 175 | panic(fmt.Errorf("invalid utf8 string %+v", str)) 176 | } 177 | 178 | return str 179 | } 180 | 181 | func parseIntResult(result string) (res int) { 182 | defer func() { 183 | if err := recover(); err != nil { 184 | res = -1 185 | } 186 | }() 187 | 188 | result = removeLeading0x(result) 189 | 190 | decimals, err := strconv.ParseInt(result, 16, 64) 191 | 192 | if err != nil { 193 | panic(err) 194 | } 195 | 196 | return int(decimals) 197 | } 198 | 199 | func parseBigIntResult(result string) *big.Int { 200 | if len(removeLeading0x(result)) == 0 || len(result) > 66 { 201 | return big.NewInt(-1) 202 | } 203 | 204 | n := new(big.Int) 205 | n.SetString(result, 0) 206 | return n 207 | } 208 | 209 | func callContract(e *Erc20Service, address string, data string) string { 210 | result, err := e.client.EthCall(ethrpc.T{ 211 | From: "0x0000000000000000000000000000000000000000", 212 | To: address, 213 | Data: data, 214 | }, "latest") 215 | 216 | if err != nil { 217 | panic(err) 218 | } 219 | 220 | return result 221 | } 222 | 223 | func tuncate(str string, len int) string { 224 | if utf8.RuneCountInString(str) > len { 225 | return string(([]rune(str))[0:len]) 226 | } 227 | 228 | return str 229 | } 230 | 231 | func removeLeading0x(s string) string { 232 | if s[0:2] == "0x" { 233 | return s[2:] 234 | } 235 | return s 236 | } 237 | -------------------------------------------------------------------------------- /sdk/ethereum/erc20_test.go: -------------------------------------------------------------------------------- 1 | package ethereum 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | "net/http" 7 | "os" 8 | 9 | "github.com/stretchr/testify/suite" 10 | "github.com/tidwall/gjson" 11 | // "math/big" 12 | "testing" 13 | ) 14 | 15 | type erc20TestSuite struct { 16 | suite.Suite 17 | erc20 IErc20 18 | } 19 | 20 | const rpcURL = "http://127.0.0.1:8545" 21 | 22 | func TestErc20(t *testing.T) { 23 | os.Setenv("HSK_BLOCKCHAIN_RPC_URL", rpcURL) 24 | erc20 := NewErc20Service(nil) 25 | fmt.Println(erc20.Name("0x4c4fa7e8ea4cfcfc93deae2c0cff142a1dd3a218")) 26 | } 27 | 28 | func (s *erc20TestSuite) SetupSuite() { 29 | os.Setenv("HSK_BLOCKCHAIN_RPC_URL", rpcURL) 30 | s.erc20 = NewErc20Service(nil) 31 | } 32 | 33 | func (s *erc20TestSuite) TearDownSuite() { 34 | } 35 | 36 | func (s *erc20TestSuite) TearDownTest() { 37 | } 38 | 39 | func (s *erc20TestSuite) methodEqual(body []byte, expected string) { 40 | value := gjson.GetBytes(body, "method").String() 41 | s.Require().Equal(expected, value) 42 | } 43 | 44 | func (s *erc20TestSuite) paramsEqual(body []byte, expected string) { 45 | value := gjson.GetBytes(body, "params").Raw 46 | if expected == "null" { 47 | s.Require().Equal(expected, value) 48 | } else { 49 | s.JSONEq(expected, value) 50 | } 51 | } 52 | 53 | func (s *erc20TestSuite) getBody(request *http.Request) []byte { 54 | defer request.Body.Close() 55 | body, err := ioutil.ReadAll(request.Body) 56 | s.Require().Nil(err) 57 | 58 | return body 59 | } 60 | 61 | func (s *erc20TestSuite) TestBalanceOf() { 62 | _, balance := s.erc20.BalanceOf("0x4c4fa7e8ea4cfcfc93deae2c0cff142a1dd3a218", "0x31ebd457b999bf99759602f5ece5aa5033cb56b3") 63 | s.Require().Equal("100000000000000000000000", balance.String()) 64 | } 65 | 66 | func (s *erc20TestSuite) TestAllowanceOf() { 67 | _, allowance := s.erc20.AllowanceOf("0x4c4fa7e8ea4cfcfc93deae2c0cff142a1dd3a218", "0x04f67e8b7c39a25e100847cb167460d715215feb", "0x31ebd457b999bf99759602f5ece5aa5033cb56b3") 68 | s.Require().Equal("108555083659983933209597798445644913612440610624038028786991485007418559037440", allowance.String()) 69 | } 70 | 71 | func (s *erc20TestSuite) TestTotalSupply() { 72 | _, totalSupply := s.erc20.TotalSupply("0x4c4fa7e8ea4cfcfc93deae2c0cff142a1dd3a218") 73 | s.Require().Equal("1560000000000000000000000000", totalSupply.String()) 74 | } 75 | 76 | func (s *erc20TestSuite) TestGetSymbol() { 77 | _, symbol := s.erc20.Symbol("0x4c4fa7e8ea4cfcfc93deae2c0cff142a1dd3a218") 78 | s.Require().Equal("Hot", symbol) 79 | } 80 | 81 | func (s *erc20TestSuite) TestGetName() { 82 | _, name := s.erc20.Name("0x4c4fa7e8ea4cfcfc93deae2c0cff142a1dd3a218") 83 | s.Require().Equal("HydroToken", name) 84 | } 85 | 86 | func (s *erc20TestSuite) TestGetDecimals() { 87 | _, decimals := s.erc20.Decimals("0x4c4fa7e8ea4cfcfc93deae2c0cff142a1dd3a218") 88 | s.Require().Equal(18, decimals) 89 | } 90 | 91 | func TestTuncate(t *testing.T) { 92 | if "醉翁之" != tuncate("醉翁之意不在酒", 3) { 93 | t.Error("wrong") 94 | } 95 | if "醉翁之意不在酒" != tuncate("醉翁之意不在酒", 300) { 96 | t.Error("wrong") 97 | } 98 | } 99 | 100 | func TestNewErc20Service(t *testing.T) { 101 | suite.Run(t, new(erc20TestSuite)) 102 | } 103 | -------------------------------------------------------------------------------- /sdk/ethereum/ethereum.go: -------------------------------------------------------------------------------- 1 | package ethereum 2 | 3 | import ( 4 | "encoding/hex" 5 | "errors" 6 | "fmt" 7 | "github.com/HydroProtocol/hydro-sdk-backend/sdk" 8 | "github.com/HydroProtocol/hydro-sdk-backend/sdk/crypto" 9 | "github.com/HydroProtocol/hydro-sdk-backend/sdk/rlp" 10 | "github.com/HydroProtocol/hydro-sdk-backend/sdk/signer" 11 | "github.com/HydroProtocol/hydro-sdk-backend/sdk/types" 12 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 13 | "github.com/labstack/gommon/log" 14 | "github.com/onrik/ethrpc" 15 | "github.com/shopspring/decimal" 16 | "math/big" 17 | "os" 18 | "strconv" 19 | "strings" 20 | ) 21 | 22 | var EIP712_DOMAIN_TYPEHASH []byte 23 | var EIP712_ORDER_TYPE []byte 24 | 25 | // compile time interface check 26 | var _ sdk.BlockChain = &Ethereum{} 27 | var _ sdk.HydroProtocol = &EthereumHydroProtocol{} 28 | var _ sdk.Hydro = &EthereumHydro{} 29 | 30 | func init() { 31 | EIP712_DOMAIN_TYPEHASH = crypto.Keccak256([]byte(`EIP712Domain(string name)`)) 32 | EIP712_ORDER_TYPE = crypto.Keccak256([]byte(`Order(address trader,address relayer,address baseToken,address quoteToken,uint256 baseTokenAmount,uint256 quoteTokenAmount,uint256 gasTokenAmount,bytes32 data)`)) 33 | } 34 | 35 | type EthereumBlock struct { 36 | *ethrpc.Block 37 | } 38 | 39 | func (block *EthereumBlock) Hash() string { 40 | return block.Block.Hash 41 | } 42 | 43 | func (block *EthereumBlock) ParentHash() string { 44 | return block.Block.ParentHash 45 | } 46 | 47 | func (block *EthereumBlock) GetTransactions() []sdk.Transaction { 48 | txs := make([]sdk.Transaction, 0, 20) 49 | 50 | for i := range block.Block.Transactions { 51 | tx := block.Block.Transactions[i] 52 | txs = append(txs, &EthereumTransaction{&tx}) 53 | } 54 | 55 | return txs 56 | } 57 | 58 | func (block *EthereumBlock) Number() uint64 { 59 | return uint64(block.Block.Number) 60 | } 61 | 62 | func (block *EthereumBlock) Timestamp() uint64 { 63 | return uint64(block.Block.Timestamp) 64 | } 65 | 66 | type EthereumTransaction struct { 67 | *ethrpc.Transaction 68 | } 69 | 70 | func (t *EthereumTransaction) GetBlockHash() string { 71 | return t.BlockHash 72 | } 73 | 74 | func (t *EthereumTransaction) GetFrom() string { 75 | return t.From 76 | } 77 | 78 | func (t *EthereumTransaction) GetGas() int { 79 | return t.Gas 80 | } 81 | 82 | func (t *EthereumTransaction) GetGasPrice() big.Int { 83 | return t.GasPrice 84 | } 85 | 86 | func (t *EthereumTransaction) GetValue() big.Int { 87 | return t.Value 88 | } 89 | 90 | func (t *EthereumTransaction) GetTo() string { 91 | return t.To 92 | } 93 | 94 | func (t *EthereumTransaction) GetHash() string { 95 | return t.Hash 96 | } 97 | func (t *EthereumTransaction) GetBlockNumber() uint64 { 98 | return uint64(*t.BlockNumber) 99 | } 100 | 101 | type EthereumTransactionReceipt struct { 102 | *ethrpc.TransactionReceipt 103 | } 104 | 105 | func (r *EthereumTransactionReceipt) GetLogs() (rst []sdk.IReceiptLog) { 106 | for i:= range r.Logs { 107 | l := ReceiptLog{&r.Logs[i]} 108 | rst = append(rst, l) 109 | } 110 | 111 | return 112 | } 113 | 114 | func (r *EthereumTransactionReceipt) GetResult() bool { 115 | res, err := strconv.ParseInt(r.Status, 0, 64) 116 | 117 | if err != nil { 118 | panic(err) 119 | } 120 | 121 | return res == 1 122 | } 123 | 124 | func (r *EthereumTransactionReceipt) GetBlockNumber() uint64 { 125 | return uint64(r.BlockNumber) 126 | } 127 | 128 | func (r *EthereumTransactionReceipt) GetBlockHash() string { 129 | return r.BlockHash 130 | } 131 | func (r *EthereumTransactionReceipt) GetTxHash() string { 132 | return r.TransactionHash 133 | } 134 | func (r *EthereumTransactionReceipt) GetTxIndex() int { 135 | return r.TransactionIndex 136 | } 137 | 138 | type ReceiptLog struct { 139 | *ethrpc.Log 140 | } 141 | 142 | func (log ReceiptLog) GetRemoved() bool { 143 | return log.Removed 144 | } 145 | 146 | func (log ReceiptLog) GetLogIndex() int { 147 | return log.LogIndex 148 | } 149 | 150 | func (log ReceiptLog) GetTransactionIndex() int { 151 | return log.TransactionIndex 152 | } 153 | 154 | func (log ReceiptLog) GetTransactionHash() string { 155 | return log.TransactionHash 156 | } 157 | 158 | func (log ReceiptLog) GetBlockNum() int { 159 | return log.BlockNumber 160 | } 161 | 162 | func (log ReceiptLog) GetBlockHash() string { 163 | return log.BlockHash 164 | } 165 | 166 | func (log ReceiptLog) GetAddress() string { 167 | return log.Address 168 | } 169 | 170 | func (log ReceiptLog) GetData() string { 171 | return log.Data 172 | } 173 | 174 | func (log ReceiptLog) GetTopics() []string { 175 | return log.Topics 176 | } 177 | 178 | type Ethereum struct { 179 | client *ethrpc.EthRPC 180 | hybridExAddr string 181 | } 182 | 183 | func (e *Ethereum) EnableDebug(b bool) { 184 | e.client.Debug = b 185 | } 186 | 187 | func (e *Ethereum) GetBlockByNumber(number uint64) (sdk.Block, error) { 188 | 189 | block, err := e.client.EthGetBlockByNumber(int(number), true) 190 | 191 | if err != nil { 192 | log.Errorf("get Block by Number failed %+v", err) 193 | return nil, err 194 | } 195 | 196 | if block == nil { 197 | log.Errorf("get Block by Number returns nil block for num: %d", number) 198 | return nil, errors.New("get Block by Number returns nil block for num: " + strconv.Itoa(int(number))) 199 | } 200 | 201 | return &EthereumBlock{block}, nil 202 | } 203 | 204 | func (e *Ethereum) GetBlockNumber() (uint64, error) { 205 | number, err := e.client.EthBlockNumber() 206 | 207 | if err != nil { 208 | log.Errorf("GetBlockNumber failed, %v", err) 209 | return 0, err 210 | } 211 | 212 | return uint64(number), nil 213 | } 214 | 215 | func (e *Ethereum) GetTransaction(ID string) (sdk.Transaction, error) { 216 | tx, err := e.client.EthGetTransactionByHash(ID) 217 | 218 | if err != nil { 219 | log.Errorf("GetTransaction failed, %v", err) 220 | return nil, err 221 | } 222 | 223 | return &EthereumTransaction{tx}, nil 224 | } 225 | 226 | func signTransaction(tx *types.Transaction, pkHex string) string { 227 | privateKey, _ := crypto.NewPrivateKeyByHex(pkHex) 228 | signTx, err := signer.SignTx(tx, privateKey) 229 | 230 | if err != nil { 231 | panic(err) 232 | } 233 | 234 | rlpBytes := rlp.Encode([]interface{}{ 235 | rlp.EncodeUint64ToBytes(signTx.Nonce), 236 | signTx.GasPrice.Bytes(), 237 | rlp.EncodeUint64ToBytes(signTx.Nonce), 238 | utils.Hex2Bytes(signTx.To[2:]), 239 | signTx.Value.Bytes(), 240 | signTx.Data, 241 | signTx.Signature[64:], 242 | signTx.Signature[0:32], 243 | signTx.Signature[32:64], 244 | }) 245 | 246 | return utils.Bytes2HexP(rlpBytes) 247 | } 248 | 249 | func (e *Ethereum) SendTransaction(txAttributes map[string]interface{}, privateKey []byte) (transactionHash string, err error) { 250 | tx := types.NewTransaction( 251 | txAttributes["nonce"].(uint64), 252 | txAttributes["to"].(string), 253 | utils.DecimalToBigInt(txAttributes["value"].(decimal.Decimal)), 254 | txAttributes["gasLimit"].(uint64), 255 | utils.DecimalToBigInt(txAttributes["gasPrice"].(decimal.Decimal)), 256 | txAttributes["data"].([]byte), 257 | ) 258 | 259 | pkHex := hex.EncodeToString(privateKey) 260 | rawTransactionString := signTransaction(tx, pkHex) 261 | 262 | return e.client.EthSendRawTransaction(rawTransactionString) 263 | } 264 | 265 | func (e *Ethereum) GetTransactionReceipt(ID string) (sdk.TransactionReceipt, error) { 266 | txReceipt, err := e.client.EthGetTransactionReceipt(ID) 267 | 268 | if err != nil { 269 | log.Errorf("GetTransactionReceipt failed, %v", err) 270 | return nil, err 271 | } 272 | 273 | return &EthereumTransactionReceipt{txReceipt}, nil 274 | } 275 | 276 | func (e *Ethereum) GetTransactionAndReceipt(ID string) (sdk.Transaction, sdk.TransactionReceipt, error) { 277 | txReceiptChannel := make(chan sdk.TransactionReceipt) 278 | 279 | go func() { 280 | rec, _ := e.GetTransactionReceipt(ID) 281 | txReceiptChannel <- rec 282 | }() 283 | 284 | txInfoChannel := make(chan sdk.Transaction) 285 | go func() { 286 | tx, _ := e.GetTransaction(ID) 287 | txInfoChannel <- tx 288 | }() 289 | 290 | return <-txInfoChannel, <-txReceiptChannel, nil 291 | } 292 | 293 | func (e *Ethereum) GetTokenBalance(tokenAddress, address string) decimal.Decimal { 294 | res, err := e.client.EthCall(ethrpc.T{ 295 | To: tokenAddress, 296 | From: address, 297 | Data: fmt.Sprintf("0x70a08231000000000000000000000000%s", without0xPrefix(address)), 298 | }, "latest") 299 | 300 | if err != nil { 301 | panic(err) 302 | } 303 | 304 | return utils.StringToDecimal(res) 305 | } 306 | 307 | func without0xPrefix(address string) string { 308 | if address[:2] == "0x" { 309 | address = address[2:] 310 | } 311 | 312 | return address 313 | } 314 | 315 | func (e *Ethereum) GetTokenAllowance(tokenAddress, proxyAddress, address string) decimal.Decimal { 316 | res, err := e.client.EthCall(ethrpc.T{ 317 | To: tokenAddress, 318 | From: address, 319 | Data: fmt.Sprintf("0xdd62ed3e000000000000000000000000%s000000000000000000000000%s", without0xPrefix(address), without0xPrefix(proxyAddress)), 320 | }, "latest") 321 | 322 | if err != nil { 323 | panic(err) 324 | } 325 | 326 | return utils.StringToDecimal(res) 327 | } 328 | 329 | func (e *Ethereum) GetHotFeeDiscount(address string) decimal.Decimal { 330 | if address == "" { 331 | return decimal.New(1, 0) 332 | } 333 | 334 | from := address 335 | 336 | res, err := e.client.EthCall(ethrpc.T{ 337 | To: e.hybridExAddr, 338 | From: from, 339 | Data: fmt.Sprintf("0x4376abf1000000000000000000000000%s", without0xPrefix(address)), 340 | }, "latest") 341 | 342 | if err != nil { 343 | panic(err) 344 | } 345 | 346 | return utils.StringToDecimal(res).Div(decimal.New(1, 2)) 347 | } 348 | 349 | func (e *Ethereum) IsValidSignature(address string, message string, signature string) (bool, error) { 350 | if len(address) != 42 { 351 | return false, errors.New("address must be 42 size long") 352 | } 353 | 354 | if len(signature) != 132 { 355 | return false, errors.New("signature must be 132 size long") 356 | } 357 | 358 | var hashBytes []byte 359 | if strings.HasPrefix(message, "0x") { 360 | hashBytes = utils.Hex2Bytes(message[2:]) 361 | } else { 362 | hashBytes = []byte(message) 363 | } 364 | 365 | signatureByte := utils.Hex2Bytes(signature[2:]) 366 | pk, err := crypto.PersonalEcRecover(hashBytes, signatureByte) 367 | 368 | if err != nil { 369 | return false, err 370 | } 371 | 372 | return "0x"+strings.ToLower(pk) == strings.ToLower(address), nil 373 | } 374 | 375 | func (e *Ethereum) SendRawTransaction(tx interface{}) (string, error) { 376 | rawTransaction := tx.(string) 377 | return e.client.EthSendRawTransaction(rawTransaction) 378 | } 379 | 380 | func (e *Ethereum) GetTransactionCount(address string) (int, error) { 381 | return e.client.EthGetTransactionCount(address, "latest") 382 | } 383 | 384 | func NewEthereum(rpcUrl string, hybridExAddr string) *Ethereum { 385 | if hybridExAddr == "" { 386 | hybridExAddr = os.Getenv("HSK_HYBRID_EXCHANGE_ADDRESS") 387 | } 388 | 389 | if hybridExAddr == "" { 390 | panic(fmt.Errorf("NewEthereum need argument hybridExAddr")) 391 | } 392 | 393 | return &Ethereum{ 394 | client: ethrpc.New(rpcUrl), 395 | hybridExAddr: hybridExAddr, 396 | } 397 | } 398 | 399 | func IsValidSignature(address string, message string, signature string) (bool, error) { 400 | return new(Ethereum).IsValidSignature(address, message, signature) 401 | } 402 | 403 | func PersonalSign(message []byte, privateKey string) ([]byte, error) { 404 | return crypto.PersonalSign(message, privateKey) 405 | } 406 | -------------------------------------------------------------------------------- /sdk/ethereum/ethereum_hydro.go: -------------------------------------------------------------------------------- 1 | package ethereum 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | type EthereumHydro struct { 9 | *Ethereum 10 | *EthereumHydroProtocol 11 | } 12 | 13 | func NewEthereumHydro(rpcURL, hybridExAddr string) *EthereumHydro { 14 | if rpcURL == "" { 15 | rpcURL = os.Getenv("HSK_BLOCKCHAIN_RPC_URL") 16 | } 17 | 18 | if rpcURL == "" { 19 | panic(fmt.Errorf("NewEthereumHydro need argument rpcURL")) 20 | } 21 | 22 | return &EthereumHydro{ 23 | NewEthereum(rpcURL, hybridExAddr), 24 | &EthereumHydroProtocol{}, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /sdk/ethereum/ethereum_hydro_protocol.go: -------------------------------------------------------------------------------- 1 | package ethereum 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "fmt" 7 | "math/big" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/HydroProtocol/hydro-sdk-backend/sdk" 12 | "github.com/HydroProtocol/hydro-sdk-backend/sdk/crypto" 13 | "github.com/HydroProtocol/hydro-sdk-backend/sdk/types" 14 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 15 | "github.com/shopspring/decimal" 16 | ) 17 | 18 | type EthereumHydroProtocol struct{} 19 | 20 | func (*EthereumHydroProtocol) GenerateOrderData(version, expiredAtSeconds, salt int64, asMakerFeeRate, asTakerFeeRate, makerRebateRate decimal.Decimal, isSell, isMarket, isMakerOnly bool) string { 21 | data := strings.Builder{} 22 | data.WriteString("0x") 23 | data.WriteString(addLeadingZero(strconv.FormatInt(version, 10), 2)) 24 | if isSell { 25 | data.WriteString("01") 26 | } else { 27 | data.WriteString("00") 28 | } 29 | 30 | if isMarket { 31 | data.WriteString("01") 32 | } else { 33 | data.WriteString("00") 34 | } 35 | 36 | data.WriteString(addTailingZero(fmt.Sprintf("%x", expiredAtSeconds), 5*2)) 37 | data.WriteString(addLeadingZero(utils.Bytes2Hex(utils.DecimalToBigInt(asMakerFeeRate.Mul(decimal.New(FeeRateBase, 0))).Bytes()), 2*2)) 38 | data.WriteString(addLeadingZero(utils.Bytes2Hex(utils.DecimalToBigInt(asTakerFeeRate.Mul(decimal.New(FeeRateBase, 0))).Bytes()), 2*2)) 39 | data.WriteString(addLeadingZero(utils.Bytes2Hex(utils.DecimalToBigInt(makerRebateRate.Mul(decimal.New(FeeRateBase, 0))).Bytes()), 2*2)) 40 | data.WriteString(addLeadingZero(fmt.Sprintf("%x", salt), 8*2)) 41 | 42 | if isMakerOnly { 43 | data.WriteString("01") 44 | } else { 45 | data.WriteString("00") 46 | } 47 | 48 | return addTailingZero(data.String(), 66) 49 | } 50 | 51 | func (*EthereumHydroProtocol) GetOrderHash(order *sdk.Order) []byte { 52 | return getEIP712MessageHash( 53 | crypto.Keccak256( 54 | EIP712_ORDER_TYPE, 55 | types.HexToHash(order.Trader).Bytes(), 56 | types.HexToHash(order.Relayer).Bytes(), 57 | types.HexToHash(order.BaseTokenAddress).Bytes(), 58 | types.HexToHash(order.QuoteTokenAddress).Bytes(), 59 | types.BytesToHash(order.BaseTokenAmount.Bytes()).Bytes(), 60 | types.BytesToHash(order.QuoteTokenAmount.Bytes()).Bytes(), 61 | types.BytesToHash(order.GasTokenAmount.Bytes()).Bytes(), 62 | types.HexToHash(order.Data).Bytes(), 63 | ), 64 | ) 65 | } 66 | func (*EthereumHydroProtocol) GetMatchOrderCallData(takerOrder *sdk.Order, makerOrders []*sdk.Order, baseTokenFilledAmounts []*big.Int) []byte { 67 | var buf bytes.Buffer 68 | 69 | //buf.Write([]byte{'\x8d', '\x10', '\x88', '\x3d'}) // function id v1.0 70 | buf.Write([]byte{'\x88', '\x4d', '\xad', '\x2e'}) // function id v1.1 71 | buf.Write(getLightOrderBytesFromOrder(takerOrder)) 72 | 73 | // offset of makerOrders 74 | buf.Write(uint64ToPaddingBytes(uint64(13*32), 32)) 75 | // offset of fillAmounts 76 | buf.Write(uint64ToPaddingBytes(uint64((14+len(makerOrders)*8)*32), 32)) 77 | 78 | buf.Write(types.HexToHash(takerOrder.BaseTokenAddress).Bytes()) 79 | buf.Write(types.HexToHash(takerOrder.QuoteTokenAddress).Bytes()) 80 | 81 | relayerAdx := types.HexToAddress(strings.Trim(takerOrder.Relayer, "0x")) 82 | buf.Write(utils.LeftPadBytes(relayerAdx.Bytes(), 32)) 83 | 84 | // makerCount 85 | buf.Write(uint64ToPaddingBytes(uint64(len(makerOrders)), 32)) 86 | // makerLightOrders 87 | for _, makerOrder := range makerOrders { 88 | buf.Write(getLightOrderBytesFromOrder(makerOrder)) 89 | } 90 | 91 | // baseTokenFilledAmount count 92 | buf.Write(uint64ToPaddingBytes(uint64(len(baseTokenFilledAmounts)), 32)) 93 | // baseTokenFilledAmounts 94 | for _, baseTokenFilledAmount := range baseTokenFilledAmounts { 95 | buf.Write(types.BigToHash(baseTokenFilledAmount).Bytes()) 96 | } 97 | 98 | return buf.Bytes() 99 | } 100 | 101 | func (*EthereumHydroProtocol) IsValidOrderSignature(address string, orderID string, signature string) bool { 102 | // ethereum signature config: [:32] r[32:64] s[64:] 103 | // first byte of config is v 104 | sigBytes := utils.Hex2Bytes(signature) 105 | 106 | if len(sigBytes) != 96 { 107 | panic(fmt.Errorf("order signature for ethereum should have 96 bytes. %s", signature)) 108 | } 109 | 110 | ethSig := make([]byte, 65) 111 | copy(ethSig[:64], sigBytes[32:]) 112 | ethSig[64] = sigBytes[0] 113 | 114 | res, _ := IsValidSignature(address, orderID, utils.Bytes2HexP(ethSig)) 115 | 116 | return res 117 | } 118 | 119 | func getDomainSeparator() []byte { 120 | return crypto.Keccak256( 121 | EIP712_DOMAIN_TYPEHASH, 122 | crypto.Keccak256([]byte("Hydro Protocol")), 123 | ) 124 | } 125 | 126 | func getEIP712MessageHash(message []byte) []byte { 127 | return crypto.Keccak256( 128 | []byte{'\x19', '\x01'}, 129 | getDomainSeparator(), 130 | message, 131 | ) 132 | } 133 | 134 | func uint64ToPaddingBytes(num uint64, bytesLength int) []byte { 135 | numStr := strconv.FormatUint(num, 16) 136 | if len(numStr)&1 == 1 { 137 | numStr = fmt.Sprintf("0%s", numStr) 138 | } 139 | return utils.LeftPadBytes(utils.Hex2Bytes(numStr), bytesLength) 140 | } 141 | 142 | func GetOrderData( 143 | version uint64, 144 | isSell bool, 145 | isMarketOrder bool, 146 | expiredAt, rawMakerFeeRate, rawTakerFeeRate, rawMakerRebateRate uint64, 147 | salt uint64, 148 | isMakerOnly bool, 149 | ) string { 150 | var buf bytes.Buffer 151 | 152 | buf.WriteByte(uint64ToPaddingBytes(version, 1)[0]) 153 | 154 | if isSell { 155 | buf.WriteByte('\x01') 156 | } else { 157 | buf.WriteByte('\x00') 158 | } 159 | 160 | if isMarketOrder { 161 | buf.WriteByte('\x01') 162 | } else { 163 | buf.WriteByte('\x00') 164 | } 165 | 166 | buf.Write(uint64ToPaddingBytes(expiredAt, 5)) 167 | 168 | buf.Write(uint64ToPaddingBytes(rawMakerFeeRate, 2)) 169 | buf.Write(uint64ToPaddingBytes(rawTakerFeeRate, 2)) 170 | buf.Write(uint64ToPaddingBytes(rawMakerRebateRate, 2)) 171 | buf.Write(uint64ToPaddingBytes(salt, 8)) 172 | 173 | if isMakerOnly { 174 | buf.WriteByte('\x01') 175 | } else { 176 | buf.WriteByte('\x00') 177 | } 178 | 179 | rst := utils.Bytes2Hex(utils.RightPadBytes(buf.Bytes(), 32)) 180 | 181 | return rst 182 | } 183 | 184 | func GetHash(order *sdk.Order) []byte { 185 | return getEIP712MessageHash( 186 | crypto.Keccak256( 187 | EIP712_ORDER_TYPE, 188 | types.HexToHash(order.Trader).Bytes(), 189 | types.HexToHash(order.Relayer).Bytes(), 190 | types.HexToHash(order.BaseTokenAddress).Bytes(), 191 | types.HexToHash(order.QuoteTokenAddress).Bytes(), 192 | types.BytesToHash(order.BaseTokenAmount.Bytes()).Bytes(), 193 | types.BytesToHash(order.QuoteTokenAmount.Bytes()).Bytes(), 194 | types.BytesToHash(order.GasTokenAmount.Bytes()).Bytes(), 195 | types.HexToHash(order.Data).Bytes(), 196 | ), 197 | ) 198 | } 199 | 200 | func GetRawMakerFeeRateFromOrderData(data string) uint16 { 201 | return binary.BigEndian.Uint16(types.HexToHash(data).Bytes()[8:10]) 202 | } 203 | func GetRawTakerFeeRateFromOrderData(data string) uint16 { 204 | return binary.BigEndian.Uint16(types.HexToHash(data).Bytes()[10:12]) 205 | } 206 | func GetRawMakerRebateRateFromOrderData(data string) uint16 { 207 | return binary.BigEndian.Uint16(types.HexToHash(data).Bytes()[12:14]) 208 | } 209 | func GetIsMakerOnlyFromOrderData(data string) bool { 210 | return int(types.HexToHash(data).Bytes()[22]) >= 1 211 | } 212 | 213 | func GetOrderExpireTsFromOrderData(data string) uint64 { 214 | bytes := types.HexToHash(data).Bytes()[3:8] 215 | paddedBytes := utils.LeftPadBytes(bytes[:], 8) 216 | 217 | return binary.BigEndian.Uint64(paddedBytes) 218 | } 219 | 220 | func getLightOrderBytesFromOrder(order *sdk.Order) []byte { 221 | var buf bytes.Buffer 222 | 223 | buf.Write(types.HexToHash(order.Trader).Bytes()) 224 | buf.Write(types.BytesToHash(order.BaseTokenAmount.Bytes()).Bytes()) 225 | buf.Write(types.BytesToHash(order.QuoteTokenAmount.Bytes()).Bytes()) 226 | buf.Write(types.BytesToHash(order.GasTokenAmount.Bytes()).Bytes()) 227 | buf.Write(types.HexToHash(order.Data).Bytes()) 228 | 229 | buf.Write(utils.Hex2Bytes(order.Signature)) 230 | 231 | return buf.Bytes() 232 | } 233 | 234 | const FeeRateBase = 100000 235 | 236 | func addTailingZero(data string, length int) string { 237 | return data + strings.Repeat("0", length-len(data)) 238 | } 239 | 240 | func addLeadingZero(data string, length int) string { 241 | return strings.Repeat("0", length-len(data)) + data 242 | } 243 | -------------------------------------------------------------------------------- /sdk/ethereum/ethereum_hydro_protocol_test.go: -------------------------------------------------------------------------------- 1 | package ethereum 2 | 3 | import ( 4 | "math/big" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/HydroProtocol/hydro-sdk-backend/sdk" 9 | "github.com/HydroProtocol/hydro-sdk-backend/sdk/crypto" 10 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 11 | "github.com/shopspring/decimal" 12 | "github.com/stretchr/testify/suite" 13 | ) 14 | 15 | type hydroTestSuite struct { 16 | suite.Suite 17 | } 18 | 19 | func (suite *hydroTestSuite) SetupSuite() { 20 | } 21 | 22 | func (suite *hydroTestSuite) TearDownSuite() { 23 | } 24 | 25 | func (suite *hydroTestSuite) TearDownTest() { 26 | } 27 | 28 | func (suite *hydroTestSuite) TestDomainHash() { 29 | suite.Equal( 30 | "0xb2178a58fb1eefb359ecfdd57bb19c0bdd0f4e6eed8547f46600e500ed111af3", 31 | utils.Bytes2HexP(EIP712_DOMAIN_TYPEHASH), 32 | ) 33 | } 34 | 35 | func (suite *hydroTestSuite) TestDomainSeparator() { 36 | suite.Equal( 37 | "0x097976fcea7606c3ff7a3beb3e4d47c93030165478ea6a99683bb493608d36bc", 38 | utils.Bytes2HexP(getDomainSeparator()), 39 | ) 40 | } 41 | 42 | func (suite *hydroTestSuite) TestGetEIP712MessageHash() { 43 | message := utils.Hex2Bytes("ea83cdcdd06bf61e414054115a551e23133711d0507dcbc07a4bab7dc4581935") 44 | suite.Equal( 45 | "0xf77f07f3ec21820e65cf13028dc5deaa9fbab93e3cba2bc7acdab12813004459", 46 | utils.Bytes2HexP(getEIP712MessageHash(message)), 47 | ) 48 | } 49 | 50 | func (suite *hydroTestSuite) TestIsValidSignature() { 51 | taker := "0x3870b6f2c0b723f4855d8ad53ab7599b02d4df84" 52 | orderHash := "0x4e418856ca6935c955df6afd9cb780f84be72e3256ade452811d8ebbc8ea42e1" 53 | 54 | sig := append( 55 | utils.Hex2Bytes("19cef14892021d56d31b8f6ca6ed99ab89ac918a2d8e2e9d034b14ccf1dfa17f"), 56 | utils.Hex2Bytes("27601ed6b0d1a7fd64f6e62c2fea27580f36521d2609baaf2f9921ecd6cb761b")...) 57 | 58 | sig = append(sig, (byte)(int('\x1c')-27)) 59 | 60 | pub, err := crypto.SigToPub(utils.Hex2Bytes(orderHash), sig) 61 | if err != nil { 62 | panic(err) 63 | } 64 | 65 | realAdx := strings.ToLower(crypto.PubKey2Address(*pub)) 66 | 67 | suite.Equal(taker, realAdx) 68 | } 69 | 70 | func (suite *hydroTestSuite) TestIsValidOrderSignature() { 71 | taker := "0xe269e891a2ec8585a378882ffa531141205e92e9" 72 | orderHash := "0x4e418856ca6935c955df6afd9cb780f84be72e3256ade452811d8ebbc8ea42e1" 73 | 74 | signature := "0x1b00000000000000000000000000000000000000000000000000000000000000" + 75 | "3966c24be7df61e273ae732f825140ffbe157c10a9ff6e2665e671b92d64d01a" + 76 | "5e8f0e9bb00962812ab7abca3467ab47c72a980c7acc18280f34496464176c70" 77 | 78 | suite.Equal(true, new(EthereumHydroProtocol).IsValidOrderSignature(taker, orderHash, signature)) 79 | } 80 | 81 | var version = uint64(2) 82 | 83 | // data component: 84 | // ╔════════════════════╤═══════════════════════════════════════════════════════════╗ 85 | // ║ │ length(bytes) desc ║ 86 | // ╟────────────────────┼───────────────────────────────────────────────────────────╢ 87 | // ║ version │ 1 order version ║ 88 | // ║ side │ 1 0: buy, 1: sell ║ 89 | // ║ isMarketOrder │ 1 0: limitOrder, 1: marketOrder ║ 90 | // ║ expiredAt │ 5 second of this order expiration time ║ 91 | // ║ asMakerFeeRate │ 2 maker fee rate base 100,000 ║ 92 | // ║ asTakerFeeRate │ 2 taker fee rate base 100,000 ║ 93 | // ║ makerRebateRate │ 2 rebate rate for maker base 1,000,000 ║ 94 | // ║ salt │ 8 salt ║ 95 | // ║ isMakerOnly │ 1 0: not isMakerOnly, 1: isMakerOnly ║ 96 | // ║ │ 9 reserved ║ 97 | // ╚════════════════════╧═══════════════════════════════════════════════════════════╝ 98 | // bytes32 data; 99 | 100 | func (suite hydroTestSuite) TestGetOrderData() { 101 | res := GetOrderData(version, true, true, 1539247438, 10000, 50000, 10000, 488701836, false) 102 | 103 | // 1539247438 hex 00 5b bf 0d 4e 104 | // 10000 hex 27 10 105 | // 50000 hex c3 50 106 | // 488701836 hex 00 00 00 00 1d 20 ff 8c 107 | suite.Equal("020101005bbf0d4e2710c3502710000000001d20ff8c00000000000000000000", res) 108 | 109 | res = GetOrderData(version, true, true, 0, 0, 1, 1, 1, false) 110 | suite.Equal("0201010000000000000000010001000000000000000100000000000000000000", res) 111 | } 112 | 113 | func (suite *hydroTestSuite) TestGetHash() { 114 | taker := "0x3870b6f2c0b723f4855d8ad53ab7599b02d4df84" 115 | relayer := "0xd4a1963e645244c7fb4fe8efab12e4bc02c5fad3" 116 | 117 | baseCurrency := "0xfe1e07852eb0fa0df66843e84a41da212b455e98" 118 | quoteCurrency := "0x9712e6cadf82d1902088ef858502ca17261bb893" 119 | 120 | takerOrder := NewOrder( 121 | taker, 122 | relayer, 123 | baseCurrency, 124 | quoteCurrency, 125 | utils.Hex2BigInt("0x1bc16d674ec80000"), 126 | big.NewInt(0), 127 | utils.Hex2BigInt("0x572255eb17edfc4"), 128 | true, 129 | true, 130 | version, 131 | 9999999999, 132 | 100, 133 | 200, 134 | 100, 135 | 520496, 136 | "0x"+"1c01000000000000000000000000000000000000000000000000000000000000"+"19cef14892021d56d31b8f6ca6ed99ab89ac918a2d8e2e9d034b14ccf1dfa17f"+"27601ed6b0d1a7fd64f6e62c2fea27580f36521d2609baaf2f9921ecd6cb761b", 137 | ) 138 | 139 | computedOrderHash := GetHash(takerOrder) 140 | suite.Equal("6ae837ed30cba8174b589c644e351166c5e7dfa1ffdbd1a1333287daf63c9b43", utils.Bytes2Hex(computedOrderHash)) 141 | } 142 | 143 | func (suite *hydroTestSuite) TestGetMatchOrdersDataHex() { 144 | taker := "0x3870b6f2c0b723f4855d8ad53ab7599b02d4df84" 145 | maker := "0x85cf54dd216997bcf324c72aa1c845be2f059299" 146 | relayer := "0x93388b4efe13b9b18ed480783c05462409851547" 147 | 148 | baseCurrency := "0xfe1e07852eb0fa0df66843e84a41da212b455e98" 149 | quoteCurrency := "0x9712e6cadf82d1902088ef858502ca17261bb893" 150 | 151 | takerOrder := NewOrder( 152 | taker, 153 | relayer, 154 | baseCurrency, 155 | quoteCurrency, 156 | utils.Hex2BigInt("0x1bc16d674ec80000"), 157 | big.NewInt(0), 158 | utils.Hex2BigInt("0x572255eb17edfc4"), 159 | true, 160 | true, 161 | version, 162 | 9999999999, 163 | 100, 164 | 200, 165 | 100, 166 | 520496, 167 | "0x"+ 168 | "1c01000000000000000000000000000000000000000000000000000000000000"+ 169 | "19cef14892021d56d31b8f6ca6ed99ab89ac918a2d8e2e9d034b14ccf1dfa17f"+ 170 | "27601ed6b0d1a7fd64f6e62c2fea27580f36521d2609baaf2f9921ecd6cb761b", 171 | ) 172 | 173 | makerOrders := []*sdk.Order{ 174 | NewOrder( 175 | maker, 176 | relayer, 177 | baseCurrency, 178 | quoteCurrency, 179 | utils.Hex2BigInt("0xde0b6b3a7640000"), 180 | utils.Hex2BigInt("0xc7d713b49da0000"), 181 | utils.Hex2BigInt("0x572255eb17edfc4"), 182 | false, 183 | false, 184 | version, 185 | 9999999999, 186 | 100, 187 | 200, 188 | 100, 189 | 183426, 190 | "0x"+ 191 | "1b01000000000000000000000000000000000000000000000000000000000000"+ 192 | "a096a43f547bd361d79f965723604965cf45fb9e67754c67accb100eef344804"+ 193 | "571c23cc135c501eb72d137eeb6f430bb3a62da2122610223e3d80b99cecdff9", 194 | ), 195 | NewOrder( 196 | maker, 197 | relayer, 198 | baseCurrency, 199 | quoteCurrency, 200 | utils.Hex2BigInt("0xde0b6b3a7640000"), 201 | utils.Hex2BigInt("0xb1a2bc2ec500000"), 202 | utils.Hex2BigInt("0x572255eb17edfc4"), 203 | false, 204 | false, 205 | version, 206 | 9999999999, 207 | 100, 208 | 200, 209 | 100, 210 | 821893, 211 | "0x"+ 212 | "1b01000000000000000000000000000000000000000000000000000000000000"+ 213 | "583d5bece6c5b0ef5b1807f681270c864e987ffeed66578d39343ab6996851c5"+ 214 | "371013591a920621cd32c8bfd3f7d89d8aef36dfed7c636e08a0b0871491e765", 215 | ), 216 | } 217 | 218 | expectedResult := "0x884dad2e0000000000000000000000003870b6f2c0b723f4855d8ad53ab7599b02d4df840000000000000000000000000000000000000000000000001bc16d674ec8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000572255eb17edfc402010102540be3ff006400c80064000000000007f130000000000000000000001c0100000000000000000000000000000000000000000000000000000000000019cef14892021d56d31b8f6ca6ed99ab89ac918a2d8e2e9d034b14ccf1dfa17f27601ed6b0d1a7fd64f6e62c2fea27580f36521d2609baaf2f9921ecd6cb761b00000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000003c0000000000000000000000000fe1e07852eb0fa0df66843e84a41da212b455e980000000000000000000000009712e6cadf82d1902088ef858502ca17261bb89300000000000000000000000093388b4efe13b9b18ed480783c05462409851547000000000000000000000000000000000000000000000000000000000000000200000000000000000000000085cf54dd216997bcf324c72aa1c845be2f0592990000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000c7d713b49da00000000000000000000000000000000000000000000000000000572255eb17edfc402000002540be3ff006400c80064000000000002cc82000000000000000000001b01000000000000000000000000000000000000000000000000000000000000a096a43f547bd361d79f965723604965cf45fb9e67754c67accb100eef344804571c23cc135c501eb72d137eeb6f430bb3a62da2122610223e3d80b99cecdff900000000000000000000000085cf54dd216997bcf324c72aa1c845be2f0592990000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000b1a2bc2ec5000000000000000000000000000000000000000000000000000000572255eb17edfc402000002540be3ff006400c8006400000000000c8a85000000000000000000001b01000000000000000000000000000000000000000000000000000000000000583d5bece6c5b0ef5b1807f681270c864e987ffeed66578d39343ab6996851c5371013591a920621cd32c8bfd3f7d89d8aef36dfed7c636e08a0b0871491e76500000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000de0b6b3a76400000000000000000000000000000000000000000000000000000de0b6b3a7640000" 219 | 220 | var baseTokenFilledAmt []*big.Int 221 | baseTokenFilledAmt = append(baseTokenFilledAmt, utils.DecimalToBigInt(decimal.New(1, 18))) 222 | baseTokenFilledAmt = append(baseTokenFilledAmt, utils.DecimalToBigInt(decimal.New(1, 18))) 223 | 224 | ep := &EthereumHydroProtocol{} 225 | res := utils.Bytes2HexP(ep.GetMatchOrderCallData(takerOrder, makerOrders, baseTokenFilledAmt)) 226 | suite.Equal(expectedResult, res) 227 | } 228 | 229 | func (suite *hydroTestSuite) TestGetAsTakerFeeRateFromOrderData() { 230 | data := "01010102540be3ff006400c8006400000000000df8f400000000000000000000" 231 | takerFee := GetRawTakerFeeRateFromOrderData(data) 232 | makerFee := GetRawMakerFeeRateFromOrderData(data) 233 | rebate := GetRawMakerRebateRateFromOrderData(data) 234 | 235 | suite.Equal(uint16(200), takerFee) 236 | suite.Equal(uint16(100), makerFee) 237 | suite.Equal(uint16(100), rebate) 238 | } 239 | 240 | func (suite *hydroTestSuite) TestGetAsTakerFeeRateFromOrderData2() { 241 | data := "01010102540be3ff006400c8006400000000000df8f400000000000000000000" 242 | 243 | asTakerFeeRate := decimal.New(int64(GetRawTakerFeeRateFromOrderData(data)), 0) 244 | 245 | suite.True(decimal.NewFromFloat(200).Equal(asTakerFeeRate)) 246 | 247 | considerFee := asTakerFeeRate.Div(decimal.New(1, 5)).Add(decimal.New(1, 0)) 248 | suite.True(considerFee.Equal(decimal.NewFromFloat(1.002))) 249 | } 250 | 251 | func (suite *hydroTestSuite) TestA() { 252 | config := "0x1234" 253 | 254 | var configBytes [32]byte 255 | copy(configBytes[:], utils.RightPadBytes(utils.Hex2Bytes(config[2:]), 32)) 256 | } 257 | 258 | func (suite *hydroTestSuite) TestExpireTs() { 259 | data := "0x01010002540be3ff006400c8006400000000000d119600000000000000000000" 260 | 261 | ts := GetOrderExpireTsFromOrderData(data) 262 | 263 | suite.Equal(ts, uint64(9999999999)) 264 | } 265 | 266 | func NewOrder( 267 | trader, relayer, baseCurrency, quoteCurrency string, 268 | baseCurrencyHugeAmount, quoteCurrencyHugeAmount, gasTokenHugeAmount *big.Int, 269 | isSell, isMarketOrder bool, 270 | version, expiredAt, rawMakerFeeRate, rawTakerFeeRate, rawMakerRebateRate, salt uint64, 271 | signature string, 272 | ) *sdk.Order { 273 | return &sdk.Order{ 274 | Trader: trader, 275 | Relayer: relayer, 276 | BaseTokenAmount: baseCurrencyHugeAmount, 277 | QuoteTokenAmount: quoteCurrencyHugeAmount, 278 | BaseTokenAddress: baseCurrency, 279 | QuoteTokenAddress: quoteCurrency, 280 | GasTokenAmount: gasTokenHugeAmount, 281 | Data: GetOrderData(version, isSell, isMarketOrder, expiredAt, rawMakerFeeRate, rawTakerFeeRate, rawMakerRebateRate, salt, false), 282 | Signature: signature, 283 | } 284 | } 285 | 286 | func TestHydroTestSuite(t *testing.T) { 287 | suite.Run(t, new(hydroTestSuite)) 288 | } 289 | -------------------------------------------------------------------------------- /sdk/ethereum/ethereum_test.go: -------------------------------------------------------------------------------- 1 | package ethereum 2 | 3 | import ( 4 | "encoding/hex" 5 | "github.com/HydroProtocol/hydro-sdk-backend/sdk" 6 | "github.com/HydroProtocol/hydro-sdk-backend/sdk/crypto" 7 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 8 | "github.com/stretchr/testify/suite" 9 | "testing" 10 | ) 11 | 12 | type ethereumTestSuite struct { 13 | suite.Suite 14 | blockchain sdk.BlockChain 15 | } 16 | 17 | func (s *ethereumTestSuite) SetupSuite() { 18 | s.blockchain = NewEthereum("http://localhost:8545", "foo") 19 | } 20 | 21 | func (s *ethereumTestSuite) TearDownSuite() { 22 | } 23 | 24 | func (s *ethereumTestSuite) TearDownTest() { 25 | } 26 | 27 | func (s *ethereumTestSuite) TestGetTransactions() { 28 | _, err := s.blockchain.GetBlockNumber() 29 | s.Nil(err) 30 | } 31 | 32 | func (s *ethereumTestSuite) TestGetBlockByNumber() { 33 | blockNumber, err := s.blockchain.GetBlockNumber() 34 | s.Nil(err) 35 | 36 | _, err = s.blockchain.GetBlockByNumber(blockNumber) 37 | s.Nil(err) 38 | } 39 | 40 | func (s *ethereumTestSuite) TestGetTransaction() { 41 | blockNumber, err := s.blockchain.GetBlockNumber() 42 | s.Nil(err) 43 | 44 | block, err := s.blockchain.GetBlockByNumber(blockNumber) 45 | s.Nil(err) 46 | 47 | block.Number() 48 | block.Timestamp() 49 | 50 | transactions := block.GetTransactions() 51 | s.NotZero(len(transactions)) 52 | 53 | hash := transactions[0].GetHash() 54 | 55 | tx, err := s.blockchain.GetTransaction(hash) 56 | s.Nil(err) 57 | s.Equal(hash, tx.GetHash()) 58 | 59 | txReceipt, err := s.blockchain.GetTransactionReceipt(hash) 60 | s.Nil(err) 61 | s.Equal(blockNumber, txReceipt.GetBlockNumber()) 62 | 63 | tx2, txReceipt2, err := s.blockchain.GetTransactionAndReceipt(hash) 64 | txReceipt2.GetResult() 65 | s.Nil(err) 66 | s.Equal(hash, tx2.GetHash()) 67 | s.Equal(blockNumber, txReceipt2.GetBlockNumber()) 68 | } 69 | 70 | func (s *ethereumTestSuite) TestAddTailingZero() { 71 | s.Equal("3000000000", addTailingZero("3", 10)) 72 | } 73 | 74 | func (s *ethereumTestSuite) TestAddLeadingZero() { 75 | s.Equal("0000000003", addLeadingZero("3", 10)) 76 | } 77 | 78 | func (s *ethereumTestSuite) TestGetTokenBalance() { 79 | balance := s.blockchain.GetTokenBalance("0x4C4Fa7E8EA4cFCfC93DEAE2c0Cff142a1DD3a218", "0x126aa4ef50a6e546aa5ecd1eb83c060fb780891a") 80 | s.Equal("100000000000000000000000", balance.String()) 81 | } 82 | 83 | func (s *ethereumTestSuite) TestGetTokenAllowance() { 84 | allowance := s.blockchain.GetTokenAllowance("0x4C4Fa7E8EA4cFCfC93DEAE2c0Cff142a1DD3a218", "0x04f67E8b7C39A25e100847Cb167460D715215FEb", "0x126aa4ef50a6e546aa5ecd1eb83c060fb780891a") 85 | s.True(allowance.GreaterThanOrEqual(utils.StringToDecimal("0x0f00000000000000000000000000000000000000000000000000000000000000"))) 86 | } 87 | 88 | func (s *ethereumTestSuite) TestSignAndVerify() { 89 | address := "0x126aa4ef50a6e546aa5ecd1eb83c060fb780891a" 90 | privakeKey := "a6553a3cbade744d6c6f63e557345402abd93e25cd1f1dba8bb0d374de2fcf4f" 91 | message := "🌞🌛👌😄💗" 92 | 93 | sigBytes, err := crypto.PersonalSign([]byte(message), privakeKey) 94 | s.Nil(err) 95 | 96 | match, err := IsValidSignature(address, message, "0x"+hex.EncodeToString(sigBytes)) 97 | s.Nil(err) 98 | 99 | s.True(match) 100 | } 101 | 102 | func TestSignAndVerify1(t *testing.T) { 103 | privakeKey := "b7a0c9d2786fc4dd080ea5d619d36771aeb0c8c26c290afd3451b92ba2b7bc2c" 104 | message := "0xc44ba4a2d189d0eacd82c83f63dd5dd681b21800f317a3e94fe306db4fb91cf0" 105 | 106 | sigBytes, _ := crypto.PersonalSign(utils.Hex2Bytes(message[2:]), privakeKey) 107 | 108 | utils.Infof("0x" + hex.EncodeToString(sigBytes)) 109 | } 110 | 111 | func TestEthereumSuite(t *testing.T) { 112 | suite.Run(t, new(ethereumTestSuite)) 113 | } 114 | -------------------------------------------------------------------------------- /sdk/interface.go: -------------------------------------------------------------------------------- 1 | package sdk 2 | 3 | import ( 4 | "math/big" 5 | 6 | "github.com/shopspring/decimal" 7 | ) 8 | 9 | type BlockChain interface { 10 | GetTokenBalance(tokenAddress, address string) decimal.Decimal 11 | GetTokenAllowance(tokenAddress, proxyAddress, address string) decimal.Decimal 12 | GetHotFeeDiscount(address string) decimal.Decimal 13 | 14 | GetBlockNumber() (uint64, error) 15 | GetBlockByNumber(blockNumber uint64) (Block, error) 16 | 17 | GetTransaction(ID string) (Transaction, error) 18 | GetTransactionReceipt(ID string) (TransactionReceipt, error) 19 | GetTransactionAndReceipt(ID string) (Transaction, TransactionReceipt, error) 20 | 21 | IsValidSignature(address string, message string, signature string) (bool, error) 22 | //GetTransactionCount(address string) (int, error) 23 | 24 | SendTransaction(txAttributes map[string]interface{}, privateKey []byte) (transactionHash string, err error) 25 | SendRawTransaction(tx interface{}) (string, error) 26 | } 27 | 28 | type HydroProtocol interface { 29 | GenerateOrderData(version, expiredAtSeconds, salt int64, asMakerFeeRate, asTakerFeeRate, makerRebateRate decimal.Decimal, isSell, isMarket, isMakerOnly bool) string 30 | GetOrderHash(*Order) []byte 31 | GetMatchOrderCallData(*Order, []*Order, []*big.Int) []byte 32 | 33 | IsValidOrderSignature(address string, orderID string, signature string) bool 34 | } 35 | 36 | type Hydro interface { 37 | HydroProtocol 38 | BlockChain 39 | } 40 | 41 | type Block interface { 42 | Number() uint64 43 | Timestamp() uint64 44 | GetTransactions() []Transaction 45 | 46 | Hash() string 47 | ParentHash() string 48 | } 49 | 50 | type Transaction interface { 51 | GetBlockHash() string 52 | GetBlockNumber() uint64 53 | GetFrom() string 54 | GetGas() int 55 | GetGasPrice() big.Int 56 | GetHash() string 57 | GetTo() string 58 | GetValue() big.Int 59 | } 60 | 61 | type TransactionReceipt interface { 62 | GetResult() bool 63 | GetBlockNumber() uint64 64 | 65 | GetBlockHash() string 66 | GetTxHash() string 67 | GetTxIndex() int 68 | 69 | GetLogs() []IReceiptLog 70 | } 71 | 72 | type IReceiptLog interface { 73 | GetRemoved() bool 74 | GetLogIndex() int 75 | GetTransactionIndex() int 76 | GetTransactionHash() string 77 | GetBlockNum() int 78 | GetBlockHash() string 79 | GetAddress() string 80 | GetData() string 81 | GetTopics() []string 82 | } 83 | 84 | type ( 85 | OrderSignature struct { 86 | Config [32]byte 87 | R [32]byte 88 | S [32]byte 89 | } 90 | 91 | Order struct { 92 | Trader string 93 | BaseTokenAmount *big.Int 94 | QuoteTokenAmount *big.Int 95 | GasTokenAmount *big.Int 96 | Data string 97 | Signature string 98 | 99 | Relayer string 100 | BaseTokenAddress string 101 | QuoteTokenAddress string 102 | } 103 | 104 | OrderParam struct { 105 | Trader string `json:"trader"` 106 | BaseTokenAmount *big.Int `json:"base_token_amount"` 107 | QuoteTokenAmount *big.Int `json:"quote_token_amount"` 108 | GasTokenAmount *big.Int `json:"gas_token_amount"` 109 | Data string `json:"data"` 110 | Signature *OrderSignature `json:"signature"` 111 | } 112 | 113 | OrderAddressSet struct { 114 | BaseToken string `json:"baseToken"` 115 | QuoteToken string `json:"quoteToken"` 116 | Relayer string `json:"relayer"` 117 | } 118 | ) 119 | 120 | func NewOrderWithData( 121 | trader, relayer, baseTokenAddress, quoteTokenAddress string, 122 | baseTokenAmount, quoteTokenAmount, gasTokenAddress *big.Int, 123 | data string, 124 | signature string, 125 | ) *Order { 126 | return &Order{ 127 | Trader: trader, 128 | Relayer: relayer, 129 | BaseTokenAmount: baseTokenAmount, 130 | QuoteTokenAmount: quoteTokenAmount, 131 | BaseTokenAddress: baseTokenAddress, 132 | QuoteTokenAddress: quoteTokenAddress, 133 | GasTokenAmount: gasTokenAddress, 134 | Data: data, 135 | Signature: signature, 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /sdk/rlp/rlp.go: -------------------------------------------------------------------------------- 1 | // rlp encoding standard 2 | // https://github.com/ethereum/wiki/wiki/RLP 3 | package rlp 4 | 5 | import ( 6 | "bytes" 7 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 8 | ) 9 | 10 | func Encode(items interface{}) []byte { 11 | switch v := items.(type) { 12 | case []byte: 13 | if len(v) == 1 && v[0] < 0x80 { 14 | return v 15 | } else { 16 | return append(encodeLength(len(v), 0x80), v...) 17 | } 18 | case []interface{}: 19 | res := make([]byte, 0, 128) 20 | 21 | for i := range v { 22 | res = append(res, Encode(v[i])...) 23 | } 24 | 25 | return append(encodeLength(len(res), 0xc0), res...) 26 | } 27 | 28 | return nil 29 | } 30 | 31 | func encodeLength(L int, offset int) []byte { 32 | var bts bytes.Buffer 33 | if L < 56 { 34 | bts.WriteByte(byte(L + offset)) 35 | return bts.Bytes() 36 | } else if L < 1<<31-1 { 37 | // In the rlp wiki page, the upper bound of L is 256**8 38 | // There is no need to support such a big size in hydro, we use maxInt as a limit here. 39 | BL := utils.Int2Bytes(uint64(L)) 40 | bts.WriteByte(byte(len(BL) + int(offset) + 55)) 41 | bts.Write(BL) 42 | return bts.Bytes() 43 | } else { 44 | panic("input length out of range") 45 | } 46 | } 47 | 48 | func EncodeUint64ToBytes(n uint64) []byte { 49 | if n == 0 { 50 | return []byte{} 51 | } 52 | 53 | return utils.Int2Bytes(n) 54 | } 55 | -------------------------------------------------------------------------------- /sdk/rlp/rlp_test.go: -------------------------------------------------------------------------------- 1 | package rlp 2 | 3 | import ( 4 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 5 | "github.com/stretchr/testify/suite" 6 | "testing" 7 | ) 8 | 9 | type rlpTestSuite struct { 10 | suite.Suite 11 | } 12 | 13 | func (s *rlpTestSuite) TestString() { 14 | s.Equal([]byte{0x83, 'd', 'o', 'g'}, Encode([]byte("dog"))) 15 | 16 | longStringBytes := []byte("Lorem ipsum dolor sit amet, consectetur adipisicing elit") 17 | s.Equal(append([]byte{0xb8, 0x38}, longStringBytes...), Encode(longStringBytes)) 18 | 19 | } 20 | 21 | func (s *rlpTestSuite) TestStringList() { 22 | s.Equal([]byte{0xc8, 0x83, 'c', 'a', 't', 0x83, 'd', 'o', 'g'}, Encode([]interface{}{ 23 | []byte("cat"), 24 | []byte("dog"), 25 | })) 26 | } 27 | 28 | func (s *rlpTestSuite) TestEmptyString() { 29 | s.Equal([]byte{0x80}, Encode([]byte(""))) 30 | } 31 | 32 | func (s *rlpTestSuite) TestUint64Zero() { 33 | s.Equal([]byte{0x80}, Encode(EncodeUint64ToBytes(0))) 34 | } 35 | 36 | func (s *rlpTestSuite) TestEmptyList() { 37 | s.Equal([]byte{0xc0}, Encode([]interface{}{})) 38 | } 39 | 40 | func (s *rlpTestSuite) TestZero() { 41 | s.Equal([]byte{0x00}, Encode([]byte{0})) 42 | } 43 | 44 | func (s *rlpTestSuite) TestNumber() { 45 | s.Equal([]byte{0x0f}, Encode([]byte{15})) 46 | s.Equal([]byte{0x82, 0x04, 0x00}, Encode([]byte{0x04, 0x00})) 47 | } 48 | 49 | func (s *rlpTestSuite) TestNestedList() { 50 | s.Equal([]byte{0xc7, 0xc0, 0xc1, 0xc0, 0xc3, 0xc0, 0xc1, 0xc0}, Encode([]interface{}{ 51 | []interface{}{}, 52 | []interface{}{ 53 | []interface{}{}, 54 | }, 55 | []interface{}{ 56 | []interface{}{}, 57 | []interface{}{ 58 | []interface{}{}, 59 | }, 60 | }, 61 | })) 62 | } 63 | 64 | func (s *rlpTestSuite) TestOldEncodeCase() { 65 | data1 := []byte{0x1} 66 | data2 := []byte{ 67 | 0xff, 0xff, 0xff, 0xff, 68 | 0xff, 0xff, 0xff, 0xff, 69 | 0xff, 0xff, 0xff, 0xff, 70 | 0xff, 0xff, 0xff, 0xff} 71 | 72 | s.Equal("0xd20190ffffffffffffffffffffffffffffffff", utils.Bytes2HexP(Encode([]interface{}{data1, data2}))) 73 | } 74 | 75 | func TestRlpSuite(t *testing.T) { 76 | suite.Run(t, new(rlpTestSuite)) 77 | } 78 | -------------------------------------------------------------------------------- /sdk/signer/signer.go: -------------------------------------------------------------------------------- 1 | package signer 2 | 3 | import ( 4 | "crypto/ecdsa" 5 | "github.com/HydroProtocol/hydro-sdk-backend/sdk/crypto" 6 | "github.com/HydroProtocol/hydro-sdk-backend/sdk/rlp" 7 | "github.com/HydroProtocol/hydro-sdk-backend/sdk/types" 8 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 9 | ) 10 | 11 | // HomesteadHash returns the hash of an unsigned transaction 12 | func HomesteadHash(t *types.Transaction) []byte { 13 | rlpTx := rlp.Encode([]interface{}{ 14 | rlp.EncodeUint64ToBytes(t.Nonce), 15 | t.GasPrice.Bytes(), 16 | rlp.EncodeUint64ToBytes(t.GasLimit), 17 | utils.Hex2Bytes(t.To[2:]), 18 | t.Value.Bytes(), 19 | t.Data, 20 | }) 21 | hash := crypto.Keccak256(rlpTx) 22 | return hash 23 | } 24 | 25 | // EncodeRlp returns the rlp encoded content of a signed transaction 26 | func EncodeRlp(t *types.Transaction) []byte { 27 | return rlp.Encode([]interface{}{ 28 | rlp.EncodeUint64ToBytes(t.Nonce), 29 | t.GasPrice.Bytes(), 30 | rlp.EncodeUint64ToBytes(t.GasLimit), 31 | utils.Hex2Bytes(t.To[2:]), 32 | t.Value.Bytes(), 33 | t.Data, 34 | t.Signature[64:], 35 | t.Signature[0:32], 36 | t.Signature[32:64], 37 | }) 38 | } 39 | 40 | // Hash returns the hash of a signed transaction 41 | func Hash(t *types.Transaction) []byte { 42 | rlpTx := EncodeRlp(t) 43 | hash := crypto.Keccak256(rlpTx) 44 | return hash 45 | } 46 | 47 | func SignTx(transaction *types.Transaction, key *ecdsa.PrivateKey) (*types.Transaction, error) { 48 | // We use homesteadHash to get best compatibility 49 | hash := HomesteadHash(transaction) 50 | 51 | sig, err := crypto.Sign(hash, key) 52 | 53 | // Since we are using HomesteadHash, the v is either 27 or 28 54 | // Mode details about EIP155 goes https://github.com/ethereum/EIPs/blob/master/EIPS/eip-155.md 55 | if sig[64] < 27 { 56 | sig[64] = sig[64] + 27 57 | } 58 | 59 | transaction.Signature = sig 60 | return transaction, err 61 | } 62 | -------------------------------------------------------------------------------- /sdk/signer/signer_test.go: -------------------------------------------------------------------------------- 1 | package signer 2 | 3 | import ( 4 | "github.com/HydroProtocol/hydro-sdk-backend/sdk/crypto" 5 | "github.com/HydroProtocol/hydro-sdk-backend/sdk/types" 6 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 7 | "github.com/stretchr/testify/assert" 8 | "math/big" 9 | "testing" 10 | ) 11 | 12 | func TestSignTx(t *testing.T) { 13 | privateKey := "b7a0c9d2786fc4dd080ea5d619d36771aeb0c8c26c290afd3451b92ba2b7bc2c" 14 | 15 | nonce := uint64(1) 16 | to := "0x93388b4efe13b9b18ed480783c05462409851547" 17 | amount := big.NewInt(10) 18 | gasLimit := uint64(2) 19 | gasPrice := big.NewInt(20) 20 | data := []byte("hello") 21 | //pk1, _ := crypto.HexToECDSA(privateKey) 22 | // 23 | //transaction1 := types.NewTransaction( 24 | // nonce, 25 | // common.HexToAddress(to), 26 | // amount, 27 | // gasLimit, 28 | // gasPrice, 29 | // data, 30 | //) 31 | //signedTransaction1, _ := types.SignTx(transaction1, types.HomesteadSigner{}, pk1) 32 | //fmt.Println(signedTransaction1.Hash().String()) 33 | 34 | except := utils.Hex2Bytes("0xc7dfbe726632d8d4edcaf1a9f59eb8976e06fd55989c82c79b9654a93c703f01") 35 | pk2, _ := crypto.NewPrivateKeyByHex(privateKey) 36 | transaction2 := types.NewTransaction( 37 | nonce, 38 | to, 39 | amount, 40 | gasLimit, 41 | gasPrice, 42 | data, 43 | ) 44 | signedTransaction2, _ := SignTx(transaction2, pk2) 45 | assert.EqualValues(t, except, Hash(signedTransaction2)) 46 | } 47 | 48 | func TestRlpEncode(t *testing.T) { 49 | privateKey := "b7a0c9d2786fc4dd080ea5d619d36771aeb0c8c26c290afd3451b92ba2b7bc2c" 50 | 51 | nonce := uint64(1) 52 | to := "0x93388b4efe13b9b18ed480783c05462409851547" 53 | amount := big.NewInt(10) 54 | gasLimit := uint64(2) 55 | gasPrice := big.NewInt(20) 56 | data := []byte("hello") 57 | 58 | //pk1, _ := crypto.HexToECDSA(privateKey) 59 | //transaction1 := types.NewTransaction( 60 | // nonce, 61 | // common.HexToAddress(to), 62 | // amount, 63 | // gasLimit, 64 | // gasPrice, 65 | // data, 66 | //) 67 | //signedTransaction1, _ := types.SignTx(transaction1, types.HomesteadSigner{}, pk1) 68 | //buf := new(bytes.Buffer) 69 | //signedTransaction1.EncodeRLP(buf) 70 | //fmt.Println(utils.Bytes2HexP(buf.Bytes())) 71 | except := utils.Hex2Bytes("0xf8620114029493388b4efe13b9b18ed480783c054624098515470a8568656c6c6f1ba0218482bc5f636f0c0ac6bce6b24c2d12fbd8a96653fb6dfdc35d1cfbf6d8218da07148e667e2cb66568dcd4f777c1067afaaf4a8e55a5f604aba5172df54dd7d30") 72 | 73 | pk2, _ := crypto.NewPrivateKeyByHex(privateKey) 74 | transaction2 := types.NewTransaction( 75 | nonce, 76 | to, 77 | amount, 78 | gasLimit, 79 | gasPrice, 80 | data, 81 | ) 82 | signedTransaction2, _ := SignTx(transaction2, pk2) 83 | assert.EqualValues(t, except, EncodeRlp(signedTransaction2)) 84 | } 85 | -------------------------------------------------------------------------------- /sdk/test_helper.go: -------------------------------------------------------------------------------- 1 | package sdk 2 | 3 | import ( 4 | "math/big" 5 | 6 | "github.com/shopspring/decimal" 7 | "github.com/stretchr/testify/mock" 8 | ) 9 | 10 | type MockBlockchain struct { 11 | BlockChain 12 | mock.Mock 13 | } 14 | 15 | type MockHydroProtocol struct { 16 | HydroProtocol 17 | mock.Mock 18 | } 19 | 20 | type MockHydro struct { 21 | HydroProtocol 22 | BlockChain 23 | } 24 | 25 | func NewMockHydro() *MockHydro { 26 | return &MockHydro{ 27 | &MockHydroProtocol{}, 28 | &MockBlockchain{}, 29 | } 30 | } 31 | 32 | func (m *MockBlockchain) GetBlockNumber() (uint64, error) { 33 | args := m.Called() 34 | return args.Get(0).(uint64), args.Error(1) 35 | } 36 | 37 | func (m *MockBlockchain) GetBlockByNumber(blockNumber uint64) (Block, error) { 38 | args := m.Called(blockNumber) 39 | return args.Get(0).(Block), args.Error(1) 40 | } 41 | 42 | func (m *MockBlockchain) GetTransaction(ID string) (Transaction, error) { 43 | args := m.Called(ID) 44 | return args.Get(0).(Transaction), args.Error(1) 45 | } 46 | 47 | func (m *MockBlockchain) GetTransactionReceipt(ID string) (TransactionReceipt, error) { 48 | args := m.Called(ID) 49 | return args.Get(0).(TransactionReceipt), args.Error(1) 50 | } 51 | 52 | func (m *MockBlockchain) GetTransactionAndReceipt(ID string) (Transaction, TransactionReceipt, error) { 53 | args := m.Called(ID) 54 | return args.Get(0).(Transaction), args.Get(1).(TransactionReceipt), args.Error(2) 55 | } 56 | 57 | func (m *MockBlockchain) GetTokenBalance(tokenAddress string, address string) decimal.Decimal { 58 | args := m.Called(tokenAddress, address) 59 | return args.Get(0).(decimal.Decimal) 60 | } 61 | 62 | func (m *MockBlockchain) GetTokenAllowance(tokenAddress, proxyAddress, address string) decimal.Decimal { 63 | args := m.Called(tokenAddress, address) 64 | return args.Get(0).(decimal.Decimal) 65 | } 66 | 67 | func (m *MockBlockchain) GetHotFeeDiscount(address string) decimal.Decimal { 68 | args := m.Called(address) 69 | return args.Get(0).(decimal.Decimal) 70 | } 71 | 72 | //func (m *MockBlockchainClient) GetOrderHash(order *OrderParam, addressSet OrderAddressSet, hydroContractAddress string) []byte { 73 | // args := m.Called(order, addressSet, hydroContractAddress) 74 | // return args.Get(0).([]byte) 75 | //} 76 | 77 | func (m *MockBlockchain) IsValidSignature(address string, message string, signature string) (bool, error) { 78 | args := m.Called(address, message, signature) 79 | return args.Bool(0), args.Error(1) 80 | } 81 | 82 | func (m *MockBlockchain) SendTransaction(txAttributes map[string]interface{}, privateKey []byte) (transactionHash string, err error) { 83 | args := m.Called(txAttributes, privateKey) 84 | return args.String(0), args.Error(1) 85 | } 86 | 87 | func (m *MockBlockchain) SendRawTransaction(tx interface{}) (string, error) { 88 | args := m.Called(tx) 89 | return args.String(0), args.Error(1) 90 | } 91 | 92 | func (m *MockHydroProtocol) GenerateOrderData(version, expiredAtSeconds, salt int64, asMakerFeeRate, asTakerFeeRate, makerRebateRate decimal.Decimal, isSell, isMarket, isMakerOnly bool) string { 93 | args := m.Called(version, expiredAtSeconds, salt, asMakerFeeRate, asTakerFeeRate, makerRebateRate, isSell, isMarket, isMakerOnly) 94 | return args.String(0) 95 | } 96 | 97 | func (m *MockHydroProtocol) GetOrderHash(order *Order) []byte { 98 | args := m.Called(order) 99 | return args.Get(0).([]byte) 100 | } 101 | 102 | func (m *MockHydroProtocol) GetMatchOrderCallData(takerOrder *Order, makerOrders []*Order, baseTokenFilledAmounts []*big.Int) []byte { 103 | args := m.Called(takerOrder, makerOrders, baseTokenFilledAmounts) 104 | return args.Get(0).([]byte) 105 | } 106 | -------------------------------------------------------------------------------- /sdk/types/hash.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | const ( 4 | // HashLength is the expected length of the hash 5 | HashLength = 32 6 | // AddressLength is the expected length of the address 7 | AddressLength = 20 8 | ) 9 | 10 | type Hash [HashLength]byte 11 | 12 | // BytesToHash sets b to hash. 13 | // If b is larger than len(h), b will be cropped from the left. 14 | 15 | func (h *Hash) SetBytes(b []byte) { 16 | if len(b) > len(h) { 17 | b = b[len(b)-HashLength:] 18 | } 19 | 20 | copy(h[HashLength-len(b):], b) 21 | } 22 | func (h Hash) Bytes() []byte { return h[:] } 23 | 24 | type Address [AddressLength]byte 25 | 26 | // BytesToAddress returns Address with value b. 27 | // If b is larger than len(h), b will be cropped from the left. 28 | 29 | func (a *Address) SetBytes(b []byte) { 30 | if len(b) > len(a) { 31 | b = b[len(b)-AddressLength:] 32 | } 33 | copy(a[AddressLength-len(b):], b) 34 | } 35 | func (a Address) Bytes() []byte { return a[:] } 36 | -------------------------------------------------------------------------------- /sdk/types/transaction.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "math/big" 5 | ) 6 | 7 | type Transaction struct { 8 | Nonce uint64 `json:"nonce"` 9 | Value big.Int `json:"value"` 10 | To string `json:"to"` 11 | Data []byte `json:"data"` 12 | GasPrice big.Int `json:"gasPrice"` 13 | GasLimit uint64 `json:"gasLimit"` 14 | Signature []byte `json:"signature"` 15 | } 16 | 17 | func NewTransaction(nonce uint64, to string, amount *big.Int, gasLimit uint64, gasPrice *big.Int, data []byte) *Transaction { 18 | return &Transaction{ 19 | Nonce: nonce, 20 | Value: *amount, 21 | To: to, 22 | GasPrice: *gasPrice, 23 | GasLimit: gasLimit, 24 | Data: data, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /sdk/types/types_util.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "math/big" 5 | 6 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 7 | ) 8 | 9 | func HexToAddress(s string) Address { return BytesToAddress(utils.Hex2Bytes(s)) } 10 | func HexToHash(s string) Hash { return BytesToHash(utils.Hex2Bytes(s)) } 11 | 12 | func BytesToAddress(b []byte) Address { 13 | var a Address 14 | a.SetBytes(b) 15 | return a 16 | } 17 | 18 | func BytesToHash(b []byte) Hash { 19 | var h Hash 20 | h.SetBytes(b) 21 | return h 22 | } 23 | 24 | func BigToHash(b *big.Int) Hash { return BytesToHash(b.Bytes()) } 25 | -------------------------------------------------------------------------------- /utils/hex.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "math/big" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | func Int2Hex(number uint64) string { 12 | return fmt.Sprintf("%x", number) 13 | } 14 | 15 | // just return uint64 type 16 | func Hex2Int(hex string) uint64 { 17 | if strings.HasPrefix(hex, "0x") || strings.HasPrefix(hex, "0X") { 18 | hex = hex[2:] 19 | } 20 | intNumber, err := strconv.ParseUint(hex, 16, 64) 21 | 22 | if err != nil { 23 | return 0 24 | } 25 | 26 | return uint64(intNumber) 27 | } 28 | 29 | func Bytes2Hex(bytes []byte) string { 30 | return hex.EncodeToString(bytes) 31 | } 32 | 33 | func Hex2Bytes(str string) []byte { 34 | if strings.HasPrefix(str, "0x") || strings.HasPrefix(str, "0X") { 35 | str = str[2:] 36 | } 37 | 38 | if len(str)%2 == 1 { 39 | str = "0" + str 40 | } 41 | 42 | h, _ := hex.DecodeString(str) 43 | return h 44 | } 45 | 46 | // with prefix '0x' 47 | func Bytes2HexP(bytes []byte) string { 48 | return "0x" + hex.EncodeToString(bytes) 49 | } 50 | 51 | func Hex2BigInt(str string) *big.Int { 52 | bytes := Hex2Bytes(str) 53 | b := big.NewInt(0) 54 | b.SetBytes(bytes) 55 | return b 56 | } 57 | 58 | func Bytes2BigInt(bytes []byte) *big.Int { 59 | b := big.NewInt(0) 60 | b.SetBytes(bytes) 61 | return b 62 | } 63 | 64 | // RightPadBytes zero-pads slice to the right up to length l. 65 | func RightPadBytes(slice []byte, l int) []byte { 66 | if l <= len(slice) { 67 | return slice 68 | } 69 | 70 | padded := make([]byte, l) 71 | copy(padded, slice) 72 | 73 | return padded 74 | } 75 | 76 | // LeftPadBytes zero-pads slice to the left up to length l. 77 | func LeftPadBytes(slice []byte, l int) []byte { 78 | if l <= len(slice) { 79 | return slice 80 | } 81 | 82 | padded := make([]byte, l) 83 | copy(padded[l-len(slice):], slice) 84 | 85 | return padded 86 | } 87 | 88 | func Int2Bytes(i uint64) []byte { 89 | return Hex2Bytes(Int2Hex(i)) 90 | } 91 | -------------------------------------------------------------------------------- /utils/hex_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "math" 6 | "math/big" 7 | "testing" 8 | ) 9 | 10 | func TestInt2Bytes(t *testing.T) { 11 | assert.EqualValues(t, []byte{0x64}, Int2Bytes(100)) 12 | assert.EqualValues(t, []byte{0x64, 0x0}, Int2Bytes(25600)) 13 | 14 | assert.EqualValues(t, []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}, Int2Bytes(math.MaxUint64)) 15 | assert.EqualValues(t, []byte{0xff, 0xff, 0xff, 0xff}, Int2Bytes(math.MaxUint32)) 16 | 17 | assert.EqualValues(t, []byte{1}, Int2Bytes(1)) 18 | assert.EqualValues(t, []byte{254}, Int2Bytes(254)) 19 | assert.EqualValues(t, []byte{0x1, 0}, Int2Bytes(256)) 20 | } 21 | 22 | func TestInt2Hex(t *testing.T) { 23 | assert.EqualValues(t, "64", Int2Hex(100)) 24 | assert.EqualValues(t, "6400", Int2Hex(25600)) 25 | 26 | assert.EqualValues(t, "ffffffffffffffff", Int2Hex(math.MaxUint64)) 27 | assert.EqualValues(t, "ffffffff", Int2Hex(math.MaxUint32)) 28 | 29 | } 30 | 31 | func TestHex2Int(t *testing.T) { 32 | assert.EqualValues(t, 0, Hex2Int("0x0")) 33 | assert.EqualValues(t, 1, Hex2Int("0x01")) 34 | assert.EqualValues(t, 100, Hex2Int("64")) 35 | assert.EqualValues(t, 25600, Hex2Int("6400")) 36 | 37 | var maxUint64 uint64 38 | maxUint64 = math.MaxUint64 39 | assert.EqualValues(t, Hex2Int("ffffffffffffffff"), maxUint64) 40 | assert.EqualValues(t, Hex2Int("ffffffff"), math.MaxUint32) 41 | 42 | assert.EqualValues(t, 0, Hex2Int("-100")) 43 | assert.EqualValues(t, 0, Hex2Int("invalid number")) 44 | } 45 | 46 | func TestHex2Bytes(t *testing.T) { 47 | assert.EqualValues(t, []byte{0xff, 0xff}, Hex2Bytes("ffff")) 48 | assert.EqualValues(t, []byte{0x0f, 0xff}, Hex2Bytes("fff")) 49 | assert.EqualValues(t, []byte{0xff, 0xff}, Hex2Bytes("0xffff")) 50 | } 51 | 52 | func TestBytes2Hex(t *testing.T) { 53 | assert.EqualValues(t, "ffff", Bytes2Hex([]byte{0xff, 0xff})) 54 | assert.EqualValues(t, "ff0f", Bytes2Hex([]byte{0xff, 0xf})) 55 | } 56 | 57 | func TestBytes2HexP(t *testing.T) { 58 | assert.EqualValues(t, "0xff12", Bytes2HexP([]byte{0xff, 0x12})) 59 | } 60 | 61 | func TestBytes2BigInt(t *testing.T) { 62 | b := big.NewInt(0) 63 | b.SetString("ffffffffffffffff", 16) 64 | assert.EqualValues(t, b, Bytes2BigInt([]byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff})) 65 | } 66 | 67 | func TestHex2BigInt(t *testing.T) { 68 | b := big.NewInt(0) 69 | b.SetString("ffffffffffffffff", 16) 70 | assert.EqualValues(t, b, Hex2BigInt("ffffffffffffffff")) 71 | } 72 | -------------------------------------------------------------------------------- /utils/http_client.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "net/url" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | type IHttpClient interface { 15 | Request(method, url string, params []KeyValue, body interface{}, header []KeyValue) (error, int, []byte) 16 | Get(url string, params []KeyValue, body interface{}, header []KeyValue) (error, int, []byte) 17 | Post(url string, params []KeyValue, body interface{}, header []KeyValue) (error, int, []byte) 18 | Delete(url string, params []KeyValue, body interface{}, header []KeyValue) (error, int, []byte) 19 | Put(url string, params []KeyValue, body interface{}, header []KeyValue) (error, int, []byte) 20 | } 21 | 22 | type KeyValue struct { 23 | Key string `json:"key"` 24 | Value string `json:"value"` 25 | } 26 | 27 | func NewHttpClient(transport *http.Transport) *HttpClient { 28 | if transport == nil { 29 | transport = http.DefaultTransport.(*http.Transport) 30 | } 31 | 32 | return &HttpClient{&http.Client{Transport: transport}} 33 | } 34 | 35 | type HttpClient struct { 36 | client *http.Client 37 | } 38 | 39 | const ErrorCode = -1 40 | 41 | func (h *HttpClient) Request(method, u string, params []KeyValue, requestBody interface{}, headers []KeyValue) (err error, code int, respBody []byte) { 42 | start := time.Now().UTC() 43 | code = ErrorCode 44 | respBody = []byte{} 45 | err = nil 46 | defer func() { 47 | Debugf("###[%s]### cost[%.0f] %s %v %v %v ###[%d]###response###%s", method, float64(time.Since(start))/1000000, u, requestBody, params, headers, code, string(respBody)) 48 | }() 49 | 50 | if len(u) == 0 { 51 | err = fmt.Errorf("url is empty") 52 | Debugf(err.Error()) 53 | return 54 | } 55 | 56 | _, err = url.Parse(u) 57 | if err != nil { 58 | Debugf("parse url %s failed, error: %v", u, err) 59 | return 60 | } 61 | 62 | var buffer bytes.Buffer 63 | buffer.WriteString(u) 64 | if len(params) > 0 && !strings.HasSuffix(u, "?") { 65 | buffer.WriteString("?") 66 | } 67 | for i, param := range params { 68 | buffer.WriteString(param.Key) 69 | buffer.WriteString("=") 70 | buffer.WriteString(param.Value) 71 | if i < len(params)-1 { 72 | buffer.WriteString("&") 73 | } 74 | } 75 | 76 | var bodyBytes []byte 77 | if requestBody != nil { 78 | bodyBytes, _ = json.Marshal(requestBody) 79 | } 80 | 81 | req, err := http.NewRequest(method, buffer.String(), bytes.NewBuffer(bodyBytes)) 82 | if err != nil { 83 | Debugf("build request error: %v", err) 84 | return 85 | } 86 | 87 | req.Header.Set("Content-Type", "application/json") 88 | for _, header := range headers { 89 | req.Header.Set(header.Key, header.Value) 90 | } 91 | 92 | resp, err := h.client.Do(req) 93 | if err != nil { 94 | Debugf("http call error: %v", err) 95 | return 96 | } 97 | 98 | bodyBytes, err = ioutil.ReadAll(resp.Body) 99 | defer closeBody(resp) 100 | if err != nil { 101 | return 102 | } else { 103 | return nil, resp.StatusCode, bodyBytes 104 | } 105 | } 106 | 107 | func (h *HttpClient) Get(url string, params []KeyValue, body interface{}, header []KeyValue) (error, int, []byte) { 108 | return h.Request(http.MethodGet, url, params, body, header) 109 | } 110 | 111 | func (h *HttpClient) Post(url string, params []KeyValue, body interface{}, header []KeyValue) (error, int, []byte) { 112 | return h.Request(http.MethodPost, url, params, body, header) 113 | } 114 | 115 | func (h *HttpClient) Delete(url string, params []KeyValue, body interface{}, header []KeyValue) (error, int, []byte) { 116 | return h.Request(http.MethodDelete, url, params, body, header) 117 | } 118 | 119 | func (h *HttpClient) Put(url string, params []KeyValue, body interface{}, header []KeyValue) (error, int, []byte) { 120 | return h.Request(http.MethodPut, url, params, body, header) 121 | } 122 | 123 | func closeBody(resp *http.Response) { 124 | if resp != nil && resp.Body != nil { 125 | err := resp.Body.Close() 126 | if err != nil { 127 | Debugf("response body close error: %v", resp.Request) 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /utils/http_client_test.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "net/http" 6 | "testing" 7 | ) 8 | 9 | const requestURL = "https://httpbin.org" 10 | 11 | func TestNewHttpClient(t *testing.T) { 12 | client := NewHttpClient(nil) 13 | assert.NotNil(t, client) 14 | } 15 | 16 | func TestRequest(t *testing.T) { 17 | client := NewHttpClient(nil) 18 | err, code, _ := client.Request(http.MethodGet, requestURL, nil, nil, nil) 19 | assert.Nil(t, err) 20 | assert.EqualValues(t, http.StatusOK, code) 21 | } 22 | 23 | func TestGet(t *testing.T) { 24 | client := NewHttpClient(nil) 25 | err, code, _ := client.Get(requestURL+"/get", nil, nil, nil) 26 | assert.Nil(t, err) 27 | assert.EqualValues(t, http.StatusOK, code) 28 | } 29 | 30 | func TestPost(t *testing.T) { 31 | client := NewHttpClient(nil) 32 | err, code, _ := client.Post(requestURL+"/post", nil, nil, nil) 33 | assert.Nil(t, err) 34 | assert.EqualValues(t, http.StatusOK, code) 35 | } 36 | 37 | func TestDelete(t *testing.T) { 38 | client := NewHttpClient(nil) 39 | err, code, _ := client.Delete(requestURL+"/delete", nil, nil, nil) 40 | assert.Nil(t, err) 41 | assert.EqualValues(t, http.StatusOK, code) 42 | } 43 | 44 | func TestPut(t *testing.T) { 45 | client := NewHttpClient(nil) 46 | err, code, _ := client.Put(requestURL+"/put", nil, nil, nil) 47 | assert.Nil(t, err) 48 | assert.EqualValues(t, http.StatusOK, code) 49 | } 50 | -------------------------------------------------------------------------------- /utils/json.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | ) 7 | 8 | func ToJsonString(o interface{}) string { 9 | b, err := json.Marshal(o) 10 | if err != nil { 11 | log.Println("json err:", err) 12 | return "" 13 | } 14 | 15 | return string(b) 16 | } 17 | -------------------------------------------------------------------------------- /utils/logger.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | log "github.com/sirupsen/logrus" 5 | "os" 6 | ) 7 | 8 | func init() { 9 | switch os.Getenv("HSK_LOG_LEVEL") { 10 | case "FATAL": 11 | log.SetLevel(log.FatalLevel) 12 | case "ERROR": 13 | log.SetLevel(log.ErrorLevel) 14 | case "WARN": 15 | log.SetLevel(log.WarnLevel) 16 | case "INFO": 17 | log.SetLevel(log.InfoLevel) 18 | case "DEBUG": 19 | log.SetLevel(log.DebugLevel) 20 | default: 21 | log.SetLevel(log.InfoLevel) 22 | } 23 | 24 | formatter := &log.TextFormatter{ 25 | FullTimestamp: true, 26 | } 27 | 28 | log.SetFormatter(formatter) 29 | } 30 | 31 | func Debugf(format string, v ...interface{}) { 32 | log.Debugf(format, v...) 33 | } 34 | 35 | func Infof(format string, v ...interface{}) { 36 | log.Infof(format, v...) 37 | } 38 | 39 | func Errorf(format string, v ...interface{}) { 40 | log.Errorf(format, v...) 41 | } 42 | -------------------------------------------------------------------------------- /utils/metrics.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | "strconv" 8 | ) 9 | 10 | const DefaultMetricPort = "3006" 11 | const DefaultMetricPath = "/metrics" 12 | 13 | func StartMetrics() { 14 | port := os.Getenv("METRICS_PORT") 15 | if len(port) == 0 { 16 | port = DefaultMetricPort 17 | } else { 18 | p, err := strconv.ParseInt(port, 10, 32) 19 | if err != nil { 20 | panic(err) 21 | } 22 | if p > 65535 || p < 0 { 23 | panic("METRICS_PORT must between 0 and 65535 ") 24 | } 25 | } 26 | 27 | err := http.ListenAndServe(fmt.Sprintf(":%s", port), MetricsHandler{}) 28 | if err != nil { 29 | Errorf("metrics service error: %v", err) 30 | } 31 | } 32 | 33 | type MetricsHandler struct { 34 | } 35 | 36 | func (MetricsHandler) ServeHTTP(resp http.ResponseWriter, req *http.Request) { 37 | if req.URL.Path != DefaultMetricPath { 38 | resp.WriteHeader(http.StatusNotFound) 39 | responseBody(resp, "Not Found") 40 | return 41 | } 42 | 43 | responseBody(resp, "Hello") 44 | } 45 | 46 | func responseBody(resp http.ResponseWriter, data string) { 47 | _, err := resp.Write([]byte(data)) 48 | if err != nil { 49 | Errorf("metrics error: %v", err) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /utils/number.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "github.com/shopspring/decimal" 6 | "math/big" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | func String2BigInt(str string) big.Int { 12 | n := new(big.Int) 13 | n.SetString(str, 0) 14 | return *n 15 | } 16 | 17 | // To Decimal 18 | func StringToDecimal(str string) decimal.Decimal { 19 | if len(str) >= 2 && str[:2] == "0x" { 20 | b := new(big.Int) 21 | b.SetString(str[2:], 16) 22 | d := decimal.NewFromBigInt(b, 0) 23 | return d 24 | } else { 25 | v, err := decimal.NewFromString(str) 26 | if err != nil { 27 | panic(err) 28 | } 29 | return v 30 | } 31 | } 32 | 33 | func IntToDecimal(value interface{}) decimal.Decimal { 34 | ret, err := decimal.NewFromString(NumberToString(value)) 35 | if err != nil { 36 | panic(fmt.Errorf("IntToDecimal error %+v", value)) 37 | } 38 | 39 | return ret 40 | } 41 | 42 | func DecimalToBigInt(d decimal.Decimal) *big.Int { 43 | n := new(big.Int) 44 | n, ok := n.SetString(d.StringFixed(0), 10) 45 | if !ok { 46 | panic(fmt.Errorf("decimalToBigInt error %+v", d)) 47 | } 48 | return n 49 | } 50 | 51 | func NumberToString(number interface{}) string { 52 | return fmt.Sprintf("%d", number) 53 | } 54 | 55 | func ParseInt(number string, defaultNumber int) int { 56 | ret, err := strconv.ParseInt(number, 10, 32) 57 | if err != nil { 58 | return int(defaultNumber) 59 | } 60 | 61 | return int(ret) 62 | } 63 | 64 | // IntToHex convert int to hexadecimal representation 65 | func IntToHex(i int) string { 66 | return fmt.Sprintf("0x%x", i) 67 | } 68 | 69 | // BigToHex covert big.Int to hexadecimal representation 70 | func BigToHex(bigInt big.Int) string { 71 | if bigInt.BitLen() == 0 { 72 | return "0x0" 73 | } 74 | 75 | return "0x" + strings.TrimPrefix(fmt.Sprintf("%x", bigInt.Bytes()), "0") 76 | } 77 | -------------------------------------------------------------------------------- /watcher/watcher.go: -------------------------------------------------------------------------------- 1 | package watcher 2 | 3 | import ( 4 | "context" 5 | "github.com/HydroProtocol/hydro-sdk-backend/common" 6 | "github.com/HydroProtocol/hydro-sdk-backend/sdk" 7 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 8 | "strconv" 9 | "time" 10 | ) 11 | 12 | type Watcher struct { 13 | lastSyncedBlockNumber uint64 14 | Ctx context.Context 15 | 16 | KVClient common.IKVStore 17 | QueueClient common.IQueue 18 | Hydro sdk.Hydro 19 | 20 | TransactionHandler *TransactionHandler 21 | } 22 | 23 | type TransactionHandler interface { 24 | Update(sdk.Transaction, uint64) 25 | } 26 | 27 | func (w *Watcher) RegisterHandler(handler TransactionHandler) { 28 | w.TransactionHandler = &handler 29 | } 30 | 31 | const SleepSeconds = 3 32 | 33 | func (w *Watcher) Run() { 34 | w.initBlockNumber() 35 | 36 | for { 37 | select { 38 | case <-w.Ctx.Done(): 39 | return 40 | default: 41 | currentBlockNumber, err := w.Hydro.GetBlockNumber() 42 | 43 | if err != nil { 44 | utils.Errorf("Watcher GetBlockNumber Failed, %v", err) 45 | w.Sleep() 46 | continue 47 | } 48 | 49 | utils.Debugf("CurrentNumber: %d, lastSyncedNumber: %d", currentBlockNumber, w.lastSyncedBlockNumber) 50 | 51 | if currentBlockNumber <= w.lastSyncedBlockNumber { 52 | utils.Infof("Watcher is Synchronized, sleep %s Seconds", SleepSeconds*time.Second) 53 | w.Sleep() 54 | continue 55 | } 56 | 57 | err = w.syncNextBlock() 58 | 59 | if err != nil { 60 | utils.Errorf("Watcher Sync Blokc Error %v", err) 61 | w.Sleep() 62 | continue 63 | } 64 | 65 | w.lastSyncedBlockNumber = w.lastSyncedBlockNumber + 1 66 | err = w.KVClient.Set(common.HYDRO_WATCHER_BLOCK_NUMBER_CACHE_KEY, strconv.FormatUint(w.lastSyncedBlockNumber, 10), 0) 67 | 68 | if err != nil { 69 | utils.Errorf("Watcher Save LastSyncedBlockNumber Error %v", err) 70 | } 71 | } 72 | } 73 | } 74 | 75 | // Sleep allows watcher to exit even thought it is sleeping 76 | func (w *Watcher) Sleep() { 77 | select { 78 | case <-w.Ctx.Done(): 79 | case <-time.After(SleepSeconds * time.Second): 80 | } 81 | } 82 | 83 | func (w *Watcher) initBlockNumber() { 84 | var blockNumber uint64 85 | 86 | val, err := w.KVClient.Get(common.HYDRO_WATCHER_BLOCK_NUMBER_CACHE_KEY) 87 | 88 | if err == common.KVStoreEmpty { 89 | blockNumber, _ = w.Hydro.GetBlockNumber() 90 | utils.Debugf("Cache block number is nil, use current block number: %d", blockNumber) 91 | } else if err != nil { 92 | panic(err) 93 | } else { 94 | blockNumber, err = strconv.ParseUint(val, 0, 64) 95 | 96 | if err != nil { 97 | panic(err) 98 | } 99 | } 100 | 101 | w.lastSyncedBlockNumber = blockNumber 102 | return 103 | } 104 | 105 | func (w *Watcher) syncNextBlock() (err error) { 106 | utils.Debugf("Sync Block %d", w.lastSyncedBlockNumber+1) 107 | 108 | block, err := w.Hydro.GetBlockByNumber(w.lastSyncedBlockNumber + 1) 109 | 110 | if err != nil { 111 | utils.Errorf("Sync Block %d Error, %+v", w.lastSyncedBlockNumber+1, err) 112 | return 113 | } 114 | 115 | txs := block.GetTransactions() 116 | 117 | for i := range txs { 118 | w.syncTransaction(txs[i], block.Timestamp()) 119 | } 120 | 121 | return 122 | } 123 | 124 | func (w *Watcher) syncTransaction(tx sdk.Transaction, timestamp uint64) { 125 | if w.TransactionHandler != nil { 126 | (*w.TransactionHandler).Update(tx, timestamp) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /watcher/watcher_test.go: -------------------------------------------------------------------------------- 1 | package watcher 2 | 3 | import ( 4 | "context" 5 | "github.com/HydroProtocol/hydro-sdk-backend/common" 6 | "github.com/HydroProtocol/hydro-sdk-backend/sdk" 7 | "github.com/stretchr/testify/suite" 8 | "testing" 9 | ) 10 | 11 | type watcherTestSuit struct { 12 | suite.Suite 13 | } 14 | 15 | func (s *watcherTestSuit) InitWatcher() *Watcher { 16 | return &Watcher{ 17 | Ctx: context.Background(), 18 | Hydro: sdk.NewMockHydro(), 19 | KVClient: &common.MockKVStore{}, 20 | QueueClient: &common.MockQueue{}, 21 | } 22 | } 23 | 24 | func (s *watcherTestSuit) SetupSuite() { 25 | } 26 | 27 | func (s *watcherTestSuit) TearDownSuite() { 28 | } 29 | 30 | func (s *watcherTestSuit) TearDownTest() { 31 | } 32 | 33 | func (s *watcherTestSuit) TestInitLastBlockNumberWithCache() { 34 | watcher := s.InitWatcher() 35 | 36 | watcher.KVClient.(*common.MockKVStore).On("Get", common.HYDRO_WATCHER_BLOCK_NUMBER_CACHE_KEY).Return("10086", nil) 37 | 38 | watcher.initBlockNumber() 39 | s.Equal(uint64(10086), watcher.lastSyncedBlockNumber) 40 | } 41 | 42 | func (s *watcherTestSuit) TestInitLastBlockNumberWithoutCache() { 43 | watcher := s.InitWatcher() 44 | 45 | watcher.KVClient.(*common.MockKVStore).On("Get", common.HYDRO_WATCHER_BLOCK_NUMBER_CACHE_KEY).Return("", common.KVStoreEmpty) 46 | watcher.Hydro.(*sdk.MockHydro).BlockChain.(*sdk.MockBlockchain).On("GetBlockNumber").Return(uint64(10086), nil) 47 | 48 | watcher.initBlockNumber() 49 | s.Equal(uint64(10086), watcher.lastSyncedBlockNumber) 50 | } 51 | 52 | func TestWatcherSuite(t *testing.T) { 53 | suite.Run(t, new(watcherTestSuit)) 54 | } 55 | -------------------------------------------------------------------------------- /websocket/channel.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "github.com/HydroProtocol/hydro-sdk-backend/common" 5 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 6 | "strings" 7 | "sync" 8 | ) 9 | 10 | // Channel is a basic type implemented IChannel 11 | type Channel struct { 12 | ID string 13 | Clients map[string]*Client 14 | 15 | Subscribe chan *Client 16 | Unsubscribe chan string 17 | Messages chan *common.WebSocketMessage 18 | } 19 | 20 | func (c *Channel) GetID() string { 21 | return c.ID 22 | } 23 | 24 | func (c *Channel) AddSubscriber(client *Client) { 25 | c.Subscribe <- client 26 | } 27 | 28 | func (c *Channel) RemoveSubscriber(ID string) { 29 | c.Unsubscribe <- ID 30 | } 31 | 32 | func (c *Channel) AddMessage(msg *common.WebSocketMessage) { 33 | c.Messages <- msg 34 | } 35 | 36 | func (c *Channel) UnsubscribeChan() chan string { 37 | return c.Unsubscribe 38 | } 39 | 40 | func (c *Channel) SubScribeChan() chan *Client { 41 | return c.Subscribe 42 | } 43 | 44 | func (c *Channel) MessagesChan() chan *common.WebSocketMessage { 45 | return c.Messages 46 | } 47 | 48 | func (c *Channel) handleMessage(msg *common.WebSocketMessage) { 49 | for _, client := range c.Clients { 50 | err := client.Send(msg.Payload) 51 | 52 | if err != nil { 53 | utils.Debugf("send message to client error: %v", err) 54 | c.handleUnsubscriber(client.ID) 55 | } else { 56 | utils.Debugf("send message to client: channel: %s, payload: %s", msg.ChannelID, msg.Payload) 57 | } 58 | } 59 | } 60 | 61 | func (c *Channel) handleSubscriber(client *Client) { 62 | c.Clients[client.ID] = client 63 | 64 | utils.Debugf("client(%s) joins channel(%s)", client.ID, c.ID) 65 | } 66 | 67 | func (c *Channel) handleUnsubscriber(ID string) { 68 | delete(c.Clients, ID) 69 | 70 | utils.Debugf("client(%s) leaves channel(%s)", ID, c.ID) 71 | } 72 | 73 | func runChannel(c IChannel) { 74 | for { 75 | select { 76 | case msg := <-c.MessagesChan(): 77 | c.handleMessage(msg) 78 | case client := <-c.SubScribeChan(): 79 | c.handleSubscriber(client) 80 | case ID := <-c.UnsubscribeChan(): 81 | c.handleUnsubscriber(ID) 82 | } 83 | } 84 | } 85 | 86 | var allChannels = make(map[string]IChannel, 10) 87 | var allChannelsMutex = &sync.RWMutex{} 88 | 89 | func findChannel(id string) IChannel { 90 | allChannelsMutex.RLock() 91 | defer allChannelsMutex.RUnlock() 92 | 93 | return allChannels[id] 94 | } 95 | 96 | func saveChannel(channel IChannel) { 97 | allChannelsMutex.Lock() 98 | defer allChannelsMutex.Unlock() 99 | 100 | allChannels[channel.GetID()] = channel 101 | } 102 | 103 | func createChannelByID(channelID string) IChannel { 104 | parts := strings.Split(channelID, "#") 105 | prefix := parts[0] 106 | 107 | var channel IChannel 108 | 109 | if creatorFunc := channelCreators[prefix]; creatorFunc != nil { 110 | channel = creatorFunc(channelID) 111 | } else { 112 | channel = createBaseChannel(channelID) 113 | } 114 | 115 | saveChannel(channel) 116 | go runChannel(channel) 117 | 118 | return channel 119 | } 120 | 121 | func createBaseChannel(channelID string) *Channel { 122 | return &Channel{ 123 | ID: channelID, 124 | Subscribe: make(chan *Client), 125 | Unsubscribe: make(chan string), 126 | Messages: make(chan *common.WebSocketMessage), 127 | Clients: make(map[string]*Client), 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /websocket/channel_test.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "github.com/HydroProtocol/hydro-sdk-backend/common" 5 | "github.com/stretchr/testify/mock" 6 | "github.com/stretchr/testify/suite" 7 | "net" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | type MockWebsocketConnection struct { 13 | mock.Mock 14 | } 15 | 16 | func (m *MockWebsocketConnection) ReadJSON(data interface{}) error { 17 | args := m.Called(data) 18 | return args.Error(0) 19 | } 20 | 21 | func (m *MockWebsocketConnection) WriteJSON(data interface{}) error { 22 | args := m.Called(data) 23 | return args.Error(0) 24 | } 25 | 26 | func (m *MockWebsocketConnection) RemoteAddr() net.Addr { 27 | args := m.Called() 28 | return args.Get(0).(net.Addr) 29 | } 30 | 31 | type channelTestSuit struct { 32 | suite.Suite 33 | } 34 | 35 | func (s *channelTestSuit) InitClient() (*Client, *MockWebsocketConnection) { 36 | client := NewClient() 37 | conn := new(MockWebsocketConnection) 38 | conn.On("WriteJSON", mock.Anything).Return(nil) 39 | 40 | client.Conn = conn 41 | return client, conn 42 | } 43 | 44 | func (s *channelTestSuit) SetupSuite() { 45 | } 46 | 47 | func (s *channelTestSuit) TearDownSuite() { 48 | } 49 | 50 | func (s *channelTestSuit) TearDownTest() { 51 | } 52 | 53 | func (s *channelTestSuit) TestFind() { 54 | channel := findChannel("not-exist-channel") 55 | s.Nil(channel) 56 | } 57 | 58 | func (s *channelTestSuit) TestCreate() { 59 | id := "test-create-id" 60 | channel := createBaseChannel(id) 61 | s.Equal(id, channel.ID) 62 | } 63 | 64 | func (s *channelTestSuit) TestRunAddressChannel() { 65 | channel := createBaseChannel("test-channel") 66 | go runChannel(channel) 67 | 68 | time.Sleep(time.Millisecond * 20) 69 | 70 | client, clientConn := s.InitClient() 71 | client2, client2Conn := s.InitClient() 72 | 73 | channel.AddSubscriber(client) 74 | channel.AddSubscriber(client2) 75 | time.Sleep(time.Millisecond * 20) 76 | 77 | message := &common.WebSocketMessage{ 78 | ChannelID: "test-channel", 79 | Payload: []byte(`{"success": true}`), 80 | } 81 | 82 | channel.AddMessage(message) 83 | time.Sleep(time.Millisecond * 20) 84 | 85 | clientConn.AssertCalled(s.T(), "WriteJSON", message.Payload) 86 | client2Conn.AssertCalled(s.T(), "WriteJSON", message.Payload) 87 | 88 | channel.RemoveSubscriber(client.ID) 89 | time.Sleep(time.Millisecond * 20) 90 | 91 | s.Equal(false, len(channel.Clients) <= 0) 92 | 93 | channel.RemoveSubscriber(client2.ID) 94 | time.Sleep(time.Millisecond * 20) 95 | s.Equal(true, len(channel.Clients) <= 0) 96 | } 97 | 98 | func (s *channelTestSuit) TestRunOrderbookChannel() { 99 | channel, mockSnapshot := s.NewMockMarketChannel("test-channel#HOT-WETH") 100 | 101 | s.Equal(true, len(channel.Clients) <= 0) 102 | s.Equal(mockSnapshot.Bids, channel.Orderbook.SnapshotV2().Bids) 103 | s.Equal(mockSnapshot.Asks, channel.Orderbook.SnapshotV2().Asks) 104 | s.Equal(mockSnapshot.Sequence, channel.Orderbook.Sequence) 105 | 106 | c1, c1Connection := s.InitClient() 107 | channel.AddSubscriber(c1) 108 | time.Sleep(time.Millisecond * 20) 109 | s.Equal(false, len(channel.Clients) <= 0) 110 | c1Connection.AssertNumberOfCalls(s.T(), "WriteJSON", 1) 111 | 112 | // test receive overdue message 113 | // first overdue message 114 | channel.AddMessage(s.buildWesocketMessage(11, "buy", "1", "1")) 115 | time.Sleep(time.Millisecond * 20) 116 | c1Connection.AssertNumberOfCalls(s.T(), "WriteJSON", 1) 117 | s.Equal(uint64(12), channel.Orderbook.Sequence) 118 | 119 | // second overdue message 120 | channel.AddMessage(s.buildWesocketMessage(12, "buy", "1", "1")) 121 | time.Sleep(time.Millisecond * 20) 122 | c1Connection.AssertNumberOfCalls(s.T(), "WriteJSON", 1) 123 | s.Equal(uint64(12), channel.Orderbook.Sequence) 124 | 125 | // first valid m essage 126 | channel.AddMessage(s.buildWesocketMessage(13, "buy", "1", "1")) 127 | time.Sleep(time.Millisecond * 20) 128 | c1Connection.AssertNumberOfCalls(s.T(), "WriteJSON", 2) 129 | s.Equal(uint64(13), channel.Orderbook.Sequence) 130 | s.Equal([2]string{"1", "2"}, channel.Orderbook.SnapshotV2().Bids[0]) 131 | s.Equal(mockSnapshot.Asks, channel.Orderbook.SnapshotV2().Asks) 132 | } 133 | 134 | func (s *channelTestSuit) buildWesocketMessage(sequence uint64, side, price, changedAmount string) *common.WebSocketMessage { 135 | 136 | payload := &common.WebsocketMarketOrderChangePayload{ 137 | Side: side, 138 | Price: price, 139 | Amount: changedAmount, 140 | Sequence: sequence, 141 | } 142 | 143 | return &common.WebSocketMessage{ 144 | Payload: payload, 145 | } 146 | } 147 | 148 | func (s *channelTestSuit) NewMockMarketChannel(channelID string) (*marketChannel, *common.SnapshotV2) { 149 | mockSnapshot := &common.SnapshotV2{ 150 | Sequence: 12, 151 | Bids: [][2]string{ 152 | { 153 | "1", "1", 154 | }, 155 | }, 156 | Asks: [][2]string{ 157 | { 158 | "2", "1", 159 | }, 160 | }, 161 | } 162 | 163 | m := NewMarketChannelCreator(NewMockSnapshotFetcher(mockSnapshot))(channelID).(*marketChannel) 164 | 165 | saveChannel(m) 166 | go runChannel(m) 167 | 168 | return m, mockSnapshot 169 | } 170 | 171 | func (s *channelTestSuit) TestCreateChannel() { 172 | _, mockSnapshot := s.NewMockMarketChannel("test-channel#HOT-WETH") 173 | 174 | RegisterChannelCreator(common.MarketChannelPrefix, NewMarketChannelCreator(NewMockSnapshotFetcher(mockSnapshot))) 175 | 176 | c1 := createChannelByID("Market#123") 177 | c2 := createChannelByID("Market#1234") 178 | s.NotEqual(c1, c2) 179 | } 180 | 181 | func TestChannelSuit(t *testing.T) { 182 | suite.Run(t, new(channelTestSuit)) 183 | } 184 | -------------------------------------------------------------------------------- /websocket/client.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "github.com/satori/go.uuid" 5 | "net" 6 | "sync" 7 | ) 8 | 9 | // For Mock Test 10 | type clientConn interface { 11 | WriteJSON(interface{}) error 12 | ReadJSON(interface{}) error 13 | RemoteAddr() net.Addr 14 | } 15 | 16 | type Client struct { 17 | ID string 18 | Conn clientConn 19 | Channels map[string]*Channel 20 | mu sync.Mutex 21 | } 22 | 23 | func (c *Client) sendData(data interface{}) error { 24 | c.mu.Lock() 25 | defer c.mu.Unlock() 26 | 27 | return c.Conn.WriteJSON(data) 28 | } 29 | 30 | func (c *Client) Send(data interface{}) error { 31 | err := c.sendData(data) 32 | 33 | if err != nil { 34 | return err 35 | } 36 | 37 | return nil 38 | } 39 | 40 | func NewClient() *Client { 41 | return &Client{ 42 | ID: uuid.NewV4().String(), 43 | Channels: make(map[string]*Channel), 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /websocket/consumer.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "github.com/HydroProtocol/hydro-sdk-backend/common" 7 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 8 | ) 9 | 10 | // StartConsumer initializes a queue instance and ready events from it 11 | func startConsumer(ctx context.Context, queue common.IQueue) { 12 | for { 13 | select { 14 | case <-ctx.Done(): 15 | utils.Infof("Websocket Consumer Exit") 16 | return 17 | default: 18 | 19 | // This method should not block this go thread all the time to make it has chance to exit gracefully 20 | msg, err := queue.Pop() 21 | if err != nil { 22 | utils.Errorf("read message error %v", err) 23 | continue 24 | } 25 | 26 | utils.Debugf("rec msg: %s", string(msg)) 27 | 28 | var wsMsg common.WebSocketMessage 29 | 30 | _ = json.Unmarshal(msg, &wsMsg) 31 | 32 | channel := findChannel(wsMsg.ChannelID) 33 | 34 | if channel == nil { 35 | channel = createChannelByID(wsMsg.ChannelID) 36 | } 37 | 38 | channel.AddMessage(&wsMsg) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /websocket/interface.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import "github.com/HydroProtocol/hydro-sdk-backend/common" 4 | 5 | type IChannel interface { 6 | GetID() string 7 | 8 | // Thread safe calls 9 | AddSubscriber(*Client) 10 | RemoveSubscriber(string) 11 | AddMessage(message *common.WebSocketMessage) 12 | 13 | UnsubscribeChan() chan string 14 | SubScribeChan() chan *Client 15 | MessagesChan() chan *common.WebSocketMessage 16 | 17 | handleMessage(*common.WebSocketMessage) 18 | handleSubscriber(*Client) 19 | handleUnsubscriber(string) 20 | } 21 | -------------------------------------------------------------------------------- /websocket/market_channel.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/HydroProtocol/hydro-sdk-backend/common" 7 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 8 | "strings" 9 | ) 10 | 11 | type marketChannel struct { 12 | *Channel 13 | MarketID string 14 | Orderbook *Orderbook 15 | } 16 | 17 | func (c *marketChannel) handleSubscriber(client *Client) { 18 | c.Channel.handleSubscriber(client) 19 | snapshot := c.Orderbook.SnapshotV2() 20 | 21 | msg := newOrderbookLevel2Snapshot(c.MarketID, snapshot.Bids, snapshot.Asks) 22 | 23 | err := client.Send(msg) 24 | 25 | if err != nil { 26 | utils.Debugf("send message to client error: %v", err) 27 | c.handleUnsubscriber(client.ID) 28 | } 29 | } 30 | 31 | func (c *marketChannel) handleMessage(msg *common.WebSocketMessage) { 32 | var commonPayload struct { 33 | Type string 34 | } 35 | 36 | bts, _ := json.Marshal(msg.Payload) 37 | _ = json.Unmarshal(bts, &commonPayload) 38 | 39 | var messageToBeSent interface{} 40 | 41 | switch commonPayload.Type { 42 | case common.WsTypeNewMarketTrade: 43 | var p common.WebsocketMarketNewMarketTradePayload 44 | _ = json.Unmarshal(bts, &p) 45 | messageToBeSent = &p 46 | default: 47 | var p common.WebsocketMarketOrderChangePayload 48 | _ = json.Unmarshal(bts, &p) 49 | 50 | // if current message is already aggregated in orderbook, skip it 51 | if p.Sequence <= c.Orderbook.Sequence { 52 | return 53 | } 54 | 55 | res := c.Orderbook.onMessage(&p) 56 | 57 | messageToBeSent = newOrderbookLevel2Update(c.MarketID, res.Side, res.Price.String(), res.Amount.String()) 58 | } 59 | 60 | for _, client := range c.Clients { 61 | err := client.Send(messageToBeSent) 62 | 63 | if err != nil { 64 | utils.Debugf("send message to client error: %v", err) 65 | c.handleUnsubscriber(client.ID) 66 | } else { 67 | utils.Debugf("send market message to client, client: %s, channel: %s, msg: %v", client.ID, c.ID, messageToBeSent) 68 | } 69 | } 70 | } 71 | 72 | func NewMarketChannelCreator(fetcher SnapshotFetcher) func(channelID string) IChannel { 73 | return func(channelID string) IChannel { 74 | marketID := strings.Replace(channelID, fmt.Sprintf("%s#", common.MarketChannelPrefix), "", -1) 75 | 76 | channel := &marketChannel{ 77 | MarketID: marketID, 78 | Channel: createBaseChannel(channelID), 79 | } 80 | 81 | snapshot := fetcher.GetV2(marketID) 82 | 83 | channel.Orderbook = initOrderbook(marketID, snapshot) 84 | 85 | return channel 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /websocket/message.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | type orderbookLevel2Snapshot struct { 4 | Type string `json:"type"` 5 | MarketID string `json:"marketID"` 6 | Bids [][2]string `json:"bids"` 7 | Asks [][2]string `json:"asks"` 8 | } 9 | 10 | func newOrderbookLevel2Snapshot(marketID string, bids, asks [][2]string) *orderbookLevel2Snapshot { 11 | return &orderbookLevel2Snapshot{ 12 | Bids: bids, 13 | Asks: asks, 14 | MarketID: marketID, 15 | Type: "level2OrderbookSnapshot", 16 | } 17 | } 18 | 19 | type orderbookLevel2Update struct { 20 | Type string `json:"type"` 21 | MarketID string `json:"marketID"` 22 | Price string `json:"price"` 23 | Side string `json:"side"` 24 | Amount string `json:"amount"` 25 | } 26 | 27 | func newOrderbookLevel2Update(marketID string, side, price, amount string) *orderbookLevel2Update { 28 | return &orderbookLevel2Update{ 29 | Type: "level2OrderbookUpdate", 30 | MarketID: marketID, 31 | Side: side, 32 | Price: price, 33 | Amount: amount, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /websocket/orderbook.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "fmt" 5 | "github.com/HydroProtocol/hydro-sdk-backend/common" 6 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 7 | "github.com/shopspring/decimal" 8 | "sync" 9 | ) 10 | 11 | type Orderbook struct { 12 | Sequence uint64 13 | *common.Orderbook 14 | lock sync.Mutex 15 | } 16 | 17 | type OnMessageResult struct { 18 | Price decimal.Decimal 19 | Side string 20 | Amount decimal.Decimal 21 | } 22 | 23 | func initOrderbook(marketID string, snapshot *common.SnapshotV2) *Orderbook { 24 | orderbook := &Orderbook{ 25 | Orderbook: common.NewOrderbook(marketID), 26 | Sequence: snapshot.Sequence, 27 | } 28 | 29 | for _, v := range snapshot.Bids { 30 | price, _ := decimal.NewFromString(v[0]) 31 | amount, _ := decimal.NewFromString(v[1]) 32 | order := &common.MemoryOrder{ 33 | Side: "buy", 34 | ID: fmt.Sprintf("buy-%s", v[0]), 35 | Amount: amount, 36 | Price: price, 37 | } 38 | orderbook.Orderbook.InsertOrder(order) 39 | } 40 | 41 | for _, v := range snapshot.Asks { 42 | price, _ := decimal.NewFromString(v[0]) 43 | amount, _ := decimal.NewFromString(v[1]) 44 | order := &common.MemoryOrder{ 45 | Side: "sell", 46 | ID: fmt.Sprintf("sell-%s", v[0]), 47 | Amount: amount, 48 | Price: price, 49 | } 50 | orderbook.Orderbook.InsertOrder(order) 51 | } 52 | 53 | return orderbook 54 | } 55 | 56 | func (o *Orderbook) onMessage(payload *common.WebsocketMarketOrderChangePayload) *OnMessageResult { 57 | o.lock.Lock() 58 | defer o.lock.Unlock() 59 | 60 | res := &OnMessageResult{ 61 | Side: payload.Side, 62 | Price: utils.StringToDecimal(payload.Price), 63 | } 64 | 65 | orderID := fmt.Sprintf("%s-%s", payload.Side, payload.Price) 66 | 67 | if order, ok := o.Orderbook.GetOrder(orderID, payload.Side, utils.StringToDecimal(payload.Price)); ok { 68 | changedAmount := utils.StringToDecimal(payload.Amount) 69 | order.Amount = order.Amount.Add(changedAmount) 70 | priceLevelAmountAfterChange := order.Amount 71 | res.Amount = priceLevelAmountAfterChange 72 | 73 | if priceLevelAmountAfterChange.LessThanOrEqual(decimal.Zero) { 74 | o.Orderbook.RemoveOrder(order) 75 | } else { 76 | o.Orderbook.ChangeOrder(order, changedAmount) 77 | } 78 | } else { 79 | //s := o.SnapshotV2() 80 | //s.Sequence = o.Sequence 81 | 82 | o.Orderbook.InsertOrder(&common.MemoryOrder{ 83 | ID: orderID, 84 | Price: utils.StringToDecimal(payload.Price), 85 | Amount: utils.StringToDecimal(payload.Amount), 86 | Side: payload.Side, 87 | }) 88 | 89 | if utils.StringToDecimal(payload.Amount).LessThan(decimal.Zero) { 90 | panic(fmt.Errorf("Can't find order in orderbook, change payload is %v", payload)) 91 | } 92 | 93 | res.Amount = utils.StringToDecimal(payload.Amount) 94 | } 95 | 96 | o.Sequence = payload.Sequence 97 | 98 | return res 99 | } 100 | -------------------------------------------------------------------------------- /websocket/server.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "github.com/HydroProtocol/hydro-sdk-backend/utils" 7 | "github.com/gorilla/websocket" 8 | "log" 9 | "net/http" 10 | ) 11 | 12 | type ClientRequest struct { 13 | Type string 14 | Channels []string 15 | } 16 | 17 | func handleClientRequest(client *Client) { 18 | utils.Infof("New Client(%s) IP:(%s) Connect", client.ID, client.Conn.RemoteAddr()) 19 | 20 | defer utils.Infof("Client(%s) IP:(%s) Disconnect", client.ID, client.Conn.RemoteAddr()) 21 | 22 | for { 23 | var req ClientRequest 24 | 25 | err := client.Conn.ReadJSON(&req) 26 | 27 | switch err.(type) { 28 | case *json.SyntaxError: 29 | utils.Errorf("request must be json") 30 | continue 31 | case *websocket.CloseError: 32 | return 33 | } 34 | 35 | utils.Debugf("Recv c(%s): %+v", client.ID, req) 36 | 37 | switch req.Type { 38 | case "subscribe": 39 | for _, id := range req.Channels { 40 | channel := findChannel(id) 41 | 42 | if channel == nil { 43 | // There is a risk to let user create channel freely. 44 | channel = createChannelByID(id) 45 | } 46 | 47 | if channel != nil { 48 | channel.AddSubscriber(client) 49 | } 50 | } 51 | case "unsubscribe": 52 | for _, id := range req.Channels { 53 | channel := findChannel(id) 54 | 55 | if channel == nil { 56 | continue 57 | } 58 | 59 | channel.RemoveSubscriber(client.ID) 60 | } 61 | } 62 | } 63 | } 64 | 65 | var upgrader = websocket.Upgrader{ 66 | CheckOrigin: func(r *http.Request) bool { 67 | return true 68 | }, 69 | } 70 | 71 | func connectHandler(w http.ResponseWriter, r *http.Request) { 72 | c, err := upgrader.Upgrade(w, r, nil) 73 | 74 | if err != nil { 75 | log.Print("upgrade error:", err) 76 | return 77 | } 78 | 79 | defer c.Close() 80 | 81 | client := NewClient() 82 | client.Conn = c 83 | 84 | handleClientRequest(client) 85 | } 86 | 87 | func startSocketServer(ctx context.Context, addr string) { 88 | srv := &http.Server{Addr: addr} 89 | 90 | http.HandleFunc("/", connectHandler) 91 | 92 | go func() { 93 | // returns ErrServerClosed on graceful close 94 | utils.Infof("Websocket Server is listening on %s", addr) 95 | if err := srv.ListenAndServe(); err != http.ErrServerClosed { 96 | log.Fatalf("Serve Exit Error: %s", err) 97 | } 98 | }() 99 | 100 | <-ctx.Done() 101 | 102 | // now close the server gracefully ("shutdown") 103 | if err := srv.Shutdown(context.Background()); err != nil { 104 | panic(err) 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /websocket/snapshot.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/HydroProtocol/hydro-sdk-backend/common" 7 | "io/ioutil" 8 | "net/http" 9 | ) 10 | 11 | type SnapshotFetcher interface { 12 | GetV2(marketID string) *common.SnapshotV2 13 | } 14 | 15 | type DefaultHttpSnapshotFetcher struct { 16 | ApiUrl string 17 | } 18 | 19 | func (f *DefaultHttpSnapshotFetcher) GetV2(marketID string) *common.SnapshotV2 { 20 | res, err := http.Get(fmt.Sprintf("%s/markets/%s/orderbook", f.ApiUrl, marketID)) 21 | 22 | if err != nil { 23 | panic(err) 24 | } 25 | 26 | bts, err := ioutil.ReadAll(res.Body) 27 | 28 | if err != nil { 29 | panic(err) 30 | } 31 | 32 | var resStruct struct { 33 | Status int 34 | Data struct { 35 | Orderbook *common.SnapshotV2 `json:"orderBook"` 36 | } 37 | } 38 | 39 | err = json.Unmarshal(bts, &resStruct) 40 | 41 | if err != nil { 42 | panic(err) 43 | } 44 | 45 | return resStruct.Data.Orderbook 46 | } 47 | -------------------------------------------------------------------------------- /websocket/snapshot_test.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "github.com/HydroProtocol/hydro-sdk-backend/common" 5 | "github.com/stretchr/testify/mock" 6 | ) 7 | 8 | type MockSnapshotFetcher struct { 9 | mock.Mock 10 | } 11 | 12 | func (m *MockSnapshotFetcher) GetV2(marketID string) *common.SnapshotV2 { 13 | args := m.Called(marketID) 14 | return args.Get(0).(*common.SnapshotV2) 15 | } 16 | 17 | func NewMockSnapshotFetcher(expectedSnapshot *common.SnapshotV2) *MockSnapshotFetcher { 18 | fetcher := new(MockSnapshotFetcher) 19 | fetcher.On("GetV2", mock.Anything).Return(expectedSnapshot) 20 | return fetcher 21 | } 22 | -------------------------------------------------------------------------------- /websocket/websocket.go: -------------------------------------------------------------------------------- 1 | package websocket 2 | 3 | import ( 4 | "context" 5 | "github.com/HydroProtocol/hydro-sdk-backend/common" 6 | ) 7 | 8 | var channelCreators = make(map[string]func(channelID string) IChannel) 9 | 10 | type WSServer struct { 11 | addr string // addr the websocket is listened on 12 | sourceQueue common.IQueue // a queue to get 13 | } 14 | 15 | func NewWSServer(addr string, sourceQueue common.IQueue) *WSServer { 16 | if addr == "" { 17 | addr = ":3002" 18 | } 19 | 20 | s := &WSServer{ 21 | addr: addr, 22 | sourceQueue: sourceQueue, 23 | } 24 | 25 | return s 26 | } 27 | 28 | func RegisterChannelCreator(prefix string, fn func(channelID string) IChannel) { 29 | channelCreators[prefix] = fn 30 | } 31 | 32 | func (s *WSServer) Start(ctx context.Context) { 33 | go startConsumer(ctx, s.sourceQueue) 34 | startSocketServer(ctx, s.addr) 35 | } 36 | --------------------------------------------------------------------------------